@zhushanwen/pi-session-reader 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +100 -0
  2. package/package.json +6 -3
  3. package/src/__tests__/entry-anchor.test.ts +226 -0
  4. package/src/__tests__/execution-tree.test.ts +6 -0
  5. package/src/__tests__/family-zcode.test.ts +255 -0
  6. package/src/__tests__/family.test.ts +1 -1
  7. package/src/__tests__/find.test.ts +23 -0
  8. package/src/__tests__/parser.test.ts +1 -2
  9. package/src/__tests__/render.test.ts +1 -2
  10. package/src/__tests__/result.test.ts +2 -2
  11. package/src/__tests__/tool-handler.test.ts +12 -8
  12. package/src/__tests__/toolcall.test.ts +1 -1
  13. package/src/__tests__/tree.test.ts +1 -1
  14. package/src/__tests__/turns.test.ts +1 -2
  15. package/src/__tests__/zcode-manifest.test.ts +361 -0
  16. package/src/__tests__/zcode-routing.test.ts +751 -0
  17. package/src/__tests__/zcode-whitelist.test.ts +119 -0
  18. package/src/core/execution-tree.ts +3 -1
  19. package/src/core/family.ts +80 -1
  20. package/src/core/render.ts +1 -1
  21. package/src/core/toolcall.ts +2 -2
  22. package/src/core/tree.ts +1 -1
  23. package/src/core/turns.ts +1 -1
  24. package/src/discovery/entry-anchor.ts +108 -0
  25. package/src/discovery/session-header.ts +30 -69
  26. package/src/discovery/subagents.ts +9 -2
  27. package/src/discovery/whitelist.ts +87 -0
  28. package/src/discovery/workflows.ts +1 -1
  29. package/src/discovery/zcode-manifest.ts +277 -0
  30. package/src/index.ts +7 -0
  31. package/src/result-action.ts +14 -5
  32. package/src/search-across.ts +1 -1
  33. package/src/tool-format.ts +455 -0
  34. package/src/tool-handler.ts +321 -325
  35. package/src/zcode-anchor-classify.ts +60 -0
  36. package/src/core/parser.ts +0 -167
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { tmpdir } from 'node:os'
3
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ import {
8
+ assertZcodeDbPathAllowed,
9
+ zcodeDbAllowlistFor,
10
+ } from '../discovery/whitelist.js'
11
+
12
+ // ============================================================
13
+ // P-whitelist(design session-reader-shared-core §3.3 D5 / §3.4 检查顺序 / 附录 A):
14
+ // 派生白名单在 taiji dataDir 布局下精确命中隔离库。
15
+ //
16
+ // 布局(mkdtemp 自建自删):root/agent = agentDir(dirname = dataDir root),
17
+ // root/engines/zcode/session-db/db.sqlite = 隔离库。测试内手拼路径段是**测试对
18
+ // 派生正确性的唯一验证手段**(生产 src 禁手拼,段单源 = SDK ZCODE_ISOLATED_DB_
19
+ // SEGMENTS 经 zcode-session-source zcodeImportDbAllowlist 引用)。
20
+ //
21
+ // 检查顺序铁律断言:存在性(不存在 → unreadable)先于路径(集合外 → forbidden)——
22
+ // 集合外且不存在的路径必须报 unreadable 而非 forbidden(场景 5 ④ 顺序断言的单元锚)。
23
+ // ============================================================
24
+
25
+ const ISOLATED_DB_SEGMENTS = ['engines', 'zcode', 'session-db', 'db.sqlite']
26
+
27
+ let root: string
28
+ let agentDir: string
29
+ let isolatedDbPath: string
30
+
31
+ beforeEach(() => {
32
+ root = mkdtempSync(join(tmpdir(), 'zcode-whitelist-test-'))
33
+ agentDir = join(root, 'agent')
34
+ mkdirSync(agentDir, { recursive: true })
35
+ isolatedDbPath = join(root, ...ISOLATED_DB_SEGMENTS)
36
+ })
37
+ afterEach(() => {
38
+ rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
39
+ })
40
+
41
+ describe('zcodeDbAllowlistFor:tmp dataDir 布局精确命中隔离库(P-whitelist)', () => {
42
+ it('第一项 = zcodeIsolatedDbPath(dirname(agentDir)),集合共两项(隔离库 + 宿主库)', () => {
43
+ const allow = zcodeDbAllowlistFor(agentDir)
44
+ expect(allow).toHaveLength(2)
45
+ // 精确命中:手拼段与派生结果逐字节相等(同源常量 + 同一 join 派生式的行为锚)
46
+ expect(allow[0]).toBe(isolatedDbPath)
47
+ })
48
+
49
+ it('realpath 对齐:agentDir 经 symlink 到达(macOS /var → /private/var 形态)仍命中', () => {
50
+ // tmpdir 本身在 macOS 即 symlink 链(/var → /private/var):mkdtemp 产物 realpath
51
+ // 后 ≠ 字面串。允许集合与 dbPath 同经 realpath 归一(whitelist.ts realOf),
52
+ // 两侧一致归一后比对 = 命中。isolatedDbPath 真实存在才有 realpath 归一输入。
53
+ mkdirSync(join(root, ...ISOLATED_DB_SEGMENTS.slice(0, -1)), { recursive: true })
54
+ writeFileSync(isolatedDbPath, '', 'utf8')
55
+ expect(() => assertZcodeDbPathAllowed(isolatedDbPath, agentDir)).not.toThrow()
56
+ })
57
+
58
+ it('纯 pi 宿主边界(D5):agentDir 无对应 dataDir 布局时集合外路径照常拒绝', () => {
59
+ const outside = join(root, 'somewhere-else', 'db.sqlite')
60
+ mkdirSync(join(root, 'somewhere-else'), { recursive: true })
61
+ writeFileSync(outside, '', 'utf8')
62
+ expect(() => assertZcodeDbPathAllowed(outside, agentDir)).toThrow('zcode_db_path_forbidden')
63
+ })
64
+ })
65
+
66
+ describe('assertZcodeDbPathAllowed:检查顺序三段递进(存在性先于路径)', () => {
67
+ it('第 1 段存在性:白名单内路径但文件不存在 → zcode_db_unreadable(不误报 forbidden)', () => {
68
+ expect(() => assertZcodeDbPathAllowed(isolatedDbPath, agentDir)).toThrow(
69
+ 'zcode_db_unreadable',
70
+ )
71
+ })
72
+
73
+ it('第 1 段先于第 2 段:集合外且文件不存在的路径 → 仍是 unreadable(不是 forbidden)', () => {
74
+ const outsideMissing = join(root, 'nowhere', 'evil.sqlite')
75
+ expect(() => assertZcodeDbPathAllowed(outsideMissing, agentDir)).toThrow(
76
+ 'zcode_db_unreadable',
77
+ )
78
+ })
79
+
80
+ it('第 2 段路径:存在但集合外 → zcode_db_path_forbidden(安全面错误语义更重)', () => {
81
+ const outside = join(root, 'evil.sqlite')
82
+ writeFileSync(outside, '', 'utf8')
83
+ try {
84
+ assertZcodeDbPathAllowed(outside, agentDir)
85
+ expect.unreachable('should have thrown')
86
+ } catch (e) {
87
+ const msg = (e as Error).message
88
+ expect(msg).toContain('zcode_db_path_forbidden')
89
+ expect(msg).toContain(outside)
90
+ expect(msg).toContain('👉')
91
+ }
92
+ })
93
+
94
+ it('通过闸(集合内且存在)→ 不抛错(开库由调用方进行)', () => {
95
+ mkdirSync(join(root, ...ISOLATED_DB_SEGMENTS.slice(0, -1)), { recursive: true })
96
+ writeFileSync(isolatedDbPath, '', 'utf8')
97
+ expect(() => assertZcodeDbPathAllowed(isolatedDbPath, agentDir)).not.toThrow()
98
+ })
99
+
100
+ it('unreadable 指引含动态推导的隔离库路径与 -wal 告诫(§3.4 文案)', () => {
101
+ try {
102
+ assertZcodeDbPathAllowed(isolatedDbPath, agentDir)
103
+ expect.unreachable('should have thrown')
104
+ } catch (e) {
105
+ const msg = (e as Error).message
106
+ expect(msg).toContain(isolatedDbPath)
107
+ expect(msg).toContain('-wal')
108
+ expect(msg).toContain('👉')
109
+ }
110
+ })
111
+
112
+ it('宿主库第二项 = join(homedir(), SDK 宿主段)(存量兼容锚点,不在本梯队触碰)', () => {
113
+ const allow = zcodeDbAllowlistFor(agentDir)
114
+ // 第二项是 homedir 派生的宿主库(真实 HOME 下的 .zcode/cli/db/db.sqlite 形态)——
115
+ // 测试不触碰真实文件,只断言其位于集合且 ≠ 隔离库项
116
+ expect(allow[1]).not.toBe(allow[0])
117
+ expect(allow[1].startsWith(homedir())).toBe(true)
118
+ })
119
+ })
@@ -437,7 +437,9 @@ export async function buildExecutionTree(
437
437
  sessionId: rootSessionId,
438
438
  // MF-1:main root 携带 sessionFile(调用方传入),否则 attachWorkflowChildren 对无
439
439
  // sessionFile 节点直接 return,main 自身发起的 workflow run 永远进不了执行树。
440
- sessionFile: mainSessionFile,
440
+ // falsy 归一为 undefined:调用方的「路径不可得」哨兵是空串(zcode family 路由
441
+ // 反查未命中),root 节点 details 不携带 sessionFile:"" 这类非路径占位值。
442
+ sessionFile: mainSessionFile || undefined,
441
443
  depth: 0,
442
444
  rootSessionId,
443
445
  children: [],
@@ -1,4 +1,4 @@
1
- import type { Entry } from './parser.js'
1
+ import type { Entry } from '@zhushanwen/session-core'
2
2
 
3
3
  /**
4
4
  * 家族索引与解析(design §3.3 D-7)。
@@ -78,6 +78,74 @@ export interface FamilyIndex {
78
78
  fileStats: Map<string, { mtime: number; size: number }>
79
79
  }
80
80
 
81
+ // ---- zcode 节点(design session-reader-shared-core §3.3 D5-1)----
82
+
83
+ /**
84
+ * zcode subagent 节点的判据输入视图(discovery 层 zcode-manifest.ts 枚举产出,
85
+ * core 层零 IO 消费)。扁平化:dbPath 取自 manifest engineHandle.sessionRef.dbPath
86
+ * (zcode 锚),dbFileExists 由 discovery 枚举时 statSync 预计算——stat 只查存在性
87
+ * **不开库**(D5-1:静息态 CANTOPEN 会把活会话误判已 GC,且列表视图每节点开库的
88
+ * 成本不该付;库内 session 已被 zcode GC 的情形由 read 路径 zcode_session_not_found
89
+ * 权威承接,列表视图保持廉价是本判据的设计核心)。
90
+ */
91
+ export interface ZcodeFamilyNode {
92
+ /** record id(= sa-id,manifest 文件名 stem)。作 SubagentRef.sessionId(pi 孤儿链同款:entry.id = manifest.id) */
93
+ id: string
94
+ /** subagent 的发起 session id(subagentsByRoot 挂载键,与 pi identity.data.rootSessionId 同语义) */
95
+ rootSessionId: string
96
+ /** slug 标签(discovery 组装时已兜底 m.slug ?? m.agentName ?? '',与 pi 孤儿链同款) */
97
+ slug: string
98
+ /** agent 类型名 */
99
+ agentName?: string
100
+ task?: string
101
+ model?: string
102
+ status?: string
103
+ /** zcode 锚的库路径(engineHandle.sessionRef.dbPath;枚举谓词已保证非空 string) */
104
+ dbPath: string
105
+ /** 库文件存在性(discovery 枚举时 statSync 预计算;D5-1 第二判据的唯一输入) */
106
+ dbFileExists: boolean
107
+ }
108
+
109
+ /**
110
+ * zcode 节点 → subagentsByRoot(D5-1 判据落地)。
111
+ *
112
+ * 判据(两项、不开库):**锚可解析 ∧ 库文件存在 → cleanedUp=false;缺一 → true**。
113
+ * 「锚可解析」在枚举层已构造性成立(listZcodeManifests 谓词 = engine==='zcode' ∧
114
+ * sessionRef 双键齐,未通过的记录不产出)——core 层只需消费 dbFileExists。
115
+ *
116
+ * 与 pi 节点**刻意不同构**(D5-1 显式声明):pi 判据 = fileStats JSONL 文件存在性
117
+ * (buildSubagentsByRoot 的 !fileStats.has(ident.id));zcode 节点住在 SQLite 库里、
118
+ * 没有 JSONL 文件,「文件在不在」对它是无效判据。二者不互相回归。
119
+ */
120
+ export function buildZcodeSubagentsByRoot(
121
+ nodes: readonly ZcodeFamilyNode[],
122
+ ): Map<string, SubagentRef[]> {
123
+ const byRoot = new Map<string, SubagentRef[]>()
124
+ for (const n of nodes) {
125
+ const ref: SubagentRef = {
126
+ // zcode 节点无 subagent session 文件,sessionId 用 record id(pi 孤儿链同款占位)
127
+ sessionId: n.id,
128
+ rootSessionId: n.rootSessionId,
129
+ slug: n.slug,
130
+ fileName: '', // 占位空串(family 路径不回填,与 pi 一致)
131
+ mtime: 0, // 无 JSONL 文件可 stat,恒 0(占位约定,见文件头注释)
132
+ sizeBytes: 0,
133
+ cwd: '',
134
+ // D5-1:锚可解析(枚举谓词已过)∧ 库文件存在 → false;缺一 → true(GC 语义)
135
+ cleanedUp: !n.dbFileExists,
136
+ task: n.task,
137
+ agentName: n.agentName,
138
+ model: n.model,
139
+ status: n.status,
140
+ // sessionFile 不设:zcode 无 session.jsonl(pi 孤儿保留 GC 路径,zcode 无路径可保留)
141
+ }
142
+ const list = byRoot.get(n.rootSessionId)
143
+ if (list) list.push(ref)
144
+ else byRoot.set(n.rootSessionId, [ref])
145
+ }
146
+ return byRoot
147
+ }
148
+
81
149
  // ---- 类型守卫:从 unknown 的 entry.data 提取 subagent identity 字段 ----
82
150
 
83
151
  /**
@@ -225,6 +293,8 @@ function buildSubagentsByRoot(
225
293
  * - headers(type=session)→ byId + childrenOf(parentSession 文件路径反查父 sessionId)
226
294
  * - subagentIdentities(type=custom, customType=subagent-identity)→ subagentsByRoot
227
295
  * - fileStats 原样存入 index,供 cleanedUp 判断
296
+ * - zcodeNodes(可选,U6)→ zcode subagent 节点并入 subagentsByRoot(D5-1 判据,
297
+ * 与 pi 判据不同构;不传 = pi 行为逐字节不变——零涟漪)
228
298
  *
229
299
  * 坏数据容错:identity 缺 rootSessionId/slug 跳过;parentSession 反查不到父(父文件
230
300
  * 未被扫描到)该 entry 不进 childrenOf——均不报错,符合 pi 坏 session 容错(design §2)。
@@ -233,10 +303,19 @@ export function buildFamilyIndex(
233
303
  headers: Entry[],
234
304
  subagentIdentities: Entry[],
235
305
  fileStats: Map<string, { mtime: number; size: number }>,
306
+ zcodeNodes?: readonly ZcodeFamilyNode[],
236
307
  ): FamilyIndex {
237
308
  const byId = buildById(headers, fileStats)
238
309
  const childrenOf = buildChildrenOf(headers, byId)
239
310
  const subagentsByRoot = buildSubagentsByRoot(subagentIdentities, fileStats)
311
+ if (zcodeNodes !== undefined && zcodeNodes.length > 0) {
312
+ const zcodeByRoot = buildZcodeSubagentsByRoot(zcodeNodes)
313
+ for (const [rootId, refs] of zcodeByRoot) {
314
+ const existing = subagentsByRoot.get(rootId)
315
+ if (existing) existing.push(...refs)
316
+ else subagentsByRoot.set(rootId, refs)
317
+ }
318
+ }
240
319
  return { byId, childrenOf, subagentsByRoot, fileStats }
241
320
  }
242
321
 
@@ -1,4 +1,4 @@
1
- import type { Entry } from './parser.js'
1
+ import type { Entry } from '@zhushanwen/session-core'
2
2
  import { BYTES_PER_KB } from './units.js'
3
3
  import { extractToolCalls, formatToolCallSummary, type ToolCallInfo } from './toolcall.js'
4
4
  import type { Turn } from './turns.js'
@@ -6,9 +6,9 @@
6
6
  * message.toolCalls 顶层字段从未存在——v1 render.ts 读 toolCalls 恒返 [] 是 O1 要修的 bug。
7
7
  * - arguments 始终是 object(10 工具 100%);string 形态做 JSON.parse 兜底(失败返 {}),防御历史/异类实现。
8
8
  * - toolResult.message 自带 toolName + toolCallId(515/515,全部匹配 toolCall.id),
9
- * parser.ts 已 additive 透出这两个字段,O2/O3 据此精确关联取参数。
9
+ * Entry 接口(session-core 基座)已 additive 透出这两个字段,O2/O3 据此精确关联取参数。
10
10
  */
11
- import type { Entry } from './parser.js'
11
+ import type { Entry } from '@zhushanwen/session-core'
12
12
  import { BYTES_PER_KB } from './units.js'
13
13
 
14
14
  // ---------------------------------------------------------------------------
package/src/core/tree.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Entry } from './parser.js'
1
+ import type { Entry } from '@zhushanwen/session-core'
2
2
 
