bootstrap 做了什么:环境检测与运行时初始化

拆解 opencode 源码 · 第二章 CLI 入口与启动流程 · 第二篇

如果你要设计一个 CLI 工具的启动流程——用户敲了 opencode run,到 AI Agent 真正开始处理问题之前——你觉得中间需要做多少准备工作?

一个直观的想法是:读一下配置文件,加载必要的插件,然后启动 Agent Loop。

问题是:谁负责加载配置?加载到哪一层可以访问?插件加载失败了怎么办?配置改动了 Agent 的参数,但 Agent 启动在插件加载之后,顺序怎么保证?更微妙的是——多项目的场景下,用户在目录 A 和目录 B 启动 opencode,配置和项目上下文完全不同,这些上下文是怎么隔离的?

02-01 我们看了 CLI 入口层的 yargs + effectCmd 架构。effectCmd 自动做了三件事:InstanceStore.load({ directory })provide(InstanceRef, ctx)dispose(ctx)。但 load 里面到底做了什么?这就是本文要拆的内容。

实际上,bootstrap 不是一件事,而是三层职责的叠加:实例上下文创建(把目录变成 InstanceContext)→ 运行时就绪(配置文件 + 插件 + 6 个后台服务)→ 上下文传播(让所有下游代码都能拿到当前目录的上下文)。这三层任何一层出问题,Agent 都无法正常工作。

涉及的源码文件和调用关系整理如下:

bootstrap 模块文件依赖树

从这棵树可以看到,bootstrap 的代码分散在 8 个文件中,但核心逻辑只有三条线:bootstrap.ts 入口 → instance-runtime.ts 桥接层 → instance-store.ts 缓存 + boot,然后 instance-store.ts 分支到 bootstrap.ts(project/)的 run 方法,run 方法再调度 config、plugin、6 services。每条线的文件都不超过 50 行,但组合起来完成了从「用户敲命令」到「Agent 可工作」的全部初始化。


【衔接】从 effectCmd 到 Bootstrap

effectCmd 的 load→handler→dispose

先看 effectCmd 的入口。每一条需要项目上下文的命令(instance: trueinstance: (args) => boolean),effectCmd 都自动包裹了一个三层生命周期:

// 简化自 packages/opencode/src/cli/effect-cmd.ts:87-94
const { store, ctx } = await AppRuntime.runPromise(
  InstanceStore.Service.use((store) => store.load({ directory })
    .pipe(Effect.map((ctx) => ({ store, ctx })))),
)
try {
  await AppRuntime.runPromise(
    opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))
  )
} finally {
  await AppRuntime.runPromise(store.dispose(ctx))
}

effectCmd 的三层生命周期

这段代码的意义在于:你把 handler 写成一个纯 Effect,不需要知道 Instance 什么时候加载、什么时候释放。框架替你 cover 了三种退出路径——正常 return、抛出异常、Effect 中断——全部命中 finally

为什么这件事值得单独提?因为如果每条命令自己管理 init/dispose,两条命令之间就可能出现"前一条泄漏了监听器,后一条工作不正常"的鬼畜问题。opencode 的作者在 effectCmd 里用 30 行代码把这个隐患从 20+ 条命令中一次性清除了。

这层封装的代价是:你必须理解 AppRuntime.runPromise 是一个必要的桥接——Promise 世界和 Effect 世界之间的通道。InstanceStore.Service.use() 拿到 store,store.load() 跑出 ctx,Effect.provideService 把 ctx 注入到 handler 的 Effect 环境中。每一层都是精确设计的取舍。

衔接下一节:store.load 内部调用了 boot()——这是 InstanceContext 的真正构建入口。

InstanceRuntime.load:桥接层的设计取舍

opencode 内部有两种调用风格:一种是在 Effect 运行时内(Layer.effectEffect.gen),可以直接 yield* 获取服务;另一种是传统 async/await 代码(如 CLI handler、测试文件),它们无法 yield Effect。

