@wolido/async-subagent-isolation 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ADVANCED.en.md CHANGED
@@ -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
@@ -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
@@ -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.3.0",
4
4
  "description": "A pi extension that asynchronously delegates tasks to specialized subagents running in isolated pi processes.",
5
5
  "license": "MIT",
6
6
  "author": "Wolido",
package/src/index.ts CHANGED
@@ -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;
@@ -1537,6 +1564,13 @@ export interface SubagentResultDetails {
1537
1564
  stopReason?: string;
1538
1565
  /** Present only on cancelled tasks: who cancelled ("user" | "agent"). */
1539
1566
  cancelledBy?: "user" | "agent";
1567
+ /**
1568
+ * Run duration in milliseconds (>= 0): the real run time
1569
+ * (finishedAt - startedAt) when a result exists; measured from the
1570
+ * dispatch time (task.startedAt) when result is null (cancel/internal
1571
+ * error).
1572
+ */
1573
+ durationMs: number;
1540
1574
  usage: UsageStats;
1541
1575
  sessionId: string;
1542
1576
  output: string;
@@ -1577,6 +1611,12 @@ export function buildResultEnvelope(
1577
1611
  const usage: UsageStats =
1578
1612
  result?.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
1579
1613
  const sessionId = result?.sessionId ?? task.taskId;
1614
+ // Real run time when the subagent produced a result; for a null result
1615
+ // (user/agent cancel, session shutdown, internal error) the run never
1616
+ // reported back, so measure from the dispatch time instead.
1617
+ const durationMs = result
1618
+ ? Math.max(0, (result.finishedAt ?? Date.now()) - result.startedAt)
1619
+ : Math.max(0, Date.now() - task.startedAt);
1580
1620
  let body = output;
1581
1621
  if (!body && result) body = result.errorMessage || result.stderr.trim();
1582
1622
  // Only genuine failures are labelled "内部错误"; a user cancel or session
@@ -1588,7 +1628,7 @@ export function buildResultEnvelope(
1588
1628
  "",
1589
1629
  `- 状态: ${statusWord}`,
1590
1630
  `- 任务: ${truncateTaskDescription(task.task)}`,
1591
- `- 耗时: ${formatElapsed(task.startedAt)} · 用量: ${formatUsageStats(usage, result?.model) || "-"}`,
1631
+ `- 耗时: ${formatDuration(durationMs)} · 用量: ${formatUsageStats(usage, result?.model) || "-"}`,
1592
1632
  `- 会话: ${sessionId}`,
1593
1633
  "",
1594
1634
  // 在途 block: completeAsyncTask deletes this task from the registry
@@ -1607,6 +1647,7 @@ export function buildResultEnvelope(
1607
1647
  exitCode: result?.exitCode ?? null,
1608
1648
  stopReason,
1609
1649
  cancelledBy: task.cancelledBy,
1650
+ durationMs,
1610
1651
  usage,
1611
1652
  sessionId,
1612
1653
  output:
@@ -2073,6 +2114,14 @@ export default function (pi: ExtensionAPI) {
2073
2114
  const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
2074
2115
  const displayItems = getDisplayItems(r.messages);
2075
2116
  const finalOutput = getFinalOutput(r.messages);
2117
+ // Run duration, shown only when both timestamps are present (older
2118
+ // results lack them); a typeof check keeps 0ms runs visible and
2119
+ // missing fields from rendering "NaN".
2120
+ const durationStr =
2121
+ typeof r.startedAt === "number" && Number.isFinite(r.startedAt) &&
2122
+ typeof r.finishedAt === "number" && Number.isFinite(r.finishedAt)
2123
+ ? formatDuration(r.finishedAt - r.startedAt)
2124
+ : null;
2076
2125
 
2077
2126
  if (expanded) {
2078
2127
  const container = new Container();
@@ -2080,6 +2129,7 @@ export default function (pi: ExtensionAPI) {
2080
2129
  if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
2081
2130
  if (r.phase !== "idle") header += ` ${theme.fg("warning", formatPhase(r.phase))}`;
2082
2131
  header += ` ${theme.fg("muted", `[session: ${r.sessionId}]`)}`;
2132
+ if (durationStr) header += ` ${theme.fg("dim", durationStr)}`;
2083
2133
  container.addChild(new Text(header, 0, 0));
2084
2134
  if (isError && r.errorMessage)
2085
2135
  container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
@@ -2125,6 +2175,7 @@ export default function (pi: ExtensionAPI) {
2125
2175
  if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
2126
2176
  if (r.phase !== "idle") text += ` ${theme.fg("warning", formatPhase(r.phase))}`;
2127
2177
  text += ` ${theme.fg("muted", `[session: ${r.sessionId}]`)}`;
2178
+ if (durationStr) text += ` ${theme.fg("dim", durationStr)}`;
2128
2179
  if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
2129
2180
  if (displayItems.length === 0) {
2130
2181
  if (!isError || !r.errorMessage) text += `\n${theme.fg("muted", "(no output)")}`;
@@ -2319,6 +2370,11 @@ export default function (pi: ExtensionAPI) {
2319
2370
  if (details?.taskId) text += ` ${theme.fg("muted", `(taskId: ${details.taskId})`)}`;
2320
2371
  const usageStr = details ? formatUsageStats(details.usage) : "";
2321
2372
  if (usageStr) text += ` ${theme.fg("dim", usageStr)}`;
2373
+ // durationMs is typed as a number and 0 is a valid duration, so the
2374
+ // presence check must not be falsy-based; old-shape details without
2375
+ // it simply omit the duration.
2376
+ if (typeof details?.durationMs === "number" && Number.isFinite(details.durationMs))
2377
+ text += ` ${theme.fg("dim", `耗时 ${formatDuration(details.durationMs)}`)}`;
2322
2378
  if (details?.taskId) text += `\n${theme.fg("muted", `查看全文: /subagent-result ${details.taskId}`)}`;
2323
2379
  // Background tint mirrors the dispatch-receipt tool rows: success and
2324
2380
  // failure reuse the tool-row colors; timeout, cancelled and unknown