3
3
  export interface TreeView {
4
4
  /** root → leaf 的 id 序列(pi 重开视角的当前对话线,design D-2) */
package/src/core/turns.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Entry } from './parser.js'
1
+ import type { Entry } from '@zhushanwen/session-core'
2
2
 
3
3
  /**
4
4
  * 一轮对话(design §3.5 算法 3 的分段产物,冻结接口)。
@@ -0,0 +1,108 @@
1
+ import { readFile } from 'node:fs/promises'
2
+
3
+ import { parseSessionContent, type Entry } from '@zhushanwen/session-core'
4
+
5
+ /**
6
+ * zcode 锚的 entry 兜底定位(设计 session-reader-shared-core §3.1 第②步)。
7
+ *
8
+ * 定位链第②层:zcode manifest 直读(第①层,zcode-manifest.ts)缺位/残缺时,
9
+ * 在候选主 session 文件里按 sa-id 扫 `subagent-record` custom entry,取其
10
+ * `engineHandle.sessionRef`(zcode 形态 = `{sessionId, dbPath}`)。
11
+ *
12
+ * 边界承载(设计四要素登记):本函数只认「候选文件列表」,越界语义(不做根内
13
+ * 全扫、liveSessionDir 优先排序)由调用方构造候选集时决定——第一梯队只传
14
+ * liveSessionDir 内的主 session 文件,越界查询返回 not-found 信号(undefined,
15
+ * 调用方映射 `zcode_record_not_found` 错误码,§3.4)。
16
+ */
17
+
18
+ /**
19
+ * zcode 锚(zcode 引擎自描述定位符的 zcode 形态)。与 manifest 直读产出的锚同形;
20
+ * `sessionRef` 写侧整体透传不枚举内部键(subagent-core record-entry.ts
21
+ * engineHandle.sessionRef 注释),本单元按 zcode 形态校验并窄化为双键。
22
+ */
23
+ export interface ZcodeAnchor {
24
+ sessionId: string
25
+ dbPath: string
26
+ }
27
+
28
+ /**
29
+ * `subagent-record` custom entry 的 customType 值。
30
+ *
31
+ * 磁盘 JSONL 协议字符串(跨侧契约):写侧单源 = subagent-core record-entry.ts 的
32
+ * SUBAGENT_RECORD_CUSTOM_TYPE。reader 生产依赖面不引 subagent-core(该包是
33
+ * devDependency + vitest alias 形态),故按协议字符串本地持有;两侧漂移由
34
+ * entry-anchor.test.ts 的跨包断言守卫。
35
+ */
36
+ const SUBAGENT_RECORD_CUSTOM_TYPE = 'subagent-record'
37
+
38
+ /**
39
+ * 从单条 entry 提取 zcode 锚。形状校验窄而严,任一不满足 = 该条不算命中(返回
40
+ * undefined,调用方继续扫——缺键条目不遮蔽更早的完整条目):
41
+ * - `type === 'custom'` ∧ `customType === 'subagent-record'`
42
+ * - `data.v === 1`(schema 版本守卫,不认识的版本跳过而非猜测——写侧 record-entry.ts v 注释同口径)
43
+ * - `data.id === saId`(record id 即 sa-id,record-access.ts `sa-${uuid}`)
44
+ * - `engineHandle.sessionRef.sessionId` / `.dbPath` 均为非空 string(F8 双键齐判据的
45
+ * 锚字段部分;pi record 的 sessionRef 无 dbPath → 天然不命中,无需 engine 判别)
46
+ */
47
+ function zcodeAnchorOfEntry(entry: Entry, saId: string): ZcodeAnchor | undefined {
48
+ if (entry.type !== 'custom' || entry.customType !== SUBAGENT_RECORD_CUSTOM_TYPE) return undefined
49
+ const data: unknown = entry.data
50
+ if (typeof data !== 'object' || data === null) return undefined
51
+ const d = data as Record<string, unknown>
52
+ if (d.v !== 1 || d.id !== saId) return undefined
53
+ const handle: unknown = d.engineHandle
54
+ if (typeof handle !== 'object' || handle === null) return undefined
55
+ const ref: unknown = (handle as Record<string, unknown>).sessionRef
56
+ if (typeof ref !== 'object' || ref === null) return undefined
57
+ const r = ref as Record<string, unknown>
58
+ const { sessionId, dbPath } = r
59
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined
60
+ if (typeof dbPath !== 'string' || dbPath === '') return undefined
61
+ return { sessionId, dbPath }
62
+ }
63
+
64
+ /**
65
+ * 在候选主 session 文件里按 sa-id 定位 zcode 锚。
66
+ *
67
+ * 取条语义(设计 §2.5 裁决,subagent-core collectLastRecordEntries 逐字同构——
68
+ * 「主 session 全文 → 每 id 末条 record data」,record-store-rebuild.ts:212-227):
69
+ * 同一 sa-id 每次状态迁移写一条全量快照 entry 且 sessionRef 每轮覆写(F16),故
70
+ * 同文件内后条覆盖前条 = 文件顺序末条。**禁止另创取首条/合并语义**(附录 B-6)。
71
+ *
72
+ * 多候选文件:按候选列表顺序逐文件扫,文件内有命中锚(含末条裁决后)即止——
73
+ * 候选顺序 = 调用方声明的优先级(liveSessionDir 优先),不跨文件合并。
74
+ *
75
+ * 行级容错:逐行解析由基座 parseSessionContent 承担(坏行跳过继续、末行半行记
76
+ * lastLinePartial 不中断)——best-effort,不因一行坏 JSON 失败整文件。
77
+ *
78
+ * 候选文件读失败(不存在/不可读)= 该候选无命中,跳过继续下一候选;本函数契约
79
+ * 是「锚或 not-found」,IO 错误同样收敛到 not-found 信号(调用方映射错误码),
80
+ * 不向上抛。
81
+ *
82
+ * 读取用 node:fs/promises 的 async readFile——本函数跑在 pi 扩展进程内,同步
83
+ * readFileSync 全文读入会阻塞事件循环(同函数族 classifyIncompleteEntryAnchor
84
+ * 同形态;对照 readSessionHeaderIdSync 只在首行 4KB 有界读时才允许同步)。
85
+ *
86
+ * @returns 命中的锚;所有候选均未命中(或全部候选不可读)→ undefined(not-found 信号)
87
+ */
88
+ export async function findZcodeEntryAnchor(
89
+ candidateFiles: readonly string[],
90
+ saId: string,
91
+ ): Promise<ZcodeAnchor | undefined> {
92
+ for (const file of candidateFiles) {
93
+ let content: string
94
+ try {
95
+ content = await readFile(file, 'utf8')
96
+ } catch {
97
+ continue
98
+ }
99
+ const { entries } = parseSessionContent(content)
100
+ let anchor: ZcodeAnchor | undefined
101
+ for (const entry of entries) {
102
+ const hit = zcodeAnchorOfEntry(entry, saId)
103
+ if (hit !== undefined) anchor = hit
104
+ }
105
+ if (anchor !== undefined) return anchor
106
+ }
107
+ return undefined
108
+ }
@@ -1,44 +1,28 @@
1
- import { open, type FileHandle } from 'node:fs/promises'
2
- import { closeSync, openSync, readSync } from 'node:fs'
1
+ import {
2
+ readFirstJsonlLine,
3
+ readFirstJsonlLineSync,
4
+ type SessionHeader,
5
+ } from '@zhushanwen/session-core'
3
6
 
