@zhushanwen/pi-subagents 0.0.1

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 (37) hide show
  1. package/agents/context-builder.md +19 -0
  2. package/agents/oracle.md +19 -0
  3. package/agents/planner.md +19 -0
  4. package/agents/researcher.md +19 -0
  5. package/agents/reviewer.md +19 -0
  6. package/agents/scout.md +19 -0
  7. package/agents/worker.md +18 -0
  8. package/index.ts +1 -0
  9. package/package.json +59 -0
  10. package/src/commands/subagents.ts +78 -0
  11. package/src/core/agent-registry.ts +222 -0
  12. package/src/core/concurrency-pool.ts +78 -0
  13. package/src/core/event-bridge.ts +199 -0
  14. package/src/core/execution-record.ts +500 -0
  15. package/src/core/model-resolver.ts +206 -0
  16. package/src/core/output-collector.ts +118 -0
  17. package/src/core/path-encoding.ts +16 -0
  18. package/src/core/session-factory.ts +365 -0
  19. package/src/core/session-runner.ts +303 -0
  20. package/src/core/turn-limiter.ts +71 -0
  21. package/src/index.ts +104 -0
  22. package/src/runtime/config/config.ts +170 -0
  23. package/src/runtime/discovery-config.ts +135 -0
  24. package/src/runtime/execution/history-store.ts +196 -0
  25. package/src/runtime/execution/notifier.ts +209 -0
  26. package/src/runtime/execution/record-store.ts +280 -0
  27. package/src/runtime/model-config-service.ts +265 -0
  28. package/src/runtime/session-file-gc.ts +70 -0
  29. package/src/runtime/subagent-service.ts +549 -0
  30. package/src/tools/subagent-tool.ts +286 -0
  31. package/src/tui/bg-notify-render.ts +139 -0
  32. package/src/tui/config-wizard.ts +253 -0
  33. package/src/tui/format-helpers.ts +37 -0
  34. package/src/tui/format.ts +332 -0
  35. package/src/tui/list-view.ts +883 -0
  36. package/src/tui/tool-render.ts +467 -0
  37. package/src/types.ts +334 -0
