怎么读这份源码

读 OpenCode 前要掌握的 Effect 惯例(Service、Layer、Effect.fn、InstanceState)、LayerNode 依赖图、实验开关表、代码风格与测试约定,以及一条推荐的阅读路线。

作者 David更新于 第 3 篇(共 38 篇)

OpenCode 的难点不在算法而在写法:几乎每个模块都是 Effect 的 Service。

先看哪些文档

写给编码 Agent 的 AGENTS.md 也是现成的读者指南:根 AGENTS.md(依赖方向、风格、V2 会话内核的硬约束)、packages/opencode/AGENTS.md(模块形状、Effect 用法)、packages/opencode/test/AGENTS.md(测试写法)。根目录的 CONTEXT.md 是 V2 会话运行时的术语表,specs/ 下是设计规格。

文档不总跟得上代码,下面几处在 v1.18.30 里已过时:

  • CONTRIBUTING.md 仍说 TUI 在 packages/opencode/src/cli/cmd/tui/,实际已拆到 packages/tui;它提到的 bun dev spawn 在 src/index.ts 里没有对应命令。
  • packages/opencode/AGENTS.md 让人参考的 specs/effect/migration.md 不在这个版本里。
  • src/session/llm/AGENTS.md 说总开关 OPENCODE_EXPERIMENTAL=true 也能启用原生 LLM,但 runtime-flags.ts 里的 experimentalNativeLlm 只认 OPENCODE_EXPERIMENTAL_NATIVE_LLM。

模块形状:自我重导出

packages/opencode/AGENTS.md 禁止用 export namespace 组织模块:

It is not standard ESM, it prevents tree-shaking, and it breaks Node's native TypeScript runner.

替代方案是“平铺的顶层导出 + 文件末尾一行自我重导出”,以 packages/opencode/src/agent/agent.ts 为例(删减后):

export interface Interface {
  readonly get: (agent: string) => Effect.Effect<Info>
  readonly list: () => Effect.Effect<Info[]>
  // ...
}

export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}

export const use = serviceUse(Service)

const layer = Layer.effect(
  Service,
  Effect.gen(function* () {
    const config = yield* Config.Service
    // ...
  }),
)

export const node = LayerNode.make({
  service: Service,
  layer: layer,
  deps: [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node, locationServiceMapNode],
})

export * as Agent from "./agent"

消费方写 import { Agent } from "@/agent/agent",再用 Agent.Service、Agent.node。serviceUse(packages/core/src/effect/service-use.ts)用 Proxy 生成访问器,Agent.use.get("build") 直接得到一个依赖 Agent.Service 的 Effect。多文件目录(如 src/session/)不设 barrel index.ts,免得一次 import 把所有兄弟模块都求值。

Effect.fn 命名

对外方法用 Effect.fn("Domain.method"),内部辅助用 Effect.fnUntraced。名字会成为追踪 span,也是最好用的搜索锚点:搜 "SessionPrompt. 能列出 SessionPrompt.prompt、SessionPrompt.loop、SessionPrompt.run(即 runLoop)等方法。

InstanceState:按目录隔离

一个服务端进程可以同时服务多个项目目录,“每个目录一份”的状态放进 InstanceState(packages/opencode/src/effect/instance-state.ts:26,删减后):

