feat/agent-discovery:给守护进程装一个「只报事实」的 CLI 探测引擎

figuretu/eyrie · main...feat/agent-discovery · 2026-06-15 · 自包含,读完即弃

3 commits
14 文件(全新)
+1268 / −0
37% 是测试代码
类型:功能(含 2 个修复 commit)

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。本报告只做理解,不做 code review。

1TL;DR

这条分支新建了一个独立模块 apps/daemon/src/agent/discovery/——一个「只报事实」的 CLI 探测引擎。对每个登记在案的 CLI(目前只有 claudecodex),它回答四个问题:装没装、装在 PATH 的哪个位置、版本号是多少、它的元数据目录在哪

它最克制的地方在于它不下结论:从不给出「这个工具可不可用 / available」的判断,也从不打开目录读里面的内容。它只把磁盘上的事实摆出来,「能不能用」这个裁决留给将来消费这份结果的上层去做。

整个模块架在四个可注入的「接缝」(ports:跑命令、看文件、读时钟、读平台事实)之上,所以不需要真装一个 CLI 就能在任何机器上测它。

注意:这个引擎还没接进 registry / service,目前没有任何代码调用它——这一期是打地基。三个 commit 里有两个是修复,把第一版校准到真实世界:跟随符号链接(claude/codex 在 PATH 上都是软链),以及绝不让一个不可读的高优先安装被后面一个低优先命中悄悄掩盖。

2变更地图(称重)

这是一个高内聚的纯新增 PR:14 个文件全部落在同一个新目录 apps/daemon/src/agent/discovery/ 里,零删除、零改动其它文件。没有重命名、没有搬运、没有 lockfile 噪声——每一行都是设计承载代码,没有可以「放心略过」的机械变更。

probe
394 行 · 31%
ports
257 行 · 20%
types+catalog
178 行 · 14%
engine
163 行 · 13%
paths
104 行 · 8%
testing(fakes)
89 行 · 7%
version
46 行 · 4%
index
37 行 · 3%
子系统设计重心(要细读)可放心略过
types.ts + catalog.ts四个三态状态枚举的语义、DiscoveryResult 的形状;catalog 把「怎么探一个 CLI」写成纯数据
probe.ts核心算法:扫候选、定状态、问版本、定位目录,全部「不抛错只分类」
ports.ts四个接缝的契约 + 宿主实现;nodeFileProber 的 stat/lstat 取舍是修复重灾区
engine.ts探测的生命周期:启动全扫、按需重探、并发合流、dispose 中断
paths.ts / version.ts纯函数:路径拼接、版本号正则提取——好读,但承载了「Windows 留空」「去重」等决策
testing.ts四个 fake 的构造器;读懂它才看得懂测试怎么摆桩构造器本身是直白的 map 查表

测试占 37%(472 / 1268 行),其中 probe.test.ts(227 行)和 ports.test.ts(67 行,真实文件系统)是钉住行为的主力。

3数据先行:所有状态都是「三态」

这个模块的设计几乎全部体现在它的类型里。读懂四个状态枚举,后面的算法只是把磁盘事实往这些格子里填。所以先看形状,不看行为。

贯穿全模块的一条铁律:「不存在」和「读不了」永远是两回事,绝不合并成一个布尔。一个探测故障(权限不足、软链悬空)如果被当成「没装」,上层就会做出错误判断——所以每个维度都至少有三个取值。

3.1四个状态枚举

四个维度各自带一个枚举,注释把每个取值「为什么存在」都写明了:

apps/daemon/src/agent/discovery/types.ts真实代码(节选)
// 安装结果:errored 故意区别于 not-installed——探测故障绝不能读成「不存在」
export type InstallStatus = 'installed' | 'not-installed' | 'errored'

// 版本探测结果:装了但读不出版本,跟根本没装,是不同的故事
export type VersionStatus =
  | 'ok'          // 从输出里解析出了版本串
  | 'unparseable' // 命令跑了,但没有版本形状的 token
  | 'timeout'     // 版本命令超过了 deadline
  | 'errored'     // 跑版本命令抛了(非超时)
  | 'skipped'     // 压根没找到安装,所以没跑版本命令

// 目录存在性:不可读是探测故障,不是缺席,引擎不能把两者塌成一个布尔
export type DirectoryProbeStatus = 'present' | 'absent' | 'unreadable'