InstanceRuntime 就是为后者准备的桥接层:

// packages/opencode/src/project/instance-runtime.ts:9-11
export const load = (input: LoadInput) =>
  AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load(input)))
export const disposeInstance = (ctx: InstanceContext) =>
  AppRuntime.runPromise(InstanceStore.Service.use((store) => store.dispose(ctx)))

InstanceRuntime 桥接层

这里的模式是「借 Effect 的能力,还 Promise 的接口」。AppRuntime.runPromise 是全局 Effect 运行时的唯一入口——它创建了一个 Effect.Runner,把 Effect 跑完再转成 Promise。

naive 方案可能直接把 Effect 的能力爆露给调用方(yield* store.load()),但这意味着调用方也必须运行在 Effect 上下文中。bootstrap.ts 的调用方是 CLI handler,而 CLI handler 的入口来自 yargs——yargs 是纯 async/await 的。所以必须在 CLI 边界做一次转换。

bootstrap.ts 的原始版本是更直白的 11 行函数(已在本文开头给出),而 InstanceRuntime 方案就是把这个 11 行模式固化为一个可复用的模块。两者做同一件事,但 InstanceRuntime 多了桥接层的概念,方便其它 Promise 边界(如测试、HTTP handler)复用。

衔接下一节:boot() 内部做的事情——把目录变成 InstanceContext。


【第一层】config.get() — 配置就绪

配置发现:从目录到配置文件

InstanceStore.boot() 的第一步是 project.fromDirectory(directory)

// packages/opencode/src/project/instance-store.ts:45-63
const boot = (input: LoadInput & { directory: string }) =>
  Effect.gen(function* () {
    const ctx: InstanceContext =
      input.project && input.worktree
        ? { directory: input.directory, worktree: input.worktree, project: input.project }
        : yield* project.fromDirectory(input.directory).pipe(
            Effect.map((result) => ({
              directory: input.directory,
              worktree: result.sandbox,
              project: result.project,
            })),
          )
    yield* bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx))
    return ctx
  })

InstanceContext 构建流程

构建 InstanceContext 有两种路径: - 热路径(input 已含 project + worktree):跳过 fromDirectory,直接构造 - 冷路径(大部分情况):调用 project.fromDirectory(directory) 发现项目

fromDirectory 内部做了什么?它调用了 projectV2.resolve() 去解析目录:检测是否在 git 仓库内、获取 worktree 路径、计算出 project ID。然后 upsert 到 SQLite 数据库,更新 sandboxes 列表,最后发射 project.updated 事件。

这个过程之所以存在,是因为 opencode 需要知道"当前目录对应哪个项目"——同一个 git 仓库的不同子目录启动,project ID 必须一致;不同仓库之间,project ID 必须不同。没有 fromDirectory,每个目录就是一个独立的项目,多个子目录之间的 session、权限、配置就无法共享。

config.get:全部配置的第一道关卡

InstanceContext 构建完成后,bootstrap.run 调用 config.get()

// packages/opencode/src/project/bootstrap.ts:32-36
const run = Effect.gen(function* () {
  const ctx = yield* InstanceState.context
  yield* Effect.logInfo("bootstrapping", { directory: ctx.directory })
  yield* config.get()       // 第一步:配置加载
  yield* plugin.init()      // 第二步:插件初始化(可改配置)
  // ... 6 个服务并发
})

config.get() 返回的是 Effect.Effect<Info>,这意味着它不会在 get 时重新读文件——配置的解析和合并已经在 Service 初始化时完成了。这个设计有一个重要的隐藏含义:配置加载是 eager 的,但不会随着每次 get 重复

config.get 调用链路

naive 方案可能每次 get 都重新读文件,确保配置最新——但 opencode 选择了"一旦就绪就不变"的契约。原因在于:配置影响了太多下游状态(Agent 列表、provider 凭证、权限规则),如果配置在运行中突然变化,Agent 的行为会变得不可预测。opencode 的配置热更新是通过专门的控制通道实现的,不是自动重读。