export const make = <A, E = never, R = never>(
  init: (ctx: InstanceContext) => Effect.Effect<A, E, R | Scope.Scope>,
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
  Effect.gen(function* () {
    const cache = yield* ScopedCache.make<string, A, E, R>({
      capacity: Number.POSITIVE_INFINITY,
      lookup: () =>
        Effect.gen(function* () {
          return yield* init(yield* context)
        }),
    })
    // ...
  })

ScopedCache 以目录为键:init 只在某目录第一次被访问时执行,实例释放时连同作用域清理。当前目录来自 InstanceRef,服务端按查询参数 directory 或请求头 x-opencode-directory 定位实例(packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts:87)。OpenCode 是什么里的七个 Agent 就构造在这样的闭包里,所以各项目的配置互不干扰。

LayerNode:显式的依赖图

每个服务导出 node = LayerNode.make({ service, layer, deps })(packages/core/src/effect/layer-node.ts),启动时把节点列表编译成一个大 Layer:

  • 类型查漏:deps 缺了 layer 需要的服务,参数类型里会冒出名为 "Missing dependencies" 的字段,直接编译报错(layer-node.ts:10)。
  • 去重与查环:compile 带缓存遍历,同一节点只构建一次;有环则抛 Cycle detected in layer tree: ...。
  • 可替换:compile(root, replacements) 按节点名换实现,V1 的 AppNodeBuilderV1.build 就用它把 InstanceStore.bootstrapNode 换成 InstanceBootstrap.node。

节点还能打标签,packages/core/src/effect/app-node.ts 定义了两种:

export const tags = LayerNode.tags({
  location: ["global"],
  global: [],
})

location 节点可以依赖 global 节点,反之不行;V2 按位置组装服务时用 LayerNode.hoist 把 global 部分剥出来只建一次(packages/core/src/location-services.ts:96),详见 Effect 运行时与 Location。现役的 packages/opencode/src/effect/app-runtime.ts 把四十多个节点放进 LayerNode.group([...]),编译后交给 ManagedRuntime.make,与各处 makeRuntime 共享 core 里的进程级 memoMap。

两套开关:Flag 与 RuntimeFlags

Flag(packages/core/src/flag/flag.ts)直接读 process.env,多数在模块加载时求值;RuntimeFlags(packages/opencode/src/effect/runtime-flags.ts)是用 Effect Config 声明的服务,测试可用 RuntimeFlags.layer(...) 覆盖。实验开关大多在后者,关键是这个辅助函数(runtime-flags.ts:10):

const experimental = bool("OPENCODE_EXPERIMENTAL")
const enabledByExperimental = (name: string) =>
  Config.all({ experimental, enabled: Config.boolean(name).pipe(Config.option) }).pipe(
    Config.map((flags) => Option.getOrElse(flags.enabled, () => flags.experimental)),
  )

单独的开关没设置时跟随总开关,设置了以自己为准。据此分三类(省略 OPENCODE_ 前缀):

类别开关
跟随总开关EXPERIMENTAL_REFERENCES、EXPERIMENTAL_BACKGROUND_SUBAGENTS、EXPERIMENTAL_LSP_TOOL、EXPERIMENTAL_OXFMT、EXPERIMENTAL_PLAN_MODE、EXPERIMENTAL_CODE_MODE、EXPERIMENTAL_EVENT_SYSTEM、EXPERIMENTAL_WORKSPACES、EXPERIMENTAL_ICON_DISCOVERY
只认自己EXPERIMENTAL_NATIVE_LLM、EXPERIMENTAL_WEBSOCKETS、EXPERIMENTAL_LSP_TY、ENABLE_QUESTION_TOOL、ENABLE_EXPERIMENTAL_MODELS、ENABLE_PARALLEL
数值与禁用EXPERIMENTAL_OUTPUT_TOKEN_MAX、EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS、PURE、DISABLE_* 系列等

ENABLE_EXA 例外:总开关、它自己或旧名 EXPERIMENTAL_EXA 任一为真即启用。

代码风格

根 AGENTS.md 的风格很有个性:能放在一个函数里就不拆;少用 try/catch、any 与 else;不做不必要的解构;不给 import 起别名;启动敏感的路径对重模块用动态 import()——所以 cli/cmd/tui.ts、cli/cmd/serve.ts 等处理函数里满是 await import(...)。提交信息用 type(scope): summary。

测试约定

  • bun test 只能在包目录里跑,类型检查用 bun typecheck(背后是 tsgo);test/preload.ts 先把 XDG 目录指向临时目录。
  • Effect 测试用 testEffect(layer)(test/lib/effect.ts),分 it.effect(TestClock)、it.live(真实时间)、it.instance(附带临时实例目录);模型调用打到 test/lib/llm-server.ts 的假服务。
  • 尽量不 mock,局部替换用 Layer.mock;等并发任务要等就绪信号,不用 Effect.sleep 硬等。

推荐阅读路线

除注明外,路径相对 packages/opencode/src:

顺序入口文件对应篇目
1index.ts、cli/cmd/tui.ts、cli/tui/worker.ts启动链路
2server/routes/instance/httpapi/server.ts、handlers/session.tsHTTP 服务端
3session/prompt.ts、session/llm.ts、session/processor.ts、session/tools.ts主循环 SessionPrompt
4tool/registry.ts、permission/index.ts工具契约与注册表
5packages/tui/src/app.tsx、context/sdk.tsx、context/sync.tsx终端界面
6根 CONTEXT.md、specs/v2/session.md、packages/core/src/session/V2 会话内核

下一篇:启动链路——从敲下 opencode 到 TUI 画出第一帧,Worker、RPC 隧道与服务端各自在做什么。

本页目录