@zhushanwen/pi-session-reader 0.1.0 → 0.2.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.
@@ -11,11 +11,15 @@
11
11
  * 闭包 catch 转 isError:true 文本返回——handler 可抛(纯逻辑可测),execute 不抛(pi 契约)。
12
12
  * 例外:F2 多匹配与 F1 find 零匹配「不视为错误」,返回消歧/提示结果而非抛错。
13
13
  */
14
+ import { existsSync, openSync, readSync, closeSync } from 'node:fs'
14
15
  import { mkdir, writeFile } from 'node:fs/promises'
15
- import { join } from 'node:path'
16
+ import { join, isAbsolute } from 'node:path'
17
+ import { homedir } from 'node:os'
16
18
  import { findSessions, type MatchedSession } from './discovery/find.js'
17
- import { buildFamilyFromFs } from './discovery/subagents.js'
19
+ import { buildFamilyFromFs, listRecordManifests, type RecordManifest } from './discovery/subagents.js'
20
+ import { readRunSnapshot, resolveWorkflows } from './discovery/workflows.js'
18
21
  import { parseSessionFile, type Entry, type ParseResult } from './core/parser.js'
22
+ import { parseRunSnapshot, renderWorkflowOverview, type WorkflowOverview } from './core/workflow.js'
19
23
  import { buildTreeView } from './core/tree.js'
20
24
  import { segmentTurns, type Turn } from './core/turns.js'
21
25
  import { extractToolCalls, formatToolCallSummary, basename } from './core/toolcall.js'
@@ -28,7 +32,12 @@ import {
28
32
  type EntryBrief,
29
33
  type ToolResultSummaryEntry,
30
34
  } from './core/render.js'
31
- import type { Family } from './core/family.js'
35
+ import type { Family, SessionRef, WorkflowRef } from './core/family.js'
36
+ import {
37
+ buildExecutionTree,
38
+ formatExecutionTreeText,
39
+ type ExecutionTree,
40
+ } from './core/execution-tree.js'
32
41
 
33
42
  // ---------------------------------------------------------------------------
34
43
  // 公共类型(与 index.ts 的 TypeBox schema 对齐)
@@ -43,6 +52,7 @@ export type SessionReadAction =
43
52
  | 'search'
44
53
  | 'export'
45
54
  | 'extract'
55
+ | 'workflow'
46
56
 
