opencode Npm.install 源码分析:为什么每个 .opencode 目录都会触发依赖安装

七月 21, 2026 [debug, source-analysis] #opencode #npm #plugin #effect #arborist #source-code

现象回顾

上一篇 中我们定位到:配置 OPENCODE_CONFIG_DIR 后 opencode 启动变慢,根因是 @opencode-ai/plugin 的 npm 依赖下载。

但还有一个悬念没解开:为什么没有插件配置的空目录(如 ~/.config/opencode/)也会触发 npm install? 以及 为什么不设 OPENCODE_CONFIG_DIR 时不会卡,设了就卡?

本文通过源码分析给出完整答案。

核心代码路径

1. 目录发现 — ConfigPaths.directories()

packages/opencode/src/config/paths.ts:23-41

export const directories = Effect.fn("ConfigPaths.directories")(function* (directory, worktree) {
  const afs = yield* FSUtil.Service
  return unique([
    Global.Path.config,              // ① ~/.config/opencode/ — 永远存在
    ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
      ? yield* afs.up({              // ② 往 cwd 上走的 .opencode/ 目录
          targets: [".opencode"],
          start: directory,
          stop: worktree,
        })
      : []),
    ...(yield* afs.up({              // ③ ~/.opencode/
      targets: [".opencode"],
      start: Global.Path.home,
      stop: Global.Path.home,
    })),
    ...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),  // ④ 设置变量的目录
  ])
})

四个来源,unique() 去重,返回结果例如:

不设 OPENCODE_CONFIG_DIR: [/root/.config/opencode]
设置 OPENCODE_CONFIG_DIR: [/root/.config/opencode, /agent-config]

2. 无条件安装 — config.ts 中的遍历

packages/opencode/src/config/config.ts:424-477

for (const dir of directories) {
  // ... 加载 opencode.json / opencode.jsonc ...
  yield* ensureGitignore(dir).pipe(Effect.orDie)

  // 对每个目录,forkDetach 一个后台 fiber 安装 @opencode-ai/plugin
  const dep = yield* npmSvc
    .install(dir, {
      add: [{ name: "@opencode-ai/plugin", version: InstallationVersion }],
    })
    .pipe(
      Effect.exit,
      Effect.tap((exit) =>
        Exit.isFailure(exit)
          ? Effect.logWarning("background dependency install failed", ...)
          : Effect.void,
      ),
      Effect.forkDetach,   // ← 后台执行,不阻塞循环
    )
  deps.push(dep)            // ← 收集 fiber 引用

  // 扫描本地插件文件
  const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
}

关键点:

为什么只安装 @opencode-ai/plugin

config.ts:462 中写死了这个包名:

add: [{ name: "@opencode-ai/plugin", version: ... }]

插件如果还需要其他依赖呢?有两种方式:

