跳转到内容

配置系统

旧版 eec0843c 源码提交 eec0843ce422
状态已完成
难度较难
预计阅读45 分钟
  • 章节 ID:10-config-system
  • 章节摘要:沿一次项目配置的合并路径,理解远程、全局、项目、内联与托管来源如何经过替换、校验、规范化和 provenance 保留,最终影响 agent、permission 与 plugin。
  • 教程版本:eec0843c
  • 源码基线:eec0843ce42298080569ca31a6455bc3f699d213
  • 章节元数据:/versions/eec0843c/data/chapters.json
  • 源码映射:/versions/eec0843c/data/source-map.json
  • packages/opencode/src/config/config.ts
  • packages/opencode/src/config/agent.ts
  • packages/opencode/src/config/permission.ts
  • packages/opencode/src/config/plugin.ts
  • packages/opencode/src/project/bootstrap.ts

源码基线:eec0843ce422。本章中的流程是根据源码整理出的典型路径,不是一次真实运行日志。

读完本章,你应该能:

  • 画出远程、全局、项目、配置目录、内联内容和托管配置的合并顺序。
  • 解释为什么“后加载者覆盖先加载者”还不足以描述 OpenCode 的配置语义。
  • 沿着一个项目配置,追到 agentpermissionplugin 的规范化结果。
  • 区分配置文件的来源、最终值和运行时初始化这三件事。
  • 为自己的 agent 设计一个可验证、可追溯的配置管线。

OpenCode 配置系统不是“读一个 JSON”,而是把多个来源按明确顺序加载、替换变量、校验、规范化和深度合并,最后以实例级状态提供给 agent、provider、tool 与 plugin。

本章的中心问题是:当两个地方都配置了同一个 agent 或权限时,运行时究竟相信谁,又怎样保留足够的来源信息?

前面几章已经讲过 tool、permission 和 provider。但如果你只读这些模块,会误以为它们的行为都写死在源码中。实际上:

  • modelprovider 决定模型入口;
  • agent 决定提示词、模型、步数和局部权限;
  • permission 决定工具是否直接执行、询问或拒绝;
  • pluginmcplspformatter 决定运行时还会接入哪些能力。

因此,配置系统像一张控制面:它不执行 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 结果。

这里有三条边界:

  1. 来源层回答“值从哪里来”。
  2. 解析层回答“这个值是否合法、如何统一形状”。
  3. 消费层回答“agent/tool/plugin 如何使用最终值”。

不要把三层混成一个巨大的 loadConfig()

先忽略插件来源、缓存和兼容迁移,配置内核只有五步:

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-396merge 在每次合并后补记 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        }
字段合并行为原因
普通对象字段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. 读源码前需要分清的三个概念”

Info 描述允许出现的字段和类型,例如 agentprovidermcppermission,见 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" })

合并决定优先级;规范化把多种写法变成一种内部形状。例如 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" })

plugin_origins 是合并过程中派生出的状态,不会作为用户配置写回。它把 plugin spec 与 sourcescope 绑在一起,见 packages/opencode/src/config/config.ts:296-300packages/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}

这只是教学输入;下面是源码证明的典型路径,不声称实际执行过这份配置。

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-511

    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        }
  • packages/opencode/src/config/plugin.ts:67-81

    packages/opencode/src/config/plugin.ts packages/opencode/src/config/plugin.ts:67-81
    67// 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 接受 modelpromptstepspermission 等字段。它还把:

  • 未知的 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.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          )

这说明“远程配置”不是一个统一策略;其可靠性语义取决于来源。不要从一条路径推断全部远程来源。

loadGlobalcachedInvalidateWithTTL(..., Duration.infinity) 包装,更新全局配置后显式 invalidate,见 packages/opencode/src/config/config.ts:440-452786-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    })

取舍是:频繁读取便宜,但所有写入口必须维护失效规则。

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        }

这是组织策略优先于个人偏好的产品选择,不是通用配置系统都必须如此。

对每个配置目录,OpenCode 会确保 .gitignore,后台安装 @opencode-ai/plugin,并收集 fiber;插件真正加载前可以等待这些依赖,见 packages/opencode/src/config/config.ts:573-625767-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。

方法一:把优先级写成可执行顺序

Section titled “方法一:把优先级写成可执行顺序”

不要只在 README 写“项目覆盖全局”。让加载代码按优先级从低到高排列,并用冲突用例验证。

验证问题:同一字段分别出现在全局、项目、内联和托管配置时,测试能否指出最终值及获胜来源?

方法二:在来源消失前解析位置敏感值

Section titled “方法二:在来源消失前解析位置敏感值”

相对路径、密钥引用、include 文件都依赖声明位置。读取文件后立刻绝对化或附上 provenance,不要等合并结束再猜。

验证问题:从另一个工作目录启动时,./plugin.ts 是否仍指向声明文件旁边?

toolsmaxStepsmode 在配置层收敛,消费端只读一种内部模型。

验证问题:删除兼容别名后,agent/tool 消费代码是否完全不需要改?如果需要,边界还没有收干净。

先不用源码,用 90 秒回答:

  1. 为什么配置系统不是 JSON.parse
  2. 为什么插件列表不能像普通字符串数组一样合并?
  3. 为什么 MDM 配置放在最后?
  4. 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:563
    563        if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {按条件进入分支。
    packages/opencode/src/config/agent.ts packages/opencode/src/config/agent.ts:94
    94  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。