@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
@@ -15,13 +15,13 @@
15
15
  // 真实注册表文件,仅 mock ./pi-invocation.ts(把 pi 二进制替换为 node -e 脚本)。
16
16
  //
17
17
  // mock 最小化原则:
18
- // - node:child_process 不 mock(真实 spawn / execFileSync git)
18
+ // - node:child_process 不 mock(真实 spawn / spawnSync git)
19
19
  // - node:fs 不 mock(真实目录/文件:worktree checkout、注册表 JSON)
20
20
  // - alive-store 不 mock(真实 process.kill(pid, 0) 探活)
21
21
  // - 仅 vi.mock("./pi-invocation.ts"):getPiInvocation 返回 node -e 脚本
22
22
  // - fake timers 仅 toFake: ["Date"]:推进注册表宽限判定用,不干扰真实 I/O 事件
23
23
 
24
- import { execFileSync } from "node:child_process";
24
+ import { spawnSync } from "node:child_process";
25
25
  import * as fs from "node:fs";
26
26
  import * as os from "node:os";
27
27
  import * as path from "node:path";
@@ -67,7 +67,8 @@ let handle: WorktreeHandle | undefined;
67
67
  let spawnedPid: number | undefined;
68
68
 
69
69
  function git(args: string[], cwd: string): string {
70
- return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
70
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
71
+ return (r.stdout ?? "").trim();
71
72
  }
72
73
 
73
74
  /** 初始化临时 git repo(至少一个 commit,worktreeManager.create 需要 clean tree + HEAD)。 */
@@ -97,7 +98,7 @@ async function waitForPid(branch: string, timeoutMs = 5000): Promise<number> {
97
98
  }
98
99
 
99
100
  /** 清理:kill 子进程 + worktree cleanup + 删除临时目录。 */
