@zhushanwen/pi-subagent-workflow 0.1.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 (143) hide show
  1. package/agents/context-builder.md +17 -0
  2. package/agents/general-purpose.md +16 -0
  3. package/agents/oracle.md +17 -0
  4. package/agents/planner.md +17 -0
  5. package/agents/researcher.md +17 -0
  6. package/agents/reviewer.md +17 -0
  7. package/agents/scout.md +17 -0
  8. package/agents/worker.md +16 -0
  9. package/examples/README.md +43 -0
  10. package/examples/chain.example.js +92 -0
  11. package/examples/map-reduce.example.js +99 -0
  12. package/examples/parallel.example.js +82 -0
  13. package/examples/scatter-gather.example.js +106 -0
  14. package/index.ts +1 -0
  15. package/package.json +66 -0
  16. package/skills/workflow-script-format/SKILL.md +328 -0
  17. package/src/execution/__tests__/agent-registry.test.ts +164 -0
  18. package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
  19. package/src/execution/__tests__/alive-store.test.ts +147 -0
  20. package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
  21. package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
  22. package/src/execution/__tests__/config.test.ts +110 -0
  23. package/src/execution/__tests__/crash-recovery.test.ts +311 -0
  24. package/src/execution/__tests__/execute-nesting.test.ts +359 -0
  25. package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
  26. package/src/execution/__tests__/execution-record.test.ts +959 -0
  27. package/src/execution/__tests__/finalized-marker.test.ts +82 -0
  28. package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
  29. package/src/execution/__tests__/format.test.ts +320 -0
  30. package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
  31. package/src/execution/__tests__/list-component.test.ts +347 -0
  32. package/src/execution/__tests__/model-resolver.test.ts +356 -0
  33. package/src/execution/__tests__/output-collector.test.ts +61 -0
  34. package/src/execution/__tests__/path-encoding.test.ts +75 -0
  35. package/src/execution/__tests__/pi-invocation.test.ts +73 -0
  36. package/src/execution/__tests__/record-store.test.ts +545 -0
  37. package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
  38. package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
  39. package/src/execution/__tests__/sdk-contract.test.ts +272 -0
  40. package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
  41. package/src/execution/__tests__/session-file-gc.test.ts +247 -0
  42. package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
  43. package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
  44. package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
  45. package/src/execution/__tests__/spawn-args.test.ts +244 -0
  46. package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
  47. package/src/execution/__tests__/subagent-service.test.ts +678 -0
  48. package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
  49. package/src/execution/__tests__/temp-prompt.test.ts +53 -0
  50. package/src/execution/__tests__/timeout-integration.test.ts +381 -0
  51. package/src/execution/__tests__/tombstone-store.test.ts +73 -0
  52. package/src/execution/__tests__/tool-action.test.ts +330 -0
  53. package/src/execution/__tests__/turn-limiter.test.ts +65 -0
  54. package/src/execution/__tests__/worktree-manager.test.ts +423 -0
  55. package/src/execution/__tests__/worktree-registry.test.ts +161 -0
  56. package/src/execution/agent-registry.ts +252 -0
  57. package/src/execution/agent-result-mapper.ts +84 -0
  58. package/src/execution/alive-store.ts +92 -0
  59. package/src/execution/best-effort.ts +30 -0
  60. package/src/execution/concurrency-pool.ts +84 -0
  61. package/src/execution/config.ts +73 -0
  62. package/src/execution/execute-options-mapper.ts +86 -0
  63. package/src/execution/execution-record.ts +778 -0
  64. package/src/execution/finalized-marker.ts +51 -0
  65. package/src/execution/model-config-service.ts +225 -0
  66. package/src/execution/model-resolver.ts +247 -0
  67. package/src/execution/notifier.ts +168 -0
  68. package/src/execution/output-collector.ts +88 -0
  69. package/src/execution/path-encoding.ts +34 -0
  70. package/src/execution/pi-invocation.ts +70 -0
  71. package/src/execution/record-store.ts +350 -0
  72. package/src/execution/session-context-resolver.ts +64 -0
  73. package/src/execution/session-file-gc.ts +98 -0
  74. package/src/execution/session-reconstructor.ts +450 -0
  75. package/src/execution/session-runner.ts +725 -0
  76. package/src/execution/spawn-event-adapter.ts +150 -0
  77. package/src/execution/subagent-service.ts +973 -0
  78. package/src/execution/subprocess-agent-runner.ts +108 -0
  79. package/src/execution/temp-prompt.ts +57 -0
  80. package/src/execution/tombstone-store.ts +72 -0
  81. package/src/execution/turn-limiter.ts +88 -0
  82. package/src/execution/types.ts +634 -0
  83. package/src/execution/worktree-manager.ts +285 -0
  84. package/src/execution/worktree-registry.ts +144 -0
  85. package/src/index.ts +454 -0
  86. package/src/interface/bg-notify-render.ts +286 -0
  87. package/src/interface/commands.ts +157 -0
  88. package/src/interface/format.ts +501 -0
  89. package/src/interface/gui-adapter.ts +136 -0
  90. package/src/interface/helpers.ts +110 -0
  91. package/src/interface/list-component.ts +643 -0
  92. package/src/interface/list-shared.ts +84 -0
  93. package/src/interface/list-view.ts +373 -0
  94. package/src/interface/reentry-guard.ts +30 -0
  95. package/src/interface/subagent-actions.ts +294 -0
  96. package/src/interface/subagent-tool.ts +294 -0
  97. package/src/interface/subagents.ts +30 -0
  98. package/src/interface/tool-render.ts +333 -0
  99. package/src/interface/tool-workflow-script.ts +351 -0
  100. package/src/interface/tool-workflow.ts +485 -0
  101. package/src/interface/views/WorkflowsView.ts +944 -0
  102. package/src/interface/views/detail-content.ts +298 -0
  103. package/src/interface/views/format.ts +320 -0
  104. package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
  105. package/src/orchestration/__tests__/config-loader.test.ts +381 -0
  106. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
  107. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
  108. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
  109. package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
  110. package/src/orchestration/__tests__/script-lint.test.ts +347 -0
  111. package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
  112. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
  113. package/src/orchestration/agent-opts-resolver.ts +128 -0
  114. package/src/orchestration/concurrency-gate.ts +69 -0
  115. package/src/orchestration/config-loader.ts +313 -0
  116. package/src/orchestration/error-recovery.ts +578 -0
  117. package/src/orchestration/execute-agent-call.ts +174 -0
  118. package/src/orchestration/jsonl-run-store.ts +292 -0
  119. package/src/orchestration/launcher.ts +368 -0
  120. package/src/orchestration/lifecycle.ts +373 -0
  121. package/src/orchestration/models/__tests__/budget.test.ts +367 -0
  122. package/src/orchestration/models/agent-call.ts +76 -0
  123. package/src/orchestration/models/budget.ts +148 -0
  124. package/src/orchestration/models/ports.ts +165 -0
  125. package/src/orchestration/models/run-runtime.ts +91 -0
  126. package/src/orchestration/models/run-spec.ts +54 -0
  127. package/src/orchestration/models/run-state.ts +44 -0
  128. package/src/orchestration/models/trace.ts +102 -0
  129. package/src/orchestration/models/types.ts +242 -0
  130. package/src/orchestration/models/workflow-run.ts +275 -0
  131. package/src/orchestration/models/workflow-script-registry.ts +32 -0
  132. package/src/orchestration/models/workflow-script.ts +90 -0
  133. package/src/orchestration/node-ops.ts +192 -0
  134. package/src/orchestration/script-lint.ts +387 -0
  135. package/src/orchestration/skill-discovery.ts +60 -0
  136. package/src/orchestration/worker-handle.ts +115 -0
  137. package/src/orchestration/worker-host.ts +93 -0
  138. package/src/orchestration/worker-script-builder.ts +281 -0
  139. package/src/orchestration/workflow-files.ts +85 -0
  140. package/src/orchestration/workflow-script-registry-impl.ts +128 -0
  141. package/src/shared/__tests__/resource-discovery.test.ts +226 -0
  142. package/src/shared/agent-event.ts +13 -0
  143. package/src/shared/resource-discovery.ts +535 -0