4
7
  /**
5
- * [ext-simplify-18 D5] session 文件首行 header 读取的包内单源(原 subagents.ts / find.ts
6
- * 两份 async 副本 + tool-handler.ts 一份 sync 副本合一)。不进 shared——跨包第二消费方
7
- * 不存在(runtime 侧首行读取是另一形态 + 分层禁反向 import,设计负面清单同口径)。
8
+ * [session-reader-shared-core Phase 1] session 文件首行 header 读取的包内单源(原
9
+ * subagents.ts / find.ts 两份 async 副本 + tool-handler.ts 一份 sync 副本合一后的单源)。
10
+ *
11
+ * 字节原语(buffer 管理/续读/CRLF/空文件)已收敛至基座 `readFirstJsonlLine`(sync/async
12
+ * 双形态)——本模块只剩两件 reader 私有事:
13
+ * ① **header 谓词薄包装**(G4 红线,设计 §5 Phase 0):reader 仅要求 `type==='session'`
14
+ * 且 `id` 为 string,cwd/parentSession 可缺。runtime 侧「id+cwd 非空」严谓词是不同的
15
+ * 行为契约(缺 cwd 的 header 在 reader 候选集必须可见),谓词语义绝不统一进基座;
16
+ * ② **错误分流薄包装**:旧副本契约是「任何失败都视为无 header → undefined/null」,基座
17
+ * 原语 IO 错误上抛(错误分类留给有消费方的调用侧),reader 无错误分类消费方,catch-all
18
+ * 收敛回旧契约。
8
19
  */
