配置系统
eec0843ce422
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
10-config-system - 章节摘要:沿一次项目配置的合并路径,理解远程、全局、项目、内联与托管来源如何经过替换、校验、规范化和 provenance 保留,最终影响 agent、permission 与 plugin。
- 教程版本:
eec0843c - 源码基线:
eec0843ce42298080569ca31a6455bc3f699d213 - 章节元数据:/versions/eec0843c/data/chapters.json
- 源码映射:/versions/eec0843c/data/source-map.json
主要源码路径
Section titled “主要源码路径”packages/opencode/src/config/config.tspackages/opencode/src/config/agent.tspackages/opencode/src/config/permission.tspackages/opencode/src/config/plugin.tspackages/opencode/src/project/bootstrap.ts
源码基线:
eec0843ce422。本章中的流程是根据源码整理出的典型路径,不是一次真实运行日志。
0. 本章学习目标
Section titled “0. 本章学习目标”读完本章,你应该能:
- 画出远程、全局、项目、配置目录、内联内容和托管配置的合并顺序。
- 解释为什么“后加载者覆盖先加载者”还不足以描述 OpenCode 的配置语义。
- 沿着一个项目配置,追到
agent、permission和plugin的规范化结果。 - 区分配置文件的来源、最终值和运行时初始化这三件事。
- 为自己的 agent 设计一个可验证、可追溯的配置管线。
1. 一句话讲明白
Section titled “1. 一句话讲明白”OpenCode 配置系统不是“读一个 JSON”,而是把多个来源按明确顺序加载、替换变量、校验、规范化和深度合并,最后以实例级状态提供给 agent、provider、tool 与 plugin。
本章的中心问题是:当两个地方都配置了同一个 agent 或权限时,运行时究竟相信谁,又怎样保留足够的来源信息?
2. 为什么现在必须理解配置
Section titled “2. 为什么现在必须理解配置”前面几章已经讲过 tool、permission 和 provider。但如果你只读这些模块,会误以为它们的行为都写死在源码中。实际上:
model、provider决定模型入口;agent决定提示词、模型、步数和局部权限;permission决定工具是否直接执行、询问或拒绝;plugin、mcp、lsp、formatter决定运行时还会接入哪些能力。
因此,配置系统像一张控制面:它不执行 agent loop,却决定 loop 拿到什么零件。
3. 先画地图:来源、管线、消费者
Section titled “3. 先画地图:来源、管线、消费者”远程 well-known |全局 config.json -> opencode.json -> opencode.jsonc |OPENCODE_CONFIG 指定文件 |项目路径上的 opencode 配置 |.opencode / OPENCODE_CONFIG_DIR 中的配置与 Markdown 条目 |OPENCODE_CONFIG_CONTENT |组织配置 -> managed 目录 -> macOS MDM |兼容字段与环境 flag 归一化 v Config.Info | +--> Agent / Provider / Permission / Tool +--> Plugin / MCP / LSP / Formatter +--> UI 与 Server顺序来自 packages/opencode/src/config/config.ts:472-735。同一实例的结果由 InstanceState 保存,Config.get() 读取的是合并后的 Info,见 packages/opencode/src/config/config.ts:749-760。
packages/opencode/src/config/config.ts
packages/opencode/src/config/config.ts:472-735
472 const loadInstanceState = Effect.fn("Config.loadInstanceState")(Effect 异步工作流。473 function* (ctx: InstanceContext) {定义一段可复用逻辑。474 const auth = yield* authSvc.all().pipe(Effect.orDie)Effect 异步工作流。475476 let result: Info = {}477 const consoleManagedProviders = new Set<string>()选择模型或 provider。478 let activeOrgName: string | undefined479480 const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {Effect 异步工作流。481 if (source.startsWith("http://") || source.startsWith("https://")) return "global"按条件进入分支。482 if (source === "OPENCODE_CONFIG_CONTENT") return "local"按条件进入分支。483 if (containsPath(source, ctx)) return "local"按条件进入分支。484 return "global"返回给上一层。485 })486487 const mergePluginOrigins = Effect.fnUntraced(function* (调用插件扩展点。488 source: string,489 // mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step490 // is attached.491 list: ConfigPlugin.Spec[] | undefined,调用插件扩展点。492 // Scope can be inferred from the source path, but some callers already know whether the config should493 // behave as global or local and can pass that explicitly.494 kind?: ConfigPlugin.Scope,调用插件扩展点。495 ) {496 if (!list?.length) return按条件进入分支。497 const hit = kind ?? (yield* pluginScopeForSource(source))等待 Effect 结果。498 // Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while499 // keeping the winning source/scope metadata for downstream installs, writes, and diagnostics.500 const plugins = ConfigPlugin.deduplicatePluginOrigins([调用插件扩展点。501 ...(result.plugin_origins ?? []),502 ...list.map((spec) => ({ spec, source, scope: hit })),503 ])504 result.plugin = plugins.map((item) => item.spec)505 result.plugin_origins = plugins506 })507508 const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {调用插件扩展点。509 result = mergeConfigConcatArrays(result, next)510 return mergePluginOrigins(source, next.plugin, kind)调用插件扩展点。511 }512513 for (const [key, value] of Object.entries(auth)) {遍历集合。514 if (value.type === "wellknown") {按条件进入分支。515 const url = key.replace(/\/+$/, "")516 process.env[value.key] = value.token517 log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })518 const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))Effect 异步工作流。519 if (!response.ok) {按条件进入分支。520 throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)失败时抛出错误。521 }522 const wellknown = (yield* Effect.promise(() => response.json())) as {Effect 异步工作流。523 config?: Record<string, unknown>524 remote_config?: unknown525 }526 const remote = yield* Effect.promise(() =>Effect 异步工作流。527 substituteWellKnownRemoteConfig({528 value: wellknown.remote_config,529 dir: url,530 source: `${url}/.well-known/opencode`,531 }),532 )533 const fetchedConfig = remote534 ? ((yield* Effect.promise(async () => {Effect 异步工作流。535 log.debug("fetching remote config", { url: remote.url })536 const response = await fetch(remote.url, { headers: remote.headers })537 if (!response.ok)按条件进入分支。538 throw new Error(`failed to fetch remote config from ${remote.url}: ${response.status}`)失败时抛出错误。539 const data = await response.json()540 return isRecord(data) && isRecord(data.config) ? data.config : data返回给上一层。541 })) as Record<string, unknown>)542 : {}543 const remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig as Info)544 if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"读取运行配置。545 const source = `${url}/.well-known/opencode`546 const next = yield* loadConfig(JSON.stringify(remoteConfig), {等待 Effect 结果。547 dir: path.dirname(source),548 source,549 })550 yield* merge(source, next, "global")等待 Effect 结果。551 log.debug("loaded remote config from well-known", { url })552 }553 }554555 const global = yield* getGlobal()读写本地文件。556 yield* merge(Global.Path.config, global, "global")读写本地文件。557558 if (Flag.OPENCODE_CONFIG) {按条件进入分支。559 yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))等待 Effect 结果。560 log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })561 }562563 if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {按条件进入分支。564 for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {Effect 异步工作流。565 yield* merge(file, yield* loadFile(file), "local")等待 Effect 结果。566 }567 }568569 result.agent = result.agent || {}570 result.mode = result.mode || {}571 result.plugin = result.plugin || []572573 const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree)等待 Effect 结果。574575 if (Flag.OPENCODE_CONFIG_DIR) {按条件进入分支。576 log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })577 }578579 const deps: Fiber.Fiber<void, never>[] = []580581 for (const dir of directories) {遍历集合。582 if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {按条件进入分支。583 for (const file of ["opencode.json", "opencode.jsonc"]) {遍历集合。584 const source = path.join(dir, file)585 log.debug(`loading config from ${source}`)586 yield* merge(source, yield* loadFile(source))等待 Effect 结果。587 result.agent ??= {}588 result.mode ??= {}589 result.plugin ??= []590 }591 }592593 yield* ensureGitignore(dir).pipe(Effect.orDie)Effect 异步工作流。594595 const dep = yield* npmSvc等待 Effect 结果。596 .install(dir, {597 add: [598 {599 name: "@opencode-ai/plugin",600 version: InstallationLocal ? undefined : InstallationVersion,601 },602 ],603 })604 .pipe(605 Effect.exit,Effect 异步工作流。606 Effect.tap((exit) =>Effect 异步工作流。607 Exit.isFailure(exit)608 ? Effect.sync(() => {Effect 异步工作流。609 log.warn("background dependency install failed", { dir, error: String(exit.cause) })610 })611 : Effect.void,Effect 异步工作流。612 ),613 Effect.asVoid,Effect 异步工作流。614 Effect.forkDetach,Effect 异步工作流。615 )616 deps.push(dep)617618 result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))处理命令执行。619 result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir)))Effect 异步工作流。620 result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir)))Effect 异步工作流。621 // Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load622 // returns normalized Specs and we only need to attach origin metadata here.623 const list = yield* Effect.promise(() => ConfigPlugin.load(dir))调用插件扩展点。624 yield* mergePluginOrigins(dir, list)调用插件扩展点。625 }626627 if (process.env.OPENCODE_CONFIG_CONTENT) {按条件进入分支。628 const source = "OPENCODE_CONFIG_CONTENT"629 const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {等待 Effect 结果。630 dir: ctx.directory,631 source,632 })633 yield* merge(source, next, "local")等待 Effect 结果。634 log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")635 }636637 const activeAccount = Option.getOrUndefined(638 yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))),Effect 异步工作流。639 )640 if (activeAccount?.active_org_id) {按条件进入分支。641 const accountID = activeAccount.id642 const orgID = activeAccount.active_org_id643 const url = activeAccount.url644 yield* Effect.gen(function* () {Effect 异步工作流。645 const [configOpt, tokenOpt] = yield* Effect.all(Effect 异步工作流。646 [accountSvc.config(accountID, orgID), accountSvc.token(accountID)],647 { concurrency: 2 },648 )649 if (Option.isSome(tokenOpt)) {按条件进入分支。650 process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value651 yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)等待 Effect 结果。652 }653654 if (Option.isSome(configOpt)) {按条件进入分支。655 const source = `${url}/api/config`656 const next = yield* loadConfig(JSON.stringify(configOpt.value), {等待 Effect 结果。657 dir: path.dirname(source),658 source,659 })660 for (const providerID of Object.keys(next.provider ?? {})) {选择模型或 provider。661 consoleManagedProviders.add(providerID)选择模型或 provider。662 }663 yield* merge(source, next, "global")等待 Effect 结果。664 }665 }).pipe(666 Effect.withSpan("Config.loadActiveOrgConfig"),Effect 异步工作流。667 Effect.catch((err) => {Effect 异步工作流。668 log.debug("failed to fetch remote account config", {669 error: err instanceof Error ? err.message : String(err),670 })671 return Effect.voidEffect 异步工作流。672 }),673 )674 }675676 const managedDir = ConfigManaged.managedConfigDir()677 if (existsSync(managedDir)) {按条件进入分支。678 for (const file of ["opencode.json", "opencode.jsonc"]) {遍历集合。679 const source = path.join(managedDir, file)680 yield* merge(source, yield* loadFile(source), "global")等待 Effect 结果。681 }682 }683684 // macOS managed preferences (.mobileconfig deployed via MDM) override everything685 const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences())Effect 异步工作流。686 if (managed) {按条件进入分支。687 result = mergeConfigConcatArrays(688 result,689 yield* loadConfig(managed.text, {等待 Effect 结果。690 dir: path.dirname(managed.source),691 source: managed.source,692 }),693 )694 }695696 for (const [name, mode] of Object.entries(result.mode ?? {})) {遍历集合。697 result.agent = mergeDeep(result.agent ?? {}, {698 [name]: {699 ...mode,700 mode: "primary" as const,701 },702 })703 }704705 if (Flag.OPENCODE_PERMISSION) {按条件进入分支。706 result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))开始解析命令参数。707 }708709 if (result.tools) {按条件进入分支。710 const perms: Record<string, ConfigPermission.Action> = {}711 for (const [tool, enabled] of Object.entries(result.tools)) {遍历集合。712 const action: ConfigPermission.Action = enabled ? "allow" : "deny"713 if (tool === "write" || tool === "edit" || tool === "patch") {按条件进入分支。714 perms.edit = action715 continue716 }717 perms[tool] = action718 }719 result.permission = mergeDeep(perms, result.permission ?? {})720 }721722 if (!result.username) result.username = os.userInfo().username按条件进入分支。723724 if (result.autoshare === true && !result.share) {按条件进入分支。725 result.share = "auto"726 }727728 if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {按条件进入分支。729 result.compaction = { ...result.compaction, auto: false }730 }731 if (Flag.OPENCODE_DISABLE_PRUNE) {按条件进入分支。732 result.compaction = { ...result.compaction, prune: false }733 }734735 return {返回给上一层。
packages/opencode/src/config/config.ts
packages/opencode/src/config/config.ts:749-760
749 const state = yield* InstanceState.make<State>(等待 Effect 结果。750 Effect.fn("Config.state")(function* (ctx) {Effect 异步工作流。751 return yield* loadInstanceState(ctx).pipe(Effect.orDie)Effect 异步工作流。752 }),753 )754755 const get = Effect.fn("Config.get")(function* () {Effect 异步工作流。756 return yield* InstanceState.use(state, (s) => s.config)等待 Effect 结果。757 })758759 const directories = Effect.fn("Config.directories")(function* () {Effect 异步工作流。760 return yield* InstanceState.use(state, (s) => s.directories)等待 Effect 结果。
这里有三条边界:
- 来源层回答“值从哪里来”。
- 解析层回答“这个值是否合法、如何统一形状”。
- 消费层回答“agent/tool/plugin 如何使用最终值”。
不要把三层混成一个巨大的 loadConfig()。
4. 最小机制
Section titled “4. 最小机制”先忽略插件来源、缓存和兼容迁移,配置内核只有五步:
result = {}
for source in sourcesByPriority: text = read(source) expanded = substituteVariables(text) parsed = parseJsonc(expanded) valid = decodeSchema(parsed) result = deepMerge(result, valid)
return normalizeCompatibility(result)OpenCode 的 loadConfig 承担变量替换、JSONC 解析和 Schema 校验,见 packages/opencode/src/config/config.ts:375-396;merge 在每次合并后补记 plugin 来源,见 packages/opencode/src/config/config.ts:487-511。
packages/opencode/src/config/config.ts
packages/opencode/src/config/config.ts:375-396
375 const loadConfig = Effect.fnUntraced(function* (Effect 异步工作流。376 text: string,377 options: { path: string } | { dir: string; source: string },378 ) {379 const source = "path" in options ? options.path : options.source380 const expanded = yield* Effect.promise(() =>Effect 异步工作流。381 ConfigVariable.substitute(382 "path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options },383 ),384 )385 const parsed = ConfigParse.jsonc(expanded, source)386 const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source)387 if (!("path" in options)) return data按条件进入分支。388389 yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))调用插件扩展点。390 if (!data.$schema) {按条件进入分支。391 data.$schema = "https://opencode.ai/config.json"读取运行配置。392 const updated = text.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')读取运行配置。393 yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))读写本地文件。394 }395 return data返回给上一层。396 })
packages/opencode/src/config/config.ts
packages/opencode/src/config/config.ts:487-511
487 const mergePluginOrigins = Effect.fnUntraced(function* (调用插件扩展点。488 source: string,489 // mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step490 // is attached.491 list: ConfigPlugin.Spec[] | undefined,调用插件扩展点。492 // Scope can be inferred from the source path, but some callers already know whether the config should493 // behave as global or local and can pass that explicitly.494 kind?: ConfigPlugin.Scope,调用插件扩展点。495 ) {496 if (!list?.length) return按条件进入分支。497 const hit = kind ?? (yield* pluginScopeForSource(source))等待 Effect 结果。498 // Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while499 // keeping the winning source/scope metadata for downstream installs, writes, and diagnostics.500 const plugins = ConfigPlugin.deduplicatePluginOrigins([调用插件扩展点。501 ...(result.plugin_origins ?? []),502 ...list.map((spec) => ({ spec, source, scope: hit })),503 ])504 result.plugin = plugins.map((item) => item.spec)505 result.plugin_origins = plugins506 })507508 const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {调用插件扩展点。509 result = mergeConfigConcatArrays(result, next)510 return mergePluginOrigins(source, next.plugin, kind)调用插件扩展点。511 }
“后者覆盖前者”有哪些例外
Section titled ““后者覆盖前者”有哪些例外”| 字段 | 合并行为 | 原因 |
|---|---|---|
| 普通对象字段 | mergeDeep,后来源覆盖冲突值 | 允许项目覆盖全局默认值 |
instructions | 合并、去重 | 多层指令通常需要累积 |
plugin | 按加载身份去重,并保留获胜来源 | 相对路径和安装作用域依赖来源 |
旧 tools 布尔表 | 转成 permission | 兼容旧配置,消费端只面对新模型 |
mode | 最终折叠进 agent | 兼容旧名称 |
instructions 的特殊规则在 packages/opencode/src/config/config.ts:46-58;旧字段归一化在 packages/opencode/src/config/config.ts:696-720。
packages/opencode/src/config/config.ts
packages/opencode/src/config/config.ts:46-58
46// Custom merge function that concatenates array fields instead of replacing them47// Keep remeda's deep conditional merge type out of hot config-loading paths; TS profiling showed it dominates here.48function mergeConfig(target: Info, source: Info): Info {定义一段可复用逻辑。49 return mergeDeep(target, source) as Info返回给上一层。50}5152function mergeConfigConcatArrays(target: Info, source: Info): Info {定义一段可复用逻辑。53 const merged = mergeConfig(target, source)54 if (target.instructions && source.instructions) {按条件进入分支。55 merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))56 }57 return merged返回给上一层。58}
packages/opencode/src/config/config.ts
packages/opencode/src/config/config.ts:696-720
696 for (const [name, mode] of Object.entries(result.mode ?? {})) {遍历集合。697 result.agent = mergeDeep(result.agent ?? {}, {698 [name]: {699 ...mode,700 mode: "primary" as const,701 },702 })703 }704705 if (Flag.OPENCODE_PERMISSION) {按条件进入分支。706 result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))开始解析命令参数。707 }708709 if (result.tools) {按条件进入分支。710 const perms: Record<string, ConfigPermission.Action> = {}711 for (const [tool, enabled] of Object.entries(result.tools)) {遍历集合。712 const action: ConfigPermission.Action = enabled ? "allow" : "deny"713 if (tool === "write" || tool === "edit" || tool === "patch") {按条件进入分支。714 perms.edit = action715 continue716 }717 perms[tool] = action718 }719 result.permission = mergeDeep(perms, result.permission ?? {})720 }
5. 读源码前需要分清的三个概念
Section titled “5. 读源码前需要分清的三个概念”5.1 Schema 不是默认值仓库
Section titled “5.1 Schema 不是默认值仓库”Info 描述允许出现的字段和类型,例如 agent、provider、mcp、permission,见 packages/opencode/src/config/config.ts:119-291。它主要负责边界校验和生成可理解的契约,不代表每个字段都会在解析时补齐默认值。
packages/opencode/src/config/config.ts
packages/opencode/src/config/config.ts:119-291
119export const Info = Schema.Struct({定义并校验数据形状。120 $schema: Schema.optional(Schema.String).annotate({定义并校验数据形状。121 description: "JSON schema reference for configuration validation",122 }),123 shell: Schema.optional(Schema.String).annotate({定义并校验数据形状。124 description: "Default shell to use for terminal and bash tool",处理命令执行。125 }),126 logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }),定义并校验数据形状。127 server: Schema.optional(ConfigServer.Server).annotate({定义并校验数据形状。128 description: "Server configuration for opencode serve and web commands",处理命令执行。129 }),130 command: Schema.optional(Schema.Record(Schema.String, ConfigCommand.Info)).annotate({定义并校验数据形状。131 description: "Command configuration, see https://opencode.ai/docs/commands",处理命令执行。132 }),133 skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }),定义并校验数据形状。134 reference: Schema.optional(ConfigReference.Info).annotate({定义并校验数据形状。135 description: "Named git or local directory references that can be mentioned as @alias or @alias/path",136 }),137 watcher: Schema.optional(定义并校验数据形状。138 Schema.Struct({定义并校验数据形状。139 ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),定义并校验数据形状。140 }),141 ),142 snapshot: Schema.optional(Schema.Boolean).annotate({定义并校验数据形状。143 description:144 "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",145 }),146 // User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.147 plugin: Schema.optional(Schema.mutable(Schema.Array(ConfigPlugin.Spec))),定义并校验数据形状。148 share: Schema.optional(Schema.Literals(["manual", "auto", "disabled"])).annotate({定义并校验数据形状。149 description:150 "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing",处理命令执行。151 }),152 autoshare: Schema.optional(Schema.Boolean).annotate({定义并校验数据形状。153 description: "@deprecated Use 'share' field instead. Share newly created sessions automatically",154 }),155 autoupdate: Schema.optional(Schema.Union([Schema.Boolean, Schema.Literal("notify")])).annotate({定义并校验数据形状。156 description:157 "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications",158 }),159 disabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({定义并校验数据形状。160 description: "Disable providers that are loaded automatically",选择模型或 provider。161 }),162 enabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({定义并校验数据形状。163 description: "When set, ONLY these providers will be enabled. All other providers will be ignored",选择模型或 provider。164 }),165 model: Schema.optional(ConfigModelID).annotate({定义并校验数据形状。166 description: "Model to use in the format of provider/model, eg anthropic/claude-2",选择模型或 provider。167 }),168 small_model: Schema.optional(ConfigModelID).annotate({定义并校验数据形状。169 description: "Small model to use for tasks like title generation in the format of provider/model",选择模型或 provider。170 }),171 default_agent: Schema.optional(Schema.String).annotate({定义并校验数据形状。172 description:173 "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.",174 }),175 username: Schema.optional(Schema.String).annotate({定义并校验数据形状。176 description: "Custom username to display in conversations instead of system username",177 }),178 mode: Schema.optional(定义并校验数据形状。179 Schema.StructWithRest(定义并校验数据形状。180 Schema.Struct({定义并校验数据形状。181 build: Schema.optional(ConfigAgent.Info),定义并校验数据形状。182 plan: Schema.optional(ConfigAgent.Info),定义并校验数据形状。183 }),184 [Schema.Record(Schema.String, ConfigAgent.Info)],定义并校验数据形状。185 ),186 ).annotate({ description: "@deprecated Use `agent` field instead." }),187 agent: Schema.optional(定义并校验数据形状。188 Schema.StructWithRest(定义并校验数据形状。189 Schema.Struct({定义并校验数据形状。190 // primary191 plan: Schema.optional(ConfigAgent.Info),定义并校验数据形状。192 build: Schema.optional(ConfigAgent.Info),定义并校验数据形状。193 // subagent194 general: Schema.optional(ConfigAgent.Info),定义并校验数据形状。195 explore: Schema.optional(ConfigAgent.Info),定义并校验数据形状。196 scout: Schema.optional(ConfigAgent.Info),定义并校验数据形状。197 // specialized198 title: Schema.optional(ConfigAgent.Info),定义并校验数据形状。199 summary: Schema.optional(ConfigAgent.Info),定义并校验数据形状。200 compaction: Schema.optional(ConfigAgent.Info),定义并校验数据形状。201 }),202 [Schema.Record(Schema.String, ConfigAgent.Info)],定义并校验数据形状。203 ),204 ).annotate({ description: "Agent configuration, see https://opencode.ai/docs/agents" }),205 provider: Schema.optional(Schema.Record(Schema.String, ConfigProvider.Info)).annotate({定义并校验数据形状。206 description: "Custom provider configurations and model overrides",选择模型或 provider。207 }),208 mcp: Schema.optional(定义并校验数据形状。209 Schema.Record(定义并校验数据形状。210 Schema.String,定义并校验数据形状。211 Schema.Union([定义并校验数据形状。212 ConfigMCP.Info,213 // Matches the legacy `{ enabled: false }` form used to disable a server.214 Schema.Struct({ enabled: Schema.Boolean }),定义并校验数据形状。215 ]),216 ),217 ).annotate({ description: "MCP (Model Context Protocol) server configurations" }),218 formatter: Schema.optional(ConfigFormatter.Info).annotate({定义并校验数据形状。219 description:220 "Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.",221 }),222 lsp: Schema.optional(ConfigLSP.Info).annotate({定义并校验数据形状。223 description:224 "Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.",处理语言服务诊断。225 }),226 instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({定义并校验数据形状。227 description: "Additional instruction files or patterns to include",228 }),229 layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),定义并校验数据形状。230 permission: Schema.optional(ConfigPermission.Info),定义并校验数据形状。231 tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),定义并校验数据形状。232 attachment: Schema.optional(ConfigAttachment.Info).annotate({定义并校验数据形状。233 description: "Attachment processing configuration, including image size limits and resizing behavior",234 }),235 enterprise: Schema.optional(定义并校验数据形状。236 Schema.Struct({定义并校验数据形状。237 url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),定义并校验数据形状。238 }),239 ),240 tool_output: Schema.optional(定义并校验数据形状。241 Schema.Struct({定义并校验数据形状。242 max_lines: Schema.optional(PositiveInt).annotate({定义并校验数据形状。243 description: "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)",244 }),245 max_bytes: Schema.optional(PositiveInt).annotate({定义并校验数据形状。246 description: "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)",247 }),248 }),249 ).annotate({250 description:251 "Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.",252 }),253 compaction: Schema.optional(定义并校验数据形状。254 Schema.Struct({定义并校验数据形状。255 auto: Schema.optional(Schema.Boolean).annotate({定义并校验数据形状。256 description: "Enable automatic compaction when context is full (default: true)",257 }),258 prune: Schema.optional(Schema.Boolean).annotate({定义并校验数据形状。259 description: "Enable pruning of old tool outputs (default: true)",260 }),261 tail_turns: Schema.optional(NonNegativeInt).annotate({定义并校验数据形状。262 description:263 "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)",264 }),265 preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({定义并校验数据形状。266 description: "Maximum number of tokens from recent turns to preserve verbatim after compaction",267 }),268 reserved: Schema.optional(NonNegativeInt).annotate({定义并校验数据形状。269 description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",270 }),271 }),272 ),273 experimental: Schema.optional(定义并校验数据形状。274 Schema.Struct({定义并校验数据形状。275 disable_paste_summary: Schema.optional(Schema.Boolean),定义并校验数据形状。276 batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }),定义并校验数据形状。277 openTelemetry: Schema.optional(Schema.Boolean).annotate({定义并校验数据形状。278 description: "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)",279 }),280 primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({定义并校验数据形状。281 description: "Tools that should only be available to primary agents.",282 }),283 continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({定义并校验数据形状。284 description: "Continue the agent loop when a tool call is denied",285 }),286 mcp_timeout: Schema.optional(PositiveInt).annotate({定义并校验数据形状。287 description: "Timeout in milliseconds for model context protocol (MCP) requests",288 }),289 }),290 ),291}).annotate({ identifier: "Config" })
5.2 规范化不是合并
Section titled “5.2 规范化不是合并”合并决定优先级;规范化把多种写法变成一种内部形状。例如 permission 可以写成一个动作字符串,也可以写成目标规则对象:
1{ "permission": "ask" }
会被解释成等价的:
1{ "permission": { "*": "ask" } }
证据在 packages/opencode/src/config/permission.ts:39-56。
packages/opencode/src/config/permission.ts
packages/opencode/src/config/permission.ts:39-56
39// Input the user writes in config: either a single Action (shorthand for "*")40// or an object of per-target rules.41const InputSchema = Schema.Union([Action, InputObject])定义并校验数据形状。4243// Normalise the Action shorthand into `{ "*": action }`. Object inputs pass44// through untouched.45const normalizeInput = (input: Schema.Schema.Type<typeof InputSchema>): Schema.Schema.Type<typeof InputObject> =>定义并校验数据形状。46 typeof input === "string" ? { "*": input } : input4748export const Info = InputSchema.pipe(定义并校验数据形状。49 Schema.decodeTo(InputObject, {定义并校验数据形状。50 decode: SchemaGetter.transform(normalizeInput),51 // Not perfectly invertible (we lose whether the user originally typed an52 // Action shorthand), but the object form is always a valid representation53 // of the same rules.54 encode: SchemaGetter.passthrough({ strict: false }),55 }),56).annotate({ identifier: "PermissionConfig" })
5.3 来源信息不是普通配置值
Section titled “5.3 来源信息不是普通配置值”plugin_origins 是合并过程中派生出的状态,不会作为用户配置写回。它把 plugin spec 与 source、scope 绑在一起,见 packages/opencode/src/config/config.ts:296-300 与 packages/opencode/src/config/plugin.ts:15-24。
packages/opencode/src/config/config.ts
packages/opencode/src/config/config.ts:296-300
296export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {定义并校验数据形状。297 // plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together298 // with the file and scope it came from so later runtime code can make location-sensitive decisions.299 plugin_origins?: ConfigPlugin.Origin[]调用插件扩展点。300}
packages/opencode/src/config/plugin.ts
packages/opencode/src/config/plugin.ts:15-24
15export type Scope = "global" | "local"定义数据结构约束。1617// Origin keeps the original config provenance attached to a spec.18// After multiple config files are merged, callers still need to know which file declared the plugin19// and whether it should behave like a global or project-local plugin.20export type Origin = {定义数据结构约束。21 spec: Spec22 source: string23 scope: Scope24}
6. 一条具体源码旅程:项目 agent、权限和插件怎样生效
Section titled “6. 一条具体源码旅程:项目 agent、权限和插件怎样生效”假设项目中的某个 opencode.jsonc 声明:
1{2 "agent": {3 "review": {4 "model": "anthropic/claude-sonnet",5 "steps": 8,6 "tools": { "write": false, "read": true }7 }8 },9 "plugin": ["./plugin/reviewer.ts"],10 "instructions": ["CONTRIBUTING.md"]11}
这只是教学输入;下面是源码证明的典型路径,不声称实际执行过这份配置。
第一步:找到项目配置
Section titled “第一步:找到项目配置”loadInstanceState 先合并全局和显式文件,再在未禁用项目配置时调用 ConfigPaths.files(...),逐个 loadFile,见 packages/opencode/src/config/config.ts:555-567。
packages/opencode/src/config/config.ts
packages/opencode/src/config/config.ts:555-567
555 const global = yield* getGlobal()读写本地文件。556 yield* merge(Global.Path.config, global, "global")读写本地文件。557558 if (Flag.OPENCODE_CONFIG) {按条件进入分支。559 yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))等待 Effect 结果。560 log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })561 }562563 if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {按条件进入分支。564 for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {Effect 异步工作流。565 yield* merge(file, yield* loadFile(file), "local")等待 Effect 结果。566 }567 }
这意味着项目配置处于全局配置之后,可以覆盖全局冲突值。
第二步:变量替换、解析、校验
Section titled “第二步:变量替换、解析、校验”loadFile 读到文本后进入 loadConfig:
ConfigVariable.substitute -> ConfigParse.jsonc -> ConfigParse.schema(Info, ...)对应 packages/opencode/src/config/config.ts:375-403。顺序很重要:如果变量替换后的结果破坏了 JSONC 或类型,错误应在配置边界暴露,而不是等到 provider/tool 使用时才爆炸。
packages/opencode/src/config/config.ts
packages/opencode/src/config/config.ts:375-403
375 const loadConfig = Effect.fnUntraced(function* (Effect 异步工作流。376 text: string,377 options: { path: string } | { dir: string; source: string },378 ) {379 const source = "path" in options ? options.path : options.source380 const expanded = yield* Effect.promise(() =>Effect 异步工作流。381 ConfigVariable.substitute(382 "path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options },383 ),384 )385 const parsed = ConfigParse.jsonc(expanded, source)386 const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source)387 if (!("path" in options)) return data按条件进入分支。388389 yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))调用插件扩展点。390 if (!data.$schema) {按条件进入分支。391 data.$schema = "https://opencode.ai/config.json"读取运行配置。392 const updated = text.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')读取运行配置。393 yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))读写本地文件。394 }395 return data返回给上一层。396 })397398 const loadFile = Effect.fnUntraced(function* (filepath: string) {Effect 异步工作流。399 log.info("loading", { path: filepath })400 const text = yield* readConfigFile(filepath)等待 Effect 结果。401 if (!text) return {} as Info按条件进入分支。402 return yield* loadConfig(text, { path: filepath })等待 Effect 结果。403 })
第三步:相对 plugin 路径立刻绑定声明文件
Section titled “第三步:相对 plugin 路径立刻绑定声明文件”在仍然知道配置文件路径时,resolveLoadedPlugins 调用 ConfigPlugin.resolvePluginSpec,见 packages/opencode/src/config/config.ts:102-109。
packages/opencode/src/config/config.ts
packages/opencode/src/config/config.ts:102-109
102async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(config: T, filepath: string) {调用插件扩展点。103 if (!config.plugin) return config读取运行配置。104 for (let i = 0; i < config.plugin.length; i++) {读取运行配置。105 // Normalize path-like plugin specs while we still know which config file declared them.106 // This prevents `./plugin.ts` from being reinterpreted relative to some later merge location.107 config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], filepath)读取运行配置。108 }109 return config返回给上一层。
./plugin/reviewer.ts 会相对“声明它的配置文件”解析为 file URL,而不是相对最终工作目录猜测,见 packages/opencode/src/config/plugin.ts:48-64。
packages/opencode/src/config/plugin.ts
packages/opencode/src/config/plugin.ts:48-64
48// Path-like specs are resolved relative to the config file that declared them so merges later on do not49// accidentally reinterpret `./plugin.ts` relative to some other directory.50export async function resolvePluginSpec(plugin: Spec, configFilepath: string): Promise<Spec> {调用插件扩展点。51 const spec = pluginSpecifier(plugin)52 if (!isPathPluginSpec(spec)) return plugin调用插件扩展点。5354 const base = path.dirname(configFilepath)55 const file = (() => {56 if (spec.startsWith("file://")) return spec按条件进入分支。57 if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href按条件进入分支。58 return pathToFileURL(path.resolve(base, spec)).href返回给上一层。59 })()6061 const resolved = await resolvePathPluginTarget(file).catch(() => file)调用插件扩展点。6263 if (Array.isArray(plugin)) return [resolved, plugin[1]]按条件进入分支。64 return resolved返回给上一层。
这是本章最值得迁移的设计:位置敏感的值要在来源上下文尚未丢失时解析。
第四步:合并值,同时保留插件 provenance
Section titled “第四步:合并值,同时保留插件 provenance”普通字段走深度合并;instructions 累积去重。插件则附上来源和 global/local scope,再按插件加载身份去重,后出现的声明获胜,见:
-
packages/opencode/src/config/config.ts:487-511packages/opencode/src/config/config.ts
packages/opencode/src/config/config.ts:487-511487 const mergePluginOrigins = Effect.fnUntraced(function* (调用插件扩展点。488 source: string,489 // mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step490 // is attached.491 list: ConfigPlugin.Spec[] | undefined,调用插件扩展点。492 // Scope can be inferred from the source path, but some callers already know whether the config should493 // behave as global or local and can pass that explicitly.494 kind?: ConfigPlugin.Scope,调用插件扩展点。495 ) {496 if (!list?.length) return按条件进入分支。497 const hit = kind ?? (yield* pluginScopeForSource(source))等待 Effect 结果。498 // Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while499 // keeping the winning source/scope metadata for downstream installs, writes, and diagnostics.500 const plugins = ConfigPlugin.deduplicatePluginOrigins([调用插件扩展点。501 ...(result.plugin_origins ?? []),502 ...list.map((spec) => ({ spec, source, scope: hit })),503 ])504 result.plugin = plugins.map((item) => item.spec)505 result.plugin_origins = plugins506 })507508 const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {调用插件扩展点。509 result = mergeConfigConcatArrays(result, next)510 return mergePluginOrigins(source, next.plugin, kind)调用插件扩展点。511 } -
packages/opencode/src/config/plugin.ts:67-81packages/opencode/src/config/plugin.ts
packages/opencode/src/config/plugin.ts:67-8167// Dedupe on the load identity (package name for npm specs, exact file URL for local specs), but keep the68// full Origin so downstream code still knows which config file won and where follow-up writes should go.69export function deduplicatePluginOrigins(plugins: Origin[]): Origin[] {调用插件扩展点。70 const seen = new Set<string>()71 const list: Origin[] = []7273 for (const plugin of plugins.toReversed()) {遍历集合。74 const spec = pluginSpecifier(plugin.spec)75 const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg调用插件扩展点。76 if (seen.has(name)) continue按条件进入分支。77 seen.add(name)78 list.push(plugin)79 }8081 return list.toReversed()返回给上一层。
如果只保留最终字符串,后续代码就无法判断插件应在哪个目录安装、错误该指向哪个文件。
第五步:agent 配置收敛为统一内部形状
Section titled “第五步:agent 配置收敛为统一内部形状”ConfigAgent.Info 接受 model、prompt、steps、permission 等字段。它还把:
- 未知的 provider 相关键收进
options; - 旧
tools布尔表翻译成permission; maxSteps合并到steps。
对应 packages/opencode/src/config/agent.ts:20-102。例子里的 write: false 会折叠到 permission.edit = "deny",因为 write/edit/patch 被视为同一编辑权限族,见 packages/opencode/src/config/agent.ts:82-94。
packages/opencode/src/config/agent.ts
packages/opencode/src/config/agent.ts:20-102
20const AgentSchema = Schema.StructWithRest(定义并校验数据形状。21 Schema.Struct({定义并校验数据形状。22 model: Schema.optional(ConfigModelID),定义并校验数据形状。23 variant: Schema.optional(Schema.String).annotate({定义并校验数据形状。24 description: "Default model variant for this agent (applies only when using the agent's configured model).",25 }),26 temperature: Schema.optional(Schema.Finite),定义并校验数据形状。27 top_p: Schema.optional(Schema.Finite),定义并校验数据形状。28 prompt: Schema.optional(Schema.String),定义并校验数据形状。29 tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({定义并校验数据形状。30 description: "@deprecated Use 'permission' field instead",31 }),32 disable: Schema.optional(Schema.Boolean),定义并校验数据形状。33 description: Schema.optional(Schema.String).annotate({ description: "Description of when to use the agent" }),定义并校验数据形状。34 mode: Schema.optional(Schema.Literals(["subagent", "primary", "all"])),定义并校验数据形状。35 hidden: Schema.optional(Schema.Boolean).annotate({定义并校验数据形状。36 description: "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)",37 }),38 options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),定义并校验数据形状。39 color: Schema.optional(Color).annotate({定义并校验数据形状。40 description: "Hex color code (e.g., #FF5733) or theme color (e.g., primary)",41 }),42 steps: Schema.optional(PositiveInt).annotate({定义并校验数据形状。43 description: "Maximum number of agentic iterations before forcing text-only response",44 }),45 maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }),定义并校验数据形状。46 permission: Schema.optional(ConfigPermission.Info),定义并校验数据形状。47 }),48 [Schema.Record(Schema.String, Schema.Any)],定义并校验数据形状。49)5051const KNOWN_KEYS = new Set([52 "name",53 "model",54 "variant",55 "prompt",56 "description",57 "temperature",58 "top_p",59 "mode",60 "hidden",61 "color",62 "steps",63 "maxSteps",64 "options",65 "permission",66 "disable",67 "tools",68])6970// Post-parse normalisation:71// - Promote any unknown-but-present keys into `options` so they survive the72// round-trip in a well-known field.73// - Translate the deprecated `tools: { name: boolean }` map into the new74// `permission` shape (write-adjacent tools collapse into `permission.edit`).75// - Coalesce `steps ?? maxSteps` so downstream can ignore the deprecated alias.76const normalize = (agent: Schema.Schema.Type<typeof AgentSchema>): Schema.Schema.Type<typeof AgentSchema> => {定义并校验数据形状。77 const options: Record<string, unknown> = { ...agent.options }78 for (const [key, value] of Object.entries(agent)) {遍历集合。79 if (!KNOWN_KEYS.has(key)) options[key] = value按条件进入分支。80 }8182 const permission: ConfigPermission.Info = {}83 for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {遍历集合。84 const action = enabled ? "allow" : "deny"85 if (tool === "write" || tool === "edit" || tool === "patch") {按条件进入分支。86 permission.edit = action87 continue88 }89 permission[tool] = action90 }91 globalThis.Object.assign(permission, agent.permission)9293 const steps = agent.steps ?? agent.maxSteps94 return { ...agent, options, permission, ...(steps !== undefined ? { steps } : {}) }返回给上一层。95}9697export const Info = AgentSchema.pipe(定义并校验数据形状。98 Schema.decodeTo(AgentSchema, {定义并校验数据形状。99 decode: SchemaGetter.transform(normalize),100 encode: SchemaGetter.passthrough({ strict: false }),101 }),102).annotate({ identifier: "AgentConfig" })
packages/opencode/src/config/agent.ts
packages/opencode/src/config/agent.ts:82-94
82 const permission: ConfigPermission.Info = {}83 for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {遍历集合。84 const action = enabled ? "allow" : "deny"85 if (tool === "write" || tool === "edit" || tool === "patch") {按条件进入分支。86 permission.edit = action87 continue88 }89 permission[tool] = action90 }91 globalThis.Object.assign(permission, agent.permission)9293 const steps = agent.steps ?? agent.maxSteps94 return { ...agent, options, permission, ...(steps !== undefined ? { steps } : {}) }返回给上一层。
第六步:配置先于其他实例服务物化
Section titled “第六步:配置先于其他实例服务物化”项目 bootstrap 明确先 config.get(),再初始化 plugin;plugin 可能修改配置,所以又必须早于其他服务,见 packages/opencode/src/project/bootstrap.ts:38-51。
packages/opencode/src/project/bootstrap.ts
packages/opencode/src/project/bootstrap.ts:38-51
38 const run = Effect.gen(function* () {Effect 异步工作流。39 const ctx = yield* InstanceState.context等待 Effect 结果。40 yield* Effect.logInfo("bootstrapping").pipe(Effect.annotateLogs("directory", ctx.directory))Effect 异步工作流。41 // everything depends on config so eager load it for nice traces42 yield* config.get()读取运行配置。43 // Plugin can mutate config so it has to be initialized before anything else.44 yield* plugin.init()等待 Effect 结果。45 // Each service self-manages its own slow work via Effect.forkScoped against46 // its per-instance state scope. We just await materialization here.47 yield* Effect.forEach(Effect 异步工作流。48 [reference, lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],49 (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),Effect 异步工作流。50 { concurrency: "unbounded", discard: true },51 ).pipe(Effect.withSpan("InstanceBootstrap.init"))Effect 异步工作流。
config.get() -> plugin.init() -> reference / lsp / format / file / watcher / vcs / snapshot / project配置因此不是“任意时刻读磁盘”,而是实例启动阶段的有序依赖。
7. 关键分支与失败边界
Section titled “7. 关键分支与失败边界”7.1 远程配置失败是硬失败还是软失败
Section titled “7.1 远程配置失败是硬失败还是软失败”well-known 远程配置请求非 2xx 时直接抛错,见 packages/opencode/src/config/config.ts:513-551。而活动组织配置的读取被 catch 后记录 debug 并继续,见 packages/opencode/src/config/config.ts:637-673。
packages/opencode/src/config/config.ts
packages/opencode/src/config/config.ts:513-551
513 for (const [key, value] of Object.entries(auth)) {遍历集合。514 if (value.type === "wellknown") {按条件进入分支。515 const url = key.replace(/\/+$/, "")516 process.env[value.key] = value.token517 log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })518 const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))Effect 异步工作流。519 if (!response.ok) {按条件进入分支。520 throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)失败时抛出错误。521 }522 const wellknown = (yield* Effect.promise(() => response.json())) as {Effect 异步工作流。523 config?: Record<string, unknown>524 remote_config?: unknown525 }526 const remote = yield* Effect.promise(() =>Effect 异步工作流。527 substituteWellKnownRemoteConfig({528 value: wellknown.remote_config,529 dir: url,530 source: `${url}/.well-known/opencode`,531 }),532 )533 const fetchedConfig = remote534 ? ((yield* Effect.promise(async () => {Effect 异步工作流。535 log.debug("fetching remote config", { url: remote.url })536 const response = await fetch(remote.url, { headers: remote.headers })537 if (!response.ok)按条件进入分支。538 throw new Error(`failed to fetch remote config from ${remote.url}: ${response.status}`)失败时抛出错误。539 const data = await response.json()540 return isRecord(data) && isRecord(data.config) ? data.config : data返回给上一层。541 })) as Record<string, unknown>)542 : {}543 const remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig as Info)544 if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"读取运行配置。545 const source = `${url}/.well-known/opencode`546 const next = yield* loadConfig(JSON.stringify(remoteConfig), {等待 Effect 结果。547 dir: path.dirname(source),548 source,549 })550 yield* merge(source, next, "global")等待 Effect 结果。551 log.debug("loaded remote config from well-known", { url })
packages/opencode/src/config/config.ts
packages/opencode/src/config/config.ts:637-673
637 const activeAccount = Option.getOrUndefined(638 yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))),Effect 异步工作流。639 )640 if (activeAccount?.active_org_id) {按条件进入分支。641 const accountID = activeAccount.id642 const orgID = activeAccount.active_org_id643 const url = activeAccount.url644 yield* Effect.gen(function* () {Effect 异步工作流。645 const [configOpt, tokenOpt] = yield* Effect.all(Effect 异步工作流。646 [accountSvc.config(accountID, orgID), accountSvc.token(accountID)],647 { concurrency: 2 },648 )649 if (Option.isSome(tokenOpt)) {按条件进入分支。650 process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value651 yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)等待 Effect 结果。652 }653654 if (Option.isSome(configOpt)) {按条件进入分支。655 const source = `${url}/api/config`656 const next = yield* loadConfig(JSON.stringify(configOpt.value), {等待 Effect 结果。657 dir: path.dirname(source),658 source,659 })660 for (const providerID of Object.keys(next.provider ?? {})) {选择模型或 provider。661 consoleManagedProviders.add(providerID)选择模型或 provider。662 }663 yield* merge(source, next, "global")等待 Effect 结果。664 }665 }).pipe(666 Effect.withSpan("Config.loadActiveOrgConfig"),Effect 异步工作流。667 Effect.catch((err) => {Effect 异步工作流。668 log.debug("failed to fetch remote account config", {669 error: err instanceof Error ? err.message : String(err),670 })671 return Effect.voidEffect 异步工作流。672 }),673 )
这说明“远程配置”不是一个统一策略;其可靠性语义取决于来源。不要从一条路径推断全部远程来源。
7.2 全局配置使用无限 TTL 缓存
Section titled “7.2 全局配置使用无限 TTL 缓存”loadGlobal 被 cachedInvalidateWithTTL(..., Duration.infinity) 包装,更新全局配置后显式 invalidate,见 packages/opencode/src/config/config.ts:440-452、786-808。
packages/opencode/src/config/config.ts
packages/opencode/src/config/config.ts:440-452
440 const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(读写本地文件。441 loadGlobal().pipe(读写本地文件。442 Effect.tapError((error) =>Effect 异步工作流。443 Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),Effect 异步工作流。444 ),445 Effect.orElseSucceed((): Info => ({})),Effect 异步工作流。446 ),447 Duration.infinity,448 )449450 const getGlobal = Effect.fn("Config.getGlobal")(function* () {读写本地文件。451 return yield* cachedGlobal读写本地文件。452 })
取舍是:频繁读取便宜,但所有写入口必须维护失效规则。
7.3 MDM 托管配置最后覆盖
Section titled “7.3 MDM 托管配置最后覆盖”managed 目录和 macOS managed preferences 在本地、项目、内联与组织配置之后合并;源码注释明确说明 MDM “override everything”,见 packages/opencode/src/config/config.ts:676-694。
packages/opencode/src/config/config.ts
packages/opencode/src/config/config.ts:676-694
676 const managedDir = ConfigManaged.managedConfigDir()677 if (existsSync(managedDir)) {按条件进入分支。678 for (const file of ["opencode.json", "opencode.jsonc"]) {遍历集合。679 const source = path.join(managedDir, file)680 yield* merge(source, yield* loadFile(source), "global")等待 Effect 结果。681 }682 }683684 // macOS managed preferences (.mobileconfig deployed via MDM) override everything685 const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences())Effect 异步工作流。686 if (managed) {按条件进入分支。687 result = mergeConfigConcatArrays(688 result,689 yield* loadConfig(managed.text, {等待 Effect 结果。690 dir: path.dirname(managed.source),691 source: managed.source,692 }),693 )694 }
这是组织策略优先于个人偏好的产品选择,不是通用配置系统都必须如此。
7.4 配置目录会触发依赖准备
Section titled “7.4 配置目录会触发依赖准备”对每个配置目录,OpenCode 会确保 .gitignore,后台安装 @opencode-ai/plugin,并收集 fiber;插件真正加载前可以等待这些依赖,见 packages/opencode/src/config/config.ts:573-625、767-770。
packages/opencode/src/config/config.ts
packages/opencode/src/config/config.ts:573-625
573 const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree)等待 Effect 结果。574575 if (Flag.OPENCODE_CONFIG_DIR) {按条件进入分支。576 log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })577 }578579 const deps: Fiber.Fiber<void, never>[] = []580581 for (const dir of directories) {遍历集合。582 if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {按条件进入分支。583 for (const file of ["opencode.json", "opencode.jsonc"]) {遍历集合。584 const source = path.join(dir, file)585 log.debug(`loading config from ${source}`)586 yield* merge(source, yield* loadFile(source))等待 Effect 结果。587 result.agent ??= {}588 result.mode ??= {}589 result.plugin ??= []590 }591 }592593 yield* ensureGitignore(dir).pipe(Effect.orDie)Effect 异步工作流。594595 const dep = yield* npmSvc等待 Effect 结果。596 .install(dir, {597 add: [598 {599 name: "@opencode-ai/plugin",600 version: InstallationLocal ? undefined : InstallationVersion,601 },602 ],603 })604 .pipe(605 Effect.exit,Effect 异步工作流。606 Effect.tap((exit) =>Effect 异步工作流。607 Exit.isFailure(exit)608 ? Effect.sync(() => {Effect 异步工作流。609 log.warn("background dependency install failed", { dir, error: String(exit.cause) })610 })611 : Effect.void,Effect 异步工作流。612 ),613 Effect.asVoid,Effect 异步工作流。614 Effect.forkDetach,Effect 异步工作流。615 )616 deps.push(dep)617618 result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))处理命令执行。619 result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir)))Effect 异步工作流。620 result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir)))Effect 异步工作流。621 // Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load622 // returns normalized Specs and we only need to attach origin metadata here.623 const list = yield* Effect.promise(() => ConfigPlugin.load(dir))调用插件扩展点。624 yield* mergePluginOrigins(dir, list)调用插件扩展点。625 }
所以配置加载包含副作用。测试这部分时,不能只断言最终 JSON,还要覆盖依赖准备与失败降级。
8. OpenCode 的选择:替代方案与取舍
Section titled “8. OpenCode 的选择:替代方案与取舍”| 选择 | 好处 | 代价 |
|---|---|---|
| 多来源深度合并 | 全局默认与项目定制可组合 | 优先级不透明时难排错 |
| Schema 边界校验 | 错误更早、SDK/文档契约更清晰 | 兼容旧字段需要额外 normalize |
| 保留 plugin provenance | 相对路径、作用域、诊断都可靠 | 最终状态不再只是纯 JSON |
| 实例级缓存 | 消费端读取稳定且便宜 | 更新后必须正确 invalidate |
| plugin 先于其他服务初始化 | 插件可参与配置和能力装配 | bootstrap 顺序形成真实耦合 |
Java 类比可以暂时把它看成 Spring Environment + PropertySource + Binder,但类比到此为止:OpenCode 还会扫描 Markdown agent、解析本地 plugin file URL,并在 Effect 实例状态中管理缓存与依赖 fiber。
9. 可以带走的方法
Section titled “9. 可以带走的方法”方法一:把优先级写成可执行顺序
Section titled “方法一:把优先级写成可执行顺序”不要只在 README 写“项目覆盖全局”。让加载代码按优先级从低到高排列,并用冲突用例验证。
验证问题:同一字段分别出现在全局、项目、内联和托管配置时,测试能否指出最终值及获胜来源?
方法二:在来源消失前解析位置敏感值
Section titled “方法二:在来源消失前解析位置敏感值”相对路径、密钥引用、include 文件都依赖声明位置。读取文件后立刻绝对化或附上 provenance,不要等合并结束再猜。
验证问题:从另一个工作目录启动时,./plugin.ts 是否仍指向声明文件旁边?
方法三:兼容逻辑集中在边界
Section titled “方法三:兼容逻辑集中在边界”旧 tools、maxSteps、mode 在配置层收敛,消费端只读一种内部模型。
验证问题:删除兼容别名后,agent/tool 消费代码是否完全不需要改?如果需要,边界还没有收干净。
10. 费曼复述与练习
Section titled “10. 费曼复述与练习”先不用源码,用 90 秒回答:
- 为什么配置系统不是
JSON.parse? - 为什么插件列表不能像普通字符串数组一样合并?
- 为什么 MDM 配置放在最后?
Config.get()与每次工具调用都读配置文件,有什么不同?
再做三步练习:
-
入门:画出全局、项目、内联、托管四层的覆盖箭头。
-
进阶:为你的 mini agent 写
load -> substitute -> validate -> merge -> normalize伪代码。 -
源码追踪:从
packages/opencode/src/config/config.ts:563读到packages/opencode/src/config/agent.ts:94,说明旧tools.write=false如何变成编辑拒绝规则。packages/opencode/src/config/config.ts
packages/opencode/src/config/config.ts:563563 if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {按条件进入分支。packages/opencode/src/config/agent.ts
packages/opencode/src/config/agent.ts:9494 return { ...agent, options, permission, ...(steps !== undefined ? { steps } : {}) }返回给上一层。
如果你只能说“后面的覆盖前面的”,请回到第 4、6 节:你还漏掉了数组策略、provenance 和兼容归一化。
最后复盘:配置决定有哪些 OpenCode
Section titled “最后复盘:配置决定有哪些 OpenCode”这一章的最小结论是:
多个来源 -> 替换与校验 -> 有字段语义的合并 -> 兼容归一化 -> 实例级 Config.Info -> agent/provider/tool/plugin 消费配置层解决了“同一套 runtime 如何变成不同产品形态”。但配置完成后,用户到底从 CLI、TUI、Desktop 或 IDE 看到同一个 runtime?下一章会沿着一个 IDE 文件引用,追踪多种界面怎样共享后端而不复制 agent loop。