@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.
@@ -1,11 +1,11 @@
1
- import { readFile, readdir, open } from 'node:fs/promises'
1
+ import { readFile, readdir, open, stat } from 'node:fs/promises'
2
2
  import type { FileHandle } from 'node:fs/promises'
3
3
  import { join, basename } from 'node:path'
4
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'
5
+ import type { Family, SessionRef, SubagentRef } from '../core/family.js'
7
6
  import { buildFamilyIndex, resolveFamily } from '../core/family.js'
8
7
  import { listMainSessions, listSubagentSessions } from './roots.js'
8
+ import { resolveWorkflows } from './workflows.js'
9
9
 
10
10
  /**
11
11
  * [M2 discovery] 从文件系统构建某 session 的完整家族(IO 适配层)。
@@ -28,6 +28,13 @@ import { listMainSessions, listSubagentSessions } from './roots.js'
28
28
  * @throws sessionId 不在任意 main session header → Error(M3 tool-adapter 层转 F1 恢复指引)
29
29
  */
30
30
  export async function buildFamilyFromFs(sessionId: string, agentDir: string): Promise<Family> {
31
+ // ---- 0. record manifest 索引(U4:sessionFile → manifest,供 alive 扫描查富字段)----
32
+ // manifest 主路径:alive 文件的 meta.path 命中索引 → 透 task/slug/model/status/sessionFile 全字段。
33
+ // 索引未命中(场景 A:嵌套 subagent 的 manifest 不在当前 agentDir,11.5%)走 P-fallback 回退 identity。
34
+ const manifests = await listRecordManifests(agentDir)
35
+ const manifestBySessionFile = new Map<string, RecordManifest>()
36
+ for (const m of manifests) manifestBySessionFile.set(m.sessionFile, m)
37
+
31
38
  // ---- 1. main sessions:首行 header → byId/childrenOf 素材 + fileStats + 路径反查 ----
32
39
  const mainMetas = await listMainSessions(agentDir)
33
40
  const headers: Entry[] = []
@@ -56,7 +63,9 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
56
63
  pathToRef.set(meta.path, ref)
57
64
  }
58
65
 
59
- // ---- 2. subagent sessions:首行 header(真实 id)+ 尾行 identity → 修正 identity ----
66
+ // ---- 2. subagent sessions:首行 header(真实 id)+ manifest 主/identity 回退 富字段 identity ----
67
+ // U4 数据流:manifest 命中透全字段;未命中 P-fallback 读尾行 identity 取 task/slug/agent
68
+ //(model/status 不可回退,留 undefined);无 manifest 无 identity(运行中/异常)跳过。
60
69
  const subMetas = await listSubagentSessions(agentDir)
61
70
  const identities: Entry[] = []
62
71
  /** 已扫描到的 subagent 文件路径集合,供 manifest 孤儿判定(alive 则跳过 manifest) */
@@ -69,17 +78,42 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
69
78
  // header 可解析即视为 alive(MF-3):identity 在文件尾行、完成时才写入,运行中的 subagent
70
79
  // 无 identity。若此处跳过,步骤 3 会把活文件(含其 manifest)当孤儿收编 → cleanedUp=true,
71
80
  // family 把活着的 subagent 显示成 [已清理],真实 sessionId 永远无法关联。
72
- // 注意:sessionIdToPath/pathToRef 仍需 identity(依赖 realId 的 rootSessionId/slug)。
73
81
  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 原样保留。