// 一个 PATH 候选的探测结果。缺席的候选从不记录,只记留在磁盘上的真实痕迹
export type CliCandidateStatus = 'ok' | 'errored'

这里有个容易看漏的设计:CliCandidate(一个 PATH 候选)只有 ok / errored 两态,没有「absent」。原因是——不存在的候选根本不会进列表。PATH 上每个目录都拼一个候选路径,但只有真正在磁盘上留了痕迹的(一个可执行文件,或一个存在但用不了的东西)才被记下来。否则结果会把每个不含该命令的 PATH 目录都回显一遍。

3.2DiscoveryResult:一份快照的形状

所有维度汇成一份 DiscoveryResult——一个 CLI 的「事实快照」。注意它没有 available 字段,这是刻意的,连测试都专门钉了这一点(见第 10 节)。

apps/daemon/src/agent/discovery/types.ts真实代码(节选)
// 一个 CLI 的「只报事实」快照。它不带「可用」裁决:引擎报告它找到了什么
//(装没装、在哪、什么版本、哪些目录),把可用性判断留给消费这份结果的上层。
export type DiscoveryResult = {
  kind: AgentProviderKind        // 这个 CLI 支撑哪个 provider(claude-code / codex)
  status: InstallStatus
  candidates: CliCandidate[]     // 每个留痕的候选,按 PATH 优先级排;没有则空数组
  version?: string               // 主候选解析出的版本(如果有)
  versionStatus: VersionStatus
  directories: DirectoryProbe[]  // 定位到的元数据目录 + 存在性 + 变更标记——绝不含内容
  probedAt: number               // 这份快照产生时的 Unix 毫秒
}

每个 CliCandidate 还携带 primary 标记——引擎会用的那一个:PATH 顺序里第一个 ok 候选。这保证了 primary 永远不会落在一个不可读的路径上(这正是第 7.2 节修复的核心)。

4底座:四个可替换的接缝

这个模块对外部世界的所有触碰,都收束到一个叫 DiscoveryPorts 的对象里——四个「接缝」(seam,可替换的边界)。引擎本身不直接调 execFile、不直接 stat、不直接读 Date.now(),全部走这四个口子。

apps/daemon/src/agent/discovery/ports.ts真实代码(节选)
// 探测引擎在自己进程之外触碰的一切,都做成可替换接缝:跑命令、看文件系统、
// 读时钟、读平台事实。测试给四者都传 fake,于是不需要真 CLI 就能在任何机器上跑。
export type DiscoveryPorts = {
  command: CommandRunner   // 跑版本命令——唯一真正需要子进程的接缝
  files: FileProber        // stat 文件/目录,但绝不打开读内容
  clock: Clock             // 提供探测时间戳
  platform: PlatformInfo   // 宿主平台事实:PATH 列表、home 目录、环境变量
}

这里有个值得注意的取舍:PlatformInfo 是一个快照值对象,不是实时查询。也就是说 PATH、home、env 在构造 ports 的那一刻就被拍扁成一份数据。这样一个 fake 平台就能完全决定路径解析的结果——测试能模拟「在 Windows 上」「PATH 是这三个目录」而不碰真实进程环境。

4.1同一个引擎,生产接真宿主,测试接 fake

接缝模式最直观的体现:完全相同的 DiscoveryEngine,在生产里被喂宿主实现,在测试里被喂内存假桩。两者是同一台机器的两套外设。

测试 · 内存 fakes

DiscoveryEngine
fakePorts()
fakeCommandRunner
同一份算法
查 map
fakeFileProber
无真实 IO
固定时钟
fakePlatform

生产 · 宿主实现

DiscoveryEngine
nodeDiscoveryPorts()
execFile
同一份算法
stat 真磁盘
fs/promises
真实 IO
Date.now()
process.env

宿主侧的组装就是把四个 node* 实现拼起来——这是 nodeDiscoveryPorts() 干的事,引擎默认就用它:

apps/daemon/src/agent/discovery/ports.ts真实代码(节选)
// 组装默认的宿主背书 ports
export function nodeDiscoveryPorts(): DiscoveryPorts {
  return {
    command: nodeCommandRunner,
    files: nodeFileProber,
    clock: nodeClock,
    platform: nodePlatformInfo(),  // 在这一刻把 process 的 PATH/home/env 拍成快照
  }
}