9
20
 
10
- /** session header(首行 type==='session' entry 的消费子集)。 */
11
- export interface SessionHeader {
12
- id: string
13
- cwd?: string
14
- parentSession?: string
15
- }
21
+ export type { SessionHeader }
16
22
 
17
23
  /**
18
- * 首行读取 buffer 上限。header 超 8KB 的极端情况会截断致 parse 失败(parseSessionHeader
19
- * 返回 null)——session header(id/cwd/parentSession)实测 < 300 字节,8KB 约 27 倍余量。
24
+ * 解析 header 首行为 SessionHeader(谓词薄包装)。非 session 行/缺 id/坏 JSON → null。
20
25
  */
21
- export const HEADER_READ_BYTES = 8192
22
-
23
- /** 读文件首行(header)。定长 8KB 一次 read;空文件/读失败返回 undefined。 */
24
- export async function readSessionHeaderFirstLine(path: string): Promise<string | undefined> {
25
- let fh: FileHandle | undefined
26
- try {
27
- fh = await open(path, 'r')
28
- const buf = Buffer.alloc(HEADER_READ_BYTES)
29
- const { bytesRead } = await fh.read(buf, 0, HEADER_READ_BYTES, 0)
30
- if (bytesRead === 0) return undefined
31
- const text = buf.subarray(0, bytesRead).toString('utf8')
32
- const nl = text.indexOf('\n')
33
- return nl === -1 ? text : text.slice(0, nl)
34
- } catch {
35
- return undefined
36
- } finally {
37
- await fh?.close().catch(() => {})
38
- }
39
- }
40
-
41
- /** 解析 header 首行为 SessionHeader。非 session 行/缺 id → null。 */
42
26
  export function parseSessionHeader(line: string | undefined): SessionHeader | null {
43
27
  if (!line) return null
44
28
  let raw: unknown
@@ -56,48 +40,25 @@ export function parseSessionHeader(line: string | undefined): SessionHeader | nu
56
40
  return h
57
41
  }
