@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
@@ -14,6 +14,7 @@ import { visibleWidth } from "@earendil-works/pi-tui";
14
14
  import { computeElapsedSeconds } from "../execution/execution-record.ts";
15
15
  import type { SubagentService } from "../execution/subagent-service.ts";
16
16
  import type { SubagentRecord } from "../execution/types.ts";
17
+ import { displayAgentName } from "../shared/agent-ref.ts";
17
18
  import {
18
19
  firstLine,
19
20
  formatDisplayItem,
@@ -76,6 +77,15 @@ const DETAIL_LEN_PROBE_WIDTH = 9999;
76
77
  const VERT_CENTER_DIVISOR = 2;
77
78
  /** spinner 帧切换粒度(与 Date.now() 配合选帧)。 */
78
79
  const SPINNER_FRAME_MS = 250;
80
+ /**
81
+ * [perf] 同帧 records 共享窗口(swf-perf-impl cleanup TC3/IF3/DM5)。
82
+ * 250ms 动画帧内 hasRunning 与 buildLines(含 detail 路径 children 计算)各自
83
+ * collectRecords(LIST_LIMIT) 全量合并扫描(内存 running + 磁盘 light 重建)——帧内合一。
84
+ * 取值 50:须 < store 事件 debounce 120ms(事件驱动渲染大概率 TTL 已过走新鲜扫描)
85
+ * 且 > 同 tick 内 hasRunning→render 微秒级间隔(命中复用)。最坏滞后 = 渲染合帧场景下
86
+ * 50ms 内旧快照一帧,下一 animTimer tick / 事件渲染自愈,对 250ms 动画无感知差异。
87
+ */
88
+ const FRAME_TTL_MS = 50;
79
89
  /** 顶框嵌入标题(分屏模式)。 */
80
90
  const TITLE_SPLIT = "Subagents";
81
91
  /** 分屏分区线左/右嵌入标题。 */
@@ -85,7 +95,8 @@ const TITLE_RIGHT = "Detail";
85
95
  /**
86
96
  * 全屏带框左右分屏 list 组件。
87
97
  *
88
- * 不缓存行(records 每次 render 都从 service.collectRecords 拉最新——保证 store 变化后刷新)。
98
+ * 不缓存行(render 每次重建;records collectRecordsFrame 帧缓存拉取——TTL 50ms
99
+ * 同帧共享一份,保证 store 变化后刷新由 TTL/事件 debounce 兜底,见 FRAME_TTL_MS)。
89
100
  * 缓存的是「上次 render 的 width×rows」(用于 invalidate 后强制重建)。
90
101
  */
91
102
  export class SubagentsListComponent implements Component {
@@ -94,6 +105,9 @@ export class SubagentsListComponent implements Component {
94
105
  private closeFn: () => void = () => {};
95
106
  /** 动画 timer 句柄(dispose 兜底清理)。 */
96
107
  private animTimer: ReturnType<typeof setInterval> | undefined;
108
+ /** [perf] 帧缓存:TTL 内复用同一份 records(collectRecordsFrame,TC3/IF3/DM5)。 */
109
+ private frameRecords: SubagentRecord[] | undefined;
110
+ private frameAt = 0;
97
111
 
98
112
  constructor(
99
113
  private readonly service: SubagentService,
@@ -117,7 +131,29 @@ export class SubagentsListComponent implements Component {
117
131
 
118
132
  /** 是否有 running record(动画 timer 据此决定是否刷新)。 */
119
133
  hasRunning(): boolean {
120
- return this.service.collectRecords(LIST_LIMIT).some((r) => r.status === "running");
134
+ return this.collectRecordsFrame().some((r) => r.status === "running");
135
+ }
136
+
137
+ /**
138
+ * [perf] 帧缓存读取(TC3/IF3/DM5):TTL(FRAME_TTL_MS=50ms)内复用同一份 records,
139
+ * 过期/未缓存则调 service.collectRecords 刷新 {frameRecords, frameAt}。
140
+ * hasRunning / handleInput / buildLines / buildDetailContent(children) 四个消费点统一走这里。
141
+ * invalidate() 刻意不清帧缓存:invalidate 在 timer 序列(hasRunning→invalidate→requestRender)
142
+ * 的共享路径中间,清了共享即失效;新鲜度由 TTL 独立保证 + animTimer 250ms tick 双重兜底。
143
+ */
144
+ private collectRecordsFrame(): SubagentRecord[] {
145
+ if (this.frameRecords !== undefined && Date.now() - this.frameAt <= FRAME_TTL_MS) {
146
+ return this.frameRecords;
147
+ }
148
+ const records = this.service.collectRecords(LIST_LIMIT);
149
+ this.frameRecords = records;
150
+ this.frameAt = Date.now();
151
+ return records;
152
+ }
153
+
154
+ /** [perf] 选中项详情数据:优先全量(懒加载 + per-file 缓存),拿不到回退 light。 */
155
+ private fullRecordOf(r: SubagentRecord): SubagentRecord {
156
+ return this.service.getFullRecord(r.id) ?? r;
121
157
  }
122
158
 
123
159
  invalidate(): void {
@@ -138,16 +174,17 @@ export class SubagentsListComponent implements Component {
138
174
  handleInput(data: string): void {
139
175
  if (this.state.disposed) return;
140
176
 
141
- const records = applyFilter(this.service.collectRecords(LIST_LIMIT), this.state.filterText);
177
+ const records = applyFilter(this.collectRecordsFrame(), this.state.filterText);
142
178
  const selected = records[this.state.selectedIdx] ?? null;
143
179
  // 详情翻屏上下文:视口高 = 右侧 body 高(内框高 - SPLIT_FIXED_LINES),
144
180
  // contentLines = 详情内容总行数(含元数据/段头/eventLog/result/error,单一数据源)。
145
181
  // 与 renderRightDetail 的 viewH + max 计算保持一致。
182
+ // [perf] 详情长度需重数据:选中项懒加载全量(带缓存)。
146
183
  const innerRows = Math.max(MIN_INNER_ROWS, this.termRows() - PAD_ROWS);
147
184
  const bodyH = Math.max(1, innerRows - SPLIT_FIXED_LINES);
148
185
  const detailCtx: DetailKeyContext = {
149
186
  viewportHeight: bodyH,
150
- contentLines: selected ? this.detailContentLength(selected) : 0,
187
+ contentLines: selected ? this.detailContentLength(this.fullRecordOf(selected)) : 0,
151
188
  };
152
189
 
153
190
  const result = this.keyHandler(data, records, this.state, selected, this.service, detailCtx, this.notify);
@@ -192,7 +229,7 @@ export class SubagentsListComponent implements Component {
192
229
  const innerWidth = Math.max(MIN_INNER_WIDTH, width - PAD_COLS);
193
230
  const innerRows = Math.max(MIN_INNER_ROWS, rows - PAD_ROWS);
194
231
 
195
- const allRecords = this.service.collectRecords(LIST_LIMIT);
232
+ const allRecords = this.collectRecordsFrame();
196
233
  const records = applyFilter(allRecords, this.state.filterText);
197
234
 
198
235
  // 先在内框尺寸下生成框行
@@ -302,9 +339,12 @@ export class SubagentsListComponent implements Component {
302
339
 
303
340
  const selected = records[this.state.selectedIdx] ?? null;
304
341
  const inDetail = this.state.detailMode; // 阶段 2:右侧滚动焦点
342
+ // [perf] 预览/详情需重数据(displayItems/turns/tokens/result):选中项按需全量加载
343
+ // (per-file 缓存,stat 校验);列表其余行保持 light(identity+状态),列表扫描不开销全量解析。
344
+ const selectedFull = selected ? this.fullRecordOf(selected) : null;
305
345
  // 预构建详情内容(inDetail 时):分区线标题(detailScrollInfo 算长度)与右列(renderRightDetail 渲染)
306
346
  // 共用同一份,避免每帧双倍构建(animTimer 250ms 触发,长 eventLog 下有感)。
307
- const detailContent = inDetail && selected ? this.buildDetailContent(selected, rightWidth) : null;
347
+ const detailContent = inDetail && selectedFull ? this.buildDetailContent(selectedFull, rightWidth) : null;
308
348
 
309
349
  const lines: string[] = [];
310
350
 
@@ -313,7 +353,7 @@ export class SubagentsListComponent implements Component {
313
353
 
314
354
  // filter 行(阶段 2 时隐藏 filter 提示,显示锚定提示)
315
355
  const filterLine = inDetail
316
- ? t.fg("dim", `Pinned: ${selected?.agent ?? ""} · Esc to return to list`)
356
+ ? t.fg("dim", `Pinned: ${selected?.agent ? displayAgentName(selected.agent) : ""} · Esc to return to list`)
317
357
  : (this.state.filterText
318
358
  ? `${t.fg("dim", "filter: ")}${t.bold(this.state.filterText)}${t.fg("accent", "_")}`
319
359
  : `${t.fg("dim", "filter: ")}${t.fg("accent", "_")}`);
@@ -322,7 +362,7 @@ export class SubagentsListComponent implements Component {
322
362
  // 分区线(嵌入左/右标题,分段着色)
323
363
  const leftTitleStyled = t.fg("accent", t.bold(` ${TITLE_LEFT} `));
324
364
  const rightTitleStyled = inDetail
325
- ? t.fg("accent", t.bold(` ${TITLE_RIGHT}${this.detailScrollInfo(selected, bodyH, detailContent?.length)} `))
365
+ ? t.fg("accent", t.bold(` ${TITLE_RIGHT}${this.detailScrollInfo(selectedFull, bodyH, detailContent?.length)} `))
326
366
  : t.fg("accent", t.bold(` ${TITLE_RIGHT} `));
327
367
  lines.push(
328
368
  this.b("├") + segFillColored(leftTitleStyled, this.dash(), leftWidth)
@@ -346,8 +386,8 @@ export class SubagentsListComponent implements Component {
346
386
  ));
347
387
  leftLines = this.renderLeftColumn(records, leftWidth, leftStart, bodyH);
348
388
  rightLines = inDetail
349
- ? this.renderRightDetail(selected, rightWidth, bodyH, detailContent)
350
- : this.renderRightPreview(selected, rightWidth, bodyH);
389
+ ? this.renderRightDetail(selectedFull, rightWidth, bodyH, detailContent)
390
+ : this.renderRightPreview(selectedFull, rightWidth, bodyH);
351
391
  }
352
392
  const bodyRows = Math.max(leftLines.length, rightLines.length, bodyH);
353
393
  for (let i = 0; i < bodyRows; i++) {
@@ -421,7 +461,7 @@ export class SubagentsListComponent implements Component {
421
461
  const depthTag = r.depth > 0 ? ` ${t.fg("dim", `[L${r.depth + 1}]`)}` : "";
422
462
  // slug 非空时在 agent 后展示(accent 色),空串时省略。
423
463
  const slugTag = r.slug ? ` ${t.fg("accent", r.slug)}` : "";
424
- const label = `${iconStr} ${sid}${depthTag} ${r.agent}${slugTag} ${t.fg("dim", modeTag)} ${t.fg("dim", dur)}`;
464
+ const label = `${iconStr} ${sid}${depthTag} ${displayAgentName(r.agent)}${slugTag} ${t.fg("dim", modeTag)} ${t.fg("dim", dur)}`;
425
465
  // 阶段 2:锚定行 accent + ▶;其余行 dim。阶段 1:选中 accent + →,其余正常。
426
466
  const content = inDetail
427
467
  ? (selected ? t.fg("accent", label) : t.fg("dim", label))
@@ -449,7 +489,7 @@ export class SubagentsListComponent implements Component {
449
489
  lines.push("");
450
490
  }
451
491
  }
452
- lines.push(truncLine(`${t.bold(record.agent)} ${t.fg("dim", `· ${record.model}`)}`, width));
492
+ lines.push(truncLine(`${t.bold(displayAgentName(record.agent))} ${t.fg("dim", `· ${record.model}`)}`, width));
453
493
  lines.push(truncLine(
454
494
  t.fg("dim", `${record.status} · ${record.turns} turns · ${formatTokens(record.totalTokens)} · ${formatElapsedSeconds(elapsedSec(record))}`),
455
495
  width,
@@ -550,8 +590,7 @@ export class SubagentsListComponent implements Component {
550
590
  // parent 不需外部数据;children 需查同 session 的 record(collectRecords 有磁盘缓存,开销可接受)。
551
591
  const parentLabel = record.parentRecordId ? shortId(record.parentRecordId) : "(root)";
552
592
  content.push(truncLine(t.fg("dim", `parent: ${parentLabel}`), width));
553
- const childIds = this.service
554
- .collectRecords(LIST_LIMIT)
593
+ const childIds = this.collectRecordsFrame()
555
594
  .filter((r) => r.parentRecordId === record.id)
556
595
  .map((r) => shortId(r.id));
557
596
  content.push(truncLine(
@@ -18,12 +18,19 @@ import {
18
18
 
19
19
  import { SLUG_MAX_LENGTH } from "../execution/execute-options-mapper.ts";
20
20
  import { computeElapsedSeconds } from "../execution/execution-record.ts";
21
+ import { isResumable } from "../execution/lifecycle-predicates.ts";
22
+ import type { ExecutionRecord } from "../execution/types.ts";
21
23
  import type { ModelInfo } from "../execution/model-resolver.ts";
22
24
  import type { SubagentService } from "../execution/subagent-service.ts";
25
+ import { displayAgentName } from "../shared/agent-ref.ts";
23
26
  import type {
24
27
  BgResponse,
25
28
  CancelResponse,
29
+ CloseResponse,
30
+ ExecutionStatus,
31
+ ExternalState,
26
32
  ListResponse,
33
+ MessageResponse,
27
34
  SubagentListItem,
28
35
  SubagentRecord,
29
36
  SubagentToolResult,
@@ -70,6 +77,10 @@ export interface StartHandlerInput {
70
77
  worktree?: boolean;
71
78
  /** 覆盖子 agent 工作目录(默认 mainCwd)。 */
72
79
  cwd?: string;
80
+ /** 可持续对话模式(true = chatMode,轮次完成进 idle 等续聊)。 */
81
+ conversation?: boolean;
82
+ /** 空闲超时毫秒数(仅 conversation 模式有意义,覆盖默认 5min)。 */
83
+ idleTimeoutMs?: number;
73
84
  }
74
85
 
75
86
  /** start 领域对象(adapter 包成 bgResponse)。 */
@@ -114,18 +125,53 @@ export interface CancelHandlerResult {
114
125
  * 不新增 sessionId 到 ExecutionRecord(YAGNI,修跨 session 清理是独立问题)。
115
126
  */
116
127
 
117
- /** SubagentRecord SubagentListItem(8 字段,duration 实时计算)。 */
118
- function recordToListItem(r: SubagentRecord): SubagentListItem {
128
+ /** exhaustiveness 兜底:default 分支把 status 收敛为 never,ExecutionStatus 加态时 tsc 报错。 */
129
+ function assertNever(value: never): string {
130
+ return String(value);
131
+ }
132
+
133
+ /**
134
+ * 内部 ExecutionStatus → 对外 state 映射(设计决策 10 细则 3)。
135
+ * v4 B-1 两态收敛后的真实映射只有两条:
136
+ * running → active / closed → ended(closed 统一终态,含 cancelled)
137
+ * ExternalState 仍声明 waiting/error 四态联合(对外契约不变),但当前状态机不产生
138
+ * 这两个值——它们是历史多态映射(idle→waiting / failed+crashed→error)的遗留声明。
139
+ * 未来内部加态必须扩展此处,漏加会在 default 分支编译报错(而非静默返回 undefined
140
+ * 让 state 字段以无主值进入 listResponse JSON)。
141
+ */
142
+ export function mapExternalState(status: ExecutionStatus): ExternalState {
143
+ switch (status) {
144
+ case "running":
145
+ return "active";
146
+ case "closed":
147
+ // v4 B-1: closed 统一终态(含 cancelled)。对外映射为 ended。
148
+ return "ended";
149
+ default:
150
+ throw new Error(`mapExternalState: unhandled ExecutionStatus ${assertNever(status)}`);
151
+ }
152
+ }
153
+
154
+ /** SubagentRecord → SubagentListItem(state 四态主字段 + status 调试字段,duration 实时计算)。
155
+ * [v4 A-6] 新增 parent/resumable/closedReason:parent 从 record.parentRecordId 派生
156
+ * (配合 A-5 直接父守卫),resumable 从 isResumable 派生(B-1「可续聊」对外表达),
157
+ * closedReason 透传(SP-4 级联关闭告知替代 before_agent_start 注入)。
158
+ * agent 是 GUI/TUI list 共用的显示名——取 basename 短名(displayAgentName),
159
+ * 完整路径保留在 record.agent(数据层)。 */
160
+ export function recordToListItem(r: SubagentRecord): SubagentListItem {
119
161
  return {
120
162
  subagentId: r.id,
121
- agent: r.agent,
163
+ agent: displayAgentName(r.agent),
122
164
  slug: r.slug,
165
+ state: mapExternalState(r.status),
123
166
  status: r.status,
124
167
  mode: r.mode,
125
168
  duration: computeElapsedSeconds(r),
126
169
  model: r.model,
127
170
  totalTokens: r.totalTokens,
128
171
  sessionFile: r.sessionFile,
172
+ parent: r.parentRecordId,
173
+ resumable: isResumable(r),
174
+ closedReason: r.closedReason,
129
175
  };
130
176
  }
131
177
 
@@ -171,10 +217,11 @@ export async function startHandler(
171
217
  fork: input.fork,
172
218
  worktree: input.worktree,
173
219
  cwd: input.cwd,
220
+ conversation: input.conversation,
221
+ idleTimeoutMs: input.idleTimeoutMs,
174
222
  ctxModel,
175
223
  signal,
176
- // background 不回流 onUpdate:detached 运行,完成由 notify 驱动新 turn。
177
- onUpdate: undefined,
224
+ // background detached 运行,完成由 notify 驱动新 turn。
178
225
  });
179
226
 
180
227
  return {
@@ -207,7 +254,12 @@ export function listHandler(
207
254
  // 防截断(先多取再过滤)已下沉到 store 层——这里直接传 limit + filter。
208
255
  const filter = includeFinished ? "all" : "running";
209
256
  const all = service.collectRecords(limit, filter);
210
- const items: SubagentListItem[] = all.map(recordToListItem);
257
+ // [perf] collectRecords 磁盘源是 light(无 totalTokens/model 等):SubagentListItem 对
258
+ // LLM 消费方暴露 totalTokens/model,逐项 getFullRecord 补全(per-file 缓存,仅首次
259
+ // 全量解析;显式 tool 调用非渲染热路径,成本可接受)。
260
+ const items: SubagentListItem[] = all.map((r) =>
261
+ recordToListItem(service.getFullRecord(r.id) ?? r),
262
+ );
211
263
  const running = items.filter((i) => i.status === "running").length;
212
264
 
213
265
  return { response: { running, items } };
@@ -245,6 +297,14 @@ export async function cancelHandler(
245
297
  if (rec.mode !== "background") {
246
298
  throw new Error(`Cannot cancel subagent ${id} (unsupported mode: ${rec.mode})`);
247
299
  }
300
+ // 对话模式 cancel = close(force:true) 别名(设计决策 5):chatMode record(running/idle)
301
+ // 走 close 行为路径(idle 终态化 done;running 立即 SIGTERM cancelled),
302
+ // 返回 cancel 响应(向后兼容 cancel action 的返回类型)。非 chatMode 保持现有 cancel 行为。
303
+ if (rec.chatMode) {
304
+ const chatRecord = service.getRecordForAction(id);
305
+ await service.closeSubagent(chatRecord, true);
306
+ return { subagentId: id, response: { cancelled: true } };
307
+ }
248
308
  // step 3: service.cancel boolean(list-view 契约不变);false = 已终态(CAS 抢锁失败)。
249
309
  // 注意:不嵌入 rec.status——findRecord 快照可能已过期(TOCTOU:cancel 期间 detached
250
310
  // 路径 CAS 到 done/failed)。重新查当前状态,避免「status: running」与「already finished」矛盾。
@@ -259,6 +319,127 @@ export async function cancelHandler(
259
319
  return { subagentId: id, response: { cancelled: true } };
260
320
  }
261
321
 
322
+ // ============================================================
323
+ // message handler(M2-B3 对话模式续聊/插入)
324
+ // ============================================================
325
+
326
+ export interface MessageHandlerInput {
327
+ subagentId?: string;
328
+ text?: string;
329
+ interrupt?: boolean;
330
+ }
331
+
332
+ /** message 领域对象(adapter 包成 messageResponse)。 */
333
+ export type MessageHandlerResult = {
334
+ kind: "message";
335
+ subagentId: string;
336
+ response: MessageResponse;
337
+ };
338
+
339
+ /**
340
+ * message action handler:向对话模式 subagent 续聊/插入消息。
341
+ *
342
+ * 状态 × interrupt 自动映射(agent 只表达意图):
343
+ * running → deliverMessage 热路径(进程活:prompt + streamingBehavior,interrupt=true
344
+ * 抢占 / false 排队,pi 权威裁决 busy/idle)
345
+ * 进程死 → deliverMessage 冷路径(resumeRound 重开 session + prompt,interrupt 自动
346
+ * 退化,agent 无感)
347
+ * 终态 → throw ended(正常路径不命中——终态 record 已 archive,getRecordForAction 先 throw not found)
348
+ * (旧 deliverToRunning busy 投递已删除——SP-5 upgrade 后 running 恒走 deliverMessage)
349
+ *
350
+ * 归属守卫(决策 3):getRecordForAction 内部校验 rootSessionId。
351
+ *
352
+ * @throws Error subagentId/text 缺失 / 不存在或非本 session 所有 / 已结束
353
+ */
354
+ export async function messageHandler(
355
+ service: SubagentService,
356
+ input: MessageHandlerInput | undefined,
357
+ ): Promise<MessageHandlerResult> {
358
+ const id = input?.subagentId?.trim();
359
+ if (!id) throw new Error("messageParam.subagentId is required for action:'message'");
360
+ const text = input?.text?.trim();
361
+ if (!text) throw new Error(
362
+ "messageParam.text is required for action:'message' (must not be whitespace-only). " +
363
+ 'Correct: {"action":"message","messageParam":{"subagentId":"sa-...","text":"your follow-up"}}',
364
+ );
365
+ const interrupt = input?.interrupt === true;
366
+
367
+ // 归属守卫(决策 3):getRecordForAction 内部校验 rootSessionId
368
+ const record = service.getRecordForAction(id);
369
+
370
+ // SP-5 one-shot upgrade:非 chatMode 的 active record(running/idle)收到 message 时
371
+ // 自动升级为 chatMode,后续走 deliverMessage 统一投递路径(热路径或冷路径 resume)。
372
+ // closed/cancelled 终态 record 不可 upgrade(getRecordForAction 已抛 not found)。
373
+ // chatMode 是 ExecutionRecord 的 readonly 字段,用 Mutable<T> 显式断言绕过 readonly 约束(upgrade 语义)。
374
+ // Object.assign 隐式绕过 readonly 不可追踪,改为单字段显式赋值。
375
+ // [v4 A-3] 进程内 upgrade 入口(SP-5)——one-shot 首条 message 触发 upgrade 置位
376
+ // chatMode=true。与 subagent-service.ts getRecordForAction 磁盘重建(跨重启恢复入口)
377
+ // 分工:本入口服务进程内 one-shot,跨重启路径恒被 getRecordForAction 磁盘重建绕过
378
+ // (该处无条件 chatMode=true)。改动这两处必须协同,带 S3 回归。
379
+ if (!record.chatMode && record.status === "running") {
380
+ type Mutable<T> = { -readonly [K in keyof T]: T[K] };
381
+ (record as Mutable<ExecutionRecord>).chatMode = true;
382
+ }
383
+
384
+ // [V2 决策 3] chatMode 统一投递:按进程死活分流(热路径 prompt+streamingBehavior / 冷路径 resume),
385
+ // 不按 record.status(V2 进程长驻,idle 态进程仍活,续聊走热路径 prompt 而非重开 session)。
386
+ // SP-5:upgrade 后 record.chatMode 已为 true,统一进此分支。
387
+ if (record.chatMode) {
388
+ await service.deliverMessage(record, text, interrupt);
389
+ } else {
390
+ // 终态(closed/cancelled):防御性兜底(终态 record 已 archive,正常走 not found)
391
+ throw new Error(
392
+ `subagent ${id} has ended (status: ${record.status}), cannot message. ` +
393
+ `Recovery: use action:'close' to clean up, then action:'start' a new subagent.`,
394
+ );
395
+ }
396
+ return { kind: "message", subagentId: id, response: { delivered: true } };
397
+ }
398
+
399
+ // ============================================================
400
+ // close handler(M2-B3 对话模式结束)
401
+ // ============================================================
402
+
403
+ export interface CloseHandlerInput {
404
+ subagentId?: string;
405
+ force?: boolean;
406
+ }
407
+
408
+ /** close 领域对象(adapter 包成 closeResponse)。 */
409
+ export type CloseHandlerResult = {
410
+ kind: "close";
411
+ subagentId: string;
412
+ response: CloseResponse;
413
+ };
414
+
415
+ /**
416
+ * close action handler:结束 subagent(对话模式为主,one-shot 同样支持)。
417
+ *
418
+ * force 语义(设计决策 5/10,v4 B-1 两态收敛 + M5 修正):
419
+ * force:false(默认)= 优雅关闭——
420
+ * 无在跑轮(等待续聊 timer armed / 无活进程)→ 立即终态化(closed + user-close,回收保活进程)
421
+ * 有活进程在跑轮 → 置 closeAfterRound,轮完成时终态化(chatMode 消费点 onRoundSettled,
422
+ * one-shot 在 runAndFinalize 轮完成分支)——返回 {closed:true} 即承诺轮结束后资源已释放
423
+ * force:true = 立即终止——running 立即 SIGTERM(cancelBackground 显式 kill)+ closed+cancelled
424
+ *
425
+ * 行为分流委托 service.closeSubagent(归属守卫由 getRecordForAction 把关)。
426
+ * 已终态 record 由 getRecordForAction throw not found(「已结束的不能再操作」语义)。
427
+ */
428
+ export async function closeHandler(
429
+ service: SubagentService,
430
+ input: CloseHandlerInput | undefined,
431
+ ): Promise<CloseHandlerResult> {
432
+ const id = input?.subagentId?.trim();
433
+ if (!id) throw new Error("closeParam.subagentId is required for action:'close'");
434
+ const force = input?.force === true;
435
+
436
+ // 归属守卫(决策 3)+ 行为分流(service.closeSubagent)
437
+ const record = service.getRecordForAction(id);
438
+ await service.closeSubagent(record, force);
439
+
440
+ return { kind: "close", subagentId: id, response: { closed: true } };
441
+ }
442
+
262
443
  // ============================================================
263
444
  // adapter(领域对象 → SubagentToolResult + {content, details})
264
445
  // ============================================================
@@ -270,7 +451,9 @@ export async function cancelHandler(
270
451
  type AdapterInput =
271
452
  | { action: "start"; domain: StartHandlerResult }
272
453
  | { action: "list"; domain: ListHandlerResult }
273
- | { action: "cancel"; domain: CancelHandlerResult };
454
+ | { action: "cancel"; domain: CancelHandlerResult }
455
+ | { action: "message"; domain: MessageHandlerResult }
456
+ | { action: "close"; domain: CloseHandlerResult };
274
457
 
275
458
  export function adapter(
276
459
  input: AdapterInput,
@@ -280,20 +463,35 @@ export function adapter(
280
463
  let result: SubagentToolResult;
281
464
  if (action === "start") {
282
465
  const d = input.domain;
283
- result = { action, subagentId: d.subagentId, sessionFile: d.sessionFile ?? null, slug: d.slug, bgResponse: d.response };
466
+ // MF-3(决策 10 细则 4):LLM content (text) null 瘦身,防诱导 agent read 绕过工具
467
+ // 直接读 session 文件。真实 sessionFile 仅 details 保留(供 GUI/程序化消费)。
468
+ result = { action, subagentId: d.subagentId, sessionFile: null, slug: d.slug, bgResponse: d.response };
284
469
  } else if (action === "list") {
285
470
  result = { action, subagentId: null, sessionFile: null, listResponse: input.domain.response };
286
- } else {
471
+ } else if (action === "cancel") {
287
472
  result = { action, subagentId: input.domain.subagentId, sessionFile: null, cancelResponse: input.domain.response };
473
+ } else if (action === "message") {
474
+ result = { action, subagentId: input.domain.subagentId, sessionFile: null, messageResponse: input.domain.response };
475
+ } else {
476
+ // action === "close"
477
+ result = { action, subagentId: input.domain.subagentId, sessionFile: null, closeResponse: input.domain.response };
288
478
  }
289
479
 
290
480
  // content JSON:LLM 看的结构化结果(schema 模式 parsedOutput 作为嵌套 JSON 值可接受)。
291
481
  const text = JSON.stringify(result);
292
482
 
483
+ // MF-3:start 的 LLM content 已瘦身(sessionFile:null),details 保留真实 sessionFile 供 GUI/程序化消费。
484
+ // result 已是合法 SubagentToolResult(start 变体 sessionFile:null);start 时重建一个带真实 sessionFile 的副本。
485
+ let detailsBase: SubagentToolResult = result;
486
+ if (action === "start") {
487
+ const d = input.domain;
488
+ detailsBase = { action: "start", subagentId: d.subagentId, sessionFile: d.sessionFile ?? null, slug: d.slug, bgResponse: d.response };
489
+ }
490
+
293
491
  // GUI 协议:RPC 模式下附加结构化渲染数据(union 各成员已声明 __gui__?,无需强转)
294
492
  const details: SubagentToolResult = ctx && isGuiCapable(ctx)
295
- ? { ...result, __gui__: guiResult(buildGuiComponent(action, input, result)) }
296
- : result;
493
+ ? { ...detailsBase, __gui__: guiResult(buildGuiComponent(input, result)) }
494
+ : detailsBase;
297
495
 
298
496
  // [W3 修复] list action 追加 reminder text block:LLM 调 list 时提醒不要轮询。
299
497
  // reminder 作为第二个 text block(独立追加,不污染 details/JSON schema)。
@@ -308,16 +506,20 @@ export function adapter(
308
506
  };
309
507
  }
310
508
 
311
- /** 按 action 构造对应的 GuiComponent。 */
509
+ /**
510
+ * 按 input.action 构造对应的 GuiComponent。
511
+ * 分支判定用 discriminated key(input.action)而非独立宽类型参数——TS 自动
512
+ * 收窄 input.domain 到对应 HandlerResult,无需 `as` 断言(与 adapter() 同款模式)。
513
+ */
312
514
  export function buildGuiComponent(
313
- action: string,
314
515
  input: AdapterInput,
315
516
  _result: SubagentToolResult,
316
517
  ) {
518
+ const { action } = input;
317
519
  if (action === "start") {
318
520
  // subagent-trace 多层语义(agent名+slug+状态)用 card(stats-line) 组合表达。
319
521
  // 利用 input.domain 的身份信息,让并发 subagent 可区分。
320
- const d = input.domain as StartHandlerResult;
522
+ const d = input.domain;
321
523
  return guiComponent("card", {
322
524
  header: d.slug ? `${d.slug}` : d.subagentId.slice(0, SUBAGENT_ID_PREVIEW),
323
525
  body: [guiComponent("stats-line", {
@@ -326,7 +528,7 @@ export function buildGuiComponent(
326
528
  });
327
529
  }
328
530
  if (action === "list") {
329
- const listResp = input.domain as ListHandlerResult;
531
+ const listResp = input.domain;
330
532
  return guiComponent("list-tree", {
331
533
  items: listResp.response.items.map((it) => ({
332
534
  label: it.slug ? `${it.agent} · ${it.slug} · ${it.subagentId}` : `${it.agent} · ${it.subagentId}`,
@@ -335,8 +537,24 @@ export function buildGuiComponent(
335
537
  })),
336
538
  });
337
539
  }
338
- // cancel
540
+ if (action === "message") {
541
+ return guiComponent("stats-line", {
542
+ items: [{ label: "messaged", value: input.domain.subagentId, severity: "ok" }],
543
+ });
544
+ }
545
+ if (action === "close") {
546
+ return guiComponent("stats-line", {
547
+ items: [{ label: "closed", value: input.domain.subagentId, severity: "warn" }],
548
+ });
549
+ }
550
+ // cancel(fall-through:前四分支已 return)。
551
+ // 穷尽检查(同 mapExternalState 的 assertNever 先例):AdapterInput 未来新增
552
+ // action 时,action 在此不再收窄为 "cancel",assertNever 的 never 参数处编译
553
+ // 报错——防止新分支静默落入 cancelled 渲染。
554
+ if (action !== "cancel") {
555
+ throw new Error(`buildGuiComponent: unhandled action ${assertNever(action)}`);
556
+ }
339
557
  return guiComponent("stats-line", {
340
- items: [{ label: "cancelled", value: (input.domain as CancelHandlerResult).subagentId, severity: "warn" }],
558
+ items: [{ label: "cancelled", value: input.domain.subagentId, severity: "warn" }],
341
559
  });
342
560
  }