4.2加一个新 CLI 的配方

探测哪些 CLI,是写在 catalog.ts 里的一份纯数据。每个条目说清三件事:在 PATH 上找哪个可执行名、用什么参数让它打印版本、它的元数据目录怎么算出来。

apps/daemon/src/agent/discovery/catalog.ts真实代码(节选)
export const CLI_CATALOG: readonly CliCatalogEntry[] = [
  {
    kind: 'claude-code',
    command: 'claude',
    versionArgs: ['--version'],
    directories: [
      { role: 'config', location: homeDir('.claude') },
      { role: 'commands', location: homeDir('.claude', 'commands') },
      { role: 'sessions', location: homeDir('.claude', 'projects') },
    ],
  },
  {
    kind: 'codex',
    command: 'codex',
    versionArgs: ['--version'],
    directories: [
      { role: 'config', location: envDir('CODEX_HOME', ['.codex']) },
      // envDir:先看 CODEX_HOME 环境变量,没有再回退到 ~/.codex
      { role: 'sessions', location: envDir('CODEX_HOME', ['.codex'], 'sessions') },
    ],
  },
]

目录位置是用 homeDir(...) / envDir(...) 写成声明式数据而不是闭包的——commit 注释点明了原因:这样 catalog 保持「几句数据」,而且同一份 spec 将来能复用到「写」功能上(不只是探测,还能反过来知道往哪写)。

配方 · 让引擎认识一个新 CLI:只动 catalog.ts 一处——往 CLI_CATALOG 加一个条目(kind、command、versionArgs、directories)。引擎、探测算法、路径解析全是数据驱动的,不需要改任何逻辑。前提是这个 kind 已经在 ../types.tsAgentProviderKind 联合里(目前是 'claude-code' | 'codex' | 'acp')。

5旅程 A:探一个 CLI(probeCli)

这是模块的核心旅程:给一个 catalog 条目,产出一份 DiscoveryResult。整个过程从不抛错——每一次对外触碰都被包起来,一个故障变成一个被分类的状态,而不是一次崩溃。

全景 · 涉及 3 个文件
扫 PATH 候选
probe.ts · scanCandidates
定 install 状态
probe.ts · installStatusFor
问主候选版本
probe.ts + version.ts
定位元数据目录
probe.ts + paths.ts

probeCli 是这条旅程的总调度,把四步串起来。注意 version 探测只在有主候选时才跑,否则直接给 skipped

apps/daemon/src/agent/discovery/probe.ts真实代码(节选)
export async function probeCli(entry, ports, options = {}): Promise<DiscoveryResult> {
  const candidates = await scanCandidates(entry, ports)
  const status = installStatusFor(candidates)
  const primary = candidates.find((candidate) => candidate.primary)

  const version = primary
    ? await probeVersion(entry, primary, ports, options)
    : // 没有可用安装可以盘问,所以没跑版本命令
      { versionStatus: 'skipped' as VersionStatus }

  // 把解析到的版本盖到主候选上,让逐候选和顶层口径一致
  const stamped = candidates.map((candidate) =>
    candidate.primary && version.value !== undefined
      ? { ...candidate, version: version.value } : candidate)

  const directories = await probeDirectories(entry, ports)
  return { kind: entry.kind, status, candidates: stamped,
    ...(version.value !== undefined ? { version: version.value } : {}),
    versionStatus: version.versionStatus, directories, probedAt: ports.clock.now() }
}

A.1扫 PATH 候选:只记留痕的

第一步把命令名在 PATH 每个目录上拼成候选路径,逐个 stat。三种结局,三种处理:可执行的常规文件记成 ok;存在但用不了的(stat 故障,或不可执行的文件)记成 errored 带原因;缺席的直接跳过(这是「这里没有」的正常情况,不算痕迹)。第一个 ok 候选被标 primary。