58
42
 
59
- /** readSessionHeaderIdSync 读首行的 buffer 上限。session header 实测 < 300 字节,4KB 足够。 */
60
- const SYNC_HEADER_READ_BYTES = 4096
43
+ /** 读文件首行(header)薄包装:基座原语 + IO 失败收敛 undefined(空文件基座已返 undefined)。 */
44
+ export async function readSessionHeaderFirstLine(path: string): Promise<string | undefined> {
45
+ try {
46
+ return await readFirstJsonlLine(path)
47
+ } catch {
48
+ return undefined
49
+ }
50
+ }
61
51
 
62
52
  /**
63
53
  * 同步读 session 文件首行 header,返回 type==='session' 的 id。
64
- *
65
- * 任何异常(文件不存在/空文件/解析失败/type 不符)返回 undefined。与 async 版
66
- * readSessionHeaderFirstLine/parseSessionHeader 同构(定长 buffer 读首行 + JSON.parse +
67
- * type 校验),但用同步 fs API(resolveSessionId 内仅调用 1 次,同步开销可接受),
68
- * 窗口取 4KB(仅取 id)——sync 形态与窗口差异是刻意的调用上下文适配,独立常量、
69
- * 不与 async 版 8KB 强行统一。
54
+ * 任何失败(文件不存在/空文件/解析失败/type 不符)→ undefined(旧 sync 副本契约不变)。
70
55
  */