@@ -0,0 +1,199 @@
1
+ // src/core/event-bridge.ts
2
+ //
3
+ // 事件翻译层 + 累积器。把 Pi SDK 的 SdkEvent 流转换成 subagents 内部的
4
+ // AgentEvent 流,并累计 turn/toolCall/usage/lastError。
5
+ //
6
+ // 这是 session-factory / output-collector 共享的数据通路内核。
7
+ // 唯一依赖 types.ts(leaf)——可独立单测,无需 Pi SDK。
8
+ // 事件映射契约见 docs/subagents/session-runner.md §2。
9
+
10
+ import type {
11
+ AgentEvent,
12
+ AgentUsage,
13
+ ToolCall,
14
+ ToolCallResult,
15
+ } from "../types.ts";
16
+
17
+ // ============================================================
18
+ // SDK 事件 duck-type(订阅入口的最小可用子集)
19
+ // ============================================================
20
+
21
+ /** SDK AgentSessionEvent 的最小可用子集(duck-typed,避免强耦合 SDK 类型)。 */
22
+ export type SdkEvent = {
23
+ type: string;
24
+ toolCallId?: string;
25
+ toolName?: string;
26
+ args?: unknown;
27
+ result?: ToolCallResult;
28
+ isError?: boolean;
29
+ message?: {
30
+ usage?: AgentUsage & { cost?: { total: number } };
31
+ stopReason?: string;
32
+ errorMessage?: string;
33
+ };
34
+ assistantMessageEvent?: { type?: string; delta?: string };
35
+ reason?: string;
36
+ };
37
+
38
+ /**
39
+ * 运行时 guard:subscribe 回调收到的 event 形状未知,校验 type 字段后再交给 handle。
40
+ * 防止 SDK 事件结构变化时 switch(raw.type) 静默失配(全走 default 不报错)。
41
+ */
42
+ export function isSdkEvent(x: unknown): x is SdkEvent {
43
+ if (typeof x !== "object" || x === null) return false;
44
+ if (!("type" in x)) return false;
45
+ return typeof (x as SdkEvent).type === "string";
46
+ }
47
+
48
+ // ============================================================
49
+ // EventBridge(SDK 事件 → AgentEvent + 累积器)
50
+ // ============================================================
51
+
52
+ /**
53
+ * 把 SDK AgentSessionEvent 转换为 subagents AgentEvent,并累计 turn/toolCall/usage。
54
+ *
55
+ * bridge 累积器(turnCount/toolCalls/usage/lastError)供 collectResult 构造 AgentResult;
56
+ * 转发的 AgentEvent 供 updateFromEvent 更新 record——两套数据同源(handle 驱动)。
57
+ * 事件映射表见 docs/subagents/session-runner.md §2。
58
+ */
59
+ export interface EventBridge {
60
+ /** 传给 session.subscribe 的处理器。 */
61
+ handle(raw: SdkEvent): void;
62
+ /** 已完成 turn 数(turn_end 累积)。 */
63
+ readonly turnCount: number;
64
+ /** 累积的 tool 调用(tool_execution_end 累积)。 */
65
+ readonly toolCalls: ToolCall[];
66
+ /** 累积的 usage(所有 message_end 求和)。 */
67
+ readonly usage: AgentUsage & { cost: number };
68
+ /** 最后一次 message_end 的 stopReason=error/aborted 错误信息。 */
69
+ readonly lastError: string | undefined;
70
+ }
71
+
72
+ /** 空累积器的统一初值。 */
73
+ function zeroUsage(): AgentUsage & { cost: number } {
74
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
75
+ }
76
+
77
+ /** 创建 EventBridge 实例。onEvent 是调用方的 updateFromEvent wrapper。 */
78
+ export function createEventBridge(onEvent: (event: AgentEvent) => void): EventBridge {
79
+ let turnCount = 0;
80
+ const toolCalls: ToolCall[] = [];
81
+ let usage = zeroUsage();
82
+ let lastError: string | undefined;
83
+ // toolCallId → {toolName, args}:tool_end 取回 args(SDK end 不一定带)
84
+ const pendingTools = new Map<string, { toolName: string; args?: unknown }>();
85
+
86
+ // ── message_end 的 usage 累积 + error 判定 ──
87
+ // 独立函数:降低 handle 的圈复杂度,并集中保护「usage 与 error 不互斥」契约。
88
+ // LLM provider 常在错误响应里也携带 usage(计费需如此)。必须先累积 usage,
89
+ // 再独立判断 error/aborted,否则携带 usage 的错误响应会跳过 lastError 设置,
90
+ // 导致 session-runner 把 errored session 误判为 success=true。[HISTORICAL]
91
+ const accumulateMessageEnd = (raw: SdkEvent): void => {
92
+ const msg = raw.message;
93
+ if (msg?.usage) {
94
+ const u = msg.usage;
95
+ usage = {
96
+ input: usage.input + (u.input ?? 0),
97
+ output: usage.output + (u.output ?? 0),
98
+ cacheRead: usage.cacheRead + (u.cacheRead ?? 0),
99
+ cacheWrite: usage.cacheWrite + (u.cacheWrite ?? 0),
100
+ cost: usage.cost + (u.cost?.total ?? 0),
101
+ };
102
+ onEvent({ type: "message_end", usage: u });
103
+ }
104
+ // error/aborted:lastError 记录,转发 error 事件(与上面的 usage 累积独立)
105
+ const stopReason = msg?.stopReason;
106
+ if (stopReason === "error" || stopReason === "aborted") {
107
+ const errMsg = msg?.errorMessage ?? raw.reason ?? stopReason;
108
+ lastError = errMsg;
109
+ onEvent({ type: "error", message: errMsg });
110
+ }
111
+ };
112
+
113
+ const handle = (raw: SdkEvent): void => {
114
+ switch (raw.type) {
115
+ // ── tool ──────────────────────────────────────────
116
+ case "tool_execution_start": {
117
+ const toolName = raw.toolName ?? "";
118
+ if (raw.toolCallId) {
119
+ pendingTools.set(raw.toolCallId, { toolName, args: raw.args });
120
+ }
121
+ onEvent({ type: "tool_start", toolName, args: raw.args });
122
+ return;
123
+ }
124
+ case "tool_execution_end": {
125
+ const toolName = raw.toolName ?? "";
126
+ // args 优先取本事件的;缺失则从 pendingTools 回填(见 §2 易错点②)
127
+ let args = raw.args;
128
+ if (raw.toolCallId) {
129
+ const pending = pendingTools.get(raw.toolCallId);
130
+ if (pending) {
131
+ if (args === undefined) args = pending.args;
132
+ pendingTools.delete(raw.toolCallId);
133
+ }
134
+ }
135
+ toolCalls.push({
136
+ toolName,
137
+ args,
138
+ result: raw.result,
139
+ isError: raw.isError,
140
+ });
141
+ onEvent({
142
+ type: "tool_end",
143
+ toolName,
144
+ args,
145
+ isError: raw.isError,
146
+ });
147
+ return;
148
+ }
149
+
150
+ // ── message 流(thinking 必须在 text 之前判断,见 §2 易错点①)──
151
+ case "message_update": {
152
+ const ame = raw.assistantMessageEvent;
153
+ if (ame?.type === "thinking_delta") {
154
+ onEvent({ type: "thinking_delta", delta: ame.delta ?? "" });
155
+ } else if (ame?.delta !== undefined) {
156
+ onEvent({ type: "text_delta", delta: ame.delta });
157
+ }
158
+ return;
159
+ }
160
+
161
+ // ── turn / message 终态 ──────────────────────────
162
+ case "turn_end": {
163
+ turnCount += 1;
164
+ onEvent({ type: "turn_end" });
165
+ return;
166
+ }
167
+ case "message_end": {
168
+ accumulateMessageEnd(raw);
169
+ return;
170
+ }
171
+
172
+ // ── compaction ──────────────────────────────────
173
+ case "compaction_start": {
174
+ onEvent({ type: "compaction" });
175
+ return;
176
+ }
177
+
178
+ default:
179
+ // agent_start / message_start 等其他事件丢弃(见 §2 映射表末行)
180
+ return;
181
+ }
182
+ };
183
+
184
+ return {
185
+ handle,
186
+ get turnCount(): number {
187
+ return turnCount;
188
+ },
189
+ get toolCalls(): ToolCall[] {
190
+ return toolCalls;
191
+ },
192
+ get usage(): AgentUsage & { cost: number } {
193
+ return usage;
194
+ },
195
+ get lastError(): string | undefined {
196
+ return lastError;
197
+ },
198
+ };
199
+ }
@@ -0,0 +1,500 @@
1
+ // src/core/execution-record.ts
2
+ //
3
+ // 唯一执行状态对象 + 唯一创建/更新/完成/投影入口。
4
+ //
5
+ // 架构原则(见 data-model.md §1):
6
+ // - createRecord 唯一创建入口(model 创建时必填,消灭 poll 路径 model 丢失)
7
+ // - updateFromEvent 唯一事件更新入口(消灭 eventLog 双构建 + sink reset bug)
8
+ // - completeRecord 唯一完成入口(冻结状态)
9
+ // - project/snapshot/toPersisted 唯一投影入口(三路径字段一致)
10
+ //
11
+ // Core 层叶子原语:仅依赖 types.ts。零 Pi / Runtime / TUI 依赖。
12
+
13
+ import type {
14
+ AgentEvent,
15
+ AgentEventLogEntry,
16
+ AgentResult,
17
+ ExecutionMode,
18
+ ExecutionRecord,
19
+ PersistedAgentRecord,
20
+ RecordSnapshot,
21
+ SubagentToolDetails,
22
+ } from "../types.ts";
23
+
24
+ // ============================================================
25
+ // 常量(值经旧实现 + tests 验证)
26
+ // ============================================================
27
+
28
+ /** eventLog ring buffer 上限。超限移除最旧(while + shift)。 */
29
+ const MAX_EVENT_LOG_ENTRIES = 20;
30
+ /** text_delta 累积达此阈值推一条 text_output 条目,截断缓冲。 */
31
+ const TEXT_OUTPUT_CHUNK = 100;
32
+ /** thinking_delta 累积达此阈值推一条 thinking 条目,截断缓冲。 */
33
+ const THINKING_CHUNK = 100;
34
+ /** eventLog 条目 label 的最大长度(slice 截断,非省略号——保持列宽稳定)。 */
35
+ const EVENT_LOG_LABEL_MAX = 100;
36
+ /** turn_end 条目 label 的最大长度(取本 turn 累积文本前缀作摘要)。 */
37
+ const TURN_SUMMARY_MAX = 80;
38
+ /** ms → s 换算。elapsedSeconds 唯一计算点用。 */
39
+ const MS_PER_SECOND = 1000;
40
+ /** 持久化预览(taskPreview/resultPreview)截断长度。与旧 PERSISTED_PREVIEW_MAX 对齐。 */
41
+ const PREVIEW_MAX = 200;
42
+ /** computeCurrentActivity 中 thinking/text label 的前缀截断长度。 */
43
+ const ACTIVITY_LABEL_MAX = 60;
44
+
45
+ // ============================================================
46
+ // Label 提取(eventLog 构建的伴生逻辑,co-locate 于 Core)
47
+ // ============================================================
48
+
49
+ /**
50
+ * 从 toolName + args 提取 eventLog label(人类可读)。
51
+ *
52
+ * read/edit/write → "{tool} {basename}"(取 path 参数)
53
+ * bash → "{tool} {command 首行}"(截断 + emoji 安全)
54
+ * web_search → "{tool} {query}"
55
+ * web_fetch → "{tool} {url}"
56
+ * 其他 / 无 args → 裸 toolName
57
+ *
58
+ * 纯函数(零依赖),由 appendEventLogEntry 在 tool_start/tool_end 时调用。
59
+ * 历史上错放在 tui/format.ts,本层(Core)是唯一运行时调用方,已下沉归位。
60
+ */
61
+ export function extractLabelFromArgs(toolName: string, args: unknown): string {
62
+ if (typeof args !== "object" || args === null) return toolName;
63
+ const a = args as Record<string, unknown>;
64
+
65
+ // 读/写/编辑类:取路径 basename(~/.pi/.../foo.ts → foo.ts)
66
+ // 兼容 Pi tool 的多种路径参数名:path / file_path / filePath
67
+ const pathLike = (a.path ?? a.file_path ?? a.filePath) as unknown;
68
+ if (typeof pathLike === "string" && pathLike.length > 0) {
69
+ const base = pathLike.split(/[\\/]/).pop() ?? pathLike;
70
+ return `${toolName} ${base}`;
71
+ }
72
+
73
+ // bash:command 首行(截断 + emoji 安全——首个 emoji cluster 边界前截)
74
+ const cmd = a.command as unknown;
75
+ if (typeof cmd === "string" && cmd.length > 0) {
76
+ const firstLine = cmd.split("\n", 1)[0].trim();
77
+ return `${toolName} ${truncateLabel(firstLine)}`;
78
+ }
79
+
80
+ // web_search:query
81
+ const query = a.query as unknown;
82
+ if (typeof query === "string" && query.length > 0) {
83
+ return `${toolName} ${truncateLabel(query)}`;
84
+ }
85
+
86
+ // web_fetch:url
87
+ const url = a.url as unknown;
88
+ if (typeof url === "string" && url.length > 0) {
89
+ return `${toolName} ${truncateLabel(url)}`;
90
+ }
91
+
92
+ return toolName;
93
+ }
94
+
95
+ /** label 截断到 EVENT_LOG_LABEL_MAX(slice,非省略号——保持列宽稳定)。 */
96
+ function truncateLabel(s: string): string {
97
+ return s.length > EVENT_LOG_LABEL_MAX ? s.slice(0, EVENT_LOG_LABEL_MAX) : s;
98
+ }
99
+
100
+ // ============================================================
101
+ // 创建(唯一入口)
102
+ // ============================================================
103
+
104
+ /**
105
+ * 唯一创建入口。identity 字段(agent/model/thinkingLevel/mode/task)一次确定不可变。
106
+ *
107
+ * model 创建时必填——这是 poll 路径 model 丢失的架构修复
108
+ * (旧实现 background record 运行时丢 model,poll 返回缺字段)。
109
+ */
110
+ export function createRecord(
111
+ id: string,
112
+ identity: {
113
+ agent: string;
114
+ model: string;
115
+ thinkingLevel?: string;
116
+ mode: ExecutionMode;
117
+ task: string;
118
+ startedAt: number;
119
+ controller?: AbortController;
120
+ },
121
+ ): ExecutionRecord {
122
+ return {
123
+ id,
124
+ agent: identity.agent,
125
+ model: identity.model,
126
+ thinkingLevel: identity.thinkingLevel,
127
+ mode: identity.mode,
128
+ task: identity.task,
129
+ startedAt: identity.startedAt,
130
+
131
+ // 状态(实时更新)
132
+ status: "running",
133
+ eventLog: [],
134
+ turns: 0,
135
+ totalTokens: 0,
136
+
137
+ // 完成(completeRecord 唯一写点)
138
+ endedAt: undefined,
139
+ result: undefined,
140
+ error: undefined,
141
+ agentResult: undefined,
142
+
143
+ // 控制(仅 background 持有 controller;sync 为 undefined)
144
+ controller: identity.controller,
145
+
146
+ // chunking 缓冲(跨事件持久——修复 background sink reset bug)
147
+ _currentTurnText: "",
148
+ _currentThinking: "",
149
+ };
150
+ }
151
+
152
+ // ============================================================
153
+ // 事件更新(唯一更新点)
154
+ // ============================================================
155
+
156
+ /**
157
+ * 从 AgentEvent 更新 record。
158
+ * - eventLog 追加(tool_start/tool_end/text_output/thinking/turn_end)
159
+ * - turns 累积(turn_end++)
160
+ * - totalTokens 累积(message_end.usage 求和)
161
+ * - chunking 缓冲跨事件持久(修复 background text/thinking 丢失)
162
+ *
163
+ * ╔══════════════════════════════════════════════════════════════╗
164
+ * ║ record(唯一状态源) ║
165
+ * ║ ▲ ║
166
+ * ║ │ mutate(push/shift/累加) ║
167
+ * ║ │ ║
168
+ * ║ updateFromEvent(record, event) ◄── EventBridge 唯一调用 ║
169
+ * ╚══════════════════════════════════════════════════════════════╝
170
+ *
171
+ * 主控制流(switch 分派 + turns/tokens 累积)真实可执行;
172
+ * eventLog 构建细节(含 chunking + label 提取 + ring buffer)下沉到
173
+ * appendEventLogEntry 叶子。
174
+ */
175
+ export function updateFromEvent(record: ExecutionRecord, event: AgentEvent): void {
176
+ // 1. eventLog 构建(chunking 缓冲 + label 提取 + ring buffer,全部下沉叶子)
177
+ appendEventLogEntry(record, event);
178
+
179
+ // 2. turns 累积
180
+ if (event.type === "turn_end") {
181
+ record.turns += 1;
182
+ }
183
+
184
+ // 3. totalTokens 累积(input+output+cacheRead+cacheWrite 求和)
185
+ // 每个字段 ?? 0 防 NaN——SDK duck-typed,字段可能 undefined(M1 修复,镜像 event-bridge guard)。
186
+ if (event.type === "message_end" && event.usage) {
187
+ record.totalTokens +=
188
+ (event.usage.input ?? 0) + (event.usage.output ?? 0) +
189
+ (event.usage.cacheRead ?? 0) + (event.usage.cacheWrite ?? 0);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * eventLog 追加的核心逻辑(tool_start/tool_end/text_delta/thinking_delta/turn_end)。
195
+ * 直接 mutate record.eventLog + record._currentTurnText/_currentThinking。
196
+ *
197
+ * ╔══════════════════════════════════════════════════════════════╗
198
+ // ║ text_delta: _currentTurnText += delta ║
199
+ // ║ 达 TEXT_OUTPUT_CHUNK → push text_output, ║
200
+ // ║ 截断缓冲(while 循环处理超长 delta) ║
201
+ // ║ thinking_delta: _currentThinking += delta ║
202
+ // ║ 达 THINKING_CHUNK → push thinking,截断 ║
203
+ // ║ tool_start: extractLabelFromArgs → push {status:running} ║
204
+ // ║ tool_end: extractLabelFromArgs → push {status:done/ ║
205
+ // ║ failed(isError)} ║
206
+ // ║ turn_end: flush 残留 text/thinking 缓冲 + push turn_end ║
207
+ // ║ (label 取本 turn 文本前 TURN_SUMMARY_MAX) ║
208
+ // ║ 最后:while (log.length > MAX_EVENT_LOG_ENTRIES) log.shift() ║
209
+ // ╚══════════════════════════════════════════════════════════════╝
210
+ *
211
+ * label 用 EVENT_LOG_LABEL_MAX 截断(slice,非省略号——列宽稳定)。
212
+ * 这是映射表(event.type → push 动作)+ 缓冲操作,按深化矩阵留叶子。
213
+ */
214
+ function appendEventLogEntry(record: ExecutionRecord, event: AgentEvent): void {
215
+ const log = record.eventLog;
216
+ const now = Date.now();
217
+
218
+ switch (event.type) {
219
+ // ── text / thinking:累积型 streaming,达阈值分块 flush ──
220
+ case "text_delta":
221
+ record._currentTurnText += event.delta;
222
+ flushChunked(log, now, record, "_currentTurnText", TEXT_OUTPUT_CHUNK, "text_output");
223
+ return;
224
+
225
+ case "thinking_delta":
226
+ record._currentThinking += event.delta;
227
+ flushChunked(log, now, record, "_currentThinking", THINKING_CHUNK, "thinking");
228
+ return;
229
+
230
+ // ── tool_start:status:running ──
231
+ case "tool_start": {
232
+ log.push({
233
+ type: "tool_start",
234
+ label: truncateLabel(extractLabelFromArgs(event.toolName, event.args)),
235
+ ts: now,
236
+ status: "running",
237
+ });
238
+ trimRingBuffer(log);
239
+ return;
240
+ }
241
+
242
+ // ── tool_end:status 按 isError 定 done/failed ──
243
+ case "tool_end": {
244
+ log.push({
245
+ type: "tool_end",
246
+ label: truncateLabel(extractLabelFromArgs(event.toolName, event.args)),
247
+ ts: now,
248
+ status: event.isError ? "failed" : "done",
249
+ });
250
+ trimRingBuffer(log);
251
+ return;
252
+ }
253
+
254
+ // ── turn_end:flush 残留 text/thinking 缓冲(收尾全吐)+ 本 turn 摘要 ──
255
+ case "turn_end": {
256
+ flushRemaining(log, now, record, "_currentThinking", "thinking");
257
+ flushRemaining(log, now, record, "_currentTurnText", "text_output");
258
+ const summary = event.summary ?? "";
259
+ const turnLabel = summary
260
+ ? (summary.length > TURN_SUMMARY_MAX ? summary.slice(0, TURN_SUMMARY_MAX) : summary)
261
+ : "turn";
262
+ log.push({ type: "turn_end", label: turnLabel, ts: now });
263
+ trimRingBuffer(log);
264
+ return;
265
+ }
266
+
267
+ // ── error:直接推一条 error 条目 ──
268
+ case "error": {
269
+ log.push({ type: "error", label: truncateLabel(event.message), ts: now });
270
+ trimRingBuffer(log);
271
+ return;
272
+ }
273
+
274
+ default:
275
+ // message_end / compaction 不产生 eventLog 条目(usage/turns 累积在 updateFromEvent)
276
+ return;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * chunking 缓冲字段名(text/thinking 两块跨事件持久缓冲)。
282
+ * 用字面量联合而非 keyof,避免泄露 _currentTurnText/_currentThinking 私有意图。
283
+ */
284
+ type ChunkBufferKey = "_currentTurnText" | "_currentThinking";
285
+
286
+ /**
287
+ * 累积型 streaming(text_delta / thinking_delta)的分块 flush。
288
+ * 缓冲达 chunkSize 就 push 一条等宽条目,while 处理超长 delta,余数留在缓冲。
289
+ * 每块独立 truncateLabel 截断——保持 eventLog 列宽稳定。
290
+ */
291
+ function flushChunked(
292
+ log: AgentEventLogEntry[],
293
+ ts: number,
294
+ record: ExecutionRecord,
295
+ bufKey: ChunkBufferKey,
296
+ chunkSize: number,
297
+ type: "text_output" | "thinking",
298
+ ): void {
299
+ while (record[bufKey].length >= chunkSize) {
300
+ const chunk = record[bufKey].slice(0, chunkSize);
301
+ record[bufKey] = record[bufKey].slice(chunkSize);
302
+ log.push({ type, label: truncateLabel(chunk), ts });
303
+ }
304
+ trimRingBuffer(log);
305
+ }
306
+
307
+ /**
308
+ * turn_end 残留缓冲的收尾 flush:缓冲非空则整段 push 一条(不分块)。
309
+ * 与 flushChunked 的区别:不按阈值切片——残留是本 turn 最后的尾巴,
310
+ * 整段 push 后由 truncateLabel 兜底截断,缓冲清零。
311
+ */
312
+ function flushRemaining(
313
+ log: AgentEventLogEntry[],
314
+ ts: number,
315
+ record: ExecutionRecord,
316
+ bufKey: ChunkBufferKey,
317
+ type: "text_output" | "thinking",
318
+ ): void {
319
+ const remaining = record[bufKey];
320
+ if (remaining) {
321
+ log.push({ type, label: truncateLabel(remaining), ts });
322
+ record[bufKey] = "";
323
+ }
324
+ }
325
+
326
+ /** ring buffer 裁剪:超上限移除最旧(while + shift)。 */
327
+ function trimRingBuffer(log: AgentEventLogEntry[]): void {
328
+ while (log.length > MAX_EVENT_LOG_ENTRIES) {
329
+ log.shift();
330
+ }
331
+ }
332
+
333
+ // ============================================================
334
+ // 完成(唯一入口)
335
+ // ============================================================
336
+
337
+ /**
338
+ * status 状态机的 CAS 互斥锁。仅当 `record.status === "running"` 时改为 target
339
+ * 并返回 true,否则返回 false。**status 状态机本身就是互斥锁**——终态
340
+ * (done/failed/cancelled)不可逆,check-then-set 在 JS 单线程事件循环里天然原子。
341
+ *
342
+ * ╔══════════════════════════════════════════════════════════════╗
343
+ // ║ 用途:executor 的收尾竞争。cancelBackground 与 background ║
344
+ // ║ detached 完成回调都调 tryTransition 抢锁: ║
345
+ // ║ 抢到(true) → 负责完整收尾(completeRecord+archive+ ║
346
+ // ║ history+notify) ║
347
+ // ║ 没抢到(false)→ status 已被另一方转走,闭嘴不做事 ║
348
+ // ║ ║
349
+ // ║ 这取代了早期的 _settled 字段方案——被锁的字段(status)自身 ║
350
+ // ║ 不可逆,不需要第二个标记。详见 execution-flow.md §4。 ║
351
+ // ╚══════════════════════════════════════════════════════════════╝
352
+ */
353
+ export function tryTransition(
354
+ record: ExecutionRecord,
355
+ target: "done" | "failed" | "cancelled",
356
+ ): boolean {
357
+ if (record.status !== "running") return false;
358
+ record.status = target;
359
+ return true;
360
+ }
361
+
362
+ /**
363
+ * 唯一完成入口。冻结状态(写 endedAt/agentResult/result/error)。
364
+ * 不修改 turns/totalTokens——已由 updateFromEvent 累积,completeRecord 只读不重置。
365
+ *
366
+ * ⚠ 前置条件:调用方必须先通过 tryTransition 抢到锁(status 已被 CAS 设为 target)。
367
+ * completeRecord 本身不重复判定 status——它是抢锁之后的"写结果"步骤,
368
+ * 状态机互斥由 tryTransition 单点负责。
369
+ */
370
+ export function completeRecord(
371
+ record: ExecutionRecord,
372
+ result: AgentResult,
373
+ status: "done" | "failed" | "cancelled",
374
+ ): void {
375
+ record.status = status;
376
+ record.endedAt = Date.now();
377
+ record.agentResult = result;
378
+ record.result = result.text;
379
+ record.error = result.error;
380
+ }
381
+
382
+ // ============================================================
383
+ // 投影(唯一 → Details / Snapshot / Persisted)
384
+ // ============================================================
385
+
386
+ /**
387
+ * 投影到 SubagentToolDetails。elapsedSeconds 唯一计算点(Math.floor)。
388
+ * eventLog 必须 .slice() 快照——record.eventLog 是被 push/shift mutate 的可变数组。
389
+ */
390
+ export function project(record: ExecutionRecord): SubagentToolDetails {
391
+ return {
392
+ status: record.status,
393
+ agent: record.agent,
394
+ model: record.model,
395
+ thinkingLevel: record.thinkingLevel,
396
+ turns: record.turns,
397
+ totalTokens: record.totalTokens,
398
+ elapsedSeconds: computeElapsedSeconds(record),
399
+ eventLog: record.eventLog.slice(),
400
+ result: record.result,
401
+ error: record.error,
402
+ // running 时的当前活动行(tool > thinking > text 优先级)——下沉叶子
403
+ currentActivity: record.status === "running" ? computeCurrentActivity(record) : undefined,
404
+ // schema 产出仅在 record 完成后可用(agentResult 冻结时填)。
405
+ parsedOutput: record.agentResult?.parsedOutput,
406
+ };
407
+ }
408
+
409
+ /**
410
+ * 投影到只读快照(TUI list / poll 消费)。
411
+ * 浅拷贝 eventLog,字段标 readonly 阻止 TUI 回写。
412
+ */
413
+ export function snapshot(record: ExecutionRecord): RecordSnapshot {
414
+ return {
415
+ id: record.id,
416
+ agent: record.agent,
417
+ model: record.model,
418
+ thinkingLevel: record.thinkingLevel,
419
+ mode: record.mode,
420
+ task: record.task,
421
+ status: record.status,
422
+ eventLog: record.eventLog.slice(),
423
+ turns: record.turns,
424
+ totalTokens: record.totalTokens,
425
+ startedAt: record.startedAt,
426
+ endedAt: record.endedAt,
427
+ result: record.result,
428
+ error: record.error,
429
+ };
430
+ }
431
+
432
+ /**
433
+ * 投影到 PersistedAgentRecord(history.jsonl 一行)。预览字段截断。
434
+ */
435
+ export function toPersisted(
436
+ record: ExecutionRecord,
437
+ cwd: string,
438
+ sessionId?: string,
439
+ ): PersistedAgentRecord {
440
+ return {
441
+ id: record.id,
442
+ agent: record.agent,
443
+ status: record.status,
444
+ mode: record.mode,
445
+ taskPreview: truncatePreview(record.task),
446
+ startedAt: record.startedAt,
447
+ endedAt: record.endedAt,
448
+ turns: record.turns,
449
+ totalTokens: record.totalTokens,
450
+ error: record.error,
451
+ resultPreview: record.result ? truncatePreview(record.result) : undefined,
452
+ sessionFile: record.agentResult?.sessionFile,
453
+ cwd,
454
+ sessionId,
455
+ model: record.model,
456
+ thinkingLevel: record.thinkingLevel,
457
+ };
458
+ }
459
+
460
+ // ============================================================
461
+ // 投影内部 helper
462
+ // ============================================================
463
+
464
+ /** elapsedSeconds 唯一计算点。endedAt 缺失(running 中)用 Date.now()。 */
465
+ function computeElapsedSeconds(record: ExecutionRecord): number {
466
+ const end = record.endedAt ?? Date.now();
467
+ return Math.floor((end - record.startedAt) / MS_PER_SECOND);
468
+ }
469
+
470
+ /**
471
+ * running 时的当前活动行(tool > thinking > text 优先级)。
472
+ * 按优先级倒序扫 eventLog:最近的 tool_start(running 中)> 正在 thinking > 正在 text。
473
+ */
474
+ function computeCurrentActivity(
475
+ record: ExecutionRecord,
476
+ ): { type: "tool" | "text" | "thinking"; label: string } | undefined {
477
+ // 1. 倒序找最近的 tool_start 且 status==="running"(配对 tool_end 尚未到)
478
+ for (let i = record.eventLog.length - 1; i >= 0; i--) {
479
+ const entry = record.eventLog[i];
480
+ if (entry.type === "tool_start" && entry.status === "running") {
481
+ return { type: "tool", label: entry.label };
482
+ }
483
+ // 遇到 tool_end 说明最近一个 tool 已结束,停止回溯(其后没有"正在跑"的 tool)
484
+ if (entry.type === "tool_end") break;
485
+ }
486
+ // 2. 正在 thinking
487
+ if (record._currentThinking) {
488
+ return { type: "thinking", label: record._currentThinking.slice(0, ACTIVITY_LABEL_MAX) };
489
+ }
490
+ // 3. 正在输出 text
491
+ if (record._currentTurnText) {
492
+ return { type: "text", label: record._currentTurnText.slice(0, ACTIVITY_LABEL_MAX) };
493
+ }
494
+ return undefined;
495
+ }
496
+
497
+ /** 持久化预览截断(task/result 长文本截到单行可读长度)。 */
498
+ function truncatePreview(text: string): string {
499
+ return text.length > PREVIEW_MAX ? text.slice(0, PREVIEW_MAX) : text;
500
+ }