Config.Interface 定义了 6 个方法: - get() — 获取当前项目配置 - getGlobal() — 获取全局配置(跨项目共享) - update() / updateGlobal() — 写入配置 - invalidate() — 显式让缓存失效 - directories() — 配置源目录列表

配置合并是多层叠加:全局 ~/.config/opencode/opencode.jsonc → 项目级 ./opencode.jsonc → 环境变量覆盖。mergeConfigConcatArrays 确保数组字段(如 instructions)是拼接而非替换。

衔接下一节:配置就绪后,plugin.init() 被调用——但注意,它在 config.get 之后,这是有原因的。


【第二层】plugin.init() — 插件发现

插件必须先于其他服务的理由

回头看 bootstrap.run 的顺序:

yield* config.get()
// Plugin can mutate config so it has to be initialized before anything else.
yield* plugin.init()
yield* Effect.forEach(
  [lsp, shareNext, format, vcs, snapshot, project],
  (s) => s.init(),
  { concurrency: "unbounded", discard: true },
)

插件必须在其他服务之前初始化,原因只有一个:插件可以修改配置

bootstrap.run 初始化顺序

opencode 的插件系统里有一类特殊的「ConfigPlugin」。这类插件的初始化动作可能包括:从远程拉取配置模板、注入自定义的 Agent 定义、修改权限规则。如果先初始化了 LSP 或 VCS 服务(它们依赖于完整的配置),插件修改配置后这些服务可能工作在不一致的配置上——出现"LSP 用了旧的 provider 配置,但 Agent 用了新配置"的问题。

naive 方案可能会说:那把配置做成响应式的,服务监听配置变更不就行了?但问题是:配置变更不是增量事件驱动的——插件 init 是一个同步操作,在它完成之前,配置是不完整的。你在"配置一半"的状态下启动其他服务,这就跟汽车还没装好轮子就点火一样——也许走得动,但出问题的概率很高。

另一个方案是"所有服务都支持热重载,配置变了就重新 init"。opencode 没有走这条路,原因有二: 1. 复杂性代价:每个服务都需要实现配置变更监听、状态迁移、rollback,6 个服务 × 3 个状态约 18 个复杂度单元 2. 实际需求:配置在 bootstrap 阶段之后极少变更,为极低频场景增加永久复杂度不划算

所以作者选了最简单直接的方案——先 config、再 plugin、最后其他服务。一条直线,没有状态跃迁。

插件的发现与加载

plugin.init() 内部做三件事: - 从配置中读取 plugin 字段(plugin spec 列表) - 对每个 spec,做 resolve → 按 kind(server/tui)识别 entrypoint → 动态 import - 加载成功后在 registry 中注册

插件发现与加载流水线

它的复杂度不在加载本身,而在降级策略:某个插件 resolve 失败时,是跳过、重试、还是终止整个 bootstrap?

opencode 的插件加载器在 packages/opencode/src/plugin/loader.ts 中定义了完整的三阶段管线:resolve(定位 target + 检测 entrypoint + 兼容性检查)→ load(动态 import)→ finish(注册到运行时)。如果 resolve 失败(比如 npm 包未安装——属于 "install" 阶段错误且是 file-plugin 时),会在 wait() 之后重试一次;其他阶段的失败永久跳过。

opencode 的选择是:"跳过,但记录"。plugin.init() 不会因为某个插件加载失败就阻止 Agent 启动——但如果插件是 config plugin(可以在 load 过程中修改配置),跳过它的后果可能已经影响到配置完整性。所以 config plugin 的加载其实是惰性且安全的:它们的影响通过 Effect 的 forkIn(scope) 隔离在自己的 Effect 沙箱中。

衔接下一节:插件初始化完成后,6 个服务并发启动。


【第三层】6 个服务并发 init

并发 init 的意图:谁需要快?