71
56
  export function readSessionHeaderIdSync(filePath: string): string | undefined {
72
- let fd: number | undefined
57
+ let line: string | undefined
73
58
  try {
74
- fd = openSync(filePath, 'r')
75
- const buf = Buffer.alloc(SYNC_HEADER_READ_BYTES)
76
- const bytesRead = readSync(fd, buf, 0, SYNC_HEADER_READ_BYTES, 0)
77
- if (bytesRead === 0) return undefined
78
- const text = buf.subarray(0, bytesRead).toString('utf8')
79
- const nl = text.indexOf('\n')
80
- const line = nl === -1 ? text : text.slice(0, nl)
81
- let raw: unknown
82
- try {
83
- raw = JSON.parse(line)
84
- } catch {
85
- return undefined
86
- }
87
- if (typeof raw !== 'object' || raw === null) return undefined
88
- const o = raw as Record<string, unknown>
89
- if (o.type !== 'session' || typeof o.id !== 'string') return undefined
90
- return o.id
59
+ line = readFirstJsonlLineSync(filePath)
91
60
  } catch {
92
61
  return undefined
93
- } finally {
94
- if (fd !== undefined) {
95
- try {
96
- closeSync(fd)
97
- } catch {
98
- // closeSync 失败:fd 可能已无效,header 数据已读取,关闭失败不影响结果(best-effort)
99
- void fd
100
- }
101
- }
102
62
  }
63
+ return parseSessionHeader(line)?.id
103
64
  }