apps/daemon/src/agent/discovery/probe.ts真实代码(节选)
async function scanCandidates(entry, ports): Promise<CliCandidate[]> {
  const candidates: CliCandidate[] = []
  let hasPrimary = false
  for (const path of executableCandidates(entry.command, ports.platform)) {
    try {
      const stat = await ports.files.stat(path)
      if (!stat) continue  // 缺席:正常的「不在这」,不是痕迹
      if (stat.isFile && stat.executable) {
        candidates.push({ path, status: 'ok', primary: !hasPrimary })
        hasPrimary = true  // 只有第一个 ok 拿到 primary
      } else {
        // 存在但不是可运行的可执行文件——值得报告的痕迹,但绝非可用安装
        candidates.push({ path, status: 'errored', primary: false,
          error: 'not an executable file' })
      }
    } catch (error) {
      // stat 故障(如权限错误)是探测故障,与缺席保持区分
      candidates.push({ path, status: 'errored', primary: false, error: errorMessage(error) })
    }
  }
  return candidates
}

A.2定 install 状态:errored 只在「有痕迹但都不可用」时出现

顶层 install 状态是从候选列表推出来的,逻辑很短,但每一个分支都对应第 3 节的「三态」语义:

apps/daemon/src/agent/discovery/probe.ts真实代码(节选)
function installStatusFor(candidates: CliCandidate[]): InstallStatus {
  if (candidates.some((c) => c.status === 'ok')) return 'installed'   // 有至少一个能用
  return candidates.length > 0 ? 'errored' : 'not-installed'
  // 有痕迹但没一个可用 → errored;连痕迹都没有 → not-installed
}

A.3问版本号:跑命令,再把结局分类

有了主候选,就用它跑版本命令,套一个 deadline(默认 3 秒,「一个卡死的 CLI 不能拖住整个探测」)。结局被分成五类填进 VersionStatus。两个值得点出的细节:版本可能打到 stdout 也可能打到 stderr,所以两路都读;命令 runner 万一抛了,.catch(() => null) 兜住,归为 errored 而不是让它冒泡。

apps/daemon/src/agent/discovery/probe.ts真实代码(节选)
async function probeVersion(entry, primary, ports, options): Promise<VersionProbe> {
  const timeoutMs = options.versionTimeoutMs ?? DEFAULT_VERSION_TIMEOUT_MS
  const result = await ports.command
    .run(primary.path, entry.versionArgs, { timeoutMs, signal: options.signal })
    .catch(() => null)             // 抛了也不冒泡,归为下面的 errored
  if (!result) return { versionStatus: 'errored' }
  if (result.timedOut) return { versionStatus: 'timeout' }

  // 有的 CLI 把版本打到 stdout,有的打到 stderr;两路都读了再判断
  const version = extractVersion(`${result.stdout}\n${result.stderr}`)
  if (version !== undefined) return { value: version, versionStatus: 'ok' }
  if (result.exitCode !== 0) return { versionStatus: 'errored' }
  return { versionStatus: 'unparseable' }  // 跑成功了但没有版本形状的 token
}

「版本形状的 token」由 version.ts 里一个正则定义——匹配点分数字、可带 v 前缀和预发布后缀(如 v0.4.1-beta.2),逐行扫第一个命中。它只把数字「捞出来」,不解释版本含义、不判断是否受支持。

A.4定位元数据目录:报存在性 + 变更指纹,绝不读内容

最后一步把 catalog 里声明的每个目录算出绝对路径,stat 一下。三态结果:是目录就 present 并附一个变更标记;不是目录或不存在就 absent;stat 抛了就 unreadable。关键是它从不打开目录——只取 size 和 mtime 拼一个不透明指纹。

apps/daemon/src/agent/discovery/probe.ts真实代码(节选)
async function probeDirectory(spec, ports): Promise<DirectoryProbe> {
  const path = resolveDirectoryPath(spec.location, ports.platform)
  try {
    const stat = await ports.files.stat(path)
    if (!stat || !stat.isDirectory) return { role: spec.role, path, status: 'absent' }
    return { role: spec.role, path, status: 'present',
      marker: changeMarker(stat.size, stat.mtimeMs) }
  } catch {
    return { role: spec.role, path, status: 'unreadable' }  // 故障 ≠ 缺席
  }
}

// 不透明指纹:目录被动过时会变,但不暴露里面有什么
function changeMarker(size: number, mtimeMs: number): string {
  return `${size}:${Math.trunc(mtimeMs)}`
}

这个 marker 是为将来的「文件系统监听」第二阶段预留的——现在只是个能比对「变没变」的指纹,引擎自己还不用它。