@@ -0,0 +1,88 @@
1
+ // src/core/output-collector.ts
2
+ //
3
+ // 结果收集器(Record + CollectResultArgs → AgentResult)。
4
+ //
5
+ // 收口设计(2026-06-22):collectResult 全部从 record 读——
6
+ // text ← getFullText(record)(聚合 turns[].text,不再读 session.messages)
7
+ // turns ← record.turnCount
8
+ // toolCalls ← getAllToolCalls(record)(扁平化 turns[].toolCalls)
9
+ // usage ← getTotalUsage(record)(聚合 turns[].usageDelta)
10
+ //
11
+ // 基础层模块:依赖 execution-record(派生函数)+ types。
12
+
13
+ import type {
14
+ AgentResult,
15
+ ExecutionRecord,
16
+ ToolCall,
17
+ } from "./types.ts";
18
+ import {
19
+ getAllToolCalls,
20
+ getFullText,
21
+ getTotalUsage,
22
+ } from "./execution-record.ts";
23
+
24
+ // ============================================================
25
+ // Result 收集
26
+ // ============================================================
27
+
28
+ /** collectResult 的入参(session 身份 + 执行控制字段,执行内容从 record 读)。 */
29
+ export interface CollectResultArgs {
30
+ startTime: number;
31
+ success: boolean;
32
+ error: string | undefined;
33
+ sessionId: string;
34
+ sessionFile: string | undefined;
35
+ }
36
+
37
+ /** structured-output tool 名(与 structured-output 扩展 TOOL_NAME 一致,见 session-runner.ts)。 */
38
+ const STRUCTURED_OUTPUT_TOOL = "structured-output";
39
+
40
+ /**
41
+ * 从 toolCalls 提取 structured-output 的 result.details(schema 模式产出)。
42
+ * schema enforcement 保证 agent 调过该 tool(漏调会 steer 重试);这里只做逆向提取。
43
+ * 未调或无 details 返回 undefined。
44
+ *
45
+ * 导出以便直接单测(纯函数契约)。
46
+ */
47
+ export function extractParsedOutput(toolCalls: ToolCall[]): unknown {
48
+ for (let i = toolCalls.length - 1; i >= 0; i--) {
49
+ const tc = toolCalls[i]!;
50
+ if (tc.toolName === STRUCTURED_OUTPUT_TOOL && tc.result?.details !== undefined) {
51
+ return tc.result.details;
52
+ }
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ /**
58
+ * 从 record + args 组装 AgentResult。每个字段来源单一且收口于 record:
59
+ * text ← getFullText(record)(聚合 turns[].text,单一数据源)
60
+ * turns ← record.turnCount
61
+ * toolCalls ← getAllToolCalls(record)(扁平化 turns[].toolCalls)
62
+ * usage ← getTotalUsage(record)(聚合 turns[].usageDelta,全零则 undefined)
63
+ * parsedOutput ← extractParsedOutput(toolCalls)
64
+ *
65
+ * startTime 算 durationMs。
66
+ *
67
+ * success 双来源判定(调用方传入):
68
+ * ① session.prompt() 抛错 → args.success=false
69
+ * ② prompt 成功但 record.lastError 非空(message_end stopReason=error)→ success=false
70
+ */
71
+ export function collectResult(
72
+ record: ExecutionRecord,
73
+ args: CollectResultArgs,
74
+ ): AgentResult {
75
+ const toolCalls = getAllToolCalls(record);
76
+ return {
77
+ text: getFullText(record),
78
+ turns: record.turnCount,
79
+ durationMs: Date.now() - args.startTime,
80
+ success: args.success,
81
+ error: args.error,
82
+ sessionId: args.sessionId,
83
+ toolCalls,
84
+ usage: getTotalUsage(record),
85
+ sessionFile: args.sessionFile,
86
+ parsedOutput: extractParsedOutput(toolCalls),
87
+ };
88
+ }
@@ -0,0 +1,34 @@
1
+ // src/core/path-encoding.ts
2
+ //
3
+ // cwd → 安全目录名的编码逻辑。Core 叶子原语(零依赖)。
4
+ //
5
+ // 被 session-runner(subagent session 持久化目录)与 session-file-gc(清理过期
6
+ // session 文件)共用——两处需要相同的编码,否则同一 cwd 会落到两个不同目录。
7
+
8
+ import * as path from "node:path";
9
+
10
+ /**
11
+ * cwd → 安全目录名。复用 Pi SDK getDefaultSessionDir 的编码逻辑:
12
+ * 去开头单个分隔符,全量替换剩余分隔符/冒号为 `-`,首尾补 `--`。
13
+ * 例:`/Users/x/proj` → `--Users-x-proj--`。
14
+ */
15
+ export function encodeCwd(cwd: string): string {
16
+ return "--" + cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-") + "--";
17
+ }
18
+
19
+ /**
20
+ * 获取 subagent session 持久化目录路径。
21
+ *
22
+ * D-004: 用主 cwd 编码——保证同一主 cwd 下所有 subagent 的 session 文件
23
+ * 存放在同一目录,便于 session-file-gc 统一清理。
24
+ *
25
+ * @param agentDir agent 配置目录(如 ~/.pi/agent)
26
+ * @param mainCwd 主 agent 的工作目录(非 subagent 的 effectiveCwd)
27
+ * @returns session 持久化目录绝对路径
28
+ */
29
+ export function getSubagentSessionDir(agentDir: string, mainCwd: string): string {
30
+ // [MF#1] 保持既有布局 subagents/<enc>/sessions/——曾改为 subagents/sessions/<enc>/ 会让
31
+ // 升级用户的既有 session 文件全部落到扫描目录外(历史记录消失 + GC 扫不到,双重 orphan)。
32
+ // 本分支未发布,回退到既有布局即无需迁移、无数据丢失。
33
+ return path.join(agentDir, "subagents", encodeCwd(mainCwd), "sessions");
34
+ }
@@ -0,0 +1,70 @@
1
+ // src/core/pi-invocation.ts
2
+ //
3
+ // 定位 pi 二进制并组装 spawn 调用。Core 叶子原语(仅依赖 node 内置)。
4
+ //
5
+ // spawn 改造(in-process → spawn pi --mode json)的基座模块。
6
+ // 被 runSpawn(session-runner)调用,决定子进程用哪个命令启动。
7
+ //
8
+ // 移植自 nicobailon subagent example 的 getPiInvocation,处理三种运行时:
9
+ // 1. bun bundle(/$bunfs/root/ 虚拟脚本)→ 退化到 pi-in-PATH
10
+ // 2. 有真实脚本路径(node + script)→ node <script> <args>
11
+ // 3. node/bun generic runtime(无脚本)→ pi <args>(依赖 PATH)
12
+ //
13
+ // 注意:pi 在扩展进程内运行时 process.execPath 是 node/bun,process.argv[1]
14
+ // 是 pi 的入口脚本。子进程需要复现同样的启动方式才能保证扩展/配置一致加载。
15
+
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+
19
+ /** spawn 调用描述符:command + args(透传给 child_process.spawn)。 */
20
+ export interface PiInvocation {
21
+ /** 可执行文件路径(node/bun/pi 二进制)。 */
22
+ command: string;
23
+ /** 命令行参数(可能含 [scriptPath, ...userArgs] 或直接 [...userArgs])。 */
24
+ args: string[];
25
+ }
26
+
27
+ /**
28
+ * bun 虚拟文件系统前缀。bun bundle 模式下 process.argv[1] 形如
29
+ * /$bunfs/root/pi——这不是磁盘上的真实文件,不能直接 spawn。
30
+ */
31
+ const BUN_VIRTUAL_PREFIX = "/$bunfs/root/";
32
+
33
+ /**
34
+ * 判断 execPath 的 basename 是否为通用运行时(node/bun)。
35
+ * 通用运行时需要脚本路径才能启动 pi;非通用(如 pi 的 standalone binary)可直接执行。
36
+ */
37
+ function isGenericRuntime(execPath: string): boolean {
38
+ const execName = path.basename(execPath).toLowerCase();
39
+ return /^(node|bun)(\.exe)?$/.test(execName);
40
+ }
41
+
42
+ /**
43
+ * 组装 pi 子进程的 spawn 调用。
44
+ *
45
+ * @param userArgs pi CLI 参数(如 ["--mode", "json", "-p", "Task: ..."])
46
+ * @returns spawn 描述符(command + 完整 args)
47
+ *
48
+ * 决策链(按优先级):
49
+ * 1. process.argv[1] 是真实磁盘文件且非 bun 虚拟路径 → <execPath> <argv[1]> <userArgs>
50
+ * (复现当前 pi 进程的启动方式,确保扩展/配置/版本一致)
51
+ * 2. execPath 非通用运行时(pi standalone binary)→ <execPath> <userArgs>
52
+ * 3. 通用运行时但无可用脚本路径 → "pi" <userArgs>(依赖 PATH 中可找到 pi)
53
+ */
54
+ export function getPiInvocation(userArgs: string[]): PiInvocation {
55
+ const currentScript = process.argv[1];
56
+ const isBunVirtualScript = currentScript?.startsWith(BUN_VIRTUAL_PREFIX);
57
+
58
+ // 分支 1:有真实脚本路径 → 复现启动方式(node <pi-script> <args>)
59
+ if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
60
+ return { command: process.execPath, args: [currentScript, ...userArgs] };
61
+ }
62
+
63
+ // 分支 2:非通用运行时(pi 自带 binary)→ 直接执行
64
+ if (!isGenericRuntime(process.execPath)) {
65
+ return { command: process.execPath, args: userArgs };
66
+ }
67
+
68
+ // 分支 3:通用运行时但脚本不可用 → 依赖 PATH
69
+ return { command: "pi", args: userArgs };
70
+ }
@@ -0,0 +1,350 @@
1
+ // src/runtime/execution/record-store.ts
2
+ //
3
+ // Record 的统一容器。内存只留 running record;终态从 session.jsonl 重建。
4
+ //
5
+ // 职责:
6
+ // - 持有 running record(终态 record 在 archive 时立即从内存移除)
7
+ // - onChange 订阅(TUI widget/list 据此重渲)
8
+ // - collectRecords:内存(running) + 磁盘(sessions/*.jsonl 重建) 合并
9
+ // - 提供 snapshot() 只读视图给 TUI(永不返回可变引用)
10
+
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+
14
+ import { getCurrentActivity, getDisplayItems, getEventLog, markReconstructedStatus, snapshot as toSnapshot } from "./execution-record.ts";
15
+ import { reconstructFromFile } from "./session-reconstructor.ts";
16
+ import type {
17
+ ExecutionRecord,
18
+ ExecutionStatus,
19
+ RecordSnapshot,
20
+ SubagentRecord,
21
+ } from "./types.ts";
22
+ import { isProcessAlive, readAliveMarker } from "./alive-store.ts";
23
+ import { readFinalized } from "./finalized-marker.ts";
24
+ import { readCancelledTombstone } from "./tombstone-store.ts";
25
+
26
+ // ============================================================
27
+ // 常量
28
+ // ============================================================
29
+
30
+ /** status → 排序优先级(值小排前):running < failed < crashed < cancelled < done。 */
31
+ const STATUS_PRIORITY: Record<ExecutionStatus, number> = {
32
+ running: 0,
33
+ failed: 1,
34
+ crashed: 1,
35
+ cancelled: 2,
36
+ done: 3,
37
+ };
38
+
39
+ /** .alive sidecar 的 24 小时软超时(超过此时间即使 pid 存活也判 crashed)。 */
40
+ const ALIVE_SOFT_TIMEOUT_MS = 86_400_000; // 24h in ms
41
+
42
+ /** store 变更监听器(返回取消订阅函数)。 */
43
+ export type ChangeListener = () => void;
44
+
45
+ /** status 过滤模式(collectRecords 的核心能力参数)。 */
46
+ export type StatusFilter = "running" | "all";
47
+
48
+ // ============================================================
49
+ // RecordStore
50
+ // ============================================================
51
+
52
+ /**
53
+ * Record 容器。进程单例(随 SubagentService 重建)。
54
+ *
55
+ * 内存只留 running record——终态 record 在 archive 时立即移除,collectRecords
56
+ * 读时从 sessions/*.jsonl 重建(reconstructFromFile)。重建结果有缓存,在
57
+ * notifyChange 时失效(终态 record 不再变化,但新 finalize 触发重扫)。
58
+ *
59
+ * 任何 mutate → notifyChange()。
60
+ */
61
+ export class RecordStore {
62
+ private readonly records = new Map<string, ExecutionRecord>();
63
+ private readonly listeners = new Set<ChangeListener>();
64
+ private _disposed = false;
65
+
66
+ /** 重建缓存:sessionFile → SubagentRecord。notifyChange 时失效。 */
67
+ private reconCache: Map<string, SubagentRecord> | undefined;
68
+
69
+ constructor(private readonly sessionsDir: string) {}
70
+
71
+ /** 注册新 record。触发 onChange。 */
72
+ register(record: ExecutionRecord): void {
73
+ this.records.set(record.id, record);
74
+ this.notifyChange();
75
+ }
76
+
77
+ /**
78
+ * 归档:record 已被 completeRecord 设置了终态 status。
79
+ * 立即从内存移除(终态 record 下次读时从 session.jsonl 重建)。
80
+ * cancelled record 由调用方先写 tombstone(cancel 路径),此处只负责移除。
81
+ */
82
+ archive(record: ExecutionRecord): void {
83
+ this.records.delete(record.id);
84
+ this.notifyChange();
85
+ }
86
+
87
+ /** 按 id 查找。返回可变 record(仅 runtime 内部用)。 */
88
+ getMutable(id: string): ExecutionRecord | undefined {
89
+ return this.records.get(id);
90
+ }
91
+
92
+ /**
93
+ * abort 所有 running record 的 controller(background 子进程 SIGTERM)。
94
+ *
95
+ * 仅在 SubagentService.dispose(进程退出路径)调用。不做 CAS/tombstone——dispose
96
+ * 是终局,状态机收尾无意义;目的是让 background 子进程的 AbortSignal 触发 →
97
+ * runSpawn 的 signal listener → child.kill("SIGTERM"),防止主进程退出后子进程成孤儿。
98
+ *
99
+ * sync record 无 controller(undefined),跳过——sync 是阻塞调用,主进程不会先于
100
+ * sync subagent 退出(除非 SIGKILL/崩溃,此时任何清理都无效)。
101
+ *
102
+ * 返回被 abort 的 record 数(诊断用)。
103
+ */
104
+ abortRunningControllers(): number {
105
+ let n = 0;
106
+ for (const r of this.records.values()) {
107
+ if (r.status === "running" && r.controller) {
108
+ r.controller.abort();
109
+ n++;
110
+ }
111
+ }
112
+ return n;
113
+ }
114
+
115
+ /** 列出所有 running record 的只读快照(widget 计数、诊断用)。 */
116
+ listRunning(): RecordSnapshot[] {
117
+ return [...this.records.values()]
118
+ .filter((r) => r.status === "running")
119
+ .map((r) => toSnapshot(r));
120
+ }
121
+
122
+ /**
123
+ * 合并内存(running) + 磁盘(sessions/*.jsonl 重建) → SubagentRecord[]。
124
+ *
125
+ * ╔══════════════════════════════════════════════════════════════════╗
126
+ * ║ 1. 磁盘源:扫 sessionsDir 的 .jsonl,逐个 reconstructFromFile ║
127
+ * ║ (命中缓存则跳过读文件)。cancelled tombstone override status ║
128
+ * ║ 2. 内存源覆盖(同 id 内存优先——running record 更新鲜) ║
129
+ * ║ 3. session 过滤:只留 rootSessionId === rootSessionFilter 的 ║
130
+ * ║ record。rootSessionId 缺失(旧文件)的 record 一律排除 ║
131
+ * ║ (无法判定归属,隔离优先)。rootSessionFilter 为 undefined ║
132
+ * ║ 时不过滤(向后兼容)。 ║
133
+ * ║ 4. statusFilter:"running" → 只留 running(内存源); ║
134
+ * ║ "all"(默认)→ 内存 + 磁盘 ║
135
+ * ║ 5. 排序:STATUS_PRIORITY + startedAt desc ║
136
+ * ║ 6. slice(limit) ║
137
+ * ╚══════════════════════════════════════════════════════════════════╝
138
+ *
139
+ * statusFilter="running" 时仍先取够多再过滤(防 limit 截断把 running 滤没),
140
+ * 与旧 listHandler 的防截断逻辑一致,下沉到此。
141
+ *
142
+ * session 隔离:同一 cwd 下多个 Pi session 共享 sessionsDir,靠 rootSessionId
143
+ * 区分。内存与磁盘源都按 rootSessionFilter 过滤后再 merge/sort/slice。
144
+ */
145
+ collectRecords(
146
+ limit: number,
147
+ statusFilter: StatusFilter = "all",
148
+ rootSessionFilter?: string,
149
+ ): SubagentRecord[] {
150
+ const byId = new Map<string, SubagentRecord>();
151
+
152
+ // 1. 磁盘源(重建终态 record)。 reconstructAll 已按 rootSessionFilter 过滤。
153
+ for (const rec of this.reconstructAll(rootSessionFilter)) {
154
+ byId.set(rec.id, rec);
155
+ }
156
+
157
+ // 2. 内存源覆盖(running record 优先——它是活态,比磁盘重建更新鲜)。同样按 session 过滤。
158
+ for (const r of this.records.values()) {
159
+ if (rootSessionFilter !== undefined && r.rootSessionId !== rootSessionFilter) continue;
160
+ byId.set(r.id, RecordStore.recordToSubagent(r));
161
+ }
162
+
163
+ // 3. statusFilter。
164
+ let result = [...byId.values()];
165
+ if (statusFilter === "running") {
166
+ result = result.filter((r) => r.status === "running");
167
+ }
168
+
169
+ // 4-5. 排序 + slice。
170
+ return result
171
+ .sort(RecordStore.compareRecords)
172
+ .slice(0, limit);
173
+ }
174
+
175
+ /** 订阅变更。返回取消订阅函数。 */
176
+ onChange(listener: ChangeListener): () => void {
177
+ this.listeners.add(listener);
178
+ return () => {
179
+ this.listeners.delete(listener);
180
+ };
181
+ }
182
+
183
+ /** 触发所有监听器(TUI widget/list requestRender)。dispose 后短路。同时失效重建缓存。 */
184
+ notifyChange(): void {
185
+ if (this._disposed) return;
186
+ this.reconCache = undefined; // 失效缓存(新 finalize 可能产出新 session.jsonl)。
187
+ for (const listener of this.listeners) {
188
+ listener();
189
+ }
190
+ }
191
+
192
+ /** session 结束清理。 */
193
+ dispose(): void {
194
+ this._disposed = true;
195
+ this.listeners.clear();
196
+ }
197
+
198
+ /** /resume /fork /new 后复活(dispose 的逆操作)。 */
199
+ revive(): void {
200
+ this._disposed = false;
201
+ }
202
+
203
+ // ── 内部 ──────────────────────────────────────────────────
204
+
205
+ /**
206
+ * 四分支 sidecar 矩阵重建。
207
+ *
208
+ * 优先级:
209
+ * 1. .cancelled → cancelled
210
+ * 2. .finalized → done/failed(按 recon.stopReason 推)
211
+ * 3. .alive + pid 存活 + 未超 24h → running, externalInstance=true
212
+ * 4. 兜底 → crashed
213
+ *
214
+ * 所有分支经 markReconstructedStatus(不裸 .status=)。
215
+ *
216
+ * session 隔离:rootSessionFilter 非空时,只保留 rootSessionId 匹配的 record。
217
+ * rootSessionId 缺失(旧文件,未带身份字段)一律排除(无法判定归属)。
218
+ * 缓存以 undefined 过滤结果为基底,带 filter 时在基底上再筛(避免缓存碎片化)。
219
+ */
220
+ private reconstructAll(rootSessionFilter?: string): SubagentRecord[] {
221
+ if (this.reconCache) {
222
+ const all = [...this.reconCache.values()];
223
+ if (rootSessionFilter === undefined) return all;
224
+ return all.filter((r) => r.rootSessionId === rootSessionFilter);
225
+ }
226
+
227
+ const cache = new Map<string, SubagentRecord>();
228
+ let files: string[];
229
+ try {
230
+ files = fs.readdirSync(this.sessionsDir)
231
+ .filter((f) => f.endsWith(".jsonl"))
232
+ .map((f) => path.join(this.sessionsDir, f));
233
+ } catch {
234
+ this.reconCache = cache;
235
+ return [];
236
+ }
237
+
238
+ const now = Date.now();
239
+
240
+ for (const file of files) {
241
+ const recon = reconstructFromFile(file);
242
+ if (!recon) continue; // 文件缺失/损坏/缺 identity → 跳过。
243
+
244
+ // 读取三个 sidecar(best-effort,不存在返回 falsy)。
245
+ const tomb = readCancelledTombstone(file);
246
+ const finalized = readFinalized(file);
247
+ const alive = readAliveMarker(file);
248
+
249
+ // 构造 base record(status/error/endedAt/externalInstance 后续按分支覆盖)。
250
+ const rec: SubagentRecord = {
251
+ id: recon.id,
252
+ agent: recon.agent,
253
+ status: recon.status, // 临时值,各分支覆盖
254
+ mode: recon.mode,
255
+ startedAt: recon.startedAt,
256
+ rootSessionId: recon.rootSessionId,
257
+ parentRecordId: recon.parentRecordId,
258
+ depth: recon.depth,
259
+ endedAt: undefined,
260
+ turns: recon.turnCount,
261
+ totalTokens: recon.totalTokens,
262
+ model: recon.model,
263
+ thinkingLevel: recon.thinkingLevel,
264
+ task: recon.task,
265
+ // 磁盘重建是离线快照,无实时活动状态。
266
+ currentActivity: undefined,
267
+ // worktreeHandle 不从磁盘重建(session.jsonl 未持久化路径/分支)。
268
+ // 已结束的 worktree record 的 checkout 已被 cleanup 回收,重建句柄无意义。
269
+ // forkDepth 从 identity 重建(用于 TUI 深度标记),worktree 信息仅内存 running 时可见。
270
+ eventLog: recon.eventLog,
271
+ // [STEP3] displayItems 从重建的 turns[] 派生(getDisplayItems 参数放宽为
272
+ // { turns },ReconstructedRecord 满足)。终态 record 详情可看完整 text 输出。
273
+ displayItems: getDisplayItems(recon),
274
+ result: recon.result,
275
+ error: recon.error,
276
+ sessionFile: recon.sessionFile,
277
+ };
278
+
279
+ // ── 分支 1: .cancelled ──
280
+ if (tomb) {
281
+ markReconstructedStatus(rec, "cancelled");
282
+ rec.error = "cancelled by user";
283
+ rec.endedAt = tomb.endedAt;
284
+ }
285
+ // ── 分支 2: .finalized ──
286
+ else if (finalized) {
287
+ // done/failed 按 recon 推导的 stopReason(reconstructFromFile 已映射为 status)。
288
+ const status: ExecutionStatus = recon.status === "failed" ? "failed" : "done";
289
+ markReconstructedStatus(rec, status);
290
+ // 用最后一条 entry 的时间戳作为 endedAt(避免重建后耗时随墙钟无限增长)。
291
+ rec.endedAt = recon.endedAt;
292
+ }
293
+ // ── 分支 3: .alive + pid 存活 + 未超 24h 软超时 ──
294
+ else if (
295
+ alive !== undefined &&
296
+ isProcessAlive(alive.pid) &&
297
+ now - alive.startedAt < ALIVE_SOFT_TIMEOUT_MS
298
+ ) {
299
+ markReconstructedStatus(rec, "running");
300
+ rec.externalInstance = alive;
301
+ }
302
+ // ── 分支 4: 兜底(都无 / .alive 但 pid 死 / 超 24h)──
303
+ else {
304
+ markReconstructedStatus(rec, "crashed");
305
+ // crashed 以最后已知活动时间为准(pid 死亡时间未知,用最后 entry 时间近似)。
306
+ rec.endedAt = recon.endedAt;
307
+ }
308
+
309
+ cache.set(file, rec);
310
+ }
311
+
312
+ this.reconCache = cache;
313
+ // 带 filter 时在缓存上筛(上面已构造全量缓存,便于后续调用复用)。
314
+ if (rootSessionFilter === undefined) return [...cache.values()];
315
+ return [...cache.values()].filter((r) => r.rootSessionId === rootSessionFilter);
316
+ }
317
+
318
+ /** 排序比较器:status priority(running<failed<cancelled<done)+ startedAt desc。 */
319
+ private static compareRecords(a: SubagentRecord, b: SubagentRecord): number {
320
+ const pdiff = STATUS_PRIORITY[a.status] - STATUS_PRIORITY[b.status];
321
+ if (pdiff !== 0) return pdiff;
322
+ return b.startedAt - a.startedAt; // 新→旧
323
+ }
324
+
325
+ /** ExecutionRecord → SubagentRecord(内存源投影)。 */
326
+ private static recordToSubagent(r: ExecutionRecord): SubagentRecord {
327
+ return {
328
+ id: r.id,
329
+ agent: r.agent,
330
+ status: r.status,
331
+ mode: r.mode,
332
+ startedAt: r.startedAt,
333
+ rootSessionId: r.rootSessionId,
334
+ parentRecordId: r.parentRecordId,
335
+ depth: r.depth,
336
+ endedAt: r.endedAt,
337
+ turns: r.turnCount,
338
+ totalTokens: r.totalTokens,
339
+ model: r.model,
340
+ thinkingLevel: r.thinkingLevel,
341
+ task: r.task,
342
+ currentActivity: getCurrentActivity(r),
343
+ eventLog: getEventLog(r),
344
+ displayItems: getDisplayItems(r),
345
+ result: r.result,
346
+ error: r.error,
347
+ sessionFile: r.sessionFile,
348
+ };
349
+ }
350
+ }
@@ -0,0 +1,64 @@
1
+ // src/core/session-context-resolver.ts
2
+ //
3
+ // resolveSessionContext 纯函数——解析 fork/worktree 意图,返回执行上下文。
4
+ // D-014: 零副作用,零 Pi import。只返回意图,不调 forkFrom/不创建 sessionDir。
5
+
6
+ import type { ResolvedSessionContext, SessionResolveInput } from "./types.ts";
7
+ import { ForkDepthExceededError } from "./types.ts";
8
+ import { getSubagentSessionDir } from "./path-encoding.ts";
9
+
10
+ /**
11
+ * fork 深度硬限。export 供 session-runner 注入 LLM env block 时引用同一常量,
12
+ * 避免硬限(拦截)与展示(`N/10`)两处 10 漂移。
13
+ *
14
+ * 双层护栏(互补,共享本常量):
15
+ * 1. resolveSessionContext 的 fork 护栏(parentForkDepth >= MAX_FORK_DEPTH → 拒):
16
+ * 只计 fork 链(fork=true 才递增 parentForkDepth),控 session 体积(每层 createBranchedSession)。
17
+ * 2. SubagentService.execute 入口的通用嵌套护栏(nestingDepth > MAX_FORK_DEPTH → 拒):
18
+ * 经 execCtxAls 计所有 subagent 嵌套(fork + 非 fork),更严——混合链
19
+ * (fork→非fork→fork)下 nestingDepth >= parentForkDepth,通用护栏先生效。
20
+ * 两者均允许深度 0..MAX_FORK_DEPTH(共 MAX+1 层),第 MAX+1 层(深度=MAX+1)被拒。
21
+ */
22
+ export const MAX_FORK_DEPTH = 10;
23
+
24
+ /**
25
+ * 纯函数:解析 fork/worktree 意图 → 执行上下文。
26
+ *
27
+ * 只返回意图(shouldFork/forkSource/effectiveCwd/sessionDir),
28
+ * 不调 forkFrom / 不创建 sessionDir / 不 git worktree add。
29
+ *
30
+ * @throws {ForkDepthExceededError} fork=true 且 parentForkDepth >= 10
31
+ */
32
+ export function resolveSessionContext(input: SessionResolveInput): ResolvedSessionContext {
33
+ const { fork, cwd, mainCwd, mainSessionFile, parentForkDepth, agentDir, worktreePath } =
34
+ input;
35
+
36
+ // fork 深度检查(D-007)
37
+ if (fork && (parentForkDepth ?? 0) >= MAX_FORK_DEPTH) {
38
+ throw new ForkDepthExceededError(
39
+ `fork depth ${parentForkDepth ?? 0} >= ${MAX_FORK_DEPTH}, refusing to fork`,
40
+ );
41
+ }
42
+
43
+ const shouldFork = fork === true;
44
+ const forkSource = shouldFork ? mainSessionFile : undefined;
45
+
46
+ // [MF#5] fork 显式请求但主 session 文件不可用(session_start 未缓存)时直接抛错,
47
+ // 不静默降级到 from-scratch——否则用户显式 fork 却得到无继承 session,且无任何告警。
48
+ if (shouldFork && !forkSource) {
49
+ throw new Error(
50
+ "fork requested but main session file is unavailable " +
51
+ "(session_start did not cache it); cannot fork without a source session",
52
+ );
53
+ }
54
+
55
+ // effectiveCwd: worktree 模式用 handle.path(真实 checkout,由调用方传入),
56
+ // 否则用显式 cwd 或 mainCwd。worktreePath 与 worktree 标志同源(都来自 WorktreeHandle),
57
+ // 不再靠 tmpdir 拼凑,保证 effectiveCwd 与实际 checkout 严格一致。
58
+ const effectiveCwd = worktreePath ?? (cwd ?? mainCwd);
59
+
60
+ // sessionDir 用 mainCwd 编码(D-004: 同一主 cwd 下所有 subagent 存同一目录)
61
+ const sessionDir = getSubagentSessionDir(agentDir, mainCwd);
62
+
63
+ return { shouldFork, forkSource, effectiveCwd, sessionDir };
64
+ }