@@ -1,9 +1,10 @@
1
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
- import type { Entry } from '../core/parser.js'
4
+ import type { Entry } from '@zhushanwen/session-core'
5
5
  import type { Family, SessionRef } from '../core/family.js'
6
6
  import { buildFamilyIndex, resolveFamily } from '../core/family.js'
7
+ import { listZcodeManifests } from './zcode-manifest.js'
7
8
  import { resolveSessionRoots, type SessionFileMeta } from './roots.js'
8
9
  import { parseSessionHeader, readSessionHeaderFirstLine } from './session-header.js'
9
10
  import { resolveWorkflows } from './workflows.js'
@@ -54,7 +55,13 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
54
55
  if (!scan.sessionIdToPath.has(sessionId)) {
55
56
  throw new Error(await formatSessionNotFound(sessionId, agentDir))
56
57
  }
57
- const index = buildFamilyIndex(scan.headers, scan.identities, scan.fileStats)
58
+ // U9 family 接线(design session-reader-shared-core §5 Phase 3 单元三):zcode 节点
59
+ // 并入 subagentsByRoot(D5-1 判据在 core 层 buildZcodeSubagentsByRoot)——**纯追加**,
60
+ // pi 现路径(listRecordManifests/isRecordManifest/appendOrphanIdentities)零改动;
61
+ // zcode manifest 无 sessionFile 键本就被 isRecordManifest 整条丢弃(F21),与 pi 扫描
62
+ // 结果集天然不相交,两条来源互不污染。枚举失败容错内置(根不存在 → 空数组不报错)。
63
+ const zcodeNodes = await listZcodeManifests(agentDir)
64
+ const index = buildFamilyIndex(scan.headers, scan.identities, scan.fileStats, zcodeNodes)
58
65
  const family = resolveFamily(sessionId, index)
59
66
 
60
67
  // ---- 6. workflows(enrichRefs 回填已删除,ext-simplify-04 U4/E2:fileName/cwd 占位