排查路标 · 旅程 A
症状从哪下手
装了 CLI 却报 not-installedprobe.ts scanCandidates:看 stat 是否返回了 isFile && executable;再看 ports.ts nodeFileProber 有没有跟随软链(第 7.1 节)
明明能跑,version 却是 unparseableversion.ts VERSION_PATTERN:该 CLI 的 --version 输出是否含点分数字
version 一直 timeoutprobe.ts DEFAULT_VERSION_TIMEOUT_MS(3 秒);或构造引擎时传的 versionTimeoutMs
目录该在却报 absentpaths.ts resolveDirectoryPath:env 覆盖(如 CODEX_HOME)和 home 回退算对了没
status 是 errored 不知为何probe.ts installStatusFor:说明有候选留痕但没一个 ok,逐个看 candidates[].error

6旅程 B:引擎的生命周期

DiscoveryEngineprobeCli 之上的一层薄壳,管三件事:启动时把所有 catalog CLI 扫一遍、按需重探单个 CLI、以及销毁时中断在飞的命令。它刻意只有「启动探一次」和「按需重探」两个触发点——文件系统监听是明确推迟的第二阶段。

全景 · 涉及 1 个文件
probeAll
engine.ts · 启动全扫
reprobe
engine.ts · 按需 + 合流
dispose
engine.ts · 中断在飞命令

B.1并发合流:同一个 CLI 的重探不会重复起进程

场景:UI 上某个事件触发了对 claude 的重探,与此同时另一个事件也触发了同一个重探。如果各起各的,就会同时 spawn 两个 claude --version 进程。引擎用一张 inFlight map 把它们合流成同一个 Promise——后来的调用者直接复用前一个还没结算的探测。

apps/daemon/src/agent/discovery/engine.ts真实代码(节选)
async reprobe(kind: AgentProviderKind): Promise<DiscoveryResult> {
  if (this.disposed) throw new Error('DiscoveryEngine has been disposed')
  const entry = this.entriesByKind.get(kind)
  if (!entry) throw new Error(`Unknown discovery CLI kind: ${kind}`)

  const existing = this.inFlight.get(kind)
  if (existing) return existing  // 同 kind 已有在飞探测,直接复用,不再起进程

  const probe = probeCli(entry, this.ports, options).finally(() => {
    this.inFlight.delete(kind)   // 结算后清掉,下次 reprobe 才会跑新的一轮
  })
  this.inFlight.set(kind, probe)
  return probe
}

「合流只持续到结算」这一点是测试明确区分的:并发的两次 reprobe 只起一个进程;但先后(前一个 await 完再发第二个)的两次会各起一个——因为 finally 已经把它从 map 里清掉了。

B.2dispose:一个共享 AbortSignal 中断所有在飞命令

引擎持有一个 AbortController,它的 signal 被透传进每一次版本命令。dispose() 一调,signal 翻成 aborted,所有还卡着的 --version 子进程被中断;之后再 reprobe 直接 reject。这个方法是幂等的——重复 dispose 安全。

apps/daemon/src/agent/discovery/engine.ts真实代码(节选)
// 中断在飞的版本命令并阻止后续探测。幂等。
dispose(): void {
  if (this.disposed) return
  this.disposed = true
  this.abortController.abort()  // 翻转共享 signal,卡住的 --version 被杀
  this.inFlight.clear()
}
排查路标 · 旅程 B
症状从哪下手
一次重探起了多个同名进程engine.ts inFlight map:finally 是否过早 delete;或调用方根本没复用引擎实例
dispose 后还能探测engine.ts reprobe 开头的 this.disposed 检查
dispose 了但 --version 进程没死engine.ts abortController.signal 是否透传进了 ProbeOptionsports.command.run
reprobe 抛 Unknown discovery CLI kindcatalog.ts:该 kind 没登记;或传了 'acp'(在联合里但不在 catalog)

7校准:两个修复 commit 的尸检

第一版(d9e5ab1)把骨架搭起来了,但随后的两个修复 commit 才是真正把它校准到真实世界的地方。这两处都是「fake 桩永远测不出来、只在真实 stat/PATH 行为里才暴露」的坑——所以它们都附带了打真实文件系统的测试。对你来说,这两节是认知裂缝最值钱的部分。

7.1符号链接:lstat 把所有 PATH 上的 CLI 都漏报了

第一版的 nodeFileProberlstat。问题是:claudecodex 装在 PATH 上时几乎总是软链,指向某个带版本号的安装目录(npm / Homebrew 的常规做法)。lstat 看到的是链接节点本身——isFile=false——于是整个安装被判成 not-installed