本地文件插件plugins/*.ts):把依赖加到目录的 package.json 中。@npmcli/arboristreify() 会读取 package.jsondependencies 并自动解析传递依赖树——不需要在代码里逐个声明。

npm 包插件opencode.jsonc 中的 "plugin": ["some-pkg"]):走 Npm.add() 安装到 ~/.cache/opencode/packages/,自己的 package.json 声明了什么依赖,arborist 就装什么。

所以 opencode 只负责塞入 @opencode-ai/plugin 这一个入口包,其余依赖由标准 npm 解析链路处理。

版本锁定机制

config.ts:462-463 控制版本:

version: InstallationLocal     // 开发版吗?
  ? undefined                  // → 不锁定,按 package.json 装
  : InstallationVersion        // → 锁定为二进制版本号
二进制类型OPENCODE_CHANNEL安装行为
开发版(源码编译)"local"version=undefined,npm 按 package.json 中的版本装
正式版(你的 1.17.12)"local"version="1.17.12",强制要求 @opencode-ai/plugin@1.17.12

这意味着预置的 node_modules 必须版本精确匹配。 你的 package.json"1.17.12"node_modules 里装的也是 1.17.12、二进制要求 1.17.12——三者一致,arborist 跳过。如果装了 1.18 等其他版本,arborist 发现不匹配就会重新下载。

但实际上脏检只比对包名,不比版本npm.ts:188-192

for (const name of declared) {
  if (!locked.has(name)) {  // 只看名字,不管版本号
    yield* reify({ dir, add })
    return
  }
}

这意味着:

package.jsonpackage-lock.jsonnode_modules脏检结果
1.18.0@opencode-ai/plugin装了 1.18.0跳过(名字匹配)
1.17.12@opencode-ai/plugin装了 1.18.0跳过(名字匹配,版本不校验)
不存在没有这个包任意触发 reify

脏检的唯一目的是防止"包完全没装过"的极端情况。一旦 reify() 被触发,arborist 会按二进制指定的 InstallationVersion 安装,然后 save: true 写回 package.json——二进制版本优先级最高。所以只要 package-lock.json 中锁定过 @opencode-ai/plugin(任意版本),脏检就能通过;真正强制执行具体版本的是 arborist 的 reify()

3. 设计意图

为什么对一切目录无差别安装?因为 opencode 的 .ts 插件运行时依赖 @opencode-ai/plugin

// plugins/wfuzz-agent.ts
export const WfuzzAgent = async ({ client }) => {
  // client 的类型定义来自 @opencode-ai/plugin
  await client.app.log({ ... })
}

client 的类型和运行时由 @opencode-ai/plugin npm 包提供。opencode 的假设是:

任何目录都可能随时被放入一个 .ts 插件文件。与其加载时才发现缺依赖,不如提前在所有目录中确保运行时已就绪。

4. 阻塞点 — waitForDependencies()

packages/opencode/src/plugin/index.ts:190-193

const plugins = flags.pure ? [] : (cfg.plugin_origins ?? [])
if (plugins.length) yield* config.waitForDependencies()

waitForDependencies() 定义在 config.ts:640-643

const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
  yield* InstanceState.useEffect(state, (s) =>
    Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }),
  )
})

Fiber.join 等待所有 forkDetach 出来的 npm install fiber 完成。

5. 为什么「不设变量不卡,设了就卡」

不设 OPENCODE_CONFIG_DIR:
  directories() = [/root/.config/opencode]
  → npm install (forkDetach) → 后台跑到 reify()
  → ConfigPlugin.load() → found=0
  → plugins.length = 0
  → waitForDependencies() 不调用!                    ← 没人等
  → TUI 立即启动,后台下载在无人等待的情况下继续

设 OPENCODE_CONFIG_DIR=/agent-config:
  directories() = [/root/.config/opencode, /agent-config]
  → npm install (forkDetach) × 2 → 后台跑两个 fiber
  → ConfigPlugin.load() → found=1 (wfuzz-agent.ts)
  → plugins.length = 1
  → waitForDependencies() → Fiber.join 所有 fiber        ← 卡在这里
  → 等到 npm install 完成才启动 TUI

结论:npm 下载两边都发生了,区别在于有没有 waitForDependencies 去等它。

6. Npm.install() 内部逻辑

packages/core/src/npm.ts:146-197

const install = Effect.fn("Npm.install")(function* (dir, input) {
  // ① 可写检查:不可写 → 直接 return,fiber 立即完成
  const canWrite = yield* afs.access(dir, { writable: true })
    .pipe(Effect.as(true), Effect.orElseSucceed(() => false))
  if (!canWrite) return

  // ② node_modules 不存在 → 调用 @npmcli/arborist.reify() 安装
  if (!yield* afs.existsSafe(path.join(dir, "node_modules"))) {
    yield* reify({ add, dir })
    return
  }

  // ③ 脏检:对比 package.json + package-lock.json
  //    声明的依赖 vs 锁定的依赖,有缺失就 reify
  //    都满足 → 跳过 (no-op)
  // ...
})

v1.17.12 中脏检逻辑已经存在。真正的问题在于:容器或新环境中 ~/.config/opencode/ 没有任何 node_modules,流程会在第 ② 步(node_modules 不存在检测)就直接调用 reify(),根本走不到第 ③ 步的脏检。

7. 完整链路图

directories()
  │
  ├─ Global.Path.config       → /root/.config/opencode
  ├─ afs.up(".opencode")      → cwd 往上的 .opencode/
  ├─ afs.up(".opencode",home) → ~/.opencode/
  └─ OPENCODE_CONFIG_DIR      → /agent-config (如果设了)
         │
         ▼ 对每个 dir
  ┌──────────────────────────────────┐
  │ npmSvc.install(dir, {            │
  │   add: ["@opencode-ai/plugin"]   │
  │ }).pipe(forkDetach)              │ ← 后台 fiber
  │                                  │
  │ deps.push(fiber)                 │ ← 收集引用
  │                                  │
  │ ConfigPlugin.load(dir)           │ ← 扫描 plugins/*.ts
  └──────────────────────────────────┘
         │
         ▼ 之后,plugin/index.ts
  ┌──────────────────────────────────┐
  │ if (plugins.length)              │
  │   waitForDependencies()          │ ← Fiber.join 所有 deps
  │   // ↑ 阻塞直到 npm install 完成  │
  │                                  │
  │ PluginLoader.loadExternal(...)    │ ← import 插件
  └──────────────────────────────────┘

8. 解决方案

让 arborist 认为已安装

@npmcli/arboristreify() 在以下条件满足时是 no-op(不联网):

因此只需为 directories() 返回的所有目录预置这三个条件:

for dir in /root/.config/opencode /root/.opencode /agent-config; do
  mkdir -p "$dir"
  cp /agent-config/package.json "$dir/"
  cp /agent-config/package-lock.json "$dir/"
  mkdir -p "$dir/node_modules/@opencode-ai"
  cp -a /agent-config/node_modules/@opencode-ai/plugin "$dir/node_modules/@opencode-ai/"
done

/agent-config/ 本身已具备。其余目录补充后,waitForDependencies() 等待的所有 fiber 都能瞬间完成。

源码级修复(opencode fork)

config.ts 中添加前置判断,跳过无意义的安装:

// 前置守卫:既没有 opencode.json* 也没有 plugins/ → 跳过
const hasConfig = dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR
  || (yield* fs.existsSafe(path.join(dir, "opencode.json")))
  || (yield* fs.existsSafe(path.join(dir, "opencode.jsonc")))
const hasPlugins = (yield* fs.existsSafe(path.join(dir, "plugins")))
  || (yield* fs.existsSafe(path.join(dir, "plugin")))
if (!hasConfig && !hasPlugins) continue

配合 npm.ts 中的脏检逻辑(对比 package-lock.json),两个改动可彻底消除不必要的下载。

实测验证:上述分析的每一项推断都在验证篇中通过注入 [TRACE] 调试日志进行了逐帧还原。docker 无缓存环境下 arborist.reify() 耗时 18.6 秒,waitForDependencies 同步阻塞 18.6 秒。

总结

现象根因
空目录也触发 npm installnpmSvc.install() 对所有 directories() 结果无条件调用
OPENCODE_CONFIG_DIR 后变慢有插件 → waitForDependenciesFiber.join 等 npm fiber
不设变量不慢无插件 → 不调用 waitForDependencies → npm 在后台无人等待
预置 node_modules 可跳过arborist.reify() 发现包已安装 → no-op