@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.
@@ -0,0 +1,329 @@
1
+ // ============================================================
2
+ // workflow 概览解析与渲染(w5 新增,纯逻辑零 IO)
3
+ // ============================================================
4
+ //
5
+ // 本文件消费 discovery/workflows.ts 的 readRunSnapshot(返 unknown 原始快照对象),
6
+ // 把 unknown 类型化为 WorkflowOverview(NEW v='wf-run-v1' / OLD 无 v 双格式分支),
7
+ // 再渲染为人类可读文本。零 IO:parseRunSnapshot/renderWorkflowOverview 喂 mock 即可单测
8
+ //(w5 TC-wf-core-pure-logic,对齐 session-reader core/* 纯逻辑约定)。
9
+ //
10
+ // 不 import @zhushanwen/pi-subagent-workflow 的 RunSnapshot 类型——跨包类型耦合会使上游
11
+ // 升版连带编译期影响本扩展;且上游类型只描述 NEW,OLD 仍需自处理(TC-wf-snapshot-version-union)。
12
+ // session-reader 作为纯读取者,按字段存在性 + v 标记分支做「结构化快照」式解析,与上游解耦。
13
+
14
+ // ---- 类型守卫 helpers ----
15
+
16
+ /** unknown → Record<string, unknown> 守卫(非对象或 null → false)。 */
17
+ function isRecord(v: unknown): v is Record<string, unknown> {
18
+ return typeof v === 'object' && v !== null
19
+ }
20
+
21
+ /** 字符串截断(超 max 加省略号)。概览预览用,全文走 detail。 */
22
+ function truncate(s: string, max: number): string {
23
+ return s.length <= max ? s : s.slice(0, max) + '…'
24
+ }
25
+
26
+ /** contentPreview 截断长度(概览预览,全文走 detail)。 */
27
+ const CONTENT_PREVIEW_MAX = 120
28
+ /** step 行 call sessionId 显示截断长度(uuid 前缀段,LLM 可读)。 */
29
+ const SESSION_ID_PREVIEW_MAX = 12
30
+
31
+ // ---- 数据模型(对齐 m2 slice DM-WorkflowBudget/DM-WorkflowStep/DM-WorkflowOverview)----
32
+
33
+ /** wf-state budget 尽力提取(OLD budget 结构可能不全,缺字段 undefined)。 */
34
+ export interface WorkflowBudget {
35
+ /** NEW state.budget.usedTokens / OLD budget.usedTokens */
36
+ usedTokens?: number
37
+ /** NEW state.budget.usedCost */
38
+ usedCost?: number
39
+ /** NEW state.budget.totalCallCount */
40
+ totalCallCount?: number
41
+ /** NEW state.budget.maxTokens */
42
+ maxTokens?: number
43
+ /** NEW state.budget.maxCost */
44
+ maxCost?: number
45
+ /** NEW state.budget.maxTimeMs */
46
+ maxTimeMs?: number
47
+ }
48
+
49
+ /** workflow 单步(NEW call / OLD callCache entry)。 */
50
+ export interface WorkflowStep {
51
+ /** NEW call.id / OLD callCache 顺序索引 */
52
+ index: number
53
+ /** NEW call.status / OLD 推测(有 sessionFile 或 content → 'done',否则 'pending') */
54
+ status: 'pending' | 'running' | 'done'
55
+ /** NEW call.opts.description */
56
+ description?: string
57
+ /** NEW call.opts.model */
58
+ model?: string
59
+ /** NEW call.opts.thinkingLevel */
60
+ thinkingLevel?: string
61
+ /** NEW call.attempts / OLD 无 → undefined */
62
+ attempts?: number
63
+ /** NEW call.result.durationMs / OLD value.result.durationMs */
64
+ durationMs?: number
65
+ /** call.sessionId 或 result.sessionId(LLM 跳 outline/detail 的 id 入口) */
66
+ sessionId?: string
67
+ /** call.sessionFile 或 result.sessionFile(LLM 跳 outline/detail 的绝对路径入口,OLD 多数为 undefined) */
68
+ sessionFile?: string
69
+ /** result.content 截断前 120 字(概览预览,全文走 detail) */
70
+ contentPreview?: string
71
+ }
72
+
73
+ /** workflow run 概览(parseRunSnapshot 输出 / renderWorkflowOverview 输入)。 */
74
+ export interface WorkflowOverview {
75
+ /** WorkflowRef.runId 透传(与 family.workflows 对齐,不读 snapshot.runId 避免 OLD 不一致) */
76
+ runId: string
77
+ /** WorkflowRef.stateFile 透传 */
78
+ stateFile: string
79
+ /** NEW state.status / OLD 顶层 status */
80
+ status: string
81
+ /** 格式标记(渲染/调试用) */
82
+ version: 'wf-run-v1' | 'legacy'
83
+ /** NEW spec.scriptName / spec.name / OLD name */
84
+ script?: string
85
+ /** NEW meta.startedAt / OLD startedAt(统一 string) */
86
+ startedAt?: string
87
+ /** NEW meta.completedAt */
88
+ completedAt?: string
89
+ /** NEW state.reason */
90
+ reason?: string
91
+ /** NEW state.error */
92
+ error?: string
93
+ budget: WorkflowBudget
94
+ steps: WorkflowStep[]
95
+ }
96
+
97
+ // ---- 解析 helpers(call/cache entry → WorkflowStep)----
98
+
99
+ /** 把 budget 原始对象收窄为 WorkflowBudget(各字段类型校验,非 number → undefined)。 */
100
+ function mapBudget(b: Record<string, unknown>): WorkflowBudget {
101
+ return {
102
+ usedTokens: typeof b.usedTokens === 'number' ? b.usedTokens : undefined,
103
+ usedCost: typeof b.usedCost === 'number' ? b.usedCost : undefined,
104
+ totalCallCount: typeof b.totalCallCount === 'number' ? b.totalCallCount : undefined,
105
+ maxTokens: typeof b.maxTokens === 'number' ? b.maxTokens : undefined,
106
+ maxCost: typeof b.maxCost === 'number' ? b.maxCost : undefined,
107
+ maxTimeMs: typeof b.maxTimeMs === 'number' ? b.maxTimeMs : undefined,
108
+ }
109
+ }
110
+
111
+ /** NEW state.calls[] 单项 → WorkflowStep。sessionFile/sessionId 顶层优先回退 result。 */
112
+ function mapCallToStep(call: unknown, fallbackIndex: number): WorkflowStep {
113
+ if (!isRecord(call)) return { index: fallbackIndex, status: 'pending' }
114
+ const opts = isRecord(call.opts) ? call.opts : {}
115
+ const result = isRecord(call.result) ? call.result : {}
116
+
117
+ const index = typeof call.id === 'number' ? call.id : fallbackIndex
118
+ const rawStatus = typeof call.status === 'string' ? call.status : ''
119
+ const status: WorkflowStep['status'] =
120
+ rawStatus === 'done' || rawStatus === 'running' || rawStatus === 'pending'
121
+ ? rawStatus
122
+ : 'pending'
123
+
124
+ const sessionFile =
125
+ typeof call.sessionFile === 'string'
126
+ ? call.sessionFile
127
+ : typeof result.sessionFile === 'string'
128
+ ? result.sessionFile
129
+ : undefined
130
+ const sessionId =
131
+ typeof call.sessionId === 'string'
132
+ ? call.sessionId
133
+ : typeof result.sessionId === 'string'
134
+ ? result.sessionId
135
+ : undefined
136
+ const content = typeof result.content === 'string' ? result.content : undefined
137
+
138
+ return {
139
+ index,
140
+ status,
141
+ description: typeof opts.description === 'string' ? opts.description : undefined,
142
+ model: typeof opts.model === 'string' ? opts.model : undefined,
143
+ thinkingLevel: typeof opts.thinkingLevel === 'string' ? opts.thinkingLevel : undefined,
144
+ attempts: typeof call.attempts === 'number' ? call.attempts : undefined,
145
+ durationMs: typeof result.durationMs === 'number' ? result.durationMs : undefined,
146
+ sessionId,
147
+ sessionFile,
148
+ contentPreview: content !== undefined ? truncate(content, CONTENT_PREVIEW_MAX) : undefined,
149
+ }
150
+ }
151
+
152
+ /** OLD callCache[] 单项 {key, value} → WorkflowStep。status 推测,content 优先 result 回退 value。 */
153
+ function mapCacheEntryToStep(entry: unknown, index: number): WorkflowStep {
154
+ if (!isRecord(entry)) return { index, status: 'pending' }
155
+ const value = isRecord(entry.value) ? entry.value : {}
156
+ const result = isRecord(value.result) ? value.result : {}
157
+
158
+ // sessionFile: value.sessionFile 或 value.result.sessionFile(OLD 多数缺失,探针 112 文件 0)
159
+ const sessionFile =
160
+ typeof value.sessionFile === 'string'
161
+ ? value.sessionFile
162
+ : typeof result.sessionFile === 'string'
163
+ ? result.sessionFile
164
+ : undefined
165
+ const sessionId =
166
+ typeof value.sessionId === 'string'
167
+ ? value.sessionId
168
+ : typeof result.sessionId === 'string'
169
+ ? result.sessionId
170
+ : undefined
171
+ // content:真实 OLD 数据 value.content(wf-skip-ok)与测试 fixture value.result.content 并存
172
+ const content =
173
+ typeof result.content === 'string'
174
+ ? result.content
175
+ : typeof value.content === 'string'
176
+ ? value.content
177
+ : undefined
178
+ // OLD 无 status 字段:有 sessionFile 或非空 content → done,否则 pending
179
+ //(空 content 如 wf-skip-ok 的 '' 不算完成标志,对齐 TC-w5-parse-old expected status='pending';
180
+ // content 仍提取为 contentPreview='')
181
+ const hasContent = content !== undefined && content.length > 0
182
+ const status: WorkflowStep['status'] =
183
+ sessionFile !== undefined || hasContent ? 'done' : 'pending'
184
+
185
+ return {
186
+ index,
187
+ status,
188
+ durationMs: typeof result.durationMs === 'number' ? result.durationMs : undefined,
189
+ sessionId,
190
+ sessionFile,
191
+ contentPreview: content !== undefined ? truncate(content, CONTENT_PREVIEW_MAX) : undefined,
192
+ }
193
+ }
194
+
195
+ // ---- parseRunSnapshot(unknown → WorkflowOverview | null)----
196
+
197
+ /**
198
+ * 把 readRunSnapshot 返回的原始快照对象类型化为 WorkflowOverview(纯逻辑零 IO)。
199
+ *
200
+ * 分支(C-parserunsnapshot-dualformat,TC-wf-snapshot-version-union):
201
+ * - 非对象 → null(调用方跳过,ES-wf-snapshot-unparseable)
202
+ * - NEW (snapshot.v === 'wf-run-v1'):state.* / meta.* / spec.*
203
+ * - OLD (无 v,有 callCache 数组或顶层 status):顶层 status/budget/startedAt + callCache
204
+ * - 既非 NEW 也非 OLD → null(未来版本 wf-run-v2 / 异构内容)
205
+ *
206
+ * runId/stateFile 透传参数(不读 snapshot.runId,保证与 family.workflows 一致,避免 OLD 顶层
207
+ * runId 可信度低的不一致)。零 any(全程 typeof/Array.isArray/isRecord 守卫收窄)。
208
+ */
209
+ export function parseRunSnapshot(
210
+ snapshot: unknown,
211
+ runId: string,
212
+ stateFile: string,
213
+ ): WorkflowOverview | null {
214
+ if (!isRecord(snapshot)) return null
215
+
216
+ // NEW 格式(v === 'wf-run-v1')
217
+ if (snapshot.v === 'wf-run-v1') {
218
+ const state = isRecord(snapshot.state) ? snapshot.state : {}
219
+ const meta = isRecord(snapshot.meta) ? snapshot.meta : {}
220
+ const spec = isRecord(snapshot.spec) ? snapshot.spec : {}
221
+ const callsRaw = Array.isArray(state.calls) ? state.calls : []
222
+ return {
223
+ runId,
224
+ stateFile,
225
+ status: typeof state.status === 'string' ? state.status : '',
226
+ version: 'wf-run-v1',
227
+ script:
228
+ typeof spec.scriptName === 'string'
229
+ ? spec.scriptName
230
+ : typeof spec.name === 'string'
231
+ ? spec.name
232
+ : undefined,
233
+ startedAt: typeof meta.startedAt === 'string' ? meta.startedAt : undefined,
234
+ completedAt: typeof meta.completedAt === 'string' ? meta.completedAt : undefined,
235
+ reason: typeof state.reason === 'string' ? state.reason : undefined,
236
+ error: typeof state.error === 'string' ? state.error : undefined,
237
+ budget: mapBudget(isRecord(state.budget) ? state.budget : {}),
238
+ steps: callsRaw.map((c, i) => mapCallToStep(c, i)),
239
+ }
240
+ }
241
+
242
+ // OLD 格式(无 v,有 callCache 数组或顶层 status 字符串)
243
+ if (Array.isArray(snapshot.callCache) || typeof snapshot.status === 'string') {
244
+ const callCacheRaw = Array.isArray(snapshot.callCache) ? snapshot.callCache : []
245
+ return {
246
+ runId,
247
+ stateFile,
248
+ status: typeof snapshot.status === 'string' ? snapshot.status : '',
249
+ version: 'legacy',
250
+ script: typeof snapshot.name === 'string' ? snapshot.name : undefined,
251
+ startedAt: typeof snapshot.startedAt === 'string' ? snapshot.startedAt : undefined,
252
+ budget: mapBudget(isRecord(snapshot.budget) ? snapshot.budget : {}),
253
+ steps: callCacheRaw.map((c, i) => mapCacheEntryToStep(c, i)),
254
+ }
255
+ }
256
+
257
+ return null
258
+ }
259
+
260
+ // ---- renderWorkflowOverview(WorkflowOverview → 人类可读文本)----
261
+
262
+ /**
263
+ * 渲染 WorkflowOverview 为人类可读文本(纯逻辑零 IO)。
264
+ *
265
+ * 输出结构(IF-renderWorkflowOverview):
266
+ * - 头行:`run: <runId> [status] (script?) started=<ISO> completed?=<ISO> reason?`
267
+ * - budget 行:`budget: used=<tokens>tok $<cost> calls=<n> / max=<tokens>tok $<cost> <timeMs>ms`
268
+ * (缺省字段省略,不输出 undefined 字面量)
269
+ * - steps 块每行:` #<index> [status] <description> · model=<model> · <durationMs>ms ·
270
+ * attempts=<n> · call=<sessionId截断> <sessionFile>`
271
+ * sessionFile 缺则标 `(无 sessionFile,OLD 格式未持久化)`(TC-wf-step-sessionfile-link)
272
+ * - error 行(如有 state.error)
273
+ *
274
+ * 每个 step 的 call sessionId/sessionFile 是 LLM 跳 outline/detail 的入口(m0 resolveSessionId
275
+ * 三形态:sessionId/绝对路径/sa-id 均可深读)。多 run 场景由 doWorkflow 循环拼接多段(w6)。
276
+ */
277
+ export function renderWorkflowOverview(overview: WorkflowOverview): string {
278
+ const lines: string[] = []
279
+
280
+ // 头行
281
+ const headParts = [`run: ${overview.runId}`, `[${overview.status}]`]
282
+ if (overview.script) headParts.push(`(${overview.script})`)
283
+ if (overview.startedAt) headParts.push(`started=${overview.startedAt}`)
284
+ if (overview.completedAt) headParts.push(`completed=${overview.completedAt}`)
285
+ if (overview.reason) headParts.push(`reason=${overview.reason}`)
286
+ lines.push(headParts.join(' '))
287
+
288
+ // budget 行(缺字段省略)
289
+ const budgetParts: string[] = ['budget:']
290
+ if (overview.budget.usedTokens !== undefined) budgetParts.push(`used=${overview.budget.usedTokens}tok`)
291
+ if (overview.budget.usedCost !== undefined) budgetParts.push(`$${overview.budget.usedCost}`)
292
+ if (overview.budget.totalCallCount !== undefined) budgetParts.push(`calls=${overview.budget.totalCallCount}`)
293
+ const hasMax =
294
+ overview.budget.maxTokens !== undefined ||
295
+ overview.budget.maxCost !== undefined ||
296
+ overview.budget.maxTimeMs !== undefined
297
+ if (hasMax) {
298
+ const maxParts: string[] = ['/ max=']
299
+ if (overview.budget.maxTokens !== undefined) maxParts.push(`${overview.budget.maxTokens}tok`)
300
+ if (overview.budget.maxCost !== undefined) maxParts.push(`$${overview.budget.maxCost}`)
301
+ if (overview.budget.maxTimeMs !== undefined) maxParts.push(`${overview.budget.maxTimeMs}ms`)
302
+ budgetParts.push(maxParts.join(''))
303
+ }
304
+ lines.push(budgetParts.join(' '))
305
+
306
+ // steps 块
307
+ for (const step of overview.steps) {
308
+ const stepParts = [` #${step.index}`, `[${step.status}]`]
309
+ if (step.description) stepParts.push(step.description)
310
+ const tail: string[] = []
311
+ if (step.model) tail.push(`model=${step.model}`)
312
+ if (step.durationMs !== undefined) tail.push(`${step.durationMs}ms`)
313
+ if (step.attempts !== undefined) tail.push(`attempts=${step.attempts}`)
314
+ if (step.sessionId) tail.push(`call=${truncate(step.sessionId, SESSION_ID_PREVIEW_MAX)}`)
315
+ let line = stepParts.join(' ')
316
+ if (tail.length > 0) line += ' · ' + tail.join(' · ')
317
+ if (step.sessionFile) {
318
+ line += ' ' + step.sessionFile
319
+ } else {
320
+ line += ' (无 sessionFile,OLD 格式未持久化)'
321
+ }
322
+ lines.push(line)
323
+ }
324
+
325
+ // error 行
326
+ if (overview.error) lines.push(`error: ${overview.error}`)
327
+
328
+ return lines.join('\n')
329
+ }
@@ -1,8 +1,10 @@
1
1
  import { createReadStream, type ReadStream } from 'node:fs'