yield* Effect.forEach(
  [lsp, shareNext, format, vcs, snapshot, project],
  (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
  { concurrency: "unbounded", discard: true },
)

六个服务分别是: - LSP — 语言服务器协议客户端,提供代码补全/诊断能力 - ShareNext — 分享与协作功能 - Format — 代码格式化 - Vcs — 版本控制(git)集成 - Snapshot — 快照管理,用于安全回退 - Project — 项目元数据管理(订阅 /init 命令)

这些服务有一个共同点:它们都对 Agent 不是立即可用的。用户输入问题后的第一反应(LLM 调用、工具执行)不需要它们。因此它们被设计为后台惰性初始化——即使某个服务 init 失败(走 catchCauselogWarning),Agent 仍然可以工作。

6 服务并发 init 的隔离架构

naive 方案可能把所有服务串行 init——保证顺序确定,好调试。但代价是:用户在 opencode run 之后要等 LSP 启动 → VCS 扫描 → Format 加载 → Project 注册……全跑完才看到提示符。opencode 选择了"容错并发"——每个服务自己管理生命周期(通过 Effect.forkScoped 在 per-instance scope 内启动),bootstrap.run 只负责 await 它们第一次具体化。

"容错"的心态

注意 catchCause 而不是 catchTag——它捕获所有类型的失败,不管是可以恢复的配置缺失,还是不可恢复的数据库错误。这看起来有点粗放,但意图明确:bootstrap 的目标是让 InstanceContext 可工作,不是完美。LSP 挂了?你仍然可以写代码提问。Snapshot 挂了?顶多是无法回退到上一个安全检查点。

catchCause 容错隔离架构

这张图展示了关键设计:6 个服务中任意一个失败(走 catchCauselogWarning),不影响其他服务的 init 结果。三个服务的 init 路径汇总到同一个 DONE 状态——Agent 可以带着"部分服务缺失"继续工作。naive 方案可能会让任何一个服务的失败阻断整个 bootstrap(比如抛出异常终止),但 opencode 选择了"能用比完美更重要"的哲学。

每个服务的 init() 内部也遵循类似的容错策略。比如 Project.init() 只是通过 InstanceState.get(initState) 等待范围订阅就绪:

// packages/opencode/src/project/project.ts:427-429
const init = Effect.fn("Project.init")(function* () {
  yield* InstanceState.get(initState)
})

它只是 await 了一个缓存的 Effect——实际的订阅创建在 InstanceStore.boot() 之前的 InstanceState.make() 中就已经完成了。这意味着 init 不会失败,因为它只是"等一个已经启动的协程"。

衔接下一节:三层初始化的顺序为什么是 config → plugin → 6 services?有替代方案吗?


【权衡】bootstrap 顺序为什么是这个顺序?

三种可能的初始化排序

三种初始化排序方案对比

用一个表对比三种可能的顺序:

顺序 方案 问题
config → plugin → 6 services ✅ opencode 的选择。plugin 可改 config,6 services 不需要等待 plugin 完成
全部并发 plugin 可能在 config 完成之前执行——如果 plugin 依赖 config,出现问题
config → 6 services → plugin 6 services 在配置完整的状态下启动,但 plugin 对 config 的修改无法被 6 services 感知

为什么不是 ②「全部并发」?因为 config plugin 需要在配置就绪后才能执行——它的本质是"修改已经加载的配置"。全部并发意味着你不知道 config plugin 读到的配置是不是最终版。opencode 的 config plugin 场景虽然不多,但一旦出现(比如远程配置注入),顺序错误的影响是致命性的——Agent 可能用错误的 provider 配置跑完全程。

为什么不是 ③「config → 6 services → plugin」?因为 6 services 中的某些服务(如 Format、VCS)的初始化路径可能依赖 plugin 注册的扩展点。如果 plugin 在服务之后加载,这些服务就看不到 plugin 添加的能力——虽然目前 opencode 的 6 个服务没有深层依赖 plugin,但这个顺序为未来留下了灵活性。

所以 ① 是唯一正确的顺序:config 最先(不可动摇)→ plugin 其次(因为它能改 config)→ 6 services 最后(容错并发)。

这段顺序代码的更深层意义

回头看 bootstrap.run 的完整源码:

bootstrap.run 完整上下文

5 条 statement,定义了 config + plugin + 6 services 三层的执行顺序。但更重要的是注释:

// Each service self-manages its own slow work via Effect.forkScoped against
// its per-instance state scope. We just await materialization here.

这句注释揭示了 bootstrap.run 的本质:它不是一个"init 控制者",而是一个"init 等待者"。6 个服务的实际初始化是在 InstanceState.make() 时通过 registerDisposer 注册的——它们早就启动在后台了。bootstrap.run 只是等待它们第一次准备就绪。

这跟 naive 方案"bootstrap 执行完所有初始化动作"的预期完全不同。opencode 的作者把 bootstrap 设计成了一个"就绪信号的收集器",而不是"初始化动作的执行器"。


【锚点】Bootstrap = 运行时就绪协议

总结三层职责

三层职责概览

整个 bootstrap 链条可以浓缩为三层:

  • 衔接层:effectCmd 捕获三条命令生命周期(load→handler→dispose),InstanceRuntime 桥接 Effect ↔ Promise 边界
  • 第一层——config.get:多层配置合并,一次性加载后冻结,不被自动重读干扰
  • 第二层——plugin.init:config plugin 必须先于其他服务,因为它的本质是"修改已加载的配置"
  • 第三层——6 services:容错并发,每个服务自管理生命周期,bootstrap.run 只收集就绪信号

三层不是三个串行步骤——config → plugin 是串行(plugin 依赖 config),但 6 services 是并发的、有错容忍的。这个排序规则的精确原因是:config 不可动摇,plugin 能改 config,6 services 不需要等待 plugin 完成。

naive 方案假设 bootstrap 是一个线性的初始化过程——做完 A 做 B,做到 Z 就完事了。但 opencode 的 bootstrap 更像是一个"准备就绪"协议:config.get 确保配置能读、plugin.init 确保插件能加载、6 services 的 init 确保它们都 fork 到后台了。至于它们什么时候真正初始化完成——这不阻塞 bootstrap 的退出。

核心模式

Bootstrap 不做事——它等人。
不是 init 执行器,是 ready 信号收集器。

锚点总结卡片

这个模式在分布式系统中很常见(如 Kubernetes 的 readiness probe),但在 CLI 工具的启动流程中并不多见。opencode 把它用在一个单进程 CLI 应用的启动中——因为它的启动链不是"按顺序做完 A→B→C",而是"确保 A、B、C 各就各位,然后发令枪响"。

InstanceContext 的生命周期不仅限于 CLI。在 opencode 的 Server 模式和 HTTP API 中,InstanceStore 提供了 provide 方法来允许 Effect 运行在指定上下文下——同一个服务进程可以为多个目录的请求提供服务,每个请求独立 bootstrap 和 dispose。

这一切的根基是一个 17 行的 AsyncLocalStorage 封装:

// packages/opencode/src/util/local-context.ts:9-23
export function create<T>(name: string) {
  const storage = new AsyncLocalStorage<T>()
  return {
    use() { ... },
    provide<R>(value: T, fn: () => R) { ... },
  }
}

InstanceContextWorkspaceContextSessionContext 三个模块依赖。Node.js 的 AsyncLocalStorage 保证了即使在异步链(Promise / setTimeout / Effect 调度)中,use() 也能拿到正确的上下文。没有这个机制,bootstrap 的三层职责就无法传播到下游代码——每一层都要手动传参,而手动传参往往是泄漏和 Bug 的温床。

衔接 02-03

下一篇文章(02-03)我们来看 run 命令全流程:当 bootstrap 完成、InstanceContext 就绪、所有后台服务各就各位之后——opencode run 如何启动 Agent Loop、处理用户输入、跨度漫长的工具调用链。如果说本文是"发令枪响之前",下一篇就是"发令枪响之后"的全部。