@zhushanwen/pi-subagent-workflow 7.3.4 → 8.0.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.
Files changed (166) hide show
  1. package/README.md +39 -12
  2. package/agents/analyst.md +61 -0
  3. package/agents/coder.md +70 -0
  4. package/agents/debugger.md +67 -0
  5. package/agents/doc-reviewer.md +3 -3
  6. package/agents/explorer.md +50 -18
  7. package/agents/general-purpose.md +19 -8
  8. package/agents/orchestrator.md +37 -32
  9. package/agents/planner.md +45 -11
  10. package/agents/researcher.md +53 -11
  11. package/agents/reviewer.md +74 -0
  12. package/package.json +1 -1
  13. package/skills/workflow-script-format/SKILL.md +1 -1
  14. package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
  15. package/src/execution/__tests__/agent-registry.test.ts +13 -11
  16. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
  17. package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
  18. package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
  19. package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
  20. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
  21. package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
  22. package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
  23. package/src/execution/__tests__/crash-recovery.test.ts +8 -2
  24. package/src/execution/__tests__/delivery-methods.test.ts +385 -0
  25. package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
  26. package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
  27. package/src/execution/__tests__/execute-nesting.test.ts +20 -72
  28. package/src/execution/__tests__/execution-record.test.ts +199 -0
  29. package/src/execution/__tests__/finalize-record.test.ts +170 -14
  30. package/src/execution/__tests__/format.test.ts +131 -7
  31. package/src/execution/__tests__/gc-timer.test.ts +184 -0
  32. package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
  33. package/src/execution/__tests__/helpers/spawn-mock.ts +25 -7
  34. package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
  35. package/src/execution/__tests__/index-session-start.test.ts +257 -5
  36. package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
  37. package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
  38. package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
  39. package/src/execution/__tests__/list-component.test.ts +59 -5
  40. package/src/execution/__tests__/list-fields.test.ts +109 -0
  41. package/src/execution/__tests__/model-resolver.test.ts +38 -1
  42. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
  43. package/src/execution/__tests__/nested-visibility.test.ts +325 -0
  44. package/src/execution/__tests__/notifier-flush.test.ts +209 -7
  45. package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
  46. package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
  47. package/src/execution/__tests__/record-store.test.ts +158 -52
  48. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
  49. package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
  50. package/src/execution/__tests__/resource-policy.test.ts +109 -0
  51. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
  52. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
  53. package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
  54. package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
  55. package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
  56. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
  57. package/src/execution/__tests__/session-pending.test.ts +61 -2
  58. package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
  59. package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
  60. package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
  61. package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
  62. package/src/execution/__tests__/spawn-args.test.ts +127 -49
  63. package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
  64. package/src/execution/__tests__/spawned-children.test.ts +92 -0
  65. package/src/execution/__tests__/status-refactor.test.ts +345 -0
  66. package/src/execution/__tests__/stdin-writer.test.ts +97 -0
  67. package/src/execution/__tests__/subagent-service-message-close.test.ts +598 -0
  68. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
  69. package/src/execution/__tests__/subagent-service.test.ts +49 -11
  70. package/src/execution/__tests__/timeout-integration.test.ts +27 -13
  71. package/src/execution/__tests__/tool-action.test.ts +12 -10
  72. package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
  73. package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
  74. package/src/execution/__tests__/worktree-manager.test.ts +292 -89
  75. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +13 -12
  76. package/src/execution/argv-mirror.ts +21 -2
  77. package/src/execution/execution-record.ts +126 -9
  78. package/src/execution/finalize-record.ts +90 -13
  79. package/src/execution/host-mode.ts +1 -1
  80. package/src/execution/lifecycle-manager.ts +484 -0
  81. package/src/execution/lifecycle-predicates.ts +65 -0
  82. package/src/execution/manifest-store.ts +61 -16
  83. package/src/execution/model-resolver.ts +26 -5
  84. package/src/execution/notifier.ts +69 -12
  85. package/src/execution/pi-invocation.ts +21 -1
  86. package/src/execution/record-store.ts +554 -107
  87. package/src/execution/session-pending.ts +116 -45
  88. package/src/execution/session-reconstructor.ts +224 -7
  89. package/src/execution/session-runner.ts +290 -75
  90. package/src/execution/sessions-index.ts +304 -0
  91. package/src/execution/stdin-writer.ts +93 -7
  92. package/src/execution/stream-sink.ts +20 -3
  93. package/src/execution/subagent-service.ts +867 -138
  94. package/src/execution/turn-limiter.ts +14 -0
  95. package/src/execution/types.ts +204 -22
  96. package/src/execution/worktree-manager.ts +128 -49
  97. package/src/execution/worktree-registry.ts +13 -2
  98. package/src/index.ts +277 -19
  99. package/src/injectors/subagent-list-injector.ts +26 -8
  100. package/src/injectors/workflow-list-injector.ts +25 -8
  101. package/src/interface/__tests__/subagent-tool-prompt.test.ts +18 -5
  102. package/src/interface/__tests__/tool-render.test.ts +15 -13
  103. package/src/interface/bg-notify-render.ts +32 -8
  104. package/src/interface/command-actions.ts +26 -7
  105. package/src/interface/commands.ts +21 -22
  106. package/src/interface/format.ts +44 -17
  107. package/src/interface/gui-mappers.ts +6 -8
  108. package/src/interface/helpers.ts +170 -10
  109. package/src/interface/list-component.ts +53 -14
  110. package/src/interface/subagent-actions.ts +235 -17
  111. package/src/interface/subagent-tool.ts +82 -17
  112. package/src/interface/subagents.ts +2 -1
  113. package/src/interface/tool-render.ts +11 -24
  114. package/src/interface/tool-workflow.ts +20 -35
  115. package/src/interface/views/WorkflowsView.ts +89 -32
  116. package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
  117. package/src/interface/views/__tests__/detail-content-session-file.test.ts +1 -1
  118. package/src/interface/views/format.ts +3 -3
  119. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +325 -0
  120. package/src/orchestration/__tests__/args-validator.test.ts +1 -1
  121. package/src/orchestration/__tests__/config-loader.test.ts +38 -0
  122. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
  123. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
  124. package/src/orchestration/__tests__/execute-agent-call.test.ts +95 -0
  125. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +657 -18
  126. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
  127. package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
  128. package/src/orchestration/__tests__/skill-discovery.test.ts +157 -0
  129. package/src/orchestration/__tests__/test-mocks.ts +191 -0
  130. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +98 -0
  131. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
  132. package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
  133. package/src/orchestration/__tests__/workflows-e2e.test.ts +1 -1
  134. package/src/orchestration/agent-opts-resolver.ts +4 -1
  135. package/src/orchestration/args-validator.ts +2 -2
  136. package/src/orchestration/config-loader.ts +30 -1
  137. package/src/orchestration/error-recovery.ts +133 -29
  138. package/src/orchestration/execute-agent-call.ts +31 -7
  139. package/src/orchestration/jsonl-run-store.ts +287 -40
  140. package/src/orchestration/launcher.ts +7 -1
  141. package/src/orchestration/lifecycle.ts +135 -132
  142. package/src/orchestration/models/__tests__/trace.test.ts +408 -0
  143. package/src/orchestration/models/budget.ts +1 -1
  144. package/src/orchestration/models/run-runtime.ts +15 -17
  145. package/src/orchestration/models/run-spec.ts +2 -2
  146. package/src/orchestration/models/run-state.ts +3 -3
  147. package/src/orchestration/models/trace.ts +95 -15
  148. package/src/orchestration/models/types.ts +8 -9
  149. package/src/orchestration/models/workflow-run.ts +50 -71
  150. package/src/orchestration/models/workflow-script.ts +32 -1
  151. package/src/orchestration/skill-discovery.ts +30 -0
  152. package/src/orchestration/worker-handle.ts +1 -1
  153. package/src/orchestration/worker-host.ts +1 -1
  154. package/src/orchestration/worker-script-builder.ts +29 -10
  155. package/src/shared/__tests__/agent-ref.test.ts +34 -0
  156. package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
  157. package/src/shared/__tests__/resource-discovery.test.ts +55 -0
  158. package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
  159. package/src/shared/agent-ref.ts +16 -0
  160. package/src/shared/resource-discovery.ts +147 -58
  161. package/src/shared/schema-jsonify.ts +53 -0
  162. package/workflows/README.md +4 -4
  163. package/agents/code-reviewer.md +0 -47
  164. package/agents/context-builder.md +0 -21
  165. package/agents/oracle.md +0 -34
  166. package/agents/worker.md +0 -20