2
2
  import { open, type FileHandle } from 'node:fs/promises'
3
3
  import { createInterface } from 'node:readline'
4
+ import { basename } from 'node:path'
4
5
  import type { SessionRef } from '../core/family.js'
5
- import { listMainSessions, type SessionFileMeta } from './roots.js'
6
+ import { listMainSessions, listSubagentSessions, type SessionFileMeta } from './roots.js'
7
+ import { listRecordManifests, extractSessionIdFromFilename, type RecordManifest } from './subagents.js'
6
8
 
7
9
  /**
8
10
  * M2 discovery 发现层:按 query 定位 session(design §3.3 D-3 + §3.4 find action)。
@@ -19,7 +21,12 @@ import { listMainSessions, type SessionFileMeta } from './roots.js'
19
21
  * agentDir 注入:同 roots.ts,零 pi 依赖(仅 node:fs + 相对 import M1 core)。
20
22
  */
21
23
 
24
+ /** 候选来源标记(DM1 必填):main = agentDir/sessions/、subagent = agentDir/subagents/。 */
25
+ export type SessionSource = 'main' | 'subagent'
26
+
22
27
  export interface MatchedSession extends SessionRef {
28
+ /** 候选来源(DM1 必填标记):main 或 subagent,按文件所在目录标记 */
29
+ source: SessionSource
23
30
  /** 首条 user message text 截 80 字符(从全文读,不只首行) */
24
31
  firstMessagePreview?: string
25
32
  }