以前 · lstat
lstat(/usr/local/bin/claude)
看到链接节点本身 isFile=false
不是可执行文件 → 候选被跳过
报 not-installed(漏报)
现在 · stat + lstat 兜底
stat(...) 跟随末端软链到目标
看到目标 isFile=true, executable=true
ENOENT 时再 lstat 分辨悬空链
正确报 installed;悬空链报故障

修复后的 stat 用「跟随末端软链」,并在 ENOENT 时多走一步 lstat——因为 stat 对「真不存在」和「软链悬空(目标没了)」都报 ENOENT,但这两者必须区分:悬空链在磁盘上还在,是一个坏掉的安装(探测故障),绝不能塌成「缺席」。

apps/daemon/src/agent/discovery/ports.ts真实代码(节选)
async stat(path) {
  try {
    // 跟随末端软链:PATH 上的 CLI 几乎都是软链进版本目录(npm/Homebrew),
    // 所以可执行检查必须看链接目标,不是链接节点本身
    const stats = await followStat(path)
    return { isFile: stats.isFile(), /* ... */ executable: isExecutable(stats.mode) }
  } catch (error) {
    if (!isMissingPathError(error)) throw error
    // stat() 对「真缺席」和「目标已没的悬空软链」都报 ENOENT。lstat() 能分辨:
    // 悬空链在磁盘上还在,所以它是探测故障(坏安装),不是缺席——重抛去记录它
    const linkStats = await lstat(path).catch(() => null)
    if (linkStats) throw error
    return null
  }
}

因为这个坑活在「stat vs lstat 的真实取舍」里,fake prober 无法复现,所以这个 commit 专门加了 ports.test.ts——在真实临时文件系统上建软链、建悬空链、建可执行/不可执行文件来钉住它(见第 10 节)。

7.2不可读的高优先安装,被后面一个低优先命中悄悄掩盖

第一版用一个返回 { installations, errored } 的结构——一个共享的 errored 布尔。bug 在于:只要有任何一个安装成功,这个 errored 就被丢弃了(installStatusFor 只在 installations.length === 0 时才看 errored)。

后果:一个高优先级的安装读不了(比如权限故障),但低优先级有个能用的——结果是 status 报 installed,故障彻底消失,而且 primary 落到了 shell 实际不会去跑的那个低优先路径上。

apps/daemon/src/agent/discovery/probe.ts(修复前 · commit d8fb514)真实代码(节选)
async function scanInstallations(entry, ports): Promise<InstallationScan> {
  const installations: CliInstallation[] = []
  let errored = false              // 一个共享布尔——这就是 bug 的根
  for (const candidate of executableCandidates(entry.command, ports.platform)) {
    try {
      const stat = await ports.files.stat(candidate)
      if (stat?.isFile && stat.executable) {
        installations.push({ path: candidate, primary: installations.length === 0 })
      }
    } catch { errored = true }     // 故障路径就这么没了,连是哪个都不知道
  }
  return { installations, errored }
}
function installStatusFor(scan): InstallStatus {
  if (scan.installations.length > 0) return 'installed'  // 一旦有成功,errored 永远不被看
  return scan.errored ? 'errored' : 'not-installed'
}

修复的办法是把模型从「安装列表 + 一个全局故障旗」换成「候选列表,每个候选自带 ok/errored 状态和原因」(这正是第 3、5 节看到的最终形态)。每个 PATH 上留痕的条目都被保留、按优先级排,故障各记各的;primary 永远是第一个 ok,所以它不可能指向不可读路径;顶层 status 只在「有痕迹但没一个可用」时才读 errored

同一个 commit 还顺手修了一个相关的 PATH 怪癖:按解析后的路径给 PATH 目录去重。一个目录在 PATH 里列两遍(常见的 shell 配置产物)是一个查找位置,不是两个安装,不能被回显成重复候选。