82
+
83
+ // identity data 组装:manifest 主路径或 P-fallback identity 回退,二选一
84
+ const manifest = manifestBySessionFile.get(meta.path)
85
+ let data: Record<string, unknown>
86
+ if (manifest) {
87
+ // manifest 主(TC-u4-manifest-enrich):透 task/slug/model/status/sessionFile 全字段
88
+ data = {
89
+ rootSessionId: manifest.rootSessionId,
90
+ slug: manifest.slug ?? '', // slug 兼容旧 manifest(缺→空串兑底,m0 契约)
91
+ task: manifest.task,
92
+ agent: manifest.agentName, // 同语义异名:manifest.agentName ↔ identity.data.agent
93
+ model: manifest.model,
94
+ status: manifest.status,
95
+ sessionFile: manifest.sessionFile,
96
+ }
97
+ } else {
98
+ // P-fallback(ES-p-fallback-no-manifest):读尾行 identity 回退取 task/slug/agent
99
+ const ident = await readTailIdentity(meta.path, meta.size)
100
+ if (!ident) continue // 无 identity(ES-p-fallback-no-identity,运行中/异常)→ 跳过
101
+ data = {
102
+ rootSessionId: ident.rootSessionId,
103
+ slug: ident.slug,
104
+ task: ident.task,
105
+ agent: ident.agent,
106
+ // model/status 不可回退(identity 无,探针 15/15),留 undefined
107
+ sessionFile: meta.path,
108
+ }
109
+ }
110
+ // id 修正:entry.id 用真实 header.id 替换 sa-xxx 占位
77
111
  identities.push({
78
112
  type: 'custom',
79
113
  id: realId,
80
114
  parentId: null,
81
115
  customType: 'subagent-identity',
82
- data: { rootSessionId: ident.rootSessionId, slug: ident.slug },
116
+ data,
83
117
  })
84
118
  fileStats.set(realId, { mtime: meta.mtime, size: meta.size })
85
119
  sessionIdToPath.set(realId, meta.path)
@@ -92,11 +126,11 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
92
126
  })
93
127
  }
94
128
 
95
- // ---- 3. records manifest → 孤儿(cleanedUp)----
129
+ // ---- 3. records manifest → 孤儿(cleanedUp,ES-orphan-manifest)----
96
130
  // 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)
131
+ // 扫到)跳过;未扫到的 = 文件已 GC → 孤儿。用 manifest 完整富字段填 SubagentRef,
132
+ // sessionFile 保留 manifest GC 路径(不置空,供 LLM 知晓原位置),cleanedUp
133
+ // buildFamilyIndex 的 !fileStats.has(ident.id) 判 true(ident.id=manifest.id 不在 fileStats)。
100
134
  for (const m of manifests) {
101
135
  if (aliveSubPaths.has(m.sessionFile)) continue
102
136
  identities.push({
@@ -104,8 +138,16 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
104
138
  id: m.id,
105
139
  parentId: null,
106
140
  customType: 'subagent-identity',
107
- // 孤儿的真实 slug 随文件 GC 丢失,用 manifest.agentName 兜底(agent 类型名,非 task 标签)
108
- data: { rootSessionId: m.rootSessionId, slug: m.agentName ?? '' },
141
+ data: {
142
+ rootSessionId: m.rootSessionId,
143
+ // 孤儿 slug 随文件 GC 丢失:优先 manifest.slug,回退 agentName(agent 类型名),再兜底空串
144
+ slug: m.slug ?? m.agentName ?? '',
145
+ task: m.task,
146
+ agent: m.agentName, // 同语义异名映射
147
+ model: m.model,
148
+ status: m.status,
149
+ sessionFile: m.sessionFile,
150
+ },
109
151
  })
110
152
  }
111
153
 
@@ -179,29 +221,48 @@ function parseHeaderLine(line: string | undefined): SessionHeader | null {
179
221
  }
180
222
 
181
223
  /**
182
- * 读 subagent 文件尾部(最后 64KB)找 subagent-identity entry,返回 rootSessionId + slug。
224
+ * 读 subagent 文件尾部(最后 64KB)找 subagent-identity entry,返回 rootSessionId + slug + task + agent
183
225
  *
184
226
  * identity 在文件尾行(探查确认;design §3.3 D-7 "尾部")。用 lastIndexOf 定位最后一个
185
227
  * subagent-identity 标记(多次重写时取最新),提取该行边界内的 JSON 解析。identity 行
186
228
  * 超 64KB(极罕见,实测 1/3430)会截断 → 解析失败 → 返回 undefined(该 subagent 不收)。
229
+ *
230
+ * U4 扩展返回 task/agent(P-fallback 富化用):从 identity.data.task / data.agent 提取,
231
+ * 存在则带。model/status 在 identity 不存在(探针 15/15 无),P-fallback 时由调用方留 undefined。
232
+ *
233
+ * M3b 扩展(IF3 三级数据源 ②):返回 parentRecordId(identity.data.parentRecordId,新版本
234
+ * session-runner 才写;当前本机旧数据无此字段→undefined)。size 改 optional——buildFamilyFromFs
235
+ * 传 size(已有 meta.size 省一次 stat),buildExecutionTree 不传 size(内部 stat 获取)。
187
236
  */
