@zhushanwen/pi-session-reader 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.
@@ -0,0 +1,92 @@
1
+ import { readdir, stat } from 'node:fs/promises'
2
+ import type { Dirent } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * M2 discovery 发现层:文件系统扫描(design §3.3 D-5 首行扫描策略的文件定位部分)。
7
+ *
8
+ * agentDir 注入:本模块所有函数接收 `agentDir: string` 参数,**不调用** `getAgentDir()`
9
+ *(pi SDK,调用留到 M3 tool-adapter 层)。故本模块零 pi 依赖,仅 node:fs + 相对
10
+ * import M1 core,可完全单测。
11
+ */
12
+
13
+ export interface SessionFileMeta {
14
+ /** 绝对路径 */
15
+ path: string
16
+ mtime: number
17
+ size: number
18
+ }
19
+
20
+ /**
21
+ * main sessions 扫描时整体跳过的子目录名。
22
+ * `workflow-state` 目录存放 workflow 运行状态文件(wf-*.jsonl,首行 `{"v":"wf-run-v1"...}`),
23
+ * 非 session 文件——属 family 腿独立处理(design §3.3 D-7),扫描 main sessions 时排除,
24
+ * 否则会把 wf 文件误收为 session(且 find.ts 读其首行 header 时会因 type≠session 被丢弃,
25
+ * 在此排除可避免这批无效首行扫描)。
26
+ */
27
+ const SKIP_DIRS_MAIN = new Set(['workflow-state'])
28
+
29
+ /**
30
+ * 文件名是否为待收的 session .jsonl。
31
+ * `.jsonl.finalized` 不以 `.jsonl` 结尾,故 `endsWith('.jsonl')` 天然排除之
32
+ *(design §3.3 D-7 Q2:finalized 是已完成态快照副本,与 .jsonl 同 base name 并存,不收)。
33
+ */
34
+ function isSessionJsonl(name: string): boolean {
35
+ return name.endsWith('.jsonl')
36
+ }
37
+
38
+ /**
39
+ * 递归扫描 rootDir 下所有 .jsonl 文件(排除 .finalized),返回绝对路径 + mtime + size。
40
+ * `skipDirs` 命名的目录整体跳过。目录不存在/无权限 → 返回空数组,不抛错(design §2 坏路径容错)。
41
+ */
42
+ async function scanJsonlRecursive(
43
+ rootDir: string,
44
+ skipDirs: Set<string>,
45
+ ): Promise<SessionFileMeta[]> {
46
+ const results: SessionFileMeta[] = []
47
+
48
+ async function walk(currentDir: string): Promise<void> {
49
+ let entries: Dirent[]
50
+ try {
51
+ entries = await readdir(currentDir, { withFileTypes: true })
52
+ } catch {
53
+ return // 目录不存在/无权限 → 静默返回(容错,listXxxSessions 契约要求不抛错)
54
+ }
55
+ for (const entry of entries) {
56
+ const full = join(currentDir, entry.name)
57
+ if (entry.isDirectory()) {
58
+ if (skipDirs.has(entry.name)) continue
59
+ await walk(full)
60
+ } else if (entry.isFile() && isSessionJsonl(entry.name)) {
61
+ try {
62
+ const s = await stat(full)
63
+ results.push({ path: full, mtime: s.mtimeMs, size: s.size })
64
+ } catch {
65
+ // 文件并发删除等致 stat 失败 → 跳过(不中断整体扫描)
66
+ }
67
+ }
68
+ }
69
+ }
70
+
71
+ await walk(rootDir)
72
+ return results
73
+ }
74
+
75
+ /**
76
+ * 列出 agentDir/sessions/ 下所有主 session 文件。
77
+ * 递归扫描子目录(cwd 编码目录如 --Users-foo--),glob *.jsonl,排除 *.jsonl.finalized
78
+ *(design §3.3 D-7 Q2);跳过 workflow-state 子目录(workflow 运行状态文件,非 session)。
79
+ */
80
+ export async function listMainSessions(agentDir: string): Promise<SessionFileMeta[]> {
81
+ return scanJsonlRecursive(join(agentDir, 'sessions'), SKIP_DIRS_MAIN)
82
+ }
83
+
84
+ /**
85
+ * 列出 agentDir/subagents/ 下所有 subagent session 文件(同样排除 .finalized)。
86
+ * 结构:subagents/<cwd编码>/sessions/*.jsonl。records/ 子目录(.json manifest)无 .jsonl,
87
+ * 天然不被误收。
88
+ */
89
+ export async function listSubagentSessions(agentDir: string): Promise<SessionFileMeta[]> {
90
+ return scanJsonlRecursive(join(agentDir, 'subagents'), new Set())
91
+ }
92
+
@@ -0,0 +1,470 @@
1
+ import { readFile, readdir, open } from 'node:fs/promises'
2
+ import type { FileHandle } from 'node:fs/promises'
3
+ import { join, basename } from 'node:path'
4
+ import type { Entry } from '../core/parser.js'
5
+ import { parseSessionContent } from '../core/parser.js'
6
+ import type { Family, SessionRef, SubagentRef, WorkflowRef } from '../core/family.js'
7
+ import { buildFamilyIndex, resolveFamily } from '../core/family.js'
8
+ import { listMainSessions, listSubagentSessions } from './roots.js'
9
+
10
+ /**
11
+ * [M2 discovery] 从文件系统构建某 session 的完整家族(IO 适配层)。
12
+ *
13
+ * 组合 M1 family 纯逻辑(buildFamilyIndex/resolveFamily)+ 真实文件读取。零 pi 依赖
14
+ *(node:fs + 相对 import M1 core + M2 roots),同 roots/find,可完全单测。
15
+ *
16
+ * 关键衔接点(探查确认,详见各步骤注释):
17
+ * - identity 在 subagent 文件 **尾行**(非首行;design §3.3 D-7 "尾部",实测 019fe635
18
+ * 的 identity 在 71/71 行)。故 header 读首行、identity 读尾行,两次定长读。
19
+ * - identity id 修正:M1 用 identity entry.id(=data.id 的 sa-xxx 占位)作 SubagentRef.sessionId。
20
+ * M2 用 subagent 文件首行 header.id(真实 session id)替换之,使 SubagentRef.sessionId
21
+ * 是真实 id,Q1 隔代关联仍用 data.rootSessionId(不变)。
22
+ * - cleanedUp:subagent 文件被 30 天 TTL GC 后,identity(在文件尾)一并消失。唯一残留痕迹
23
+ * 是 records/<sa-id>.json manifest(subagent 创建时写入,持久存在)。故 manifest 是孤儿/
24
+ * cleanedUp 的来源(design §3.3 D-7 "records/*.json manifest 作孤儿补充")。
25
+ * - workflows:M1 resolveFamily 恒返回 []。M2 在此单独读目标 session 的 workflow-state-link
26
+ * custom entry → link.data.path(wf-state 文件绝对路径)→ 读该文件最后一行(最新快照)取 calls。
27
+ *
28
+ * @throws sessionId 不在任意 main session header → Error(M3 tool-adapter 层转 F1 恢复指引)
29
+ */
30
+ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Promise<Family> {
31
+ // ---- 1. main sessions:首行 header → byId/childrenOf 素材 + fileStats + 路径反查 ----
32
+ const mainMetas = await listMainSessions(agentDir)
33
+ const headers: Entry[] = []
34
+ const fileStats = new Map<string, { mtime: number; size: number }>()
35
+ /** sessionId → 真实文件路径,供 workflows 找目标文件 + enrich 补 fileName */
36
+ const sessionIdToPath = new Map<string, string>()
37
+ /** path → 完整 SessionRef(含真实 cwd/fileName/mtime/size),供 enrich + workflow calls 反查 */
38
+ const pathToRef = new Map<string, SessionRef>()
39
+
40
+ for (const meta of mainMetas) {
41
+ const h = parseHeaderLine(await readFirstLine(meta.path))
42
+ if (!h) continue // 非 session/坏 header → 跳过(不入 byId)
43
+ const entry: Entry = { type: 'session', id: h.id, parentId: null, cwd: h.cwd ?? '' }
44
+ if (h.parentSession) entry.parentSession = h.parentSession
45
+ headers.push(entry)
46
+ fileStats.set(h.id, { mtime: meta.mtime, size: meta.size })
47
+ sessionIdToPath.set(h.id, meta.path)
48
+ const ref: SessionRef = {
49
+ sessionId: h.id,
50
+ fileName: meta.path,
51
+ mtime: meta.mtime,
52
+ sizeBytes: meta.size,
53
+ cwd: h.cwd ?? '',
54
+ }
55
+ if (h.parentSession) ref.parentSession = h.parentSession
56
+ pathToRef.set(meta.path, ref)
57
+ }
58
+
59
+ // ---- 2. subagent sessions:首行 header(真实 id)+ 尾行 identity → 修正 identity ----
60
+ const subMetas = await listSubagentSessions(agentDir)
61
+ const identities: Entry[] = []
62
+ /** 已扫描到的 subagent 文件路径集合,供 manifest 孤儿判定(alive 则跳过 manifest) */
63
+ const aliveSubPaths = new Set<string>()
64
+
65
+ for (const meta of subMetas) {
66
+ const h = parseHeaderLine(await readFirstLine(meta.path))
67
+ if (!h) continue // 非 session/坏 header → 无真实 session id,无法 id 修正,跳过
68
+ const realId = h.id
69
+ // header 可解析即视为 alive(MF-3):identity 在文件尾行、完成时才写入,运行中的 subagent
70
+ // 无 identity。若此处跳过,步骤 3 会把活文件(含其 manifest)当孤儿收编 → cleanedUp=true,
71
+ // family 把活着的 subagent 显示成 [已清理],真实 sessionId 永远无法关联。
72
+ // 注意:sessionIdToPath/pathToRef 仍需 identity(依赖 realId 的 rootSessionId/slug)。
73
+ aliveSubPaths.add(meta.path)
74
+ const ident = await readTailIdentity(meta.path, meta.size)
75
+ if (!ident) continue // 无 identity → 无法确定 rootSessionId(不完整/坏 session),跳过
76
+ // id 修正:entry.id 用真实 header.id 替换 sa-xxx 占位。data.rootSessionId/slug 原样保留。
77
+ identities.push({
78
+ type: 'custom',
79
+ id: realId,
80
+ parentId: null,
81
+ customType: 'subagent-identity',
82
+ data: { rootSessionId: ident.rootSessionId, slug: ident.slug },
83
+ })
84
+ fileStats.set(realId, { mtime: meta.mtime, size: meta.size })
85
+ sessionIdToPath.set(realId, meta.path)
86
+ pathToRef.set(meta.path, {
87
+ sessionId: realId,
88
+ fileName: meta.path,
89
+ mtime: meta.mtime,
90
+ sizeBytes: meta.size,
91
+ cwd: h.cwd ?? '',
92
+ })
93
+ }
94
+
95
+ // ---- 3. records manifest → 孤儿(cleanedUp)----
96
+ // manifest 在 subagent 创建时写入,.jsonl 被 GC 后仍残留。alive 的(sessionFile 已在步骤 2
97
+ // 扫到)跳过;未扫到的 = 文件已 GC → 孤儿,ident.id 用 manifest.id(sa-xxx),不进 fileStats
98
+ // → buildFamilyIndex 的 !fileStats.has(ident.id) 判 cleanedUp=true。
99
+ const manifests = await listRecordManifests(agentDir)
100
+ for (const m of manifests) {
101
+ if (aliveSubPaths.has(m.sessionFile)) continue
102
+ identities.push({
103
+ type: 'custom',
104
+ id: m.id,
105
+ parentId: null,
106
+ customType: 'subagent-identity',
107
+ // 孤儿的真实 slug 随文件 GC 丢失,用 manifest.agentName 兜底(agent 类型名,非 task 标签)
108
+ data: { rootSessionId: m.rootSessionId, slug: m.agentName ?? '' },
109
+ })
110
+ }
111
+
112
+ // ---- 4-5. build index + resolve(sessionId 不在 byId → resolveFamily 抛 Error)----
113
+ if (!sessionIdToPath.has(sessionId)) {
114
+ throw new Error(
115
+ `session "${sessionId}" not found under ${agentDir}/sessions — ` +
116
+ `no main session file whose first-line header id matches. ` +
117
+ `Verify the sessionId or agentDir; for partial uuid, use findSessions first.`,
118
+ )
119
+ }
120
+ const index = buildFamilyIndex(headers, identities, fileStats)
121
+ const family = resolveFamily(sessionId, index)
122
+
123
+ // ---- 6. 补 M1 占位字段(fileName / subagent cwd)+ workflows ----
124
+ enrichRefs(family, sessionIdToPath, pathToRef)
125
+ family.workflows = await resolveWorkflows(sessionId, sessionIdToPath, pathToRef)
126
+
127
+ return family
128
+ }
129
+
130
+ // ============================================================
131
+ // 文件读取 helpers(定长 buffer,避免全文读:subagent 文件均 269KB、总量 ~923MB)
132
+ // ============================================================
133
+
134
+ /** 首行读取 buffer 上限。session header(id/cwd/parentSession)实测 < 300 字节,8KB 足够。 */
135
+ const HEADER_READ_BYTES = 8192
136
+ /** 尾行 identity 读取 buffer 上限。identity 含完整 task 文本可达数 KB;实测 64KB 覆盖 3203/3430。 */
137
+ const TAIL_READ_BYTES = 65536
138
+
139
+ /** 读文件首行(header)。定长 8KB 一次 read;空文件/读失败返回 undefined。 */
140
+ async function readFirstLine(path: string): Promise<string | undefined> {
141
+ let fh: FileHandle | undefined
142
+ try {
143
+ fh = await open(path, 'r')
144
+ const buf = Buffer.alloc(HEADER_READ_BYTES)
145
+ const { bytesRead } = await fh.read(buf, 0, HEADER_READ_BYTES, 0)
146
+ if (bytesRead === 0) return undefined
147
+ const text = buf.subarray(0, bytesRead).toString('utf8')
148
+ const nl = text.indexOf('\n')
149
+ return nl === -1 ? text : text.slice(0, nl)
150
+ } catch {
151
+ return undefined
152
+ } finally {
153
+ await fh?.close().catch(() => {})
154
+ }
155
+ }
156
+
157
+ interface SessionHeader {
158
+ id: string
159
+ cwd?: string
160
+ parentSession?: string
161
+ }
162
+
163
+ /** 解析 header 首行为 SessionHeader。非 session 行/缺 id → null。 */
164
+ function parseHeaderLine(line: string | undefined): SessionHeader | null {
165
+ if (!line) return null
166
+ let raw: unknown
167
+ try {
168
+ raw = JSON.parse(line)
169
+ } catch {
170
+ return null
171
+ }
172
+ if (typeof raw !== 'object' || raw === null) return null
173
+ const o = raw as Record<string, unknown>
174
+ if (o.type !== 'session' || typeof o.id !== 'string') return null
175
+ const h: SessionHeader = { id: o.id }
176
+ if (typeof o.cwd === 'string') h.cwd = o.cwd
177
+ if (typeof o.parentSession === 'string') h.parentSession = o.parentSession
178
+ return h
179
+ }
180
+
181
+ /**
182
+ * 读 subagent 文件尾部(最后 64KB)找 subagent-identity entry,返回 rootSessionId + slug。
183
+ *
184
+ * identity 在文件尾行(探查确认;design §3.3 D-7 "尾部")。用 lastIndexOf 定位最后一个
185
+ * subagent-identity 标记(多次重写时取最新),提取该行边界内的 JSON 解析。identity 行
186
+ * 超 64KB(极罕见,实测 1/3430)会截断 → 解析失败 → 返回 undefined(该 subagent 不收)。
187
+ */
188
+ async function readTailIdentity(
189
+ path: string,
190
+ size: number,
191
+ ): Promise<{ rootSessionId: string; slug: string } | undefined> {
192
+ if (size === 0) return undefined
193
+ let fh: FileHandle | undefined
194
+ try {
195
+ fh = await open(path, 'r')
196
+ const len = Math.min(TAIL_READ_BYTES, size)
197
+ const buf = Buffer.alloc(len)
198
+ await fh.read(buf, 0, len, Math.max(0, size - len))
199
+ const text = buf.toString('utf8')
200
+ const idx = text.lastIndexOf('subagent-identity')
201
+ if (idx < 0) return undefined
202
+ // 行首若在读窗口外(identity 行 > 64KB,整行塞不下)→ 无法可靠解析,跳过
203
+ const lineStartSearch = text.lastIndexOf('\n', idx)
204
+ if (lineStartSearch < 0 && size > len) return undefined
205
+ const start = lineStartSearch < 0 ? 0 : lineStartSearch + 1
206
+ let end = text.indexOf('\n', idx)
207
+ if (end < 0) end = text.length
208
+ const line = text.slice(start, end)
209
+ let raw: unknown
210
+ try {
211
+ raw = JSON.parse(line)
212
+ } catch {
213
+ return undefined
214
+ }
215
+ const data = (raw as Record<string, unknown> | undefined)?.data as
216
+ | Record<string, unknown>
217
+ | undefined
218
+ if (!data || typeof data.rootSessionId !== 'string') return undefined
219
+ return {
220
+ rootSessionId: data.rootSessionId,
221
+ slug: typeof data.slug === 'string' ? data.slug : '',
222
+ }
223
+ } catch {
224
+ return undefined
225
+ } finally {
226
+ await fh?.close().catch(() => {})
227
+ }
228
+ }
229
+
230
+ // ============================================================
231
+ // records manifest(孤儿 / cleanedUp 来源)
232
+ // ============================================================
233
+
234
+ interface RecordManifest {
235
+ id: string
236
+ rootSessionId: string
237
+ agentName?: string
238
+ /** subagent session.jsonl 绝对路径(创建时写入;文件 GC 后路径仍残留) */
239
+ sessionFile: string
240
+ }
241
+
242
+ function isRecordManifest(v: unknown): v is RecordManifest {
243
+ if (typeof v !== 'object' || v === null) return false
244
+ const o = v as Record<string, unknown>
245
+ return (
246
+ typeof o.id === 'string' &&
247
+ typeof o.rootSessionId === 'string' &&
248
+ typeof o.sessionFile === 'string'
249
+ )
250
+ }
251
+
252
+ /**
253
+ * 扫描 subagents/<cwdSlug>/records/*.json —— subagent 注册清单。
254
+ * 每个 manifest 在 subagent 创建时写入,持久存在即使 .jsonl 被 GC。坏 manifest(缺必填字段
255
+ * /JSON 损坏)跳过,不中断扫描。
256
+ */
257
+ /** 读单个 manifest 文件并校验;坏 manifest(JSON 损坏/缺必填字段)返回 undefined。 */
258
+ async function tryReadManifest(path: string): Promise<RecordManifest | undefined> {
259
+ try {
260
+ const raw: unknown = JSON.parse(await readFile(path, 'utf8'))
261
+ return isRecordManifest(raw) ? raw : undefined
262
+ } catch {
263
+ return undefined // 坏 manifest 跳过,不中断整体扫描
264
+ }
265
+ }
266
+
267
+ async function listRecordManifests(agentDir: string): Promise<RecordManifest[]> {
268
+ const root = join(agentDir, 'subagents')
269
+ const out: RecordManifest[] = []
270
+ async function walk(dir: string): Promise<void> {
271
+ let entries
272
+ try {
273
+ entries = await readdir(dir, { withFileTypes: true })
274
+ } catch {
275
+ return // 目录不存在/无权限 → 静默返回
276
+ }
277
+ for (const e of entries) {
278
+ const full = join(dir, e.name)
279
+ if (e.isDirectory()) {
280
+ await walk(full)
281
+ } else if (e.isFile() && e.name.endsWith('.json') && basename(dir) === 'records') {
282
+ const m = await tryReadManifest(full)
283
+ if (m) out.push(m)
284
+ }
285
+ }
286
+ }
287
+ await walk(root)
288
+ return out
289
+ }
290
+
291
+ // ============================================================
292
+ // workflow-state 链路(M1 family.workflows 的填充)
293
+ // ============================================================
294
+
295
+ /**
296
+ * 从 wf-state 快照对象提取 calls[].sessionFile(绝对路径数组)。
297
+ *
298
+ * 两种格式(探查确认,本机 371 个 wf 文件):
299
+ * - NEW (v="wf-run-v1"):state.calls[],每项顶层 .sessionFile(258 文件 / 1590 sessionFile)
300
+ * - OLD (无 v):callCache[]=[{key,value}],value.sessionFile(112 文件 / 0 sessionFile,旧 pi 不持久化)
301
+ */
302
+ function extractCallSessionFiles(snap: unknown): string[] {
303
+ const out: string[] = []
304
+ if (typeof snap !== 'object' || snap === null) return out
305
+ const s = snap as Record<string, unknown>
306
+ const isNew = s.v === 'wf-run-v1'
307
+ let callsRaw: unknown
308
+ if (isNew) {
309
+ const state = s.state
310
+ callsRaw = typeof state === 'object' && state !== null ? (state as Record<string, unknown>).calls : undefined
311
+ } else {
312
+ callsRaw = s.callCache
313
+ }
314
+ if (!Array.isArray(callsRaw)) return out
315
+ for (const c of callsRaw) {
316
+ if (typeof c !== 'object' || c === null) continue
317
+ const co = c as Record<string, unknown>
318
+ // NEW: call 本身;OLD: {key, value},取 value
319
+ const item: Record<string, unknown> = isNew
320
+ ? co
321
+ : typeof co.value === 'object' && co.value !== null
322
+ ? (co.value as Record<string, unknown>)
323
+ : co
324
+ const sf = item.sessionFile
325
+ if (typeof sf === 'string') {
326
+ out.push(sf)
327
+ continue
328
+ }
329
+ const result = item.result
330
+ const sf2 =
331
+ typeof result === 'object' && result !== null
332
+ ? (result as Record<string, unknown>).sessionFile
333
+ : undefined
334
+ if (typeof sf2 === 'string') out.push(sf2)
335
+ }
336
+ return out
337
+ }
338
+
339
+ /**
340
+ * 读 wf-state 文件,从最后一个有效快照行提取 calls 的 sessionFile 路径。
341
+ *
342
+ * wf 文件每行是一个完整快照(多行 = 周期性追加,最后一行最新)。从尾向头找首个可解析行。
343
+ */
344
+ async function readWorkflowCallSessionFiles(wfPath: string): Promise<string[]> {
345
+ let content: string
346
+ try {
347
+ content = await readFile(wfPath, 'utf8')
348
+ } catch {
349
+ return [] // wf 文件不存在/读失败 → 空 calls(不抛错)
350
+ }
351
+ const lines = content.split('\n')
352
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
353
+ for (let i = lines.length - 1; i >= 0; i--) {
354
+ if (lines[i].trim() === '') continue
355
+ try {
356
+ return extractCallSessionFiles(JSON.parse(lines[i]))
357
+ } catch {
358
+ continue // 坏行,试上一行
359
+ }
360
+ }
361
+ return []
362
+ }
363
+
364
+ /** 从文件名(<timestamp>_<sessionId>.jsonl)提取 sessionId;非 uuid 特征返回空串。 */
365
+ function extractSessionIdFromFilename(name: string): string {
366
+ const noExt = name.replace(/\.jsonl.*$/, '')
367
+ const idx = noExt.lastIndexOf('_')
368
+ const candidate = idx >= 0 ? noExt.slice(idx + 1) : noExt
369
+ return /^[0-9a-f-]{8,}$/i.test(candidate) ? candidate : ''
370
+ }
371
+
372
+ /**
373
+ * 从 sessionFile 绝对路径反查 SessionRef。优先用已扫描的 pathToRef(含真实 id/cwd/stat);
374
+ * 找不到(文件 GC/路径迁移)返回 fileName-only 最小 SessionRef(不抛错)。
375
+ */
376
+ function sessionRefFromPath(path: string, pathToRef: Map<string, SessionRef>): SessionRef {
377
+ const existing = pathToRef.get(path)
378
+ if (existing) return existing
379
+ return {
380
+ sessionId: extractSessionIdFromFilename(basename(path)),
381
+ fileName: path,
382
+ mtime: 0,
383
+ sizeBytes: 0,
384
+ cwd: '',
385
+ }
386
+ }
387
+
388
+ /**
389
+ * 读目标 session 文件全文,解析 workflow-state-link custom entries,构造 WorkflowRef[]。
390
+ * 同一 runId 的多个 link(workflow 多次更新产生)按 runId 去重,取最新 link(path 相同)。
391
+ */
392
+ async function resolveWorkflows(
393
+ sessionId: string,
394
+ sessionIdToPath: Map<string, string>,
395
+ pathToRef: Map<string, SessionRef>,
396
+ ): Promise<WorkflowRef[]> {
397
+ const targetPath = sessionIdToPath.get(sessionId)
398
+ if (!targetPath) return [] // 兜底(buildFamilyFromFs 已校验 sessionId 存在)
399
+ let content: string
400
+ try {
401
+ content = await readFile(targetPath, 'utf8')
402
+ } catch {
403
+ return []
404
+ }
405
+ const { entries } = parseSessionContent(content)
406
+ const linkByRunId = new Map<string, { runId: string; path: string }>()
407
+ for (const e of entries) {
408
+ if (e.customType !== 'workflow-state-link') continue
409
+ const data = e.data as Record<string, unknown> | undefined
410
+ const runId = data?.runId
411
+ const path = data?.path
412
+ if (typeof runId === 'string' && typeof path === 'string') {
413
+ linkByRunId.set(runId, { runId, path }) // 后写覆盖前写(取最新 link)
414
+ }
415
+ }
416
+ const workflows: WorkflowRef[] = []
417
+ for (const { runId, path } of linkByRunId.values()) {
418
+ const sessionFiles = await readWorkflowCallSessionFiles(path)
419
+ workflows.push({
420
+ runId,
421
+ stateFile: path,
422
+ calls: sessionFiles.map((sf) => sessionRefFromPath(sf, pathToRef)),
423
+ })
424
+ }
425
+ return workflows
426
+ }
427
+
428
+ // ============================================================
429
+ // enrich:补 M1 占位字段(fileName / subagent cwd)
430
+ // ============================================================
431
+
432
+ /**
433
+ * M1 buildFamilyIndex 设 SessionRef.fileName=''(header 推不出路径)、subagent cwd=''
434
+ *(identity 无 cwd)。此处用已扫描的真实文件信息补全:alive 的 ref 补 fileName + cwd;
435
+ * cleanedUp 孤儿(无文件)保持占位。
436
+ */
437
+ function enrichRefs(
438
+ family: Family,
439
+ sessionIdToPath: Map<string, string>,
440
+ pathToRef: Map<string, SessionRef>,
441
+ ): void {
442
+ // sessionId → 完整 ref(含真实 fileName/cwd),由 pathToRef 反建
443
+ const bySid = new Map<string, SessionRef>()
444
+ for (const ref of pathToRef.values()) bySid.set(ref.sessionId, ref)
445
+
446
+ const enrichSessionRef = (ref: SessionRef): SessionRef => {
447
+ const full = bySid.get(ref.sessionId)
448
+ if (!full) return ref
449
+ return {
450
+ ...ref,
451
+ fileName: full.fileName || ref.fileName,
452
+ cwd: full.cwd || ref.cwd,
453
+ }
454
+ }
455
+
456
+ family.root = enrichSessionRef(family.root)
457
+ family.parents = family.parents.map(enrichSessionRef)
458
+ family.forks = family.forks.map(enrichSessionRef)
459
+ family.subagents = family.subagents.map((s) => {
460
+ const full = bySid.get(s.sessionId)
461
+ if (!full) return s
462
+ return {
463
+ ...s,
464
+ fileName: full.fileName || s.fileName,
465
+ cwd: full.cwd || s.cwd,
466
+ } as SubagentRef
467
+ })
468
+ // sessionIdToPath 仅用于类型完整性占位引用,避免未用警告(实际路径信息已在 pathToRef)
469
+ void sessionIdToPath
470
+ }