@@ -153,6 +160,8 @@ function looksLikeUuidFragment(query: string): boolean {
153
160
  interface Candidate {
154
161
  meta: SessionFileMeta
155
162
  ref: SessionRef
163
+ /** 候选来源(透传到 MatchedSession.source,DM1) */
164
+ source: SessionSource
156
165
  }
157
166
 
158
167
  interface Matched extends Candidate {
@@ -160,6 +169,115 @@ interface Matched extends Candidate {
160
169
  preview?: string
161
170
  }
162
171
 
172
+ // ============================================================
173
+ // U5:subagent task/slug/agentName 匹配(manifest 索引 + P-fallback identity 回退)
174
+ // ============================================================
175
+
176
+ /** P-fallback 尾行 identity 读取窗口(同 subagents.ts,task 文本可达数 KB)。 */
177
+ const TAIL_READ_BYTES = 65536
178
+
179
+ /**
180
+ * 读 subagent 文件尾部(最后 64KB)找 subagent-identity entry,返回 task/slug/agent。
181
+ *
182
+ * find 的 P-fallback 路径(场景 A:subagent 无 manifest,本机 11.5%):manifest 索引未命中时
183
+ * 读尾行 identity 取 task/slug/agent 做 query 子串匹配。与 subagents.ts 的 readTailIdentity
184
+ * 同源(64KB 窗口 + lastIndexOf 定位),但是 find 专用最小版(只取 task/slug/agent,不要
185
+ * rootSessionId——find 候选已有 header.id)。不导出,不碰 subagents.ts(w3 冻结)。
186
+ */
187
+ async function readTailIdentityForMatch(
188
+ path: string,
189
+ size: number,
190
+ ): Promise<{ task?: string; slug?: string; agent?: string } | undefined> {
191
+ if (size === 0) return undefined
192
+ let fh: FileHandle | undefined
193
+ try {
194
+ fh = await open(path, 'r')
195
+ const len = Math.min(TAIL_READ_BYTES, size)
196
+ const buf = Buffer.alloc(len)
197
+ await fh.read(buf, 0, len, Math.max(0, size - len))
198
+ const text = buf.toString('utf8')
199
+ const idx = text.lastIndexOf('subagent-identity')
200
+ if (idx < 0) return undefined
201
+ const lineStartSearch = text.lastIndexOf('\n', idx)
202
+ if (lineStartSearch < 0 && size > len) return undefined
203
+ const start = lineStartSearch < 0 ? 0 : lineStartSearch + 1
204
+ let end = text.indexOf('\n', idx)
205
+ if (end < 0) end = text.length
206
+ const line = text.slice(start, end)
207
+ let raw: unknown
208
+ try {
209
+ raw = JSON.parse(line)
210
+ } catch {
211
+ return undefined
212
+ }
213
+ const data = (raw as Record<string, unknown> | undefined)?.data as
214
+ | Record<string, unknown>
215
+ | undefined
216
+ if (!data) return undefined
217
+ return {
218
+ task: typeof data.task === 'string' ? data.task : undefined,
219
+ slug: typeof data.slug === 'string' ? data.slug : undefined,
220
+ agent: typeof data.agent === 'string' ? data.agent : undefined,
221
+ }
222
+ } catch {
223
+ return undefined
224
+ } finally {
225
+ await fh?.close().catch(() => {})
226
+ }
227
+ }
228
+
229
+ /**
230
+ * 建 sessionId→RecordManifest 索引(U5:subagent task/slug/agentName 匹配用)。
231
+ *
232
+ * listRecordManifests 一次性读全部 manifest(json 小,几百字节),用 extractSessionIdFromFilename
233
+ * 从 manifest.sessionFile 文件名提取 sessionId 作 key(与候选 header.id 同源真实 id)。无 subagent
234
+ * 候选时跳过(避免无谓 IO——TC-find-manifest-index 的 O(1) 查表前提)。
235
+ */
236
+ async function buildManifestIndex(
237
+ agentDir: string,
238
+ hasSubagentCandidates: boolean,
239
+ ): Promise<Map<string, RecordManifest>> {
240
+ if (!hasSubagentCandidates) return new Map()
241
+ const manifests = await listRecordManifests(agentDir)
242
+ const index = new Map<string, RecordManifest>()
243
+ for (const m of manifests) {
244
+ const sid = extractSessionIdFromFilename(basename(m.sessionFile))
245
+ if (sid) index.set(sid, m)
246
+ }
247
+ return index
248
+ }
249
+
250
+ /**
251
+ * subagent 候选元数据匹配(U5):manifest 命中走 task/slug/agentName 子串;索引未命中(P-fallback,
252
+ * 场景 A)读尾行 identity 回退匹配 task/slug/agent。
253
+ *
254
+ * manifest 命中但不匹配时不再回退 identity——manifest 是权威主表,task/slug/agentName 即其提供,
255
+ * identity 同源数据回退无新信息(探针 manifest 20/20 全有 task/slug)。manifest 缺某字段(旧 manifest)
256
+ * 时该字段 undefined,includes 自然 false,不影响其他字段。
257
+ */
258
+ async function matchSubagentMetadata(
259
+ candidate: Candidate,
260
+ query: string,
261
+ manifestIndex: Map<string, RecordManifest>,
262
+ ): Promise<boolean> {
263
+ const manifest = manifestIndex.get(candidate.ref.sessionId)
264
+ if (manifest) {
265
+ return (
266
+ (manifest.task?.includes(query) ?? false) ||
267
+ (manifest.slug?.includes(query) ?? false) ||
268
+ (manifest.agentName?.includes(query) ?? false)
269
+ )
270
+ }
271
+ // P-fallback:manifest 索引未命中 → 读尾行 identity 回退
272
+ const ident = await readTailIdentityForMatch(candidate.meta.path, candidate.meta.size)
273
+ if (!ident) return false
274
+ return (
275
+ (ident.task?.includes(query) ?? false) ||
276
+ (ident.slug?.includes(query) ?? false) ||
277
+ (ident.agent?.includes(query) ?? false)
278
+ )
279
+ }
280
+
163
281
  /**
164
282
  * 按 query 找 session(接口冻结,design §3.4 find action)。
165
283
  *
@@ -170,29 +288,46 @@ interface Matched extends Candidate {
170
288
  export async function findSessions(
171
289
  query: string,
172
290
  agentDir: string,
173
- opts?: { cwd?: string; limit?: number },
291
+ opts?: { cwd?: string; limit?: number; source?: SessionSource },
174
292
  ): Promise<{ matches: MatchedSession[]; truncated: boolean }> {
175
293
  const limit = opts?.limit ?? DEFAULT_LIMIT
176
294
  const cwdFilter = opts?.cwd
177
- const files = await listMainSessions(agentDir)
295
+ const sourceFilter = opts?.source
296
+
297
+ // 0. 按来源收集文件列表(source 过滤在文件列表层:source==='main' 只扫 sessions/、
298
+ // 'subagent' 只扫 subagents/、undefined 两者合并——决策二性能意图:不扫被过滤目录)。
299
+ // 两路目录扫描相互独立 → Promise.allSettled(AGENTS.md:独立请求用 allSettled),
300
+ // 任一目录不存在(roots.ts 静默返回空数组)不影响另一路。
301
+ const sources: SessionSource[] =
302
+ sourceFilter === undefined ? ['main', 'subagent'] : [sourceFilter]
303
+ const listResults = await Promise.allSettled(
304
+ sources.map(async (src) => ({
305
+ src,
306
+ files: await (src === 'main' ? listMainSessions(agentDir) : listSubagentSessions(agentDir)),
307
+ })),
308
+ )
178
309
 
179
- // 1. 首行扫描所有文件拿 header,建候选 SessionRef(cwd 过滤在此应用)
310
+ // 1. 首行扫描建候选 SessionRef(cwd 过滤在此应用;按来源打 source 标记)
180
311
  const candidates: Candidate[] = []
181
- for (const meta of files) {
182
- const headerLine = await readFirstLine(meta.path)
183
- const header = parseHeader(headerLine)
184
- if (!header) continue // session 文件/坏 header → 跳过
185
- if (cwdFilter !== undefined && (header.cwd ?? '') !== cwdFilter) continue
186
- const ref: SessionRef = {
187
- sessionId: header.id,
188
- // 完整绝对路径(与 parentSession 同构,便于 family includes(sid) 反查)
189
- fileName: meta.path,
190
- mtime: meta.mtime,
191
- sizeBytes: meta.size,
192
- cwd: header.cwd ?? '',
312
+ for (const r of listResults) {
313
+ if (r.status !== 'fulfilled') continue
314
+ const { src, files } = r.value
315
+ for (const meta of files) {
316
+ const headerLine = await readFirstLine(meta.path)
317
+ const header = parseHeader(headerLine)
318
+ if (!header) continue // 非 session 文件/坏 header → 跳过
319
+ if (cwdFilter !== undefined && (header.cwd ?? '') !== cwdFilter) continue
320
+ const ref: SessionRef = {
321
+ sessionId: header.id,
322
+ // 完整绝对路径(与 parentSession 同构,便于 family 按 includes(sid) 反查)
323
+ fileName: meta.path,
324
+ mtime: meta.mtime,
325
+ sizeBytes: meta.size,
326
+ cwd: header.cwd ?? '',
327
+ }
328
+ if (header.parentSession) ref.parentSession = header.parentSession
329
+ candidates.push({ meta, ref, source: src })
193
330
  }
194
- if (header.parentSession) ref.parentSession = header.parentSession
195
- candidates.push({ meta, ref })
196
331
  }
197
332
 
198
333
  // 2. 匹配
@@ -211,9 +346,21 @@ export async function findSessions(
211
346
  // query 像 uuid 片段但无匹配 → uuid 写错的可能性高,不对全部候选深读首消息
212
347
  matched = []
213
348
  } else {
214
- // 名称关键词 fallback:读所有候选首消息,预览含 query 入选
349
+ // 关键词层(U5 扩展):subagent manifest 元数据 task/slug/agentName(+ P-fallback identity
350
+ // 回退)与 main/subagent 首消息预览并列匹配,命中任一即入选(TC-find-match-priority)。
351
+ // - subagent 候选:先查 manifest 索引(命中走元数据子串,未命中 P-fallback 读尾行 identity);
352
+ // 元数据命中即入选(preview 留空,第 5 步补读首消息),未命中仍可走首消息 fallback
353
+ // - main / subagent 元数据未命中:首消息预览 query 子串匹配(m0 现状路径不变)
354
+ const manifestIndex = await buildManifestIndex(
355
+ agentDir,
356
+ candidates.some((c) => c.source === 'subagent'),
357
+ )
215
358
  const keywordHits: Matched[] = []
216
359
  for (const c of candidates) {
360
+ if (c.source === 'subagent' && (await matchSubagentMetadata(c, query, manifestIndex))) {
361
+ keywordHits.push({ ...c })
362
+ continue
363
+ }
217
364
  const text = await readFirstUserMessageText(c.meta.path)
218
365
  if (text && text.includes(query)) {
219
366
  keywordHits.push({ ...c, preview: text.slice(0, PREVIEW_MAX) })
@@ -233,7 +380,7 @@ export async function findSessions(
233
380
  // 5. 填 firstMessagePreview(recent/uuid 路径未读,这里对最终 limit 个补读——最多 limit 个 IO)
234
381
  const result: MatchedSession[] = []
235
382
  for (const m of sliced) {
236
- const out: MatchedSession = { ...m.ref }
383
+ const out: MatchedSession = { ...m.ref, source: m.source }
237
384
  if (m.preview !== undefined) {
238
385
  out.firstMessagePreview = m.preview
239
386
  } else {