188
- async function readTailIdentity(
237
+ export async function readTailIdentity(
189
238
  path: string,
190
- size: number,
191
- ): Promise<{ rootSessionId: string; slug: string } | undefined> {
192
- if (size === 0) return undefined
239
+ size?: number,
240
+ ): Promise<
241
+ | { rootSessionId: string; slug: string; task?: string; agent?: string; parentRecordId?: string }
242
+ | undefined
243
+ > {
244
+ // size 未传时内部 stat 获取(execution-tree.ts 复用时无 size)
245
+ let resolvedSize = size
246
+ if (resolvedSize === undefined) {
247
+ try {
248
+ resolvedSize = (await stat(path)).size
249
+ } catch {
250
+ return undefined // 文件不存在/读失败 → undefined
251
+ }
252
+ }
253
+ if (resolvedSize === 0) return undefined
193
254
  let fh: FileHandle | undefined
194
255
  try {
195
256
  fh = await open(path, 'r')
196
- const len = Math.min(TAIL_READ_BYTES, size)
257
+ const len = Math.min(TAIL_READ_BYTES, resolvedSize)
197
258
  const buf = Buffer.alloc(len)
198
- await fh.read(buf, 0, len, Math.max(0, size - len))
259
+ await fh.read(buf, 0, len, Math.max(0, resolvedSize - len))
199
260
  const text = buf.toString('utf8')
200
261
  const idx = text.lastIndexOf('subagent-identity')
201
262
  if (idx < 0) return undefined
202
263
  // 行首若在读窗口外(identity 行 > 64KB,整行塞不下)→ 无法可靠解析,跳过
203
264
  const lineStartSearch = text.lastIndexOf('\n', idx)
204
- if (lineStartSearch < 0 && size > len) return undefined
265
+ if (lineStartSearch < 0 && resolvedSize > len) return undefined
205
266
  const start = lineStartSearch < 0 ? 0 : lineStartSearch + 1
206
267
  let end = text.indexOf('\n', idx)
207
268
  if (end < 0) end = text.length
@@ -219,6 +280,10 @@ async function readTailIdentity(
219
280
  return {
220
281
  rootSessionId: data.rootSessionId,
221
282
  slug: typeof data.slug === 'string' ? data.slug : '',
283
+ task: typeof data.task === 'string' ? data.task : undefined,
284
+ agent: typeof data.agent === 'string' ? data.agent : undefined,
285
+ parentRecordId:
286
+ typeof data.parentRecordId === 'string' ? data.parentRecordId : undefined,
222
287
  }
223
288
  } catch {
224
289
  return undefined
@@ -231,12 +296,28 @@ async function readTailIdentity(
231
296
  // records manifest(孤儿 / cleanedUp 来源)
232
297
  // ============================================================
233
298
 
234
- interface RecordManifest {
299
+ export interface RecordManifest {
235
300
  id: string
236
301
  rootSessionId: string
237
302
  agentName?: string
238
303
  /** subagent session.jsonl 绝对路径(创建时写入;文件 GC 后路径仍残留) */
239
304
  sessionFile: string
305
+ /** subagent 任务文本(探针 20/20 全有;旧 manifest 缺→undefined) */
306
+ task?: string
307
+ /** slug 标签(探针 20/20 全有;旧 manifest 缺→undefined) */
308
+ slug?: string
309
+ /** 模型 id(探针 20/20 全有;旧 manifest 缺→undefined) */
310
+ model?: string
311
+ /** 终态 completed/failed/running(探针 20/20 全有;旧 manifest 缺→undefined) */
312
+ status?: string
313
+ /**
314
+ * 直接父 subagent 的 record id(M3a 落盘镜像;depth=0 顶层 subagent 为 undefined)。
315
+ *
316
+ * session-reader 读侧镜像(IF2):manifest 主路径透出,旧 manifest 缺此字段(undefined)。
317
+ * isRecordManifest 校验不改(3 必填不变)。buildExecutionTree(core/execution-tree.ts)
318
+ * 据此建精确父子链,三级数据源优先级 ①(DM4)。
319
+ */
320
+ parentRecordId?: string
240
321
  }
241
322
 
242
323
  function isRecordManifest(v: unknown): v is RecordManifest {
@@ -264,7 +345,7 @@ async function tryReadManifest(path: string): Promise<RecordManifest | undefined
264
345
  }
265
346
  }
266
347
 
267
- async function listRecordManifests(agentDir: string): Promise<RecordManifest[]> {
348
+ export async function listRecordManifests(agentDir: string): Promise<RecordManifest[]> {
268
349
  const root = join(agentDir, 'subagents')
269
350
  const out: RecordManifest[] = []
270
351
  async function walk(dir: string): Promise<void> {
@@ -289,142 +370,22 @@ async function listRecordManifests(agentDir: string): Promise<RecordManifest[]>
289
370
  }
290
371
 
291
372
  // ============================================================
292
- // workflow-state 链路(M1 family.workflows 的填充)
373
+ // 文件名 sessionId 提取(find.ts + discovery/workflows.ts 共用 helper)
293
374
  // ============================================================
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
- }
375
+ //
376
+ // workflow-state 发现链路(resolveWorkflows / extractCallSessionFiles /
377
+ // readRunSnapshot / sessionRefFromPath)已迁至 discovery/workflows.ts(w5 架构归位,
378
+ // SSOT §6.3 workflow 与 subagent 发现解耦)。本函数被 find.ts + workflows.ts 共用,
379
+ // 故留原位 export。
363
380
 
364
381
  /** 从文件名(<timestamp>_<sessionId>.jsonl)提取 sessionId;非 uuid 特征返回空串。 */
365
- function extractSessionIdFromFilename(name: string): string {
382
+ export function extractSessionIdFromFilename(name: string): string {
366
383
  const noExt = name.replace(/\.jsonl.*$/, '')
367
384
  const idx = noExt.lastIndexOf('_')
368
385
  const candidate = idx >= 0 ? noExt.slice(idx + 1) : noExt
369
386
  return /^[0-9a-f-]{8,}$/i.test(candidate) ? candidate : ''
370
387
  }
371
388
 
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
389
  // ============================================================
429
390
  // enrich:补 M1 占位字段(fileName / subagent cwd)
430
391
  // ============================================================
@@ -0,0 +1,162 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { basename } from 'node:path'
3
+ import { parseSessionContent } from '../core/parser.js'
4
+ import type { SessionRef, WorkflowRef } from '../core/family.js'
5
+ import { extractSessionIdFromFilename } from './subagents.js'
6
+
7
+ // ============================================================
8
+ // workflow-state 发现链路(w5 从 subagents.ts 物理迁移,架构归位 SSOT §6.3)
9
+ // ============================================================
10
+ //
11
+ // 本文件持有 workflow run 的发现与 sessionFile 提取逻辑(IO 适配层):
12
+ // - resolveWorkflows:读目标 session 的 workflow-state-link custom entry → 每个 link 的
13
+ // wf-state 文件 → 提 calls 的 sessionFile → SessionRef[]。返回 WorkflowRef[](family.workflows 腿)。
14
+ // - readRunSnapshot:读 wf-state 文件尾向找首个可解析行,返回原始对象(unknown,格式收窄交 core 层)。
15
+ // - extractCallSessionFiles:从快照对象提 calls 的 sessionFile 绝对路径数组(NEW/OLD 双格式)。
16
+ // - sessionRefFromPath:sessionFile 路径 → SessionRef(命中 pathToRef 取完整,否则文件名提取最小 ref)。
17
+ //
18
+ // 分层约定(w5 TC-wf-core-pure-logic):IO 全在 discovery/,core/workflow.ts 的
19
+ // parseRunSnapshot/renderWorkflowOverview 是纯逻辑零 IO(喂 mock 可单测)。readRunSnapshot 返
20
+ // unknown 不收窄——NEW/OLD 双格式的类型化是 core 层 parseRunSnapshot 的职责(TC-wf-snapshot-version-union)。
21
+ //
22
+ // extractSessionIdFromFilename 留在 subagents.ts(find.ts + 导出契约测试直接消费),此处反向 import。
23
+
24
+ /**
25
+ * 从 wf-state 快照对象提取 calls[].sessionFile(绝对路径数组)。
26
+ *
27
+ * 两种格式(探查确认,本机 371 个 wf 文件):
28
+ * - NEW (v="wf-run-v1"):state.calls[],每项顶层 .sessionFile(258 文件 / 1590 sessionFile)
29
+ * - OLD (无 v):callCache[]=[{key,value}],value.sessionFile(112 文件 / 0 sessionFile,旧 pi 不持久化)
30
+ */
31
+ export function extractCallSessionFiles(snap: unknown): string[] {
32
+ const out: string[] = []
33
+ if (typeof snap !== 'object' || snap === null) return out
34
+ const s = snap as Record<string, unknown>
35
+ const isNew = s.v === 'wf-run-v1'
36
+ let callsRaw: unknown
37
+ if (isNew) {
38
+ const state = s.state
39
+ callsRaw =
40
+ typeof state === 'object' && state !== null
41
+ ? (state as Record<string, unknown>).calls
42
+ : undefined
43
+ } else {
44
+ callsRaw = s.callCache
45
+ }
46
+ if (!Array.isArray(callsRaw)) return out
47
+ for (const c of callsRaw) {
48
+ if (typeof c !== 'object' || c === null) continue
49
+ const co = c as Record<string, unknown>
50
+ // NEW: call 本身;OLD: {key, value},取 value
51
+ const item: Record<string, unknown> = isNew
52
+ ? co
53
+ : typeof co.value === 'object' && co.value !== null
54
+ ? (co.value as Record<string, unknown>)
55
+ : co
56
+ const sf = item.sessionFile
57
+ if (typeof sf === 'string') {
58
+ out.push(sf)
59
+ continue
60
+ }
61
+ const result = item.result
62
+ const sf2 =
63
+ typeof result === 'object' && result !== null
64
+ ? (result as Record<string, unknown>).sessionFile
65
+ : undefined
66
+ if (typeof sf2 === 'string') out.push(sf2)
67
+ }
68
+ return out
69
+ }
70
+
71
+ /**
72
+ * 读 wf-state 文件,从尾向头找首个 trim 非空且 JSON.parse 成功的行,返回解析后的原始对象。
73
+ *
74
+ * 拆解自原 readWorkflowCallSessionFiles 的读行职责(w5 TC-wf-core-pure-logic)——后者=读行+
75
+ * 提 sessionFile 的胶水,迁移后由 readRunSnapshot(读行,返 unknown)+ extractCallSessionFiles
76
+ * (提 sessionFile)组合替代。返回类型 unknown:IO 层不假设格式,类型收窄交 core/workflow.ts
77
+ * 的 parseRunSnapshot(C-readrunsnapshot-unknown,TC-wf-snapshot-version-union)。
78
+ *
79
+ * 尾向回退策略(沿用原实现,ES-wf-snapshot-partial):wf 文件是 rewrite 覆盖模式,读撞 rewrite
80
+ * 中点时末行是半截 JSON(parse 失败)→ 试上一完整行;单行文件半行 → 全失败 → undefined。
81
+ * 文件不存在/读失败/全行不可解析 → undefined(不抛错,调用方 resolveWorkflows 据此 calls=[])。
82
+ */
83
+ export async function readRunSnapshot(wfPath: string): Promise<unknown | undefined> {
84
+ let content: string
85
+ try {
86
+ content = await readFile(wfPath, 'utf8')
87
+ } catch {
88
+ return undefined // wf 文件不存在/读失败 → undefined(不抛错)
89
+ }
90
+ const lines = content.split('\n')
91
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
92
+ for (let i = lines.length - 1; i >= 0; i--) {
93
+ if (lines[i].trim() === '') continue
94
+ try {
95
+ return JSON.parse(lines[i])
96
+ } catch {
97
+ continue // 坏行(含 rewrite 中点半截 JSON),试上一行
98
+ }
99
+ }
100
+ return undefined
101
+ }
102
+
103
+ /**
104
+ * 从 sessionFile 绝对路径反查 SessionRef。优先用已扫描的 pathToRef(含真实 id/cwd/stat);
105
+ * 找不到(文件 GC/路径迁移)返回 fileName-only 最小 SessionRef(不抛错)。
106
+ */
107
+ function sessionRefFromPath(path: string, pathToRef: Map<string, SessionRef>): SessionRef {
108
+ const existing = pathToRef.get(path)
109
+ if (existing) return existing
110
+ return {
111
+ sessionId: extractSessionIdFromFilename(basename(path)),
112
+ fileName: path,
113
+ mtime: 0,
114
+ sizeBytes: 0,
115
+ cwd: '',
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 读目标 session 文件全文,解析 workflow-state-link custom entries,构造 WorkflowRef[]。
121
+ * 同一 runId 的多个 link(workflow 多次更新产生)按 runId 去重,取最新 link(path 相同)。
122
+ *
123
+ * 内部用 readRunSnapshot + extractCallSessionFiles 组合替代原 readWorkflowCallSessionFiles
124
+ *(w5 迁移,行为等价)。**签名与返回值结构(WorkflowRef[]{runId,stateFile,calls:SessionRef[]})
125
+ * 完全不变**(C-resolveworkflows-signature,保 m1 已冻结交付的消费者)。
126
+ */
127
+ export async function resolveWorkflows(
128
+ sessionId: string,
129
+ sessionIdToPath: Map<string, string>,
130
+ pathToRef: Map<string, SessionRef>,
131
+ ): Promise<WorkflowRef[]> {
132
+ const targetPath = sessionIdToPath.get(sessionId)
133
+ if (!targetPath) return [] // 兜底(buildFamilyFromFs 已校验 sessionId 存在)
134
+ let content: string
135
+ try {
136
+ content = await readFile(targetPath, 'utf8')
137
+ } catch {
138
+ return []
139
+ }
140
+ const { entries } = parseSessionContent(content)
141
+ const linkByRunId = new Map<string, { runId: string; path: string }>()
142
+ for (const e of entries) {
143
+ if (e.customType !== 'workflow-state-link') continue
144
+ const data = e.data as Record<string, unknown> | undefined
145
+ const runId = data?.runId
146
+ const path = data?.path
147
+ if (typeof runId === 'string' && typeof path === 'string') {
148
+ linkByRunId.set(runId, { runId, path }) // 后写覆盖前写(取最新 link)
149
+ }
150
+ }
151
+ const workflows: WorkflowRef[] = []
152
+ for (const { runId, path } of linkByRunId.values()) {
153
+ const snap = await readRunSnapshot(path)
154
+ const sessionFiles = snap === undefined ? [] : extractCallSessionFiles(snap)
155
+ workflows.push({
156
+ runId,
157
+ stateFile: path,
158
+ calls: sessionFiles.map((sf) => sessionRefFromPath(sf, pathToRef)),
159
+ })
160
+ }
161
+ return workflows
162
+ }
package/src/index.ts CHANGED
@@ -21,16 +21,16 @@ import { createSessionCommand } from './tui/session-command.js'
21
21
 
22
22
  const SessionReadSchema = Type.Object({
23
23
  action: StringEnum(
24
- ['find', 'family', 'outline', 'expand', 'detail', 'search', 'export', 'extract'],
24
+ ['find', 'family', 'outline', 'expand', 'detail', 'search', 'export', 'extract', 'workflow'],
25
25
  {
26
26
  description:
27
- 'Action to perform: find (locate session), family (fork/subagent/workflow relations), outline (turn-level overview), expand (single-turn entries), detail (full text of turns), search (full-text grep), export (materialize to file), extract (pull user messages / commands / files / commits / tool results by type).',
27
+ 'Action to perform: find (locate session), family (fork/subagent/workflow relations; recursive=true returns nested execution tree), outline (turn-level overview), expand (single-turn entries), detail (full text of turns), search (full-text grep), export (materialize to file), extract (pull user messages / commands / files / commits / tool results by type), workflow (workflow run overview: status/budget/steps; requires session, optional runId focuses one run; step call sessionId jumps to outline/detail).',
28
28
  },
29
29
  ),
30
30
  session: Type.Optional(
31
31
  Type.String({
32
32
  description:
33
- 'Session id or uuid fragment (e.g. e6c96). Required for family/outline/expand/detail/search/export. # prefix auto-stripped.',
33
+ 'Session id, uuid fragment (e.g. e6c96), subagent record id (sa-xxx, precise lookup), or absolute .jsonl path (~ or ~/ allowed). Required for family/outline/expand/detail/search/export/workflow. # prefix auto-stripped.',
34
34
  }),
35
35
  ),
36
36
  query: Type.Optional(
@@ -83,6 +83,11 @@ const SessionReadSchema = Type.Object({
83
83
  cwd: Type.Optional(
84
84
  Type.String({ description: 'find: filter by cwd. Optional.' }),
85
85
  ),
86
+ source: Type.Optional(
87
+ StringEnum(['main', 'subagent'], {
88
+ description: 'find action: filter by source. "main" = sessions/, "subagent" = subagents/. Default both (merged).',
89
+ }),
90
+ ),
86
91
  limit: Type.Optional(
87
92
  Type.Number({ description: 'find/search: max results. Default 20.' }),
88
93
  ),
@@ -99,6 +104,18 @@ const SessionReadSchema = Type.Object({
99
104
  description: 'extract action: filter commands/tool-results by tool name (e.g. "bash").',
100
105
  }),
101
106
  ),
107
+ runId: Type.Optional(
108
+ Type.String({
109
+ description:
110
+ 'workflow action: focus a single run by runId (disambiguate multiple runs). Omit to see all run overviews.',
111
+ }),
112
+ ),
113
+ recursive: Type.Optional(
114
+ Type.Boolean({
115
+ description:
116
+ 'family action: return nested execution tree (arbitrary-depth subagent↔workflow-call nesting, precise parentRecordId chain with flat-fallback for legacy records). Default false (flat family).',
117
+ }),
118
+ ),
102
119
  })
103
120
 
104
121
  // ---- guidelines(注入 LLM,design §3.4)----
@@ -109,12 +126,13 @@ const guidelines = [
109
126
  'outline before detail. Never read raw .jsonl files—use this tool.',
110
127
  'family traces fork parents/children, subagent sessions, and workflow runs.',
111
128
  'extract what=<type> to pull user messages / commands / files / commits / tool results across turns (optional tool= filter for commands/tool-results).',
129
+ "workflow action to see workflow run overviews (status/budget/steps). Each step's call sessionId can jump to outline/detail for deep reading.",
112
130
  'Errors carry a 👉 recovery hint—follow it to retry in one step.',
113
131
  ]
114
132
 
115
133
  // ---- 工具 description(design §3.4,照搬措辞)----
116
134
 
117
- const description = `Read pi session files (conversation history) by semantic structure instead of raw bytes. Use when you need to review another session, trace a fork/subagent/workflow family, or locate a past decision. Eight actions: find (locate by name/uuid fragment), family (fork/subagent/workflow relations), outline (turn-level overview, ~500 token), expand (single-turn entry list), detail (full text of turns), search (full-text grep across a session), export (materialize to file), extract (pull user messages / commands / files / commits / tool results by type). Progressive reading: outline → expand → detail. Do NOT use for the current session (use get_messages) or to edit sessions (pi has /resume /fork).`
135
+ const description = `Read pi session files (conversation history) by semantic structure instead of raw bytes. Use when you need to review another session, trace a fork/subagent/workflow family, or locate a past decision. Nine actions: find (locate by name/uuid fragment), family (fork/subagent/workflow relations), outline (turn-level overview, ~500 token), expand (single-turn entry list), detail (full text of turns), search (full-text grep across a session), export (materialize to file), extract (pull user messages / commands / files / commits / tool results by type), workflow (workflow run overview: status/budget/steps, step call sessionId jumps to outline/detail). Progressive reading: outline → expand → detail. Do NOT use for the current session (use get_messages) or to edit sessions (pi has /resume /fork).`
118
136
 
119
137
  /**
120
138
  * 已注册过 TUI provider/command 的 pi 实例集合。