scanInstallations 用一个共享 errored 布尔,任一安装成功就丢弃它 scanCandidates 给每个留痕候选单独记 ok/errored + 原因,primary 锁定第一个 ok
高优先级的不可读安装不再被低优先级命中掩盖;故障不再凭空消失;primary 不会落在 shell 不会跑的路径上。
排查路标 · 校准
症状从哪下手
PATH 上的 CLI(软链安装)被漏报ports.ts nodeFileProber.stat:是否在用 followStat 而非 lstat
悬空软链被当成「没装」ports.ts stat 的 ENOENT 分支:lstat 兜底有没有重抛
权限故障的安装凭空消失probe.ts scanCandidates:每个候选是否独立记录,没有共享 errored 旗
同一安装在结果里出现两次paths.ts executableCandidatesseen set 去重逻辑

8心智模型补丁

读完这条分支,对项目的理解需要打这几个补丁:

守护进程里「探测一个 CLI」是 provider 各自的事(claude 有自己的 discovery,读文件内容、解析命令/agent) 多了一个 provider 无关、只报事实的探测层 agent/discovery/:统一回答装没装/在哪/版本/目录在哪,不读内容、不下「可用」结论
它和已有的 providers/claude/discovery.ts 是两种东西:后者读内容做 provider 专属解析,前者只摆磁盘事实。
"探测结果" 里有 available 之类的可用性判断 这个引擎的 DiscoveryResult 刻意没有 available;它只给事实,可用性由上层裁决
「装了但版本读不出」「装了但权限不可读」这些都需要上层结合策略才能判断可不可用,引擎不替它做主。
"没找到" 是一个布尔(找到 / 没找到) 每个维度都是三态:缺席、存在、读不了各不相同,errored / unreadable 绝不塌成「缺席」
守护进程的外部触碰(跑命令、stat、读时钟)散落在各处直接调用 在这个模块里它们收束成四个可注入的 ports,测试用 fake 全替换
这是该模块的测试能不碰真实 CLI/文件系统就跑全的根本原因。
这个引擎已经在守护进程里跑起来了 没有agent/discovery/ 目前没被任何外部代码 import,是打好但没接线的地基

9新词表

本 PR 引入的概念
DiscoveryResult一个 CLI 的「事实快照」:装没装、候选、版本、目录、时间戳——不含可用性判断
CliCandidatePATH 上一个留了痕迹的候选路径,带 ok/errored 状态;缺席的不进列表
primary(候选)PATH 优先级里第一个可用(ok)候选——引擎实际会用的那一个
fact-only / 只报事实这个引擎的设计准则:只报磁盘上的事实,不下「可用」结论、不读目录内容
DiscoveryPorts引擎对外部世界的四个可替换接缝:command / files / clock / platform
port / seam(接缝)一个外部依赖的可注入边界;生产接宿主实现,测试接 fake
change marker(变更标记)目录的 size:mtime 不透明指纹,能比对「变没变」但不暴露内容;为将来监听预留
CLI_CATALOG引擎认识哪些 CLI 的纯数据清单;加一个 CLI 只动这里
coalesce(合流)同一 CLI 的并发重探共享同一个在飞 Promise,不重复起进程

10测试与风险地图

测试占 37%,覆盖相当扎实,尤其是把「容易塌成布尔」的三态语义逐个钉死。下面分「有兜底」和「薄冰」两列,纯事实陈述。

有兜底的(行为被测试钉住)

薄冰(无测试 / 已知遗留 / 留空的口子)

合并前没有「必办阻塞项」:这是一个自洽、全测试、纯新增、不接线的地基模块,不改变任何现有行为。真正要留意的是下一期——把它接进 registry 时,需要新写「DiscoveryResultavailable」的裁决逻辑,那一层才是把「事实」翻成「可用性」的地方,也是本模块刻意没做的部分。

11验收提示(别被这些吓到)

12覆盖声明

本报告由主力在临时克隆(非工作目录)中 checkout feat/agent-discovery全量精读得出:14 个改动文件(8 个源文件 + 6 个测试文件)全部逐行 Read,无抽样、无略读;报告中每段代码均出自亲自 Read 过的文件并亲手裁剪。三个 commit 的 message 与「修复前」代码(d9e5ab1d8fb514)均单独取出比对,第 7 节的 before/after 基于真实的中间版本代码而非 diff 推测。

额外读了模块边界外的两处以确认上下文:agent/types.ts(确认 AgentProviderKind 与已有的 AgentAvailability 形状)、providers/claude/discovery.tsregistry.ts 头部(确认新模块与已有 provider 探测的分工)。未运行测试套件——行为结论来自阅读测试断言,非实际执行。