47
57
  export interface SessionReadParams {
48
58
  action: SessionReadAction
@@ -58,11 +68,17 @@ export interface SessionReadParams {
58
68
  allBranches?: boolean
59
69
  granularity?: 'turn' | 'entry'
60
70
  cwd?: string
71
+ /** find/resolveSessionId: 按来源过滤。"main" = sessions/、"subagent" = subagents/。默认两者合并。 */
72
+ source?: 'main' | 'subagent'
73
+ /** workflow action: 可选,聚焦单个 runId(多 run 消歧)。不传 → 全部 run 概览。 */
74
+ runId?: string
61
75
  limit?: number
62
76
  /** extract action: 素材类型(必填)。其他 action 忽略。 */
63
77
  what?: 'user-messages' | 'commands' | 'files' | 'commits' | 'tool-results'
64
78
  /** extract action: 过滤 commands/tool-results 的工具名(可选)。 */
65
79
  tool?: string
80
+ /** family action: 返回嵌套执行树(任意深度 subagent↔workflow-call 相互嵌套)。默认 false(flat family)。 */
81
+ recursive?: boolean
66
82
  }
67
83
 
68
84
  export interface ToolResult {
@@ -165,26 +181,134 @@ type ResolveResult =
165
181
  | { kind: 'ok'; sessionId: string; fileName: string }
166
182
  | { kind: 'multi'; query: string; candidates: MatchedSession[] }
167
183
 
184
+ /** readSessionHeaderId 读首行的 buffer 上限。session header(id/cwd/parentSession)实测 < 300 字节,4KB 足够。 */
185
+ const HEADER_READ_BYTES = 4096
186
+
168
187
  /**
169
- * session 参数(完整 id 或片段,可能带 # 前缀)解析到唯一完整 id。
188
+ * 同步读 session 文件首行 header,返回 type==='session' id。
170
189
  *
171
- * findSessions(M2 已实现三路匹配:uuid 片段 / recent / 名称关键词)。
172
- * - 唯一匹配 {kind:'ok'}(含 fileName,后续 parseSessionFile 直接用)
173
- * - 多匹配 {kind:'multi'}(调用方据此返回 F2 消歧,不抛错)
174
- * - 零匹配 → 抛 F1(含最近 10 个 session 建议 + 👉,design §3.4 F1 模板)
190
+ * 任何异常(文件不存在/空文件/解析失败/type 不符)返回 undefined。与 find.ts readFirstLine/
191
+ * parseHeader 同构(定长 buffer 读首行 + JSON.parse + type 校验),但用同步 fs API
192
+ *(resolveSessionId 内仅调用 1 次,同步开销可接受),且不导出——避免与 w1 find.ts
193
+ * 文件交叉(CQ2 决策)。
194
+ */
195
+ function readSessionHeaderId(filePath: string): string | undefined {
196
+ let fd: number | undefined
197
+ try {
198
+ fd = openSync(filePath, 'r')
199
+ const buf = Buffer.alloc(HEADER_READ_BYTES)
200
+ const bytesRead = readSync(fd, buf, 0, HEADER_READ_BYTES, 0)
201
+ if (bytesRead === 0) return undefined
202
+ const text = buf.subarray(0, bytesRead).toString('utf8')
203
+ const nl = text.indexOf('\n')
204
+ const line = nl === -1 ? text : text.slice(0, nl)
205
+ let raw: unknown
206
+ try {
207
+ raw = JSON.parse(line)
208
+ } catch {
209
+ return undefined
210
+ }
211
+ if (typeof raw !== 'object' || raw === null) return undefined
212
+ const o = raw as Record<string, unknown>
213
+ if (o.type !== 'session' || typeof o.id !== 'string') return undefined
214
+ return o.id
215
+ } catch {
216
+ return undefined
217
+ } finally {
218
+ if (fd !== undefined) {
219
+ try {
220
+ closeSync(fd)
221
+ } catch {
222
+ // closeSync 失败:fd 可能已无效,header 数据已读取,关闭失败不影响结果(best-effort)
223
+ void fd
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ /** ~ 前缀(home 目录简写),与 expandHome 配套避免 magic number。 */
230
+ const HOME_TILDE_PREFIX = '~/'
231
+
232
+ /** 展开 ~ 前缀到 homedir('~' → homedir;'~/x' → homedir/x;其余原样)。 */
233
+ function expandHome(p: string): string {
234
+ if (p === '~') return homedir()
235
+ if (p.startsWith(HOME_TILDE_PREFIX))
236
+ return join(homedir(), p.slice(HOME_TILDE_PREFIX.length))
237
+ return p
238
+ }
239
+
240
+ /**
241
+ * 把 session 参数解析到唯一完整 id(design §6.1 M0 + U2/U3)。
175
242
  *
176
- * 仅用于 family/outline/expand/detail/search/export(find action 自行调 findSessions,
243
+ * 三形态:
244
+ * - ① 绝对路径 / ~ 前缀 → 展开后读首行 header,sessionId=header 真实 id(文件名仅定位)
245
+ * - ② sa-id 前缀 → listRecordManifests 精确反查,sessionId=sessionFile header id
246
+ * (禁止降级 record.id——sa- 形态不可当 sessionId,CQ3 决策)
247
+ * - ③ 其余 → findSessions 透传 source 沿用 F1/F2
248
+ *
249
+ * 错误契约(U3):① 文件不存在/非 .jsonl/header 读不出 → F6 风格;
250
+ * ② sessionFile GC → ES1(manifest 元数据 + 👉);sa-id 0/>1 命中 → ES2(👉 family)。
251
+ *
252
+ * 仅用于 family/outline/expand/detail/search/export/extract(find action 自行调 findSessions,
177
253
  * 零匹配时返回空 + 提示,不抛错)。
178
254
  */
179
255
  async function resolveSessionId(
180
256
  rawSession: string | undefined,
181
257
  action: SessionReadAction,
182
258
  agentDir: string,
259
+ source?: 'main' | 'subagent',
183
260
  ): Promise<ResolveResult> {
184
261
  const session = stripHash(requireStr(rawSession, 'session', action))
185
- const { matches } = await findSessions(session, agentDir, { limit: 10 })
262
+
263
+ // ① 绝对路径或 ~ 前缀(Windows 盘符由 isAbsolute 处理)
264
+ if (isAbsolute(session) || session === '~' || session.startsWith('~/')) {
265
+ const expanded = expandHome(session)
266
+ if (!expanded.endsWith('.jsonl')) {
267
+ throw err(
268
+ `读取失败:${session}(非 .jsonl session 文件)。👉 检查文件或换 session。`,
269
+ )
270
+ }
271
+ if (!existsSync(expanded)) {
272
+ throw err(`读取失败:${session}(文件不存在)。👉 检查文件或换 session。`)
273
+ }
274
+ const headerId = readSessionHeaderId(expanded)
275
+ if (headerId === undefined) {
276
+ throw err(
277
+ `读取失败:${session}(首行非合法 session header)。👉 检查文件或换 session。`,
278
+ )
279
+ }
280
+ return { kind: 'ok', sessionId: headerId, fileName: expanded }
281
+ }
282
+
283
+ // ② sa-id 前缀 → record manifest 精确反查
284
+ if (session.startsWith('sa-')) {
285
+ const manifests = await listRecordManifests(agentDir)
286
+ const hits = manifests.filter((m) => m.id === session)
287
+ if (hits.length === 0) {
288
+ throw err(formatSaIdNotFound(session))
289
+ }
290
+ if (hits.length > 1) {
291
+ throw err(formatSaIdAmbiguous(session, hits))
292
+ }
293
+ const record = hits[0]
294
+ if (!existsSync(record.sessionFile)) {
295
+ throw err(formatSessionGc(record))
296
+ }
297
+ const headerId = readSessionHeaderId(record.sessionFile)
298
+ if (headerId === undefined) {
299
+ // header 读不出不降级 record.id(sa- 形态不可当 sessionId,CQ3)
300
+ throw err(
301
+ `读取失败:${record.sessionFile}(首行非合法 session header)。👉 检查文件或换 session。`,
302
+ )
303
+ }
304
+ return { kind: 'ok', sessionId: headerId, fileName: record.sessionFile }
305
+ }
306
+
307
+ // ③ 其余:findSessions 透传 source 沿用 F1/F2
308
+ const opts = { limit: 10, ...(source ? { source } : {}) }
309
+ const { matches } = await findSessions(session, agentDir, opts)
186
310
  if (matches.length === 0) {
187
- const recent = await findSessions('recent', agentDir, { limit: 10 })
311
+ const recent = await findSessions('recent', agentDir, opts)
188
312
  throw err(formatNoMatch(session, recent.matches))
189
313
  }
190
314
  if (matches.length === 1) {
@@ -193,6 +317,37 @@ async function resolveSessionId(
193
317
  return { kind: 'multi', query: session, candidates: matches }
194
318
  }
195
319
 
320
+ /** ES1(SESSION_FILE_GC):sa-id 恰 1 命中但 sessionFile 不存在(GC/未写入)。含 manifest 元数据 + 👉。 */
321
+ function formatSessionGc(record: RecordManifest): string {
322
+ return (
323
+ `subagent "${record.id}" 的 session 文件不存在(可能已被 GC 或未写入):\n` +
324
+ ` rootSessionId: ${record.rootSessionId}\n` +
325
+ ` agentName: ${record.agentName ?? '(未记录)'}\n` +
326
+ ` sessionFile: ${record.sessionFile}\n` +
327
+ `👉 改用 session_read { action:"family" } 查该 subagent 的后代,或换一个 completed subagent 重试。`
328
+ )
329
+ }
330
+
331
+ /** ES2(SA_ID_NO_MATCH):sa-id 无精确匹配(可能仍在运行 / 片段输入)。 */
332
+ function formatSaIdNotFound(saId: string): string {
333
+ return (
334
+ `subagent "${saId}" 无匹配 record(可能仍在运行——终态 record 在 completed/failed 后才写)。` +
335
+ `\n👉 用 session_read { action:"family" } 查活跃/已完成的 subagent;` +
336
+ `若是片段输入,请用完整 sa- id 或 action:"find" 重试。`
337
+ )
338
+ }
339
+
340
+ /** ES2(SA_ID_AMBIGUOUS):sa-id 多 manifest 命中(数据异常,record.id 应唯一)。 */
341
+ function formatSaIdAmbiguous(saId: string, records: RecordManifest[]): string {
342
+ return (
343
+ `subagent "${saId}" 匹配 ${records.length} 个 record(数据异常,record.id 应唯一):\n` +
344
+ records
345
+ .map((r) => ` ${r.id} (root=${r.rootSessionId} file=${r.sessionFile})`)
346
+ .join('\n') +
347
+ `\n👉 用 session_read { action:"family" } 或完整 session uuid 重试。`
348
+ )
349
+ }
350
+
196
351
  /** F1 无匹配 message(含最近 10 + 👉)。 */
197
352
  function formatNoMatch(query: string, recent: MatchedSession[]): string {
198
353
  const lines: string[] = recent.length
@@ -462,6 +617,7 @@ async function doFind(params: SessionReadParams, agentDir: string): Promise<Tool
462
617
  const { matches, truncated } = await findSessions(query, agentDir, {
463
618
  cwd: params.cwd,
464
619
  limit: params.limit ?? 20,
620
+ ...(params.source ? { source: params.source } : {}),
465
621
  })
466
622
  if (matches.length === 0) {
467
623
  const recent = await findSessions('recent', agentDir, { limit: 10 })
@@ -476,10 +632,36 @@ async function doFind(params: SessionReadParams, agentDir: string): Promise<Tool
476
632
  }
477
633
  }
478
634
 
479
- /** family:fork 父链/子代 + 隔代 subagent + workflow run(design §3.4 family)。 */
635
+ /**
636
+ * family:fork 父链/子代 + 隔代 subagent + workflow run(design §3.4 family)。
637
+ *
638
+ * recursive=false(默认)→ flat family(buildFamilyFromFs + formatFamilyText,m0/m1/m2 行为零回归)。
639
+ * recursive=true → 嵌套执行树(buildExecutionTree + formatExecutionTreeText,任意深度
640
+ * subagent↔workflow-call 相互嵌套,IF4)。错误契约同构:multi→disambiguate;构建抛错→catch 转 👉。
641
+ */
480
642
  async function doFamily(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
481
- const resolved = await resolveSessionId(params.session, 'family', agentDir)
643
+ const resolved = await resolveSessionId(params.session, 'family', agentDir, params.source)
482
644
  if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
645
+
646
+ // recursive=true:嵌套执行树(U7/U8)
647
+ if (params.recursive) {
648
+ let tree: ExecutionTree
649
+ try {
650
+ // MF-1:传 resolved.fileName 使 main root 填 sessionFile——main session 自身发起的
651
+ // workflow run(workflow-state-link)进入执行树,与 flat family 行为一致。
652
+ tree = await buildExecutionTree(resolved.sessionId, agentDir, resolved.fileName)
653
+ } catch (e) {
654
+ throw err(
655
+ `构建执行树失败:${resolved.sessionId}(${e instanceof Error ? e.message : String(e)})。👉 检查 session 或用 find 重新定位,或改用 recursive:false 看 flat family 兑底。`,
656
+ )
657
+ }
658
+ return {
659
+ content: [{ type: 'text', text: formatExecutionTreeText(tree) }],
660
+ details: { tree },
661
+ }
662
+ }
663
+
664
+ // recursive falsy(默认):flat family(m0/m1/m2 现状零回归)
483
665
  let family: Family
484
666
  try {
485
667
  family = await buildFamilyFromFs(resolved.sessionId, agentDir)
@@ -493,7 +675,7 @@ async function doFamily(params: SessionReadParams, agentDir: string): Promise<To
493
675
 
494
676
  /** outline:turn 级全貌 TOC(design §3.4 outline,~500 token)。 */
495
677
  async function doOutline(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
496
- const resolved = await resolveSessionId(params.session, 'outline', agentDir)
678
+ const resolved = await resolveSessionId(params.session, 'outline', agentDir, params.source)
497
679
  if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
498
680
  const { entries, totalBytes } = await safeParse(resolved.fileName)
499
681
  const tree = buildTreeView(entries)
@@ -512,7 +694,7 @@ async function doOutline(params: SessionReadParams, agentDir: string): Promise<T
512
694
 
513
695
  /** expand:单 turn 的 entry 列表(design §3.4 expand)。turn 越界抛 F4。 */
514
696
  async function doExpand(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
515
- const resolved = await resolveSessionId(params.session, 'expand', agentDir)
697
+ const resolved = await resolveSessionId(params.session, 'expand', agentDir, params.source)
516
698
  if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
517
699
  const turnIdx = parseTurnIndex(requireStr(params.turn, 'turn', 'expand'))
518
700
  const { entries } = await safeParse(resolved.fileName)
@@ -534,7 +716,7 @@ async function doExpand(params: SessionReadParams, agentDir: string): Promise<To
534
716
 
535
717
  /** detail:turns 范围的完整文本(design §3.4 detail)。默认省略 toolResult/thinking。 */
536
718
  async function doDetail(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
537
- const resolved = await resolveSessionId(params.session, 'detail', agentDir)
719
+ const resolved = await resolveSessionId(params.session, 'detail', agentDir, params.source)
538
720
  if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
539
721
  const range = parseTurnsRange(requireStr(params.turns, 'turns', 'detail'))
540
722
  const { entries } = await safeParse(resolved.fileName)
@@ -563,7 +745,7 @@ async function doSearch(
563
745
  agentDir: string,
564
746
  signal?: AbortSignal,
565
747
  ): Promise<ToolResult> {
566
- const resolved = await resolveSessionId(params.session, 'search', agentDir)
748
+ const resolved = await resolveSessionId(params.session, 'search', agentDir, params.source)
567
749
  if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
568
750
  const pattern = requireStr(params.pattern, 'pattern', 'search')
569
751
  const scope = params.scope ?? 'all'
@@ -615,7 +797,7 @@ async function doSearch(
615
797
  /** export:物化摘要到 <agentDir>/tmp/session-view-<id>.md(design §3.4 export,D-8)。 */
616
798
  async function doExport(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
617
799
  const format = params.format ?? 'outline'
618
- const resolved = await resolveSessionId(params.session, 'export', agentDir)
800
+ const resolved = await resolveSessionId(params.session, 'export', agentDir, params.source)
619
801
  if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
620
802
 
621
803
  let text: string
@@ -1064,7 +1246,7 @@ function extractToolResults(turns: Turn[], tool: string | undefined): ToolResult
1064
1246
  * segmentTurns → 可选 turns 范围限定(复用 parseTurnsRange)→ F7 校验 what → 分发 5 预设。
1065
1247
  */
1066
1248
  async function doExtract(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
1067
- const resolved = await resolveSessionId(params.session, 'extract', agentDir)
1249
+ const resolved = await resolveSessionId(params.session, 'extract', agentDir, params.source)
1068
1250
  if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
1069
1251
  const { entries } = await safeParse(resolved.fileName)
1070
1252
  // extract 遍历全量 entry(含旁支/压缩历史),与 outline/expand/detail 的 leaf 视图不同:
@@ -1114,6 +1296,135 @@ async function doExtract(params: SessionReadParams, agentDir: string): Promise<T
1114
1296
  }
1115
1297
  }
1116
1298
 
1299
+ // ===========================================================================
1300
+ // workflow action(w6:消费 w5 的 readRunSnapshot/parseRunSnapshot/renderWorkflowOverview)
1301
+ // ===========================================================================
1302
+
1303
+ /** doWorkflow 的 details 结构(ES-wf-no-runs/runid-not-found/snapshot-* 错误契约的具体类型)。 */
1304
+ interface WorkflowDetails {
1305
+ runs: WorkflowOverview[]
1306
+ runIds: string[]
1307
+ skippedRuns?: Array<{ runId: string; stateFile: string; reason: string }>
1308
+ requestedRunId?: string
1309
+ sessionId?: string
1310
+ }
1311
+
1312
+ /** 单个被跳过的 run 记录(snapshot 不可读/不可解析)。 */
1313
+ interface SkippedRun {
1314
+ runId: string
1315
+ stateFile: string
1316
+ reason: string
1317
+ }
1318
+
1319
+ /**
1320
+ * workflow:workflow run 概览(design §3.4 workflow,m2 IF-doWorkflow)。
1321
+ *
1322
+ * 流程:① resolveSessionId(multi 走 disambiguate)→ ② 读目标 session 的 workflow-state-link
1323
+ * → ③ 无 run → ES-wf-no-runs(提示+👉family,不抛错)→ ④ runId 过滤,无匹配 →
1324
+ * ES-wf-runid-not-found(列候选+👉,不抛错)→ ⑤ 逐 run readRunSnapshot+parseRunSnapshot,
1325
+ * 不可读/不可解析 → skippedRuns(不中断其他 run,ES-wf-snapshot-read-fail/unparseable)
1326
+ * → ⑥ renderWorkflowOverview 拼接。
1327
+ *
1328
+ * ② 的读取(MF-2):不用 buildFamilyFromFs(其 resolveFamily 只索引 main session,subagent
1329
+ * session 会抛「session not found in family index」)——resolveSessionId 已把 session 解析到
1330
+ * 真实文件(kind==='ok' 保证文件存在,三形态:绝对路径/sa-id 均 existsSync 校验,片段匹配
1331
+ * 来自实际 fs 扫描),直接用 resolved.fileName 构造单条目 sessionIdToPath 调 resolveWorkflows
1332
+ *(与 buildFamilyFromFs 步骤 6 的 workflow 腿同源)。pathToRef 仅含目标 session,call 引用
1333
+ * 走 sessionRefFromPath 文件名最小回退(sessionId+fileName,足够 LLM 跳 outline/detail 深读)。
1334
+ *
1335
+ * 错误契约(C2):workflow 概览探索语义,三类错误均返回 ToolResult 不抛错。
1336
+ * step 的 call sessionId/sessionFile 是 LLM 跳 outline/detail 的入口(m0 resolveSessionId
1337
+ * 三形态复用:sessionId/绝对路径/sa-id 均可深读,TC-wf-step-sessionfile-link)。
1338
+ */
1339
+ async function doWorkflow(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
1340
+ const resolved = await resolveSessionId(params.session, 'workflow', agentDir, params.source)
1341
+ if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
1342
+
1343
+ let workflows: WorkflowRef[]
1344
+ try {
1345
+ // MF-2:直读 resolved.fileName(subagent session 亦可),绕过 buildFamilyFromFs 的
1346
+ // main-only byId 索引(对 subagent 抛「session not found in family index」)。
1347
+ // resolveWorkflows 自身容错(读失败返回 []),「session 真不存在」的 F 级契约已由
1348
+ // resolveSessionId 保证(kind==='ok' 前已 existsSync/扫描校验)。
1349
+ const sessionIdToPath = new Map<string, string>([[resolved.sessionId, resolved.fileName]])
1350
+ const pathToRef = new Map<string, SessionRef>()
1351
+ workflows = await resolveWorkflows(resolved.sessionId, sessionIdToPath, pathToRef)
1352
+ } catch (e) {
1353
+ throw err(
1354
+ `读取 workflow run 失败:${resolved.sessionId}(${e instanceof Error ? e.message : String(e)})。👉 检查 session 或用 find 重新定位。`,
1355
+ )
1356
+ }
1357
+ const allRunIds = workflows.map((w) => w.runId)
1358
+
1359
+ // ③ ES-wf-no-runs:session 未发起任何 workflow run(不抛错,返提示+👉family)
1360
+ if (workflows.length === 0) {
1361
+ const text =
1362
+ `session ${resolved.sessionId} 无 workflow run。\n` +
1363
+ `👉 用 session_read { action:'family' } 查该 session 的 subagent 后代,或确认 session 是否发起过 workflow。`
1364
+ const details: WorkflowDetails = { runs: [], runIds: [], sessionId: resolved.sessionId }
1365
+ return { content: [{ type: 'text', text }], details }
1366
+ }
1367
+
1368
+ // ④ runId 过滤(可选,多 run 消歧)
1369
+ const requestedRunId =
1370
+ params.runId !== undefined && params.runId.trim() !== '' ? params.runId.trim() : undefined
1371
+ let selected: WorkflowRef[] = workflows
1372
+ if (requestedRunId !== undefined) {
1373
+ selected = workflows.filter((w) => w.runId === requestedRunId)
1374
+ if (selected.length === 0) {
1375
+ // ES-wf-runid-not-found:列出可用 runId + 👉(不抛错,与 F2 多匹配消歧同构)
1376
+ const lines = allRunIds.map((rid) => ` ${rid}`).join('\n')
1377
+ const text =
1378
+ `runId "${requestedRunId}" 无匹配。可用 runId:\n${lines}\n` +
1379
+ `👉 用上述完整 runId 重试,或不传 runId 看全部 run 概览。`
1380
+ const details: WorkflowDetails = { runs: [], runIds: allRunIds, requestedRunId }
1381
+ return { content: [{ type: 'text', text }], details }
1382
+ }
1383
+ }
1384
+
1385
+ // ⑤⑥ 逐 run 读 snapshot → parse → render
1386
+ const runs: WorkflowOverview[] = []
1387
+ const runIds: string[] = []
1388
+ const skippedRuns: SkippedRun[] = []
1389
+ const contentParts: string[] = []
1390
+
1391
+ for (const wf of selected) {
1392
+ const snap = await readRunSnapshot(wf.stateFile)
1393
+ if (snap === undefined) {
1394
+ // ES-wf-snapshot-read-fail:文件不存在/读失败/全行不可解析 → 跳过,不中断其他 run
1395
+ skippedRuns.push({ runId: wf.runId, stateFile: wf.stateFile, reason: 'snapshot-unreadable' })
1396
+ contentParts.push(`run ${wf.runId}: 快照不可读(stateFile=${wf.stateFile})已跳过`)
1397
+ continue
1398
+ }
1399
+ const overview = parseRunSnapshot(snap, wf.runId, wf.stateFile)
1400
+ if (overview === null) {
1401
+ // ES-wf-snapshot-unparseable:对象既非 NEW 也非 OLD → 跳过
1402
+ skippedRuns.push({ runId: wf.runId, stateFile: wf.stateFile, reason: 'snapshot-unparseable' })
1403
+ contentParts.push(`run ${wf.runId}: 快照格式不可识别(stateFile=${wf.stateFile})已跳过`)
1404
+ continue
1405
+ }
1406
+ runs.push(overview)
1407
+ runIds.push(wf.runId)
1408
+ contentParts.push(renderWorkflowOverview(overview))
1409
+ }
1410
+
1411
+ const details: WorkflowDetails = { runs, runIds }
1412
+ if (requestedRunId !== undefined) details.requestedRunId = requestedRunId
1413
+ if (skippedRuns.length > 0) details.skippedRuns = skippedRuns
1414
+
1415
+ // 全部 run 都跳过的兑底提示(ES-wf-snapshot-read-fail 末段)
1416
+ let text: string
1417
+ if (runs.length === 0) {
1418
+ text =
1419
+ contentParts.join('\n') +
1420
+ `\n👉 检查 stateFile 或用 session_read { action:'family' } 看 call session 直接深读。`
1421
+ } else {
1422
+ text = contentParts.join('\n\n')
1423
+ }
1424
+
1425
+ return { content: [{ type: 'text', text }], details }
1426
+ }
1427
+
1117
1428
  // ===========================================================================
1118
1429
  // 入口:按 action 分发
1119
1430
  // ===========================================================================
@@ -1148,12 +1459,14 @@ export async function handleSessionRead(
1148
1459
  return doExport(params, agentDir)
1149
1460
  case 'extract':
1150
1461
  return doExtract(params, agentDir)
1462
+ case 'workflow':
1463
+ return doWorkflow(params, agentDir)
1151
1464
  default: {
1152
- // exhaustive guard:switch 覆盖全部 8 action,此处 params.action 收窄为 never;
1465
+ // exhaustive guard:switch 覆盖全部 9 action,此处 params.action 收窄为 never;
1153
1466
  // 仅防御运行时非法 action(schema 正常校验下不可达)
1154
1467
  const exhaustive: never = params.action
1155
1468
  throw err(
1156
- `未知 action "${JSON.stringify(exhaustive)}"。👉 合法 action: find/family/outline/expand/detail/search/export/extract。`,
1469
+ `未知 action "${JSON.stringify(exhaustive)}"。👉 合法 action: find/family/outline/expand/detail/search/export/extract/workflow。`,
1157
1470
  )
1158
1471
  }
1159
1472
  }