@@ -0,0 +1,87 @@
1
+ import { existsSync, realpathSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+
4
+ import { zcodeImportDbAllowlist } from '@zhushanwen/zcode-session-source'
5
+
6
+ /**
7
+ * zcode 会话库白名单闸(design session-reader-shared-core §3.1 第③步 / §3.3 D5 /
8
+ * §3.4 检查顺序 / 探针 P-whitelist)。
9
+ *
10
+ * dbPath 来自 session 数据(manifest engineHandle.sessionRef / 主 session custom
11
+ * entry),是**不可信输入**(会话文件可被构造)——开库前必须过本闸,白名单是必需
12
+ * 闸而非可选优化。集合与 runtime 导入侧 `zcodeImportDbAllowlist(dataDir)` **同源
13
+ * 同形**:隔离库(现役)+ 宿主库(存量兼容)两项,路径段单源 = 该函数内部引用的
14
+ * `@zhushanwen/subagent-engine-sdk` zcode-db-paths 常量(跨侧契约根,禁手拼字面量
15
+ * 段——SDK 头注「两侧必须同源推导,禁止各自拼字符串漂移」)。
16
+ *
17
+ * **检查顺序铁律(§3.4 三段递进,实现与测试双锚定)**:存在性先于路径——
18
+ * `existsSync` 为 false 报 `zcode_db_unreadable`(库文件不存在)而非
19
+ * `zcode_db_path_forbidden`,避免把「只是还没落库」误报成安全事件(forbidden 是
20
+ * 安全面错误,语义更重、更惊扰 agent)。存在才 realpath 后与派生允许集合精确比对。
21
+ * 开库(第 3 段)由调用方在本闸通过后进行。
22
+ *
23
+ * **TOCTOU 残留(设计 §3.4 显式登记,不在本梯队硬化)**:realpath 之后仍按原
24
+ * dbPath 开库,两步间存在理论竞态窗口;利用它需攻击者具备同用户写权限,而同用户
25
+ * 权限下本就可直接读该库——实测方向安全,fd 复用/O_NOFOLLOW 属纵深防御非实际威胁面。
26
+ *
27
+ * **realpath 对齐**:dbPath 与集合项都经 realpath 归一后再比对(realpath 失败——
28
+ * 竞态删除/权限——按原串参与)。taiji 生产布局(`~/.taiji` 下无 symlink)两者等价;
29
+ * macOS 临时目录(`/var` → `/private/var`)等 symlink 环境下不对齐会把合法库误判
30
+ * 集合外,P-whitelist 单测按 tmp dataDir 布局锚定此语义。
31
+ */
32
+
33
+ /** symlink 归一(失败按原串——不存在的路径进不了比对成功分支,由调用方顺序保证)。 */
34
+ function realOf(p: string): string {
35
+ try {
36
+ return realpathSync(p)
37
+ } catch {
38
+ return p
39
+ }
40
+ }
41
+
42
+ /**
43
+ * dbPath 的派生允许集合(realpath 对齐后):`zcodeIsolatedDbPath(dirname(agentDir))`
44
+ * + `hostZcodeDbPath()`。F11:taiji 下 agentDir = `<dataDir>/agent` →
45
+ * dirname(agentDir) = dataDir,与引擎侧/导入侧的隔离库推导同一 dataDir。纯 pi 宿主
46
+ * (agentDir 退化为 `~/.pi/...`)下隔离库项恒不可达且不存在 taiji 派生的 zcode 锚
47
+ * (D5「纯 pi 宿主边界」),无害,不需特例分支。
48
+ */
49
+ export function zcodeDbAllowlistFor(agentDir: string): readonly string[] {
50
+ return zcodeImportDbAllowlist(dirname(agentDir)).map(realOf)
51
+ }
52
+
53
+ /** `zcode_db_unreadable` 的 👉 恢复指引(§3.4 表逐行;隔离库路径运行时动态推导)。 */
54
+ export function formatZcodeDbUnreadable(dataDir: string, detail: string): string {
55
+ const isolated = zcodeImportDbAllowlist(dataDir)[0]
56
+ return (
57
+ `[zcode_db_unreadable] zcode 会话库不可读(恢复阶梯 L1-L3 均未成功)。${detail}\n` +
58
+ `👉 使原目录可写,或把库连 -wal/-shm 一起拷回白名单内原路径(隔离库 = ${isolated})后重试。\n` +
59
+ `⚠️ 告诫:缺 -wal 的库未 checkpoint 的已提交数据可能已丢——这是写者未干净退出造成的既成事实,非恢复引入;完整性以连 -wal 一起拷贝为准。`
60
+ )
61
+ }
62
+
63
+ /** `zcode_db_path_forbidden` 的 👉 恢复指引(§3.4 表逐行)。 */
64
+ export function formatZcodeDbPathForbidden(dbPath: string): string {
65
+ return (
66
+ `[zcode_db_path_forbidden] 该会话引用的 zcode 库路径不在允许范围:${dbPath}\n` +
67
+ `👉 在 taiji 内打开原会话读取,或用会话导入对话框导入后读取。`
68
+ )
69
+ }
70
+
71
+ /**
72
+ * 白名单闸(第 1 段存在性 → 第 2 段路径;通过才允许开库)。
73
+ *
74
+ * @throws Error(消费侧映射为 zcode_db_unreadable / zcode_db_path_forbidden,
75
+ * 消息即 §3.4 的 👉 指引文案——tool-handler 直接 throw 给 pi 错误面)
76
+ */
77
+ export function assertZcodeDbPathAllowed(dbPath: string, agentDir: string): void {
78
+ // 第 1 段 存在性(先于路径——「还没落库」不是安全事件)
79
+ if (!existsSync(dbPath)) {
80
+ throw new Error(formatZcodeDbUnreadable(dirname(agentDir), `(库文件不存在:${dbPath})`))
81
+ }
82
+ // 第 2 段 路径(存在才 realpath 比对;TOCTOU 残留见文件头注)
83
+ const allowed = zcodeDbAllowlistFor(agentDir)
84
+ if (!allowed.includes(realOf(dbPath))) {
85
+ throw new Error(formatZcodeDbPathForbidden(dbPath))
86
+ }
87
+ }
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { basename } from 'node:path'
3
- import { parseSessionContent } from '../core/parser.js'
3
+ import { parseSessionContent } from '@zhushanwen/session-core'
4
4
  import type { SessionRef, WorkflowRef } from '../core/family.js'
5
5
  import { extractSessionIdFromFilename } from './subagents.js'
6
6