// src/shared/resource-discovery.ts // // 统一资源发现模块——agent .md 与 workflow .js/.mjs 共享同一套扫描逻辑。 // // 设计原则(ADR-031 统一资源发现): // 1. 扫描源前缀统一:user/project 级目录用相同前缀,末级目录名(agents/workflows)参数化 // 2. 路径动态获取:user 级用 getAgentDir()(尊重 PI_CODING_AGENT_DIR),project 级用 findWorkspaceRoot(cwd) // 3. npm/dev 包内发现:有 manifest(pi.agents/pi.workflows)只走 manifest,无 manifest 扫约定目录 // 4. manifest 路径存在性校验:声明的路径不存在 → 该包发现失败,不 fallback // 5. 废弃 discovery.json:扫描路径完全由代码内推导,无外部依赖 // // 优先级(低→高):user .pi/agent → user .agents → npm global → npm dev → project .pi → project .pi/.tmp(仅workflow) → project .agents import * as fsSync from "node:fs"; import { access, readdir, readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { delimiter, join, resolve } from "node:path"; import { getLogger } from "@zhushanwen/pi-extension-logger"; // 模块级 logger(setPiHandle 注入后自动走 appendEntry,未注入时 console 兜底) const logger = getLogger("subagents"); // ── 类型 ───────────────────────────────────────────────────── /** 资源种类:agent 或 workflow */ export type ResourceKind = "agents" | "workflows"; /** 发现到的单个资源文件(原始数据,由调用方解析 frontmatter/meta) */ export interface DiscoveredResource { /** 绝对路径 */ path: string; /** 来源层级 */ source: ResourceSource; /** 是否可用(manifest 校验失败的包整体标 false) */ available: boolean; } /** 资源来源层级 */ export type ResourceSource = "user-pi" | "user-agents" | "npm" | "npm-dev" | "user-extension-paths" | "project-pi" | "project-pi-tmp" | "project-agents"; /** 扫描配置 */ export interface ScanConfig { /** 资源种类 */ kind: ResourceKind; /** 项目根目录(findWorkspaceRoot 推导结果) */ workspaceRoot: string; /** agent 配置目录(getAgentDir() 结果) */ agentDir: string; /** 是否包含 tmp 源(仅 workflow 用 .pi/workflows/.tmp/) */ includeTmp?: boolean; } // ── 常量 ───────────────────────────────────────────────────── /** workspace root 向上查找的最大深度 */ const WORKSPACE_ROOT_MAX_DEPTH = 20; // ── workspace root 推导(从 config-loader 提取,agent/workflow 共用) ── /** * 判断 dir 是否是 workspaceRoot 的直接子目录(一层深度)。 * 用于 bare+worktree 结构里识别 worktree 根。 */ function isDirectChildOfWorkspaceRoot(dir: string, workspaceRoot: string): boolean { return resolve(dir, "..") === workspaceRoot; } /** * 从 cwd 向上查找 workspace root。 * * bare+worktree 优先找 .bare;普通 repo 找最顶层 .git;fallback 找 .pi。 * 与 config-loader 原有逻辑一致(合并后提取为共享函数)。 */ // ── 统一 mtime 缓存层(m5 IF10)──────────────────────────────────── // // 模块级 Map:mtime 判变缓存文件内容。 // - sync 实现(statSync/readFileSync):agent-registry discoverAll 是同步路径, // async 缓存无法被 await(m5 design-review A1 探针实证约束) // - stat 失败/ENOENT → 驱逐条目(mtime 缓存下文件删除不自愈——A3 修复) // - 已知局限(C3 记录):内容变 mtime 未变(cp -p/rsync -t 保留源 mtime、 // 2s 粒度文件系统)→ 漏判,invalidateCache/clearFileCache 兜底; // APFS mtimeMs 微秒级浮点 === 判变可靠(探针 P-mtime-精度) interface MtimeCacheEntry { mtimeMs: number; content: string; } const mtimeCache = new Map(); // [perf] findWorkspaceRoot 结果按 cwd memo——每次调用最多 3 phase × 20 层 existsSync //(30-60 次 stat),调用点(session_start、discoverWorkflows、getWorkflow)在 spawn/ // tool 热路径上重复付。cwd 不变则祖先目录结构(.bare/.git/.pi)不变;变化场景由 // clearFileCache 兜底(invalidateCache 语义,测试隔离也走这里)。 const workspaceRootCache = new Map(); // [perf] npm/dev 包 package.json manifest 缓存(swf-perf-impl cleanup TC1/IF1)。 // key = package.json 绝对路径;缓存 parse 后的整个 pi 对象(agents/workflows 两 kind // 共享一次 parse,不按 kind 分裂条目)。失效 = mtimeMs 严格相等判定(与 mtimeCache 同构, // 『内容变 mtime 未变』漏判局限共享 C3 声明,clearFileCache 兜底)。 // 失败语义(消歧,三方 TC1/DM1/IF1 同一口径): // - stat 失败(文件不存在/不可 stat)→ 驱逐该 path 条目 + undefined(对齐 getCachedFile: // 文件已删条目无意义) // - read 失败(stat 与 read 间竞态删除/EACCES)或 JSON.parse 失败(坏 JSON)→ // 不缓存(不写新条目、不驱逐已有好条目)+ undefined,下次调用重试——毒条目永不入缓存, // 坏文件被修复且 mtime 变化后正常覆写。 // pi === undefined 条目仅在『read+parse 成功且 manifest 无 pi 字段』时写入(合法解析结果)。 interface ManifestCacheEntry { mtimeMs: number; pi: Record | undefined; } const manifestCache = new Map(); /** 从缓存的 pi 对象派生 kind manifest(廉价操作,每次调用新数组——语义与旧直读版一致)。 */ function piToManifest(pi: Record | undefined, kind: ResourceKind): string[] | undefined { if (!pi) return undefined; const entries = pi[kind]; if (!Array.isArray(entries)) return undefined; // 过滤非字符串元素 return entries.filter((p): p is string => typeof p === "string"); } /** * package.json 文本 → pi 字段对象(pi.agents / pi.workflows 的容器)。 * [review 修复] 结构守卫:JSON.parse 对 "42" / '"str"' / "null" 等合法 JSON 产出 * 非对象值,旧 `as Record` 盲断言下 .pi 访问是「碰巧不抛」 * (primitive 装箱返 undefined / null 抛 TypeError 落入 catch)——显式判非对象, * 按「无 manifest」(undefined)处理,后果与原先一致但语义不再依赖巧合。 * pi 字段本身非对象(如 {"pi":42})或为数组(如 {"pi":[]}——数组不是合法 pi 容器, * typeof "object" 守卫会放行,Record 断言对数组是谎言)同样显式归 undefined。 * JSON SyntaxError 向上抛,由调用方 catch 承担「坏 JSON 不缓存、下次重试」语义。 */ function parsePiField(content: string): Record | undefined { const parsed: unknown = JSON.parse(content); if (typeof parsed !== "object" || parsed === null) return undefined; const pi: unknown = (parsed as Record).pi; return typeof pi === "object" && pi !== null && !Array.isArray(pi) ? (pi as Record) : undefined; } /** * stat + content 统一缓存。文件不存在/不可读 → null(并驱逐条目)。 * mtime 未变 → 返回缓存 content(不重 read);变 → readFileSync + 缓存。 */ export function getCachedFile(filePath: string): { mtimeMs: number; content: string } | null { let mtimeMs: number; try { mtimeMs = fsSync.statSync(filePath).mtimeMs; } catch { mtimeCache.delete(filePath); return null; } const entry = mtimeCache.get(filePath); if (entry && entry.mtimeMs === mtimeMs) return entry; let content: string; try { content = fsSync.readFileSync(filePath, "utf-8"); } catch { // stat 与 read 之间的删除/EACCES 竞态 → 驱逐并返回 null(exec-review major-2: // docstring 承诺「不可读 → null」——readFileSync 也必须入守卫) mtimeCache.delete(filePath); return null; } const cached = { mtimeMs, content }; mtimeCache.set(filePath, cached); return cached; } /** 便捷封装:只取 content(不存在 → null)。 */ export function getCachedFileContent(filePath: string): string | null { return getCachedFile(filePath)?.content ?? null; } /** 清空(invalidateCache 语义——测试隔离 + mtime 漏判场景手动刷新兜底)。 */ export function clearFileCache(): void { mtimeCache.clear(); workspaceRootCache.clear(); manifestCache.clear(); } export function findWorkspaceRoot(cwd?: string): string { const dir = cwd ?? process.cwd(); const memo = workspaceRootCache.get(dir); if (memo !== undefined) return memo; const root = computeWorkspaceRoot(dir); workspaceRootCache.set(dir, root); return root; } function computeWorkspaceRoot(dir: string): string { const root = resolve("/"); // Phase 1: bare repo 优先——先全路径扫一遍找 .bare let probe = dir; for (let i = 0; i < WORKSPACE_ROOT_MAX_DEPTH; i++) { if (fsSync.existsSync(resolve(probe, ".bare"))) { // worktree 是 workspace 根的直接子目录。若 cwd 自身有 .pi/,优先用 cwd if (probe !== dir && isDirectChildOfWorkspaceRoot(dir, probe) && fsSync.existsSync(resolve(dir, ".pi"))) { return dir; } return probe; } if (probe === root) break; probe = resolve(probe, ".."); } // Phase 2: 无 .bare 时,找最顶层的 .git let topLevel = dir; probe = dir; for (let i = 0; i < WORKSPACE_ROOT_MAX_DEPTH; i++) { if (fsSync.existsSync(resolve(probe, ".git"))) { topLevel = probe; } if (probe === root) break; probe = resolve(probe, ".."); } if (topLevel !== dir) { return topLevel; } // Phase 3: fallback——用第一个遇到的 .pi probe = dir; for (let i = 0; i < WORKSPACE_ROOT_MAX_DEPTH; i++) { if (fsSync.existsSync(resolve(probe, ".pi"))) { return probe; } if (probe === root) break; probe = resolve(probe, ".."); } return dir; } // ── 文件扩展名判定 ─────────────────────────────────────────── /** 根据资源种类判定脚本文件扩展名 */ function isTargetFile(name: string, kind: ResourceKind): boolean { // _ 前缀 = draft/示例,不参与发现(与原 agent-registry/workflow 约定一致) if (name.startsWith("_")) return false; if (kind === "agents") { return name.endsWith(".md") && !name.endsWith(".chain.md"); } // workflows return name.endsWith(".js") || name.endsWith(".mjs"); } /** 提取文件名 stem(去目录去扩展名) */ function stem(filePath: string): string { const base = filePath.split("/").pop() ?? filePath; const dot = base.lastIndexOf("."); return dot > 0 ? base.slice(0, dot) : base; } // ── 目录扫描 ───────────────────────────────────────────────── /** * 扫描单个目录下的资源文件。 * 返回文件绝对路径列表。目录不存在时返回空数组。 */ async function scanDirectory(dirPath: string, kind: ResourceKind): Promise { try { await access(dirPath); } catch { return []; } const entries = await readdir(dirPath, { withFileTypes: true }); const files: string[] = []; for (const e of entries) { if (!isTargetFile(e.name, kind)) continue; const absPath = resolve(dirPath, e.name); // symlink 单独处理:Dirent.isFile() 对 symlink 返回 false if (e.isFile()) { files.push(absPath); } else if (e.isSymbolicLink()) { const targetStat = await stat(absPath).catch(() => null); if (targetStat?.isFile()) files.push(absPath); } } return files; } // ── npm/dev 包内 manifest 发现 ─────────────────────────────── /** * 读取 package.json 的 pi.{kind} manifest(pi.agents / pi.workflows)。 * 返回 undefined 表示无 manifest 声明(无 pi / pi[kind] 非数组 / 解析失败)。 * 内部走 manifestCache(与 readPackageManifestSync 共享同一 Map,失败语义见声明处)。 */ async function readPackageManifest(pkgDir: string, kind: ResourceKind): Promise { const pkgJsonPath = resolve(pkgDir, "package.json"); let mtimeMs: number; try { mtimeMs = (await stat(pkgJsonPath)).mtimeMs; } catch { // stat 失败:文件不存在/不可 stat → 驱逐条目(对齐 getCachedFile 语义) manifestCache.delete(pkgJsonPath); return undefined; } const entry = manifestCache.get(pkgJsonPath); if (entry && entry.mtimeMs === mtimeMs) return piToManifest(entry.pi, kind); let pi: Record | undefined; try { const content = await readFile(pkgJsonPath, "utf-8"); pi = parsePiField(content); } catch { // read 失败或坏 JSON → 不缓存不驱逐已有好条目,下次调用重试 return undefined; } manifestCache.set(pkgJsonPath, { mtimeMs, pi }); return piToManifest(pi, kind); } /** * 处理单个 npm/dev 包:按 manifest 或约定目录发现资源。 * * 规则: * - 有 manifest → 只按 manifest 声明路径加载。路径不存在 → 整包失败(返回 available=false 占位) * - 无 manifest → 扫约定目录 {kind}/(agents/ 或 workflows/) */ async function processPackage( pkgDir: string, kind: ResourceKind, ): Promise { const manifestPaths = await readPackageManifest(pkgDir, kind); // manifest 模式:只按声明路径加载,路径不存在则整包失败 if (manifestPaths && manifestPaths.length > 0) { const results: DiscoveredResource[] = []; let allFailed = true; for (const relPath of manifestPaths) { const absPath = resolve(pkgDir, relPath); const fileStat = await stat(absPath).catch(() => null); if (!fileStat) { // manifest 声明的路径不存在 → 记录失败占位(路径存在性校验) results.push({ path: absPath, source: "npm", available: false }); continue; } if (fileStat.isDirectory()) { const files = await scanDirectory(absPath, kind); for (const f of files) { results.push({ path: f, source: "npm", available: true }); allFailed = false; } } else if (fileStat.isFile()) { results.push({ path: absPath, source: "npm", available: true }); allFailed = false; } } // manifest 全失败:返回 available=false 占位,不 fallback 到约定目录 if (allFailed) { return results; } return results; } // 无 manifest:扫约定目录 {kind}/ const conventionDir = resolve(pkgDir, kind); const files = await scanDirectory(conventionDir, kind); return files.map((f) => ({ path: f, source: "npm", available: true })); } /** * 扫描 npm node_modules 目录下所有包的资源。 * 支持 scoped(@scope/pkg)和 unscoped(pkg)包。 */ async function scanNpmDir( nodeModulesDir: string, kind: ResourceKind, ): Promise { let entries: string[]; try { entries = await readdir(nodeModulesDir); } catch { return []; } // [perf] 包级并行(swf-perf-impl cleanup TC2/IF2):entries.map + Promise.all, // perEntry.flat() 按原 readdir 序 concat——输出序与串行逐包 push 等价。 // 不加新增 catch:每包失败面由 processPackage 内部既有 catch 承担,未捕获异常 // 传播语义与串行版一致(Promise.all 整体 reject ↔ 串行版向上抛)。 const perEntry = await Promise.all( entries.map(async (entry): Promise => { const entryPath = resolve(nodeModulesDir, entry); if (entry.startsWith("@")) { // scoped 包——迭代子包(子包级同样并行,flat 保序) let scopedEntries: string[]; try { scopedEntries = await readdir(entryPath); } catch { return []; } const perScoped = await Promise.all( scopedEntries.map((scopedPkg) => processPackage(resolve(entryPath, scopedPkg), kind)), ); return perScoped.flat(); } // unscoped 包 return processPackage(entryPath, kind); }), ); return perEntry.flat(); } // ── 扫描源构建 ─────────────────────────────────────────────── /** 扫描源定义:路径 + source 标签 */ interface ScanTarget { dir: string; source: ResourceSource; /** 该源是否参与本次扫描(如 tmp 仅 workflow 启用) */ enabled: boolean; } /** * 读取 XYZ_EXTENSION_PATHS 环境变量(dev-link 写入的扩展源码路径)。 * * delimiter 分隔(POSIX ':' / Windows ';'),trim + 过滤空 + ~ 展开。 * 每个路径是一个 extension 包目录(dev-link 指向源码),走 processPackage 发现其 * agents/workflows。解析逻辑与 extension-service.getUserExtensionPaths() 一致。 */ function readExtensionPaths(): string[] { const raw = process.env.XYZ_EXTENSION_PATHS; if (!raw) return []; const paths = raw .split(delimiter) .map((p) => p.trim()) .filter((p) => p.length > 0) .map((p) => (p.startsWith("~") ? join(homedir(), p.slice(1)) : p)); return [...new Set(paths)]; } /** * 构建所有扫描源(按优先级低→高排列)。 * * agent 和 workflow 共享相同的前缀体系,末级目录名由 kind 决定。 */ function buildScanTargets(config: ScanConfig): ScanTarget[] { const { kind, workspaceRoot, agentDir, includeTmp } = config; const home = homedir(); const targets: ScanTarget[] = [ // 1. user .pi/agent/{kind}/ { dir: join(agentDir, kind), source: "user-pi", enabled: true }, // 2. user .agents/{kind}/ { dir: join(home, ".agents", kind), source: "user-agents", enabled: true }, // 3. npm global: agentDir/npm/node_modules/*// { dir: join(agentDir, "npm", "node_modules"), source: "npm", enabled: true }, // 4. npm dev symlink: agentDir/extensions/*// { dir: join(agentDir, "extensions"), source: "npm-dev", enabled: true }, // user extension paths (XYZ_EXTENSION_PATHS, dev-link): each path is a package dir, // 走 processPackage 读 pi.{kind} manifest 或扫 {kind}/ 目录。优先级高于 npm/npm-dev // (dev-link 是开发版 override),低于 project(项目正式资源优先)。 ...readExtensionPaths().map((dir) => ({ dir, source: "user-extension-paths" as const, enabled: true })), // 5. project .pi/{kind}/ { dir: join(workspaceRoot, ".pi", kind), source: "project-pi", enabled: true }, ]; // 6. project .pi/{kind}/.tmp/(仅 workflow) if (includeTmp) { targets.push({ dir: join(workspaceRoot, ".pi", kind, ".tmp"), source: "project-pi-tmp", enabled: true, }); } // 7. project .agents/{kind}/ targets.push({ dir: join(workspaceRoot, ".agents", kind), source: "project-agents", enabled: true, }); return targets.filter((t) => t.enabled); } // ── 公共 API ───────────────────────────────────────────────── /** * 发现所有资源文件(agent .md 或 workflow .js/.mjs)。 * * 按优先级低→高扫描所有源,同名资源靠后覆盖靠前(last-writer-wins)。 * npm/dev 包内:有 manifest 只走 manifest(路径不存在则失败),无 manifest 扫约定目录。 * * Throws on unrecoverable scan errors——未捕获异常向上抛(Promise.all 首个 reject * 即整体拒绝,与串行版 discoverResourcesSync 的传播语义一致,见实现内 [perf] 注释)。 * 预期失败不抛:目录不存在/不可读返回空列表,manifest 声明路径缺失以 available=false 返回。 * * @returns 去重后的资源列表(按优先级合并,高优先级覆盖低优先级同名) */ export async function discoverResources(config: ScanConfig): Promise { const targets = buildScanTargets(config); // [perf] 源级并行(swf-perf-impl cleanup TC2/IF2):targets.map + Promise.all, // Promise.all 对 map 数组保序——allBySource 顺序 = targets 优先级序(低→高), // 与串行逐源 push 完全一致 → 下方合并去重逻辑零改动、输出逐字节等价。 // 不加新增 catch:每源预期失败路径由内部既有 catch 面承担(scanDirectory access // catch / scanNpmDir readdir catch / processPackage 全链 catch),未捕获异常 // 传播语义与串行版等价(ES2:串行版 target k 抛错中断后续源,并行版全部源已并发 // 启动、以首个 reject 拒绝——两版对调用方同为 discoverResources 抛出,各源只读无副作用)。 const allBySource: Array<{ source: ResourceSource; resources: DiscoveredResource[] }> = await Promise.all( targets.map(async (target) => { if (target.source === "npm" || target.source === "npm-dev") { // npm/dev 目录:迭代包,走 manifest 或约定目录 const resources = await scanNpmDir(target.dir, config.kind); // 覆盖 source 标签(scanNpmDir 内部统一标 "npm",这里修正为实际源) const tagged = resources.map((r) => ({ ...r, source: target.source })); return { source: target.source, resources: tagged }; } if (target.source === "user-extension-paths") { // XYZ_EXTENSION_PATHS(dev-link):每个 dir 是单个包目录,走 processPackage const resources = await processPackage(target.dir, config.kind); const tagged = resources.map((r) => ({ ...r, source: target.source })); return { source: target.source, resources: tagged }; } // 普通目录:直接扫 const files = await scanDirectory(target.dir, config.kind); const resources = files.map((f) => ({ path: f, source: target.source, available: true })); return { source: target.source, resources }; }), ); // 按优先级合并:targets 数组顺序即优先级(低→高),高优先级后写覆盖 // 用文件名 stem 作为去重 key(与旧逻辑一致:同名资源高优先级覆盖) const merged = new Map(); for (const { resources } of allBySource) { for (const r of resources) { const key = stem(r.path); const existing = merged.get(key); // available=false 的占位不覆盖已有的 available=true if (!r.available && existing) { continue; } // [D8d] 同名遮蔽可观测:高优先级源覆盖低优先级同名资源时 warn——此前 // 「有检测无报告」,用户自定义 agent/workflow 被静默遮蔽后排查无从下手。 if (existing && existing.path !== r.path) { logger.warn( `[resource-discovery] duplicate ${config.kind} "${key}" from ${r.source} shadows ${existing.source}`, { shadowed: existing.path, kept: r.path }, ); } merged.set(key, r); } } return Array.from(merged.values()); } /** * 同步版:扫描单个目录下的资源文件路径(供 agent-registry 的 mtime 缓存模式使用)。 * * agent .md 发现需要 mtime 缓存(hot-reload),不能走 async 全量扫描。 * 此函数提供目录级同步扫描,npm/dev 包内发现仍需 async(agent-registry 用 builtin 兜底)。 */ export function scanDirectorySync(dirPath: string, kind: ResourceKind): string[] { try { fsSync.accessSync(dirPath); } catch { return []; } let entries: string[]; try { entries = fsSync.readdirSync(dirPath); } catch { return []; } const files: string[] = []; for (const entry of entries) { if (!isTargetFile(entry, kind)) continue; files.push(resolve(dirPath, entry)); } return files; } /** * 同步版:读取 package.json 的 pi.{kind} manifest。 * 供 agent-registry 同步路径使用。与 async 版共享 manifestCache(双读者同一 Map)。 */ export function readPackageManifestSync(pkgDir: string, kind: ResourceKind): string[] | undefined { const pkgJsonPath = resolve(pkgDir, "package.json"); let mtimeMs: number; try { mtimeMs = fsSync.statSync(pkgJsonPath).mtimeMs; } catch { // stat 失败:文件不存在/不可 stat → 驱逐条目(对齐 getCachedFile 语义) manifestCache.delete(pkgJsonPath); return undefined; } const entry = manifestCache.get(pkgJsonPath); if (entry && entry.mtimeMs === mtimeMs) return piToManifest(entry.pi, kind); let pi: Record | undefined; try { const content = fsSync.readFileSync(pkgJsonPath, "utf-8"); pi = parsePiField(content); } catch { // read 失败或坏 JSON → 不缓存不驱逐已有好条目,下次调用重试 return undefined; } manifestCache.set(pkgJsonPath, { mtimeMs, pi }); return piToManifest(pi, kind); } /** * 同步版:处理单个 npm/dev 包。 * 供 agent-registry 同步路径使用。 */ export function processPackageSync(pkgDir: string, kind: ResourceKind): DiscoveredResource[] { const manifestPaths = readPackageManifestSync(pkgDir, kind); if (manifestPaths && manifestPaths.length > 0) { const results: DiscoveredResource[] = []; for (const relPath of manifestPaths) { const absPath = resolve(pkgDir, relPath); let fileStat: fsSync.Stats | null; try { fileStat = fsSync.statSync(absPath); } catch { fileStat = null; } if (!fileStat) { results.push({ path: absPath, source: "npm", available: false }); continue; } if (fileStat.isDirectory()) { const files = scanDirectorySync(absPath, kind); for (const f of files) { results.push({ path: f, source: "npm", available: true }); } } else if (fileStat.isFile()) { results.push({ path: absPath, source: "npm", available: true }); } } return results; } // 无 manifest:扫约定目录 const conventionDir = resolve(pkgDir, kind); const files = scanDirectorySync(conventionDir, kind); return files.map((f) => ({ path: f, source: "npm", available: true })); } /** * 同步版:扫描 npm node_modules 目录。 * 供 agent-registry 同步路径使用。 */ export function scanNpmDirSync(nodeModulesDir: string, kind: ResourceKind): DiscoveredResource[] { let entries: string[]; try { entries = fsSync.readdirSync(nodeModulesDir); } catch { return []; } const results: DiscoveredResource[] = []; for (const entry of entries) { const entryPath = resolve(nodeModulesDir, entry); if (entry.startsWith("@")) { let scopedEntries: string[]; try { scopedEntries = fsSync.readdirSync(entryPath); } catch { continue; } for (const scopedPkg of scopedEntries) { const scopedPkgDir = resolve(entryPath, scopedPkg); results.push(...processPackageSync(scopedPkgDir, kind)); } } else { results.push(...processPackageSync(entryPath, kind)); } } return results; } /** * 同步版:发现所有资源(agent-registry 专用,支持 mtime 缓存的 hot-reload)。 * * 与 discoverResources 对应的同步实现,扫描相同的源。 * 返回所有源的资源(未去重,调用方按需处理优先级)。 */ export function discoverResourcesSync(config: ScanConfig): DiscoveredResource[] { const targets = buildScanTargets(config); const all: DiscoveredResource[] = []; for (const target of targets) { if (target.source === "npm" || target.source === "npm-dev") { const resources = scanNpmDirSync(target.dir, config.kind); all.push(...resources.map((r) => ({ ...r, source: target.source }))); } else if (target.source === "user-extension-paths") { // XYZ_EXTENSION_PATHS(dev-link):每个 dir 是单个包目录 const resources = processPackageSync(target.dir, config.kind); all.push(...resources.map((r) => ({ ...r, source: target.source }))); } else { const files = scanDirectorySync(target.dir, config.kind); all.push(...files.map((f) => ({ path: f, source: target.source, available: true }))); } } // 按优先级合并(targets 顺序 = 优先级低→高) const merged = new Map(); for (const r of all) { const key = stem(r.path); if (!r.available && merged.has(key)) continue; merged.set(key, r); } return Array.from(merged.values()); }