100
- function cleanup(): void {
101
+ async function cleanup(): Promise<void> {
101
102
  if (spawnedPid) {
102
103
  try {
103
104
  process.kill(spawnedPid, "SIGKILL");
@@ -108,7 +109,7 @@ function cleanup(): void {
108
109
  }
109
110
  if (handle) {
110
111
  try {
111
- wtm.cleanup(handle);
112
+ await wtm.cleanup(handle);
112
113
  } catch (err) {
113
114
  // best-effort:git worktree remove 失败不阻断测试清理
114
115
  // eslint-disable-next-line no-console
@@ -131,9 +132,9 @@ beforeEach(() => {
131
132
  registry = new WorktreeRegistry(agentDir);
132
133
  });
133
134
 
134
- afterEach(() => {
135
+ afterEach(async () => {
135
136
  vi.useRealTimers();
136
- cleanup();
137
+ await cleanup();
137
138
  });
138
139
 
139
140
  // ── 用例 ──
@@ -142,7 +143,7 @@ describe("worktree pid 注册链路(真实 spawn 集成)", () => {
142
143
  it("正向:spawn 返回后注册表 pid 同步补全,活 worktree 超宽限不被 scan 误清", async () => {
143
144
  // 0. 长驻脚本(子进程 90s 内不退出,模拟长跑子 agent)
144
145
  scriptHolder.script = LONG_RUNNING_SCRIPT; // 1. 真实创建 worktree(pid=0 占位)
145
- handle = wtm.create(repoDir, "rec-1");
146
+ handle = await wtm.create(repoDir, "rec-1");
146
147
  expect(readEntry(handle.branch)).toMatchObject({ pid: 0 });
147
148
 
148
149
  // 2. runSpawn 挂后台(不 await——长驻子进程 close 不触发,await 会挂死),
@@ -170,7 +171,7 @@ describe("worktree pid 注册链路(真实 spawn 集成)", () => {
170
171
  vi.setSystemTime(Date.now() + SPAWN_GRACE_MS + 1000);
171
172
 
172
173
  // 5. scan():活 worktree 必须不被清(修复前:pid=0 超宽限 → 误删 → 红)
173
- wtm.scan();
174
+ await wtm.scan();
174
175
  expect(fs.existsSync(handle.path)).toBe(true);
175
176
 
176
177
  // 6. 收尾:真实时钟恢复 + kill 子进程让 runPromise settle
@@ -183,7 +184,7 @@ describe("worktree pid 注册链路(真实 spawn 集成)", () => {
183
184
  await runPromise;
184
185
 
185
186
  // 7. 反向:进程死后 scan 回收真孤儿
186
- wtm.scan();
187
+ await wtm.scan();
187
188
  expect(fs.existsSync(handle.path)).toBe(false);
188
189
  const entryAfter = readEntry(handle.branch);
189
190
  expect(entryAfter).toBeUndefined();
@@ -193,7 +194,7 @@ describe("worktree pid 注册链路(真实 spawn 集成)", () => {
193
194
  // 0. 短命脚本(子进程立即退出,模拟快速完成的子 agent)
194
195
  scriptHolder.script = SHORT_LIVED_SCRIPT;
195
196
  // 1. 真实创建 worktree
196
- handle = wtm.create(repoDir, "rec-2");
197
+ handle = await wtm.create(repoDir, "rec-2");
197
198
 
198
199
  // 2. 短命脚本子进程:spawn 后同步补 pid(修复前 pid=0,且未超宽限 → 不回收 → 红)
199
200
  const ctx = makeCtx({
@@ -218,7 +219,7 @@ describe("worktree pid 注册链路(真实 spawn 集成)", () => {
218
219
  spawnedPid = entry!.pid;
219
220
 
220
221
  // 4. scan:pid>0 且进程死 → 立即判孤儿回收(无需等宽限)
221
- wtm.scan();
222
+ await wtm.scan();
222
223
  expect(fs.existsSync(handle.path)).toBe(false);
223
224
  expect(readEntry(handle.branch)).toBeUndefined();
224
225
  }, 15000);
@@ -12,6 +12,7 @@ export interface MirrorFlags {
12
12
  noExtensions: boolean;
13
13
  approve: boolean;
14
14
  extensionPaths: string[];
15
+ noContextFiles: boolean;
15
16
  }
16
17
 
17
18
  /**
@@ -31,7 +32,7 @@ const VALUED_FLAGS = new Set<string>([
31
32
  const ARGV_FLAG_START = 2;
32
33
 
33
34
  /**
34
- * 从主进程 argv 解析可镜像的 flag(--no-extensions/--approve/--extension)。
35
+ * 从主进程 argv 解析可镜像的 flag(--no-extensions/--approve/--extension/--no-context-files)。
35
36
  *
36
37
  * 数据源是主 pi 进程的 process.argv:子进程 spawn 的父就是主进程,
37
38
  * 主进程 argv 完整保留启动时收到的全部 flag(已运行时验证)。这让子进程
@@ -39,14 +40,25 @@ const ARGV_FLAG_START = 2;
39
40
  *
40
41
  * 解析规则:
41
42
  * - --no-extensions / -ne、--approve / -a:布尔 flag
43
+ * - --no-context-files / -nc:布尔 flag(镜像理由:--extension 镜像会把
44
+ * xyz-system-prompt-extension.js 带进每个 subagent,该扩展靠子进程自己的
45
+ * argv 检测此 flag 决定是否注入全局 AGENTS.md——不镜像则用户 opt-out
46
+ * 在 subagent 路径失效)
42
47
  * - --extension / -e:支持 `--extension <path>`(空格)与 `--extension=<path>`(等号)
43
48
  * - 其他 flag(在 VALUED_FLAGS 中)的值不被误当 extension 路径
44
49
  * - positional 参数被忽略
45
50
  * - argv[0]/argv[1] 是 bun/pi binary 路径,从 argv[2:] 开始扫
46
51
  */
52
+ // [perf] 进程内 process.argv 引用恒定(唯一调用点 session-runner 每次 spawn 传同一引用),
53
+ // 解析结果按 argv 引用相等 memo——每次 spawn 免重复扫描/正则。测试传新数组引用不命中。
54
+ let memoArgv: readonly string[] | undefined;
55
+ let memoResult: MirrorFlags | undefined;
56
+
47
57
  export function mirrorMainProcessFlags(argv: readonly string[]): MirrorFlags {
58
+ if (memoResult !== undefined && memoArgv === argv) return memoResult;
48
59
  let hasNoExtensions = false;
49
60
  let hasApprove = false;
61
+ let hasNoContextFiles = false;
50
62
  const extensionPaths: string[] = [];
51
63
 
52
64
  // argv[0]=runtime(bun), argv[1]=pi binary 路径;flag 从 argv[2] 起
@@ -62,6 +74,10 @@ export function mirrorMainProcessFlags(argv: readonly string[]): MirrorFlags {
62
74
  hasApprove = true;
63
75
  continue;
64
76
  }
77
+ if (tok === "--no-context-files" || tok === "-nc") {
78
+ hasNoContextFiles = true;
79
+ continue;
80
+ }
65
81
  // 等号形式 --extension=path / -e=path
66
82
  const eqMatch = /^(--extension|-e)=(.*)$/.exec(tok);
67
83
  if (eqMatch) {
@@ -86,5 +102,8 @@ export function mirrorMainProcessFlags(argv: readonly string[]): MirrorFlags {
86
102
  // 其他情况(未知 flag、--flag=val 形式、positional)忽略
87
103
  }
88
104
 
89
- return { noExtensions: hasNoExtensions, approve: hasApprove, extensionPaths };
105
+ const result = { noExtensions: hasNoExtensions, approve: hasApprove, extensionPaths, noContextFiles: hasNoContextFiles };
106
+ memoArgv = argv;
107
+ memoResult = result;
108
+ return result;
90
109
  }
@@ -20,6 +20,7 @@ import type {
20
20
  AgentResult,
21
21
  AgentUsage,
22
22
  AgentUsageTotal,
23
+ ClosedReason,
23
24
  DisplayItem,
24
25
  ExecutionMode,
25
26
  ExecutionRecord,
@@ -156,6 +157,10 @@ export function createRecord(
156
157
  parentRecordId?: string;
157
158
  /** subagent 递归深度。顶层=0。 */
158
159
  depth?: number;
160
+ /** 对话模式标志(true = 可持续对话,轮次完成进 idle)。默认 undefined/false = 一次性。 */
161
+ chatMode?: boolean;
162
+ /** 空闲超时毫秒数(仅 chatMode 有意义)。覆盖默认 5min。 */
163
+ idleTimeoutMs?: number;
159
164
  controller?: AbortController;
160
165
  },
161
166
  ): ExecutionRecord {
@@ -171,6 +176,8 @@ export function createRecord(
171
176
  rootSessionId: identity.rootSessionId,
172
177
  parentRecordId: identity.parentRecordId,
173
178
  depth: identity.depth ?? 0,
179
+ chatMode: identity.chatMode,
180
+ idleTimeoutMs: identity.idleTimeoutMs,
174
181
 
175
182
  // 状态(实时更新)
176
183
  status: "running",
@@ -180,6 +187,8 @@ export function createRecord(
180
187
  turnCount: 0,
181
188
  totalTokens: 0,
182
189
  lastError: undefined,
190
+ // 对话轮次计数(首轮 = 0,每完成一轮 finalizeRoundToIdle +1)。非 chatMode 不自增。
191
+ round: 0,
183
192
 
184
193
  // 完成(completeRecord 唯一写点)
185
194
  endedAt: undefined,
@@ -233,6 +242,30 @@ function findRunningToolCall(
233
242
  return undefined;
234
243
  }
235
244
 
245
+ /**
246
+ * [perf] running toolCall 倒序索引:tool_start push 位置入索引,tool_end 弹尾定位
247
+ *(尾部 = 最后 push 的同名项,与 findRunningToolCall 倒序全扫的语义等价),把每次
248
+ * tool_end 的 O(所有 turns × toolCalls) 扫描降为 O(1)。WeakMap 按 record 实例隔离
249
+ *(createRecord 新实例从空索引开始,不影响旧实例)。
250
+ * 索引 miss(重建 record 的历史 running toolCall / 外部注入工具无 tool_start)
251
+ * 回退 findRunningToolCall 全扫兜底——正确性不依赖索引完整性。
252
+ */
253
+ const runningToolIndex = new WeakMap<ExecutionRecord, Map<string, Array<{ turn: Turn; idx: number }>>>();
254
+
255
+ function indexToolStart(record: ExecutionRecord, turn: Turn, toolName: string): void {
256
+ let byName = runningToolIndex.get(record);
257
+ if (byName === undefined) {
258
+ byName = new Map();
259
+ runningToolIndex.set(record, byName);
260
+ }
261
+ const arr = byName.get(toolName);
262
+ if (arr === undefined) {
263
+ byName.set(toolName, [{ turn, idx: turn.toolCalls.length - 1 }]);
264
+ } else {
265
+ arr.push({ turn, idx: turn.toolCalls.length - 1 });
266
+ }
267
+ }
268
+
236
269
  /**
237
270
  * 从 AgentEvent 更新 record。所有数据收口进 record.turns[]。
238
271
  * - text/thinking:流式累积进 currentTurn()(完整内容,非切片)
@@ -271,13 +304,30 @@ export function updateFromEvent(record: ExecutionRecord, event: AgentEvent): voi
271
304
  _status: "running",
272
305
  startedTs: Date.now(),
273
306
  };
274
- currentTurn(record).toolCalls.push(tc);
307
+ const turn = currentTurn(record);
308
+ turn.toolCalls.push(tc);
309
+ indexToolStart(record, turn, event.toolName);
275
310
  return;
276
311
  }
277
312
 
278
- // ── tool_end:跨 turn running 同名 toolCall,补全 result/isError/_status ──
313
+ // ── tool_end:索引弹尾 O(1) 定位 running 同名 toolCall,miss 回退全扫兜底 ──
279
314
  case "tool_end": {
280
- const matched = findRunningToolCall(record, event.toolName);
315
+ let matched: readonly [Turn, number] | undefined;
316
+ const byName = runningToolIndex.get(record);
317
+ const arr = byName?.get(event.toolName);
318
+ if (arr !== undefined && arr.length > 0) {
319
+ const item = arr[arr.length - 1];
320
+ arr.pop();
321
+ const tc = item.turn.toolCalls[item.idx];
322
+ if (tc !== undefined && tc._status === "running") {
323
+ matched = [item.turn, item.idx] as const;
324
+ }
325
+ }
326
+ if (matched === undefined) {
327
+ // 兜底:重建 record 的历史 running toolCall(索引未覆盖)、索引项被外部路径
328
+ // 置非 running 等场景——保持与旧实现一致的跨 turn 倒序全扫。
329
+ matched = findRunningToolCall(record, event.toolName);
330
+ }
281
331
  if (matched !== undefined) {
282
332
  const [turn, i] = matched;
283
333
  const tc = turn.toolCalls[i]!;
@@ -488,6 +538,51 @@ export function getFullText(record: ExecutionRecord): string {
488
538
  .join("\n\n");
489
539
  }
490
540
 
541
+ /**
542
+ * [增量通知] 从 fromTurnIndex 起聚合 turn 文本(getFullText 的增量视图)。
543
+ *
544
+ * 语义:record.turns.slice(Math.max(0, fromTurnIndex)) 的 text 数组
545
+ * filter(text => text.length > 0)(判据与 getFullText 逐字对齐——空白串 " " 按非空
546
+ * 处理,不发散)后 join("\n\n")。
547
+ *
548
+ * 不变式(单测锁死):
549
+ * - fromTurnIndex = 0 与 getFullText(record) 逐字节等价(one-shot/首轮回退零成本)
550
+ * - fromTurnIndex >= turns.length 返回 ""(不抛错、不回绕)
551
+ * - fromTurnIndex < 0 规范化为 0(防御,Math.max 不产生 slice 负偏移语义)
552
+ *
553
+ * 纯函数只读;getFullText 本体与既有调用方(collectResult 等)零改动。
554
+ * 唯一调用点 onRoundSettled 的增量派生(record.roundBaseTurnIndex ?? 0)。
555
+ */
556
+ export function getFullTextFrom(record: ExecutionRecord, fromTurnIndex: number): string {
557
+ return record.turns
558
+ .slice(Math.max(0, fromTurnIndex))
559
+ .map((t) => t.text)
560
+ .filter((text) => text.length > 0)
561
+ .join("\n\n");
562
+ }
563
+
564
+ /**
565
+ * [增量通知] 轮次边界推进公式(D1):返回下一轮增量的 turns[] 起始下标。
566
+ *
567
+ * record.turns.length - (末 turn 存在且 !closed 且 text.length === 0 ? 1 : 0)
568
+ *
569
+ * - 末 turn 已闭合(pi 现序常态:带 usage 的 message_end 恒先于 turn_end,settle 时
570
+ * 全闭合)→ 返回 length,下一轮从新 turn 起。
571
+ * - 末 turn 是滞后 message_end 开出的空 turn(防御分支,pi 现序不可达)→ 不计入边界
572
+ * (-1),留在下一轮增量内:新轮首个 text_delta 经 currentTurn 复用该空 turn,复用
573
+ * 累积被 slice(from) 覆盖;若直用 turns.length 会把这段文本挤出 slice 范围静默丢失(D1)。
574
+ * - 末 turn 未闭合且 text 非空(防御形态,pi 现序不可达)→ 计入本轮返回 length(该形态
575
+ * 即 onRoundSettled 推进前观测哨 logger.warn 的触发条件)。
576
+ *
577
+ * 纯函数只读不写 record;唯一调用点 onRoundSettled 第 5 步(notify 之后推进)。
578
+ */
579
+ export function nextRoundBaseTurnIndex(record: ExecutionRecord): number {
580
+ const last = record.turns[record.turns.length - 1];
581
+ const trailingOpenEmpty =
582
+ last !== undefined && !last.closed && last.text.length === 0 ? 1 : 0;
583
+ return record.turns.length - trailingOpenEmpty;
584
+ }
585
+
491
586
  /**
492
587
  * 聚合所有 turn 的 toolCalls(扁平化),并 strip InternalToolCall 的内部字段。
493
588
  * 供 collectResult / schema enforcement 读,替代旧闭包 toolCalls 旁路。
@@ -499,6 +594,17 @@ export function getAllToolCalls(record: ExecutionRecord): ToolCall[] {
499
594
  return record.turns.flatMap((t) => t.toolCalls.map(stripInternal));
500
595
  }
501
596
 
597
+ /**
598
+ * 聚合所有 turn 的 toolCalls 总数(免克隆计数)。
599
+ *
600
+ * getAllToolCalls 会 flatMap + strip 克隆出完整数组——只需计数的调用方(渲染签名
601
+ * 每 200ms tick 调用一次)用它是纯浪费;本函数 reduce 累加各 turn 的 length,零分配。
602
+ * 与 getAllToolCalls(...).length 恒等(同一 turns 源)。
603
+ */
604
+ export function countAllToolCalls(record: ExecutionRecord): number {
605
+ return record.turns.reduce((sum, t) => sum + t.toolCalls.length, 0);
606
+ }
607
+
502
608
  /** 把 InternalToolCall 映射回纯净的 ToolCall(丢弃 _status / startedTs)。 */
503
609
  function stripInternal(tc: InternalToolCall): ToolCall {
504
610
  return {
@@ -540,21 +646,26 @@ export function getTotalUsage(record: ExecutionRecord): AgentUsageTotal | undefi
540
646
  /**
541
647
  * status 状态机的 CAS 互斥锁。仅当 `record.status === "running"` 时改为 target
542
648
  * 并返回 true,否则返回 false。**status 状态机本身就是互斥锁**——终态
543
- * (done/failed/cancelled/crashed)不可逆,check-then-set 在 JS 单线程事件循环里天然原子。
649
+ * (closed/cancelled)不可逆,check-then-set 在 JS 单线程事件循环里天然原子。
544
650
  *
545
651
  * 用途:executor 的收尾竞争。cancelBackground 与 background detached 完成回调
546
652
  * 都调 tryTransition 抢锁:抢到负责完整收尾,没抢到闭嘴不做事。
547
653
  *
548
- * target 仅限正常执行流的三终态。crashed 不作 target——它由重建路径推断
549
- * (alive marker 存在但 session 文件无终态),不走 CAS;重建路径用 markReconstructedStatus
550
- * 直接赋值。收窄 target 类型可在编译期拒绍误用 tryTransition(record, "crashed")。
654
+ * target 仅限正常执行流的两终态(closed + cancelled)。
655
+ * closed 携带 closedReason(L2 原因子枚举),由重建路径或正常执行流写入。
656
+ * 重建路径也可用 markReconstructedStatus 直接赋值(跳过 CAS)。
657
+ *
658
+ * @param closedReason closed 终态的 L2 关闭原因。仅 target="closed" 时有意义;
659
+ * target="cancelled" 时忽略。缺省 "gc"(通用完成/失败)。
551
660
  */
552
661
  export function tryTransition(
553
662
  record: ExecutionRecord,
554
- target: "done" | "failed" | "cancelled",
663
+ target: "closed",
664
+ closedReason?: ClosedReason,
555
665
  ): boolean {
556
666
  if (record.status !== "running") return false;
557
667
  record.status = target;
668
+ record.closedReason = closedReason ?? "gc";
558
669
  return true;
559
670
  }
560
671
 
@@ -577,13 +688,18 @@ export function markReconstructedStatus(
577
688
  * 不修改 turns/totalTokens——已由 updateFromEvent 累积,completeRecord 只读不重置。
578
689
  *
579
690
  * ⚠ 前置条件:调用方必须先通过 tryTransition 抢到锁(status 已被 CAS 设为 target)。
691
+ *
692
+ * @param closedReason closed 终态的 L2 关闭原因。仅 status="closed" 时写入;
693
+ * "cancelled" 时忽略。
580
694
  */
581
695
  export function completeRecord(
582
696
  record: ExecutionRecord,
583
697
  result: AgentResult,
584
- status: "done" | "failed" | "cancelled",
698
+ status: "closed",
699
+ closedReason?: ClosedReason,
585
700
  ): void {
586
701
  record.status = status;
702
+ record.closedReason = closedReason ?? "gc";
587
703
  record.endedAt = Date.now();
588
704
  record.agentResult = result;
589
705
  record.result = result.text;
@@ -663,6 +779,7 @@ export function snapshot(record: ExecutionRecord): RecordSnapshot {
663
779
  task: record.task,
664
780
  slug: record.slug,
665
781
  status: record.status,
782
+ chatMode: record.chatMode,
666
783
  turns: record.turnCount,
667
784
  totalTokens: record.totalTokens,
668
785
  startedAt: record.startedAt,
@@ -27,7 +27,7 @@ import type { ModelConfigService } from "./model-config-service.ts";
27
27
  import { getSubagentSessionDir } from "./path-encoding.ts";
28
28
  import type { RecordStore } from "./record-store.ts";
29
29
  import { writeCancelledTombstone } from "./tombstone-store.ts";
30
- import type { AgentResult, ExecutionRecord } from "./types.ts";
30
+ import type { AgentResult, ClosedReason, ExecutionRecord } from "./types.ts";
31
31
  import type { WorktreeManager } from "./worktree-manager.ts";
32
32
 
33
33
  const logger = getLogger("subagents");
@@ -40,10 +40,10 @@ export interface FinalizeDeps {
40
40
  modelService: ModelConfigService;
41
41
  /** Pi ExtensionAPI(仅用 appendEntry 记录 manifest 写失败事件)。null 在 dispose 后。 */
42
42
  pi: { appendEntry?: (type: string, data: unknown) => void } | null;
43
- /** 清节流状态(防 trailing timer 在 record 归档后误发陈旧 onUpdate)。 */
44
- clearThrottle(id: string): void;
45
43
  /** pending-notifications 终态注销(绑定 pi.events.emit,由调用方闭包提供)。 */
46
44
  emitUnregister(id: string, status: string): void;
45
+ // [review 修复] 已删除 redeliverPending 回调(MF-1 消费确认制补投):pendingMessages
46
+ // 三段消费链随 deliverToRunning 一并移除(无生产调用方,死机制)。
47
47
  }
48
48
 
49
49
  /**
@@ -57,11 +57,9 @@ export async function doFinalizeRecord(
57
57
  deps: FinalizeDeps,
58
58
  record: ExecutionRecord,
59
59
  result: AgentResult,
60
- status: "done" | "failed" | "cancelled",
60
+ status: "closed",
61
+ closedReason?: ClosedReason,
61
62
  ): Promise<void> {
62
- // 终态清节流状态:防 trailing timer 在 record 归档后误发陈旧 onUpdate
63
- deps.clearThrottle(record.id);
64
-
65
63
  // ── Step 0: collectPatch(best-effort)──
66
64
  // [MF#3] patchFile 写到 worktree 之外(sessionsDir/<branch>.patch),避免被 cleanup 删除;
67
65
  // 路径回填 record.patchFile,供调用方(tool result / /subagents list)应用。
@@ -73,7 +71,7 @@ export async function doFinalizeRecord(
73
71
  );
74
72
  fs.mkdirSync(sessionsDir, { recursive: true });
75
73
  const patchFile = path.join(sessionsDir, `${record.worktreeHandle.branch}.patch`);
76
- const patch = deps.worktreeManager.collectPatch(record.worktreeHandle, patchFile);
74
+ const patch = await deps.worktreeManager.collectPatch(record.worktreeHandle, patchFile);
77
75
  if (patch.written) record.patchFile = patchFile;
78
76
  } catch (pe: unknown) {
79
77
  bestEffort(pe, "collectPatch (finalizeRecord Step0)");
@@ -82,7 +80,7 @@ export async function doFinalizeRecord(
82
80
 
83
81
  // ── Step 1: completeRecord(B9: 抛错→后续仍执行)──
84
82
  try {
85
- completeRecord(record, result, status);
83
+ completeRecord(record, result, status, closedReason);
86
84
  } catch (err) {
87
85
  bestEffort(err, "completeRecord (finalizeRecord B9)", "error");
88
86
  }
@@ -100,8 +98,8 @@ export async function doFinalizeRecord(
100
98
  // (否则 worktree 泄漏)。各件独立 try/catch,互不阻断。
101
99
  if (record.sessionFile) {
102
100
  try {
103
- // MF-1 fix: cancelled 状态写 tombstone 而非 finalized,防重建丢失 cancelled
104
- if (status === "cancelled") {
101
+ // MF-1 fix / v4 B-1: cancelled(closedReason='cancelled')写 tombstone 而非 finalized,防重建丢失 cancelled
102
+ if (closedReason === "cancelled") {
105
103
  writeCancelledTombstone(record.sessionFile, {
106
104
  id: record.id,
107
105
  status: "cancelled",
@@ -118,7 +116,7 @@ export async function doFinalizeRecord(
118
116
  }
119
117
  if (record.worktreeHandle) {
120
118
  try {
121
- deps.worktreeManager.cleanup(record.worktreeHandle);
119
+ await deps.worktreeManager.cleanup(record.worktreeHandle);
122
120
  } catch (err) {
123
121
  bestEffort(err, "worktree cleanup (finalizeRecord Step3)");
124
122
  }
@@ -146,7 +144,8 @@ export async function doFinalizeRecord(
146
144
  rootSessionId: record.rootSessionId ?? "",
147
145
  parentRecordId: record.parentRecordId,
148
146
  agentName: record.agent,
149
- status: status === "done" ? "completed" : status,
147
+ // v4 B-1: manifest status 统一为 closed(cancelled 折入 closed,区分靠 tombstone sidecar)
148
+ status: "closed",
150
149
  createdAt: record.startedAt,
151
150
  completedAt: record.endedAt ?? Date.now(),
152
151
  sessionFile: record.sessionFile,
@@ -163,3 +162,81 @@ export async function doFinalizeRecord(
163
162
  });
164
163
  }
165
164
  }
165
+
166
+ /**
167
+ * 对话模式轮次完成收尾:record 进 idle 态(非终态化,等待续聊)。
168
+ *
169
+ * 与 doFinalizeRecord 的关键区别(M2-A idle 语义):
170
+ * - 不调 completeRecord(record 不冻结,保留 turns[] 等运行时状态供续聊累积)
171
+ * - 不调 store.archive(record 留内存,getMutable 可查、list 可见)
172
+ * - 不 cleanup worktree(保留对话模式工作目录)
173
+ * - 不写 manifest(idle 非终态,manifest 是终态诊断辅助)
174
+ * - 删 .alive marker(进程已 SIGTERM 回收,不再是活进程)
175
+ * - emitUnregister(进程已死,从 pending 活跃后代差集移除;record 留内存不 archive)
176
+ *
177
+ * MF-2:设 record.result = result.text(否则 notifier idle 回复正文恒为 "(empty)",
178
+ * G1/G2 多轮回复送达不成立)。失败轮次(result.success=false,MF-6 回退 idle 路径)
179
+ * 的 result.text 可能为空,用 result.error 兜底让 notify 可读。
180
+ *
181
+ * 状态:record.status = "idle"(覆盖 tryTransition 设的 done/failed),record.round += 1。
182
+ * 各步骤 best-effort 互不阻断(参照 doFinalizeRecord 的 bestEffort 用法)。
183
+ *
184
+ * @param deps 与 doFinalizeRecord 同源(从 SubagentService 注入)
185
+ * @param result 本轮 AgentResult(MF-2:result.text 写入 record.result 供 notifier idle 回复)
186
+ */
187
+ export async function doFinalizeRoundToIdle(
188
+ deps: FinalizeDeps,
189
+ record: ExecutionRecord,
190
+ result: AgentResult,
191
+ ): Promise<void> {
192
+ // MF-2:设 record.result 供 notifier idle 回复正文(否则恒 "(empty)",G1/G2 不成立)。
193
+ // MF-6 兜底:失败轮次(success=false)的 result.text 可能为空,用 error 让 notify 可读。
194
+ // [增量 G2] 第三分支 chatMode 条件占位:本写点被 one-shot 成功路径共用(subagent-service
195
+ // runAndFinalize 的 `!record.chatMode && !aborted && result.success` 分支 → finalizeRoundToIdle),
196
+ // 非 chatMode 必须保持 `|| record.result` 现状——one-shot 空文本成功完成(collectResult
197
+ // getFullText 返回 ""、success=true、真实可达)时 result 前值 undefined → notifier
198
+ // buildLlmContent 的 `record.result ?? "(empty)"` 兜底确定性产出 "(empty)",通知逐字节
199
+ // 不变(G4);无条件替换会把文案漂移为占位。chatMode 空增量轮 → 固定占位
200
+ // "(no output this round)"(D5:增量语义下沿用旧 record.result = 上一轮增量,本轮通知
201
+ // 正文 = 上一轮内容,父 agent 误读为原样重复回复)。
202
+ let nextResult: string | undefined;
203
+ if (result.text) {
204
+ nextResult = result.text;
205
+ } else if (result.error) {
206
+ nextResult = `round did not complete: ${result.error}`;
207
+ } else if (record.chatMode) {
208
+ nextResult = "(no output this round)";
209
+ } else {
210
+ nextResult = record.result;
211
+ }
212
+ record.result = nextResult;
213
+
214
+ // 删 .alive marker(进程已 SIGTERM 回收)。
215
+ // sessionFile 窗口期可能 undefined(极少——对话模式轮次完成意味着 session 已跑过),
216
+ // 缺失时跳过但仍设内存 idle(重启后磁盘重建会落到 crashed,边界可接受)。
217
+ if (record.sessionFile) {
218
+ try {
219
+ removeAliveMarker(record.sessionFile);
220
+ } catch (err) {
221
+ bestEffort(err, "removeAliveMarker (doFinalizeRoundToIdle)");
222
+ }
223
+ }
224
+
225
+ // pending-notifications:进程已死,从活跃后代差集移除(record 留内存不 archive,
226
+ // v4 B-1:record 现为 running-resumable,但 pending 注册的是进程活跃性,进程死了需注销)。
227
+ deps.emitUnregister(record.id, "running");
228
+
229
+ // 状态机(v4 B-1):record 保持 running(旧 idle 折入 running,覆盖 tryTransition 设的 closed,
230
+ // 可冷路径 resume),轮次计数 +1。idleSince 时间戳独立保留供 GC 判据。
231
+ // [S10] closedReason 同步清除:调用方(runAndFinalize catch / MF-6 分支)先 tryTransition
232
+ // 设了 closed+closedReason 再回退 running——不清则 "gc"/"cancelled" 残留在 running record 上,
233
+ // 泄漏进 list 投影与后续 notify 载荷(toNotifyRecord 透传 record.closedReason),让一个
234
+ // 活跃 record 看起来像已被某原因关闭过。
235
+ record.status = "running";
236
+ record.closedReason = undefined;
237
+ record.round = (record.round ?? 0) + 1;
238
+ record.idleSince = Date.now();
239
+
240
+ // [review 修复] 已删除残留 pendingMessages 的 redeliverPending 补投段(MF-1 消费
241
+ // 确认安全网):三段消费链随 deliverToRunning 一并移除,本段不可达。
242
+ }
@@ -7,7 +7,7 @@
7
7
  // handler 工厂分流、stdio 选择等消费点统一调用。
8
8
  //
9
9
  // 判定依据见 AGENTS.md「运行时环境区分」章节 +
10
- // docs/pi-tui-development-guide.md 第四部分第 8 节。
10
+ // docs/extensions/pi-tui-development-guide.md 第四部分第 8 节。
11
11
  // ExtensionMode 来自 Pi 源码 packages/coding-agent/src/core/extensions/types.ts:299
12
12
  // (dist 中 core/extensions/types.d.ts:207)。
13
13
  //