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))
}
关键点:
npmSvc.install()对每个directories()返回的目录都调用- 不做任何前置判断:不检查目录是否有
opencode.json、也没有检查是否有plugins/ forkDetach确保它在后台运行,当前循环继续
为什么只安装 @opencode-ai/plugin
config.ts:462 中写死了这个包名:
add: [{ name: "@opencode-ai/plugin", version: ... }]
插件如果还需要其他依赖呢?有两种方式:
本地文件插件(plugins/*.ts):把依赖加到目录的 package.json 中。@npmcli/arborist 的 reify() 会读取 package.json 的 dependencies 并自动解析传递依赖树——不需要在代码里逐个声明。
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.json | package-lock.json | node_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/arborist 的 reify() 在以下条件满足时是 no-op(不联网):
package.json中有@opencode-ai/pluginpackage-lock.json中锁定了对应版本node_modules/@opencode-ai/plugin/实际存在
因此只需为 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 install | npmSvc.install() 对所有 directories() 结果无条件调用 |
设 OPENCODE_CONFIG_DIR 后变慢 | 有插件 → waitForDependencies → Fiber.join 等 npm fiber |
| 不设变量不慢 | 无插件 → 不调用 waitForDependencies → npm 在后台无人等待 |
| 预置 node_modules 可跳过 | arborist.reify() 发现包已安装 → no-op |