@@ -22,6 +22,7 @@
22
22
  import type { Component } from "@earendil-works/pi-tui";
23
23
  import type { Theme } from "@earendil-works/pi-coding-agent";
24
24
 
25
+ import { displayAgentName } from "../shared/agent-ref.ts";
25
26
  import {
26
27
  firstLine,
27
28
  padToVisible,
@@ -52,11 +53,16 @@ const MIN_BORDER_WIDTH = BORDER_CHARS + INNER_PAD_TOTAL + 1;
52
53
  */
53
54
  interface BgNotifyRecord {
54
55
  id: string;
55
- status: "done" | "failed" | "cancelled";
56
+ /** v4 B-1: closed(终态,含 cancelled)或 running(对话模式轮次完成,旧 idle)。 */
57
+ status: "running" | "closed";
58
+ /** L2 关闭原因子枚举(仅 status="closed" 时有意义)。 */
59
+ closedReason?: string;
56
60
  agent: string;
57
61
  model?: string;
58
62
  result?: string;
59
63
  error?: string;
64
+ /** 对话轮次计数(仅 idle 有意义)。 */
65
+ round?: number;
60
66
  /** [MF#1] worktree background 完成通知携带的 patch 文件路径。 */
61
67
  patchFile?: string;
62
68
  }
@@ -207,17 +213,34 @@ function sanitizeAnsiForBg(text: string): string {
207
213
  function renderRecordLines(record: BgNotifyRecord, t: ThemeLike): string[] {
208
214
  const glyph = statusGlyph(record.status);
209
215
  const icon = glyph.icon ?? "•";
210
- const agent = truncLine(record.agent, AGENT_MAX_WIDTH);
216
+ const agent = truncLine(displayAgentName(record.agent), AGENT_MAX_WIDTH);
211
217
  // model 段:agent 后、状态描述前,accent 色。空则省略(向后兼容旧 record)。
212
218
  const modelPart = record.model
213
219
  ? ` ${t.fg("dim", "·")} ${t.fg("accent", truncLine(record.model, MODEL_MAX_WIDTH))}`
214
220
  : "";
215
- const verb = record.status === "done" ? "finished" : record.status === "failed" ? "failed" : "cancelled";
221
+ // v4 B-1: closed 统一终态(含 cancelled)。按 closedReason 派生文案。
222
+ const reason = record.closedReason ?? "gc";
223
+ let verb: string;
224
+ if (reason === "cancelled") {
225
+ verb = "cancelled";
226
+ } else if (reason === "gc" && record.error) {
227
+ verb = "failed";
228
+ } else {
229
+ verb = "finished";
230
+ }
216
231
  const idShort = shortId(record.id);
217
232
  const head = `${t.fg(glyph.color, icon)} ${t.bold(agent)}${modelPart}${t.fg("dim", ` — background subagent ${verb} - ${idShort}`)}`;
218
233
 
219
234
  switch (record.status) {
220
- case "done": {
235
+ case "closed": {
236
+ // v4 B-1: closed 统一终态(含 cancelled)。cancelled 无正文;失败显示错误;否则结果/patch。
237
+ const r = record.closedReason ?? "gc";
238
+ if (r === "cancelled") {
239
+ return [head];
240
+ }
241
+ if (r === "gc" && record.error) {
242
+ return [head, t.fg("dim", truncLine(`Error: ${firstLineSanitized(record.error)}`, BODY_MAX_WIDTH))];
243
+ }
221
244
  if (!record.result && !record.patchFile) return [head];
222
245
  const lines: string[] = [];
223
246
  if (record.result) {
@@ -229,10 +252,9 @@ function renderRecordLines(record: BgNotifyRecord, t: ThemeLike): string[] {
229
252
  }
230
253
  return [head, ...lines];
231
254
  }
232
- case "failed":
233
- return [head, t.fg("dim", truncLine(`Error: ${record.error ? firstLineSanitized(record.error) : "(unknown)"}`, BODY_MAX_WIDTH))];
234
- case "cancelled":
255
+ case "running":
235
256
  default:
257
+ // v4 B-1: 对话模式轮次完成(旧 idle 折入 running)——仅标题行。
236
258
  return [head];
237
259
  }
238
260
  }
@@ -263,7 +285,7 @@ function extractBgNotifyRecord(details: unknown): BgNotifyRecord | undefined {
263
285
  const status = d.status;
264
286
  const agent = d.agent;
265
287
  if (
266
- (status !== "done" && status !== "failed" && status !== "cancelled") ||
288
+ (status !== "closed" && status !== "running") ||
267
289
  typeof agent !== "string"
268
290
  ) {
269
291
  return undefined;
@@ -275,6 +297,8 @@ function extractBgNotifyRecord(details: unknown): BgNotifyRecord | undefined {
275
297
  model: typeof d.model === "string" ? d.model : undefined,
276
298
  result: typeof d.result === "string" ? d.result : undefined,
277
299
  error: typeof d.error === "string" ? d.error : undefined,
300
+ closedReason: typeof d.closedReason === "string" ? d.closedReason : undefined,
301
+ round: typeof d.round === "number" ? d.round : undefined,
278
302
  // [MF#1] 提取 patchFile(worktree background 完成通知携带)。
279
303
  patchFile: typeof d.patchFile === "string" ? d.patchFile : undefined,
280
304
  };
@@ -16,23 +16,36 @@ export type SubagentRpcAction =
16
16
 
17
17
  /** /workflows RPC action 判别联合。 */
18
18
  export type WorkflowRpcAction =
19
- | { action: "pause"; runId: string }
20
- | { action: "resume"; runId: string }
21
19
  | { action: "abort"; runId: string }
22
- | { action: "lifecycle-missing-id"; verb: "pause" | "resume" | "abort" }
20
+ | { action: "lifecycle-missing-id"; verb: LifecycleVerb }
21
+ | { action: "lifecycle-removed"; verb: "pause" | "resume" }
23
22
  | { action: "noop" };
24
23
 
25
24
  /** workflow lifecycle verb 类型。 */
26
- type LifecycleVerb = "pause" | "resume" | "abort";
25
+ type LifecycleVerb = "abort";
27
26
 
28
27
  /** workflow lifecycle verb 集合。 */
29
- const LIFECYCLE_VERBS: ReadonlySet<LifecycleVerb> = new Set(["pause", "resume", "abort"]);
28
+ const LIFECYCLE_VERBS: ReadonlySet<LifecycleVerb> = new Set(["abort"]);
30
29
 
31
30
  /** verb 是否为 lifecycle action(类型守卫,收窄到 LifecycleVerb)。 */
32
31
  function isLifecycleVerb(verb: string): verb is LifecycleVerb {
33
32
  return LIFECYCLE_VERBS.has(verb as LifecycleVerb);
34
33
  }
35
34
 
35
+ /**
36
+ * 已移除的 lifecycle verb 集合(pause/resume——run 一次性生命周期化后删除,
37
+ * 解析为 lifecycle-removed 提示,而非 unknown noop)。
38
+ */
39
+ const REMOVED_LIFECYCLE_VERBS: ReadonlySet<"pause" | "resume"> = new Set([
40
+ "pause",
41
+ "resume",
42
+ ]);
43
+
44
+ /** verb 是否为已移除的 lifecycle action(类型守卫,收窄到 "pause" | "resume")。 */
45
+ function isRemovedLifecycleVerb(verb: string): verb is "pause" | "resume" {
46
+ return REMOVED_LIFECYCLE_VERBS.has(verb as "pause" | "resume");
47
+ }
48
+
36
49
  /**
37
50
  * 解析 /subagents RPC 命令字符串。
38
51
  *
@@ -60,8 +73,11 @@ export function parseSubagentRpcCommand(argsStr: string): SubagentRpcAction {
60
73
  * 解析 /workflows RPC 命令字符串。
61
74
  *
62
75
  * 支持格式:
63
- * - `pause|resume|abort <runId>` → 对应 lifecycle action + runId
64
- * - `pause|resume|abort`(无 runId)→ { action: "lifecycle-missing-id", verb }
76
+ * - `abort <runId>` → { action: "abort", runId }
77
+ * - `abort`(无 runId)→ { action: "lifecycle-missing-id", verb: "abort" }
78
+ * - `pause|resume ...`(已移除的 lifecycle verb,带或不带 runId 均同)→
79
+ * { action: "lifecycle-removed", verb }——removed verb 优先于 missing-id 判定
80
+ * (提示语义优先:用户应得知能力已删除并获 abort 指引,而非被引导补 runId)
65
81
  * - 其他(空 / 未知 action / 无参)→ { action: "noop" }
66
82
  */
67
83
  export function parseWorkflowRpcCommand(argsStr: string): WorkflowRpcAction {
@@ -69,6 +85,9 @@ export function parseWorkflowRpcCommand(argsStr: string): WorkflowRpcAction {
69
85
  if (args.length === 0) return { action: "noop" };
70
86
 
71
87
  const [verb, runId] = args;
88
+ if (isRemovedLifecycleVerb(verb)) {
89
+ return { action: "lifecycle-removed", verb };
90
+ }
72
91
  if (isLifecycleVerb(verb)) {
73
92
  if (!runId) return { action: "lifecycle-missing-id", verb };
74
93
  return { action: verb, runId };
@@ -11,7 +11,7 @@
11
11
  * - /workflow save <name> → 用 workflow-script tool { action: "save" }
12
12
  * - /workflow delete <name> → 用 workflow-script tool { action: "delete" }
13
13
  *
14
- * 层归属:Interface。依赖 Pi SDK + Engine lifecycle(pause/resume/abort,注入 ViewActions)
14
+ * 层归属:Interface。依赖 Pi SDK + Engine lifecycle(abort,注入 ViewActions)
15
15
  * + WorkflowsView(读 WorkflowRun 聚合根)。
16
16
  *
17
17
  * 参考:
@@ -22,7 +22,7 @@
22
22
  import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
23
23
 
24
24
  import type { LauncherDeps } from "../orchestration/launcher.ts";
25
- import { abortRun, pauseRun, resumeRun } from "../orchestration/lifecycle.ts";
25
+ import { abortRun } from "../orchestration/lifecycle.ts";
26
26
  import type { WorkflowRun } from "../orchestration/models/workflow-run.ts";
27
27
  import { parseWorkflowRpcCommand } from "./command-actions.ts";
28
28
  import { createWorkflowsView, type ViewActions } from "./views/WorkflowsView.ts";
@@ -30,10 +30,9 @@ import { createWorkflowsView, type ViewActions } from "./views/WorkflowsView.ts"
30
30
  /** runId 截断长度(显示用)。 */
31
31
  const RUNID_SHORT = 8;
32
32
 
33
- /** status 显示顺序:running/paused 优先(活跃态在前),再 startedAt 倒序。 */
33
+ /** status 显示顺序:running 优先(活跃态在前),再 startedAt 倒序。 */
34
34
  const STATUS_ORDER: Record<string, number> = {
35
35
  running: 0,
36
- paused: 1,
37
36
  done: 2,
38
37
  };
39
38
  /** 未知 status 的默认排序权重(排在已知 status 之后)。 */
@@ -52,12 +51,12 @@ const UNKNOWN_STATUS_WEIGHT = 9;
52
51
  * · 1 run → 直接打开
53
52
  * · 多 runs → select 选 → 打开选中 run 的 view
54
53
  *
55
- * ViewActions(pause/resume/abort)由本 command 注入——view 本身不持 lifecycle 依赖,
54
+ * ViewActions(abort)由本 command 注入——view 本身不持 lifecycle 依赖,
56
55
  * 只通过 actions 回调与 engine 交互(解耦,便于 view 单测)。
57
56
  *
58
57
  * @param api ExtensionAPI
59
58
  * @param getRuns 获取当前 session 的 runs(Map<runId, WorkflowRun>)
60
- * @param deps LauncherDeps(lifecycle pause/resume/abort 用)
59
+ * @param deps LauncherDeps(lifecycle abort 用)
61
60
  */
62
61
  export function registerWorkflowsCommand(
63
62
  api: ExtensionAPI,
@@ -65,7 +64,7 @@ export function registerWorkflowsCommand(
65
64
  deps: LauncherDeps,
66
65
  ): void {
67
66
  api.registerCommand("workflows", {
68
- description: "Open workflow panel. /workflows [runId] | /workflows pause|resume|abort <runId>",
67
+ description: "Open workflow panel. /workflows [runId] | /workflows abort <runId>",
69
68
  getArgumentCompletions(prefix: string) {
70
69
  const trimmed = prefix.trimStart();
71
70
  const parts = trimmed.split(/\s+/).filter(Boolean);
@@ -73,14 +72,12 @@ export function registerWorkflowsCommand(
73
72
  // 第一级:lifecycle 动词(带尾随空格,选中后继续补 runId)
74
73
  if (parts.length <= 1) {
75
74
  return [
76
- { label: "pause", value: "pause ", description: "Pause a workflow run" },
77
- { label: "resume", value: "resume ", description: "Resume a paused workflow run" },
78
75
  { label: "abort", value: "abort ", description: "Abort a workflow run" },
79
76
  ].filter((opt) => opt.label.startsWith(trimmed.toLowerCase()));
80
77
  }
81
78
 
82
79
  // 第二级:lifecycle 动词后补全当前 session 的 runId
83
- if (parts[0] === "pause" || parts[0] === "resume" || parts[0] === "abort") {
80
+ if (parts[0] === "abort") {
84
81
  try {
85
82
  const runs = sortedRuns(getRuns());
86
83
  if (runs.length === 0) return null;
@@ -102,21 +99,25 @@ export function registerWorkflowsCommand(
102
99
  if (ctx.mode === "rpc") {
103
100
  const parsed = parseWorkflowRpcCommand(args);
104
101
  switch (parsed.action) {
105
- case "pause":
106
- case "resume":
107
102
  case "abort": {
108
103
  try {
109
- if (parsed.action === "pause") await pauseRun(parsed.runId, deps);
110
- else if (parsed.action === "resume") await resumeRun(parsed.runId, deps);
111
- else await abortRun(parsed.runId, deps);
112
- const pastTense = parsed.action === "abort" ? "aborted" : `${parsed.action}d`;
113
- ctx.ui.notify(`Workflow ${parsed.runId}: ${pastTense}`, "info");
104
+ await abortRun(parsed.runId, deps);
105
+ // abort 单态后 pastTense 固定(原三态拼接随 pause/resume 删除)
106
+ ctx.ui.notify(`Workflow ${parsed.runId}: aborted`, "info");
114
107
  } catch (err) {
115
108
  const msg = err instanceof Error ? err.message : String(err);
116
- ctx.ui.notify(`Failed to ${parsed.action} workflow ${parsed.runId}: ${msg}`, "warning");
109
+ ctx.ui.notify(`Failed to abort workflow ${parsed.runId}: ${msg}`, "warning");
117
110
  }
118
111
  return;
119
112
  }
113
+ case "lifecycle-removed":
114
+ // 已移除的 lifecycle verb(pause/resume):run 一次性生命周期后不可挂起,
115
+ // 给定制指引而非 Usage(F3 定稿——提示语义优先于 missing-id)
116
+ ctx.ui.notify(
117
+ `Workflow ${parsed.verb} has been removed — runs are one-shot. To stop a run early: /workflows abort <runId>`,
118
+ "warning",
119
+ );
120
+ return;
120
121
  case "lifecycle-missing-id":
121
122
  ctx.ui.notify(`Usage: /workflows ${parsed.verb} <runId>`, "warning");
122
123
  return;
@@ -187,7 +188,7 @@ export function registerWorkflowsCommand(
187
188
  // ── Helpers ──────────────────────────────────────────────────
188
189
 
189
190
  /**
190
- * 取 runs 按 status(running/paused 优先)+ startedAt 倒序排序。
191
+ * 取 runs 按 status(running 优先)+ startedAt 倒序排序。
191
192
  * 复用 main 的 UX 顺序(活跃态在前,新的在前)。
192
193
  */
193
194
  function sortedRuns(runs: Map<string, WorkflowRun>): WorkflowRun[] {
@@ -205,7 +206,7 @@ function sortedRuns(runs: Map<string, WorkflowRun>): WorkflowRun[] {
205
206
  /**
206
207
  * 打开 WorkflowsView(三级导航 TUI),注入 lifecycle ViewActions。
207
208
  *
208
- * ViewActions 通过 deps 调 lifecycle(pause/resume/abort),与 view 解耦——
209
+ * ViewActions 通过 deps 调 lifecycle(abort),与 view 解耦——
209
210
  * view 单测可注入 mock actions(见 workflows-view.test.ts)。
210
211
  */
211
212
  async function openView(
@@ -215,8 +216,6 @@ async function openView(
215
216
  deps: LauncherDeps,
216
217
  ): Promise<void> {
217
218
  const actions: ViewActions = {
218
- pause: (runId: string) => pauseRun(runId, deps),
219
- resume: (runId: string) => resumeRun(runId, deps),
220
219
  abort: (runId: string) => abortRun(runId, deps),
221
220
  };
222
221
  await createWorkflowsView(run, theme, ctx, actions, deps.store.stateFilePath(run.runId));
@@ -174,17 +174,9 @@ export function statusGlyph(status: ExecutionStatus): { icon: string | undefined
174
174
  switch (status) {
175
175
  case "running":
176
176
  return { icon: undefined, color: "accent" };
177
- case "done":
177
+ case "closed":
178
+ // v4 B-1: closed 统一终态(含 cancelled)。默认 ✓ success 色。
178
179
  return { icon: "✓", color: "success" };
179
- case "failed":
180
- return { icon: "✗", color: "error" };
181
- case "cancelled":
182
- return { icon: "■", color: "muted" };
183
- case "crashed":
184
- return { icon: "✝", color: "error" };
185
- default:
186
- // 防御:运行时 status 可能是意外值(SDK 投影异常/未来新增状态),兜底为 running 语义
187
- return { icon: undefined, color: "accent" };
188
180
  }
189
181
  }
190
182
 
@@ -235,7 +227,7 @@ export function firstLine(text?: string): string {
235
227
  /**
236
228
  * 从 renderCall/execute 的 unknown args 安全提取 agent 名.
237
229
  * 类型守卫窄化(替代 `as { agent?: string }` 全可选断言).
238
- * 无 agent 字段或非空字符串时兌底 DEFAULT_AGENT_NAME(与 service 层 resolveIdentity 一致,
230
+ * 无 agent 字段或非空字符串时兜底 DEFAULT_AGENT_NAME(与 service 层 resolveIdentity 一致,
239
231
  * 保证 block 标题显示的名与实际加载的 agent.md 相符).
240
232
  */
241
233
  export function extractAgentName(args: unknown): string {
@@ -390,6 +382,20 @@ export function formatDisplayItem(item: DisplayItem, theme: ThemeLike): string {
390
382
  // ANSI 安全截断
391
383
  // ============================================================
392
384
 
385
+ /**
386
+ * SGR 序列 sticky regex(truncLine 专用)。
387
+ *
388
+ * lastIndex 置位后 exec/test 只在该位置匹配(等价 `flat.slice(pos).match(/^\x1b\[[0-9;]*m/)`,
389
+ * 但零子串分配)。同步代码内「设 lastIndex → 立即用」,无跨调用残留。
390
+ */
391
+ const SGR_STICKY_RE = /\x1b\[[0-9;]*m/y;
392
+
393
+ /** 判断 s 在 pos 处是否开始一个 SGR 序列(非 SGR ESC——OSC/非 SGR CSI/裸 ESC——返回 false)。 */
394
+ function isSgrStart(s: string, pos: number): boolean {
395
+ SGR_STICKY_RE.lastIndex = pos;
396
+ return SGR_STICKY_RE.test(s);
397
+ }
398
+
393
399
  /**
394
400
  * 截断文本到 maxWidth 可见宽度(带省略号 `…`,ANSI 安全).
395
401
  *
@@ -400,6 +406,16 @@ export function formatDisplayItem(item: DisplayItem, theme: ThemeLike): string {
400
406
  * 截断时 `result + activeStyles.join("") + "…"`——重应用 active 样式,背景不断裂.
401
407
  * 用 Intl.Segmenter grapheme 切分,正确处理 emoji/CJK/组合字符.
402
408
  *
409
+ * 性能(IF12/#18):旧实现逐字符 `flat.slice(end).match(...)` 是 O(n²)(每字符
410
+ * 一次子串分配 + 正则扫描);现改为 `indexOf("\x1b", ...)` 跳到下一 ESC,仅在 ESC
411
+ * 位置做一次 sticky 判定——纯文本段零分配零正则。
412
+ *
413
+ * **非 SGR ESC 行为(等价关键,逐字节等价的定义点)**:OSC(\x1b]0;..\x07)、
414
+ * 非 SGR CSI(\x1b[K)、裸 \x1b 后跟非 [ 字符——均不构成文本段边界,作为纯文本
415
+ * 并入 textPortion 被 segmenter 消费(占 currentWidth、进 result),与旧实现
416
+ * 「while 停点集合 = SGR 序列起点」完全一致。输出由 __fixtures__/truncline.snapshot.json
417
+ * (改造前实现生成)逐字节锚定。
418
+ *
403
419
  * 移植自 pi-subagents render.ts:44-89.
404
420
  */
405
421
  export function truncLine(text: string, maxWidth: number): string {
@@ -417,8 +433,9 @@ export function truncLine(text: string, maxWidth: number): string {
417
433
  let i = 0;
418
434
 
419
435
  while (i < flat.length) {
420
- // 捕获 ANSI SGR 序列
421
- const ansiMatch = flat.slice(i).match(/^\x1b\[[0-9;]*m/);
436
+ // 捕获 ANSI SGR 序列(sticky:只在 i 处匹配,等价旧 slice(i).match(/^.../m))
437
+ SGR_STICKY_RE.lastIndex = i;
438
+ const ansiMatch = SGR_STICKY_RE.exec(flat);
422
439
  if (ansiMatch) {
423
440
  const code = ansiMatch[0];
424
441
  result += code;
@@ -432,10 +449,20 @@ export function truncLine(text: string, maxWidth: number): string {
432
449
  continue;
433
450
  }
434
451
 
435
- // 找到下一段纯文本(非 ANSI)的边界
436
- let end = i;
437
- while (end < flat.length && !flat.slice(end).match(/^\x1b\[[0-9;]*m/)) {
438
- end++;
452
+ // 找到下一段纯文本(非 ANSI)的边界:下一处 SGR 序列起点(或串尾)。
453
+ // indexOf 跳到 ESC 候选位,仅对 ESC 位置做一次 sticky 判定;非 SGR ESC
454
+ // (OSC/\x1b[K/裸 \x1b)不构成边界、并入文本段(end 推进到该 ESC 之后继续找),
455
+ // 与旧逐字符 while 的停点集合逐字节一致。
456
+ let end = flat.length;
457
+ for (
458
+ let escPos = flat.indexOf("\x1b", i);
459
+ escPos !== -1;
460
+ escPos = flat.indexOf("\x1b", escPos + 1)
461
+ ) {
462
+ if (isSgrStart(flat, escPos)) {
463
+ end = escPos;
464
+ break;
465
+ }
439
466
  }
440
467
 
441
468
  // 按 grapheme 迭代这段文本,累加到 targetWidth
@@ -31,17 +31,17 @@ type TreeStatus = NonNullable<TreeItem["status"]>;
31
31
  /**
32
32
  * 把 workflow/subagent 状态字符串映射到 list-tree 的三态 status。
33
33
  *
34
- * 输入可能是纯 RunStatus(running/paused/done)、RunStatus+reason 组合
34
+ * 输入可能是纯 RunStatus(running/done)、RunStatus+reason 组合
35
35
  * (如 "done (failed)"),或 subagent status(running/done/failed/cancelled/crashed)。
36
36
  *
37
37
  * 映射规则:
38
- * - running(含 paused,paused 可恢复,语义近 running)→ running
38
+ * - running running
39
39
  * - failed / aborted / error / crashed / cancelled / budget_limited / time_limited → failed
40
40
  * - 其他(done / completed / success / pending)→ done
41
41
  */
42
42
  export function mapRunStatus(status: string): TreeStatus {
43
43
  const s = status.toLowerCase();
44
- if (s.includes("running") || s.includes("paused")) return "running";
44
+ if (s.includes("running")) return "running";
45
45
  if (
46
46
  s.includes("failed") ||
47
47
  s.includes("abort") ||
@@ -59,14 +59,12 @@ export function mapRunStatus(status: string): TreeStatus {
59
59
  /**
60
60
  * 把状态字符串映射到 TreeItem.icon。
61
61
  *
62
- * running → circle(进行中)
63
- * paused pause(暂停可恢复)
64
- * failed/abort/cancel/crash cross
65
- * 其他(done) → check
62
+ * running → circle(进行中)
63
+ * failed/abort/cancel/crash cross
64
+ * 其他(done) check
66
65
  */
67
66
  export function mapRunIcon(status: string): TreeItemIcon {
68
67
  const s = status.toLowerCase();
69
- if (s.includes("paused")) return "pause";
70
68
  if (s.includes("running")) return "circle";
71
69
  if (
72
70
  s.includes("failed") ||
@@ -25,6 +25,139 @@ import { mapRunIcon, mapRunStatus } from "./gui-mappers.ts";
25
25
  const JSON_INDENT = 2;
26
26
  const MAX_RESULT_LENGTH = 8000;
27
27
 
28
+ // ── bounded pretty 序列化(IF13/#19,TC5/ES5,helpers 私有)─────────
29
+
30
+ /**
31
+ * bounded JSON pretty 序列化:只生成会被保留的前缀(≤8000 输出与
32
+ * JSON.stringify(value, null, JSON_INDENT) 逐字节一致;>8000 输出与
33
+ * 全量序列化后 .slice(0, budget) + "\n... (truncated)" 逐字节一致——
34
+ * 等价测试锚定,见 __tests__/helpers-bounded-serialize.test.ts)。
35
+ *
36
+ * 现状成本:全量 stringify 产生数 MB 中间串再丢弃 99%;本函数在输出字符流
37
+ * 越过 budget 后停止一切生成。保真策略:原语(string/number/boolean/null/bigint)
38
+ * 逐值复用 JSON.stringify(转义/Unicode/数字格式原生一致,零重实现),仅结构
39
+ * 拼装(2 空格缩进/逗号/括号)自实现。
40
+ *
41
+ * 特殊值语义(TC5 a-d,与原生 JSON.stringify 全值域对齐):
42
+ * (a) 含 toJSON 的对象 → 该子树整体走一次 JSON.stringify(subtree)(原生会先调
43
+ * toJSON,如 Date 输出带引号序列化串;bounded 拼装展开会与原生不同)。注意
44
+ * 边界:toJSON 返回对象时原生 pretty 会按缩进展开,本实现按原语串接其
45
+ * compact 形态——设计 TC5 锚定面为 Date 型(返回字符串)toJSON。
46
+ * (b) 任何 stringify 抛出(BigInt → TypeError)→ 整体回退 String(value)(对齐
47
+ * 旧实现整体 try/catch 的整串回退;禁止逐节点回退——输出形态与整串回退不同)。
48
+ * (c) 对象属性值为 undefined/function/symbol → 拼装时跳过(原生省略)。
49
+ * (d) 数组元素为 undefined/function/symbol → 序列化为 "null"(原生行为)。
50
+ *
51
+ * 截断边界(输出字符流层级,与对完整串 slice 逐字节等价):逐段 append 时本段
52
+ * 会使总长越过 budget → 只 append 本段前 (budget - 已累积) 字符(恰好 budget,
53
+ * 可切在转义序列/括号中间,不补任何结构闭合);越过(exceeded)才追加
54
+ * "\n... (truncated)" 标记——恰好 ===budget 不加(> 判定)。
55
+ *
56
+ * 祖先 Set 循环引用守卫:命中 → 整体回退 String(value)(同 (b) 整串回退语义)。
57
+ */
58
+ function boundedPrettySerialize(value: unknown, budget: number): string {
59
+ const ancestors = new Set<object>();
60
+ let out = "";
61
+ let exceeded = false;
62
+
63
+ /** 逐段追加:越过 budget 时截到恰好 budget 并置 exceeded(后续生成全部剪枝)。 */
64
+ function append(s: string): void {
65
+ if (exceeded) return;
66
+ if (out.length + s.length <= budget) {
67
+ out += s;
68
+ return;
69
+ }
70
+ out += s.slice(0, budget - out.length);
71
+ exceeded = true;
72
+ }
73
+
74
+ function serialize(v: unknown, depth: number): void {
75
+ if (exceeded) return;
76
+ const t = typeof v;
77
+ // 原语逐值复用原生序列化;bigint 的 JSON.stringify 抛 TypeError → 顶层 catch
78
+ // 整体回退 (b);NaN/Infinity → "null"(原生行为)
79
+ if (v === null || t === "string" || t === "number" || t === "boolean" || t === "bigint") {
80
+ append(JSON.stringify(v));
81
+ return;
82
+ }
83
+ if (t === "object" && v !== null) {
84
+ const obj = v as object;
85
+ if (ancestors.has(obj)) {
86
+ // 循环引用 → 整体回退 String(value)(旧实现同款整串回退)
87
+ throw new TypeError("circular reference");
88
+ }
89
+ // (a) toJSON 子树整体 stringify(Reflect.get 取属性,避免不安全断言)
90
+ if (typeof Reflect.get(obj, "toJSON") === "function") {
91
+ append(JSON.stringify(obj));
92
+ return;
93
+ }
94
+ ancestors.add(obj);
95
+ const childIndent = " ".repeat(JSON_INDENT * (depth + 1));
96
+ const closeIndent = " ".repeat(JSON_INDENT * depth);
97
+ if (Array.isArray(obj)) {
98
+ const arr = obj as unknown[];
99
+ if (arr.length === 0) {
100
+ append("[]");
101
+ } else {
102
+ append("[\n");
103
+ for (let i = 0; i < arr.length && !exceeded; i++) {
104
+ if (i > 0) append(",\n");
105
+ append(childIndent);
106
+ const el = arr[i];
107
+ // (d) 数组元素 undefined/function/symbol → "null"
108
+ if (el === undefined || typeof el === "function" || typeof el === "symbol") {
109
+ append("null");
110
+ } else {
111
+ serialize(el, depth + 1);
112
+ }
113
+ }
114
+ append("\n" + closeIndent + "]");
115
+ }
116
+ } else {
117
+ // (c) 对象属性 undefined/function/symbol 跳过(Object.entries 同原生:
118
+ // 只取 own enumerable,symbol 键天然不出现;getter 求值语义与原生一致)
119
+ const entries = Object.entries(obj).filter(
120
+ ([, val]) => val !== undefined && typeof val !== "function" && typeof val !== "symbol",
121
+ );
122
+ if (entries.length === 0) {
123
+ append("{}");
124
+ } else {
125
+ append("{\n");
126
+ for (let i = 0; i < entries.length && !exceeded; i++) {
127
+ if (i > 0) append(",\n");
128
+ append(childIndent + JSON.stringify(entries[i][0]) + ": ");
129
+ serialize(entries[i][1], depth + 1);
130
+ }
131
+ append("\n" + closeIndent + "}");
132
+ }
133
+ }
134
+ ancestors.delete(obj);
135
+ return;
136
+ }
137
+ // 顶层 undefined/function/symbol(调用方守卫后不可达;防御兜底对齐原生 "null")
138
+ append(JSON.stringify(v) ?? "null");
139
+ }
140
+
141
+ try {
142
+ serialize(value, 0);
143
+ return exceeded ? out + "\n... (truncated)" : out;
144
+ } catch {
145
+ // (b) 整体回退:String(value)(超 budget 仍截断 + 标记,与旧实现截断路径一致)
146
+ const fallback = String(value);
147
+ return fallback.length > budget
148
+ ? fallback.slice(0, budget) + "\n... (truncated)"
149
+ : fallback;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * notifiedRunIds 去重窗口大小。
155
+ *
156
+ * 最近该数量的已通知 runId 可去重,更旧挤出后不再去重——runId 全局唯一
157
+ * (wf-<Date.now>-<rand>),旧 id 重现概率为零,语义无损。
158
+ */
159
+ export const MAX_NOTIFIED_RUN_IDS = 1000;
160
+
28
161
  /** runId 前 8 字符用于显示(与 buildWorkflowGui 的 label 格式一致)。 */
29
162
  const RUN_ID_DISPLAY_LENGTH = 8;
30
163
 
@@ -88,16 +221,10 @@ export function notifyDone(
88
221
 
89
222
  if (run.state.scriptResult !== undefined && run.state.scriptResult !== null) {
90
223
  // M10: scriptResult 来自 worker 脚本返回值(用户可控),可能含循环引用导致 JSON.stringify 抛 TypeError
91
- let serialized: string;
92
- try {
93
- serialized = JSON.stringify(run.state.scriptResult, null, JSON_INDENT);
94
- } catch {
95
- serialized = String(run.state.scriptResult);
96
- }
97
- const truncated =
98
- serialized.length > MAX_RESULT_LENGTH
99
- ? serialized.slice(0, MAX_RESULT_LENGTH) + "\n... (truncated)"
100
- : serialized;
224
+ // IF13(#19):bounded 序列化(只生成会被保留的前缀;BigInt/循环引用整体回退
225
+ // String(x) 与旧实现整串 catch 同款)——≤8000 与旧全量 pretty 逐字节一致,
226
+ // >8000 .slice(0,8000)+标记 逐字节一致(等价测试锚定)
227
+ const truncated = boundedPrettySerialize(run.state.scriptResult, MAX_RESULT_LENGTH);
101
228
  parts.push("");
102
229
  parts.push("--- Script Result ---");
103
230
  parts.push(truncated);
@@ -151,3 +278,36 @@ export function notifyDone(
151
278
  { triggerTurn: true, deliverAs: "steer" },
152
279
  );
153
280
  }
281
+
282
+ /**
283
+ * 把 runId 纳入 notifiedRunIds 去重窗口,超 cap 时删最旧(契约 W3C2)。
284
+ *
285
+ * 职责拆分:**去重判定**留在 notifyDone 的 has 读(本体零改动),
286
+ * 本函数只持**有界化**职责——返回 void,不引入双重去重判定语义。
287
+ *
288
+ * 语义:
289
+ * - 幂等 add:Set.add 对已存在元素不改变其迭代位置(重复 track 同一 id,
290
+ * 其「最旧」地位不变)。
291
+ * - FIFO 有界:Set 迭代序=插入序,超 cap 时删迭代器首元素=最旧。
292
+ * 被挤出窗口的旧 id 再经 notifyDone 会重新发送(runId 全局唯一,旧 id
293
+ * 重现概率为零,该边界由 W3TC12 单测钉死)。
294
+ *
295
+ * 调用点:index.ts onRunDone 回调内、notifyDone 之后(notifyDone 内部已 add,
296
+ * 此处 track 的 add 是幂等二次添加)。
297
+ *
298
+ * @param notifiedRunIds 去重 Set(调用方持有,scope 到 factory 实例)
299
+ * @param runId run 标识
300
+ * @param cap 窗口大小(默认 MAX_NOTIFIED_RUN_IDS)
301
+ */
302
+ export function trackNotifiedRunId(
303
+ notifiedRunIds: Set<string>,
304
+ runId: string,
305
+ cap: number = MAX_NOTIFIED_RUN_IDS,
306
+ ): void {
307
+ notifiedRunIds.add(runId);
308
+ while (notifiedRunIds.size > cap) {
309
+ const oldest = notifiedRunIds.values().next().value;
310
+ if (oldest === undefined) break;
311
+ notifiedRunIds.delete(oldest);
312
+ }
313
+ }