diff --git a/packages/core/src/config/config.ts b/packages/core/src/config.ts similarity index 55% rename from packages/core/src/config/config.ts rename to packages/core/src/config.ts index 2afa9a91ba..77c8a98e27 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config.ts @@ -3,18 +3,74 @@ export * as Config from "./config" import path from "path" import { type ParseError, parse } from "jsonc-parser" import { Context, Effect, Layer, Option, Schema } from "effect" -import { AppFileSystem } from "../filesystem" -import { Global } from "../global" -import { Location } from "../location" -import { Policy } from "../policy" -import { AbsolutePath } from "../schema" -import { ConfigV2 } from "./schema" +import { AppFileSystem } from "./filesystem" +import { Global } from "./global" +import { Location } from "./location" +import { Policy } from "./policy" +import { AbsolutePath } from "./schema" +import { ConfigExperimental } from "./config/experimental" +import { ConfigPlugin } from "./config/plugin" +import { ConfigProvider } from "./config/provider" +import { ConfigReference } from "./config/reference" +import { ConfigWatcher } from "./config/watcher" + +export class Info extends Schema.Class("Config.Info")({ + $schema: Schema.optional(Schema.String).annotate({ + description: "JSON schema reference for configuration validation", + }), + shell: Schema.String.pipe(Schema.optional).annotate({ + description: "Default shell to use for terminal and shell tool execution", + }), + model: Schema.String.pipe(Schema.optional).annotate({ + description: "Default model to use when no session or agent model is selected", + }), + autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")]).pipe(Schema.optional).annotate({ + description: "Automatically update or notify when a new version is available", + }), + snapshots: Schema.Boolean.pipe(Schema.optional).annotate({ + description: "Enable snapshots used for undo and revert behavior", + }), + watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({ + description: "Filesystem watcher configuration", + }), + skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({ + description: "Additional paths or URLs to discover skills from", + }), + instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({ + description: "Additional paths or URLs supplying ambient instructions", + }), + references: ConfigReference.Info.pipe(Schema.optional).annotate({ + description: "Named local directories or Git repositories available as external context", + }), + plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({ + description: "Ordered external plugin packages to load", + }), + experimental: ConfigExperimental.Experimental.pipe(Schema.optional), + providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), +}) {} + +export class FileSource extends Schema.Class("Config.FileSource")({ + type: Schema.Literal("file"), + path: Schema.String, +}) {} + +export class MemorySource extends Schema.Class("Config.MemorySource")({ + type: Schema.Literal("memory"), +}) {} + +export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type")) +export type Source = typeof Source.Type + +export class Loaded extends Schema.Class("Config.Loaded")({ + source: Source, + info: Info, +}) {} export interface Interface { /** Returns supplemental config directories from lowest to highest priority. */ readonly directories: () => Effect.Effect /** Loads location config files from lowest to highest priority. */ - readonly get: () => Effect.Effect + readonly get: () => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Config") {} @@ -39,15 +95,15 @@ export const layer = Layer.effect( // Accept legacy fields while v2 is migrated incrementally; recognized // fields still have to satisfy the v2 schema. const info = Option.getOrUndefined( - Schema.decodeUnknownOption(ConfigV2.Info)(input, { errors: "all", onExcessProperty: "ignore" }), + Schema.decodeUnknownOption(Info)(input, { errors: "all", onExcessProperty: "ignore" }), ) if (!info) return - return new ConfigV2.Loaded({ source: new ConfigV2.FileSource({ type: "file", path: filepath }), info }) + return new Loaded({ source: new FileSource({ type: "file", path: filepath }), info }) }) const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) { return yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe( - Effect.map((configs) => configs.filter((config): config is ConfigV2.Loaded => config !== undefined)), + Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)), ) }) @@ -74,7 +130,7 @@ export const layer = Layer.effect( .pipe(Effect.orDie)).toReversed() const direct = yield* Effect.forEach(directPaths, loadFile).pipe( Effect.orDie, - Effect.map((configs) => configs.filter((config): config is ConfigV2.Loaded => config !== undefined)), + Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)), ) const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) // Apply general settings first and more specific settings last: diff --git a/packages/core/src/config/experimental.ts b/packages/core/src/config/experimental.ts new file mode 100644 index 0000000000..377ed26ade --- /dev/null +++ b/packages/core/src/config/experimental.ts @@ -0,0 +1,18 @@ +export * as ConfigExperimental from "./experimental" + +import { Schema } from "effect" +import { Catalog } from "../catalog" +import { Policy as PolicyV2 } from "../policy" + +// Each core domain exports the policy actions it supports. Adding an action to +// this union makes it valid in authored config while keeping Policy generic. +export const PolicyAction = Schema.Union([Catalog.PolicyActions]) + +export class Policy extends Schema.Class("Config.Experimental.Policy")({ + ...PolicyV2.Info.fields, + action: PolicyAction, +}) {} + +export class Experimental extends Schema.Class("Config.Experimental")({ + policies: Policy.pipe(Schema.Array, Schema.optional), +}) {} diff --git a/packages/core/src/config/plugin.ts b/packages/core/src/config/plugin.ts new file mode 100644 index 0000000000..4268a7f79d --- /dev/null +++ b/packages/core/src/config/plugin.ts @@ -0,0 +1,13 @@ +export * as ConfigPlugin from "./plugin" + +import { Schema } from "effect" + +export class Entry extends Schema.Class("Config.Plugin.Entry")({ + package: Schema.String, + options: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), +}) {} + +export const Plugin = Schema.Union([Schema.String, Entry]) +export type Plugin = typeof Plugin.Type + +export const Plugins = Plugin.pipe(Schema.Array) diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts index 47d839302c..d4c6d0848e 100644 --- a/packages/core/src/config/provider.ts +++ b/packages/core/src/config/provider.ts @@ -2,7 +2,7 @@ export * as ConfigProvider from "./provider" import { Effect, Schema } from "effect" import { Catalog } from "../catalog" -import { Config } from "./config" +import { Config } from "../config" import { ProviderV2 } from "../provider" import { ModelV2 } from "../model" import { PluginV2 } from "../plugin" diff --git a/packages/core/src/config/reference.ts b/packages/core/src/config/reference.ts new file mode 100644 index 0000000000..55878bfab0 --- /dev/null +++ b/packages/core/src/config/reference.ts @@ -0,0 +1,17 @@ +export * as ConfigReference from "./reference" + +import { Schema } from "effect" + +export class Git extends Schema.Class("Config.Reference.Git")({ + repository: Schema.String, + branch: Schema.String.pipe(Schema.optional), +}) {} + +export class Local extends Schema.Class("Config.Reference.Local")({ + path: Schema.String, +}) {} + +export const Entry = Schema.Union([Schema.String, Git, Local]) +export type Entry = typeof Entry.Type + +export const Info = Schema.Record(Schema.String, Entry) diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts deleted file mode 100644 index e69f6161bb..0000000000 --- a/packages/core/src/config/schema.ts +++ /dev/null @@ -1,47 +0,0 @@ -export * as ConfigV2 from "./schema" - -import { Schema } from "effect" -import { Catalog } from "../catalog" -import { Policy as PolicyV2 } from "../policy" -import { ConfigProvider } from "./provider" - -// Each core domain exports the policy actions it supports. Adding an action to -// this union makes it valid in authored config while keeping Policy generic. -export const PolicyAction = Schema.Union([Catalog.PolicyActions]) - -export class Policy extends Schema.Class("ConfigV2.Policy")({ - ...PolicyV2.Info.fields, - action: PolicyAction, -}) {} - -export class Experimental extends Schema.Class("ConfigV2.Experimental")({ - policies: Policy.pipe(Schema.Array, Schema.optional), -}) {} - -export class Info extends Schema.Class("ConfigV2.Info")({ - $schema: Schema.optional(Schema.String).annotate({ - description: "JSON schema reference for configuration validation", - }), - shell: Schema.String.pipe(Schema.optional).annotate({ - description: "Default shell to use for terminal and shell tool execution", - }), - experimental: Experimental.pipe(Schema.optional), - providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), -}) {} - -export class FileSource extends Schema.Class("ConfigV2.FileSource")({ - type: Schema.Literal("file"), - path: Schema.String, -}) {} - -export class MemorySource extends Schema.Class("ConfigV2.MemorySource")({ - type: Schema.Literal("memory"), -}) {} - -export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type")) -export type Source = typeof Source.Type - -export class Loaded extends Schema.Class("ConfigV2.Loaded")({ - source: Source, - info: Info, -}) {} diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts new file mode 100644 index 0000000000..be5c91a9bf --- /dev/null +++ b/packages/core/src/config/watcher.ts @@ -0,0 +1,7 @@ +export * as ConfigWatcher from "./watcher" + +import { Schema } from "effect" + +export class Info extends Schema.Class("Config.Watcher")({ + ignore: Schema.String.pipe(Schema.Array, Schema.optional), +}) {} diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 905206fd63..67293f7c5f 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -3,7 +3,7 @@ import { Location } from "./location" import { Catalog } from "./catalog" import { PluginBoot } from "./plugin/boot" import { Policy } from "./policy" -import { Config } from "./config/config" +import { Config } from "./config" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { lookup: (ref: Location.Ref) => { diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index e6c3ac74a1..c8c8b4061a 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -3,7 +3,7 @@ export * as PluginBoot from "./boot" import { Context, Deferred, Effect, Layer } from "effect" import { AccountV2 } from "../account" import { Catalog } from "../catalog" -import { Config } from "../config/config" +import { Config } from "../config" import { ConfigProvider } from "../config/provider" import { EventV2 } from "../event" import { Npm } from "../npm" diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index a376c78211..a6f6489e17 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -2,9 +2,8 @@ import path from "path" import fs from "fs/promises" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { Config } from "@opencode-ai/core/config/config" +import { Config } from "@opencode-ai/core/config" import { ConfigProvider } from "@opencode-ai/core/config/provider" -import { ConfigV2 } from "@opencode-ai/core/config/schema" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Global } from "@opencode-ai/core/global" import { Location } from "@opencode-ai/core/location" @@ -105,8 +104,8 @@ describe("Config", () => { expect(documents).toHaveLength(3) expect(documents.map((document) => document.source.type)).toEqual(["file", "file", "file"]) expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"]) - expect(documents[0]).toBeInstanceOf(ConfigV2.Loaded) - expect(documents[0]?.source).toBeInstanceOf(ConfigV2.FileSource) + expect(documents[0]).toBeInstanceOf(Config.Loaded) + expect(documents[0]?.source).toBeInstanceOf(Config.FileSource) expect(documents[0]?.source.type === "file" ? documents[0].source.path : undefined).toBe( path.join(tmp.path, "config.json"), ) @@ -155,7 +154,7 @@ describe("Config", () => { ), ) - it.live("loads recognized v2 fields from config files that still contain legacy fields", () => + it.live("loads supported scalar and resource configuration", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), @@ -168,8 +167,20 @@ describe("Config", () => { JSON.stringify({ shell: "/bin/bash", model: "anthropic/claude", - disabled_providers: ["openai"], - server: { port: 4096 }, + autoupdate: "notify", + snapshots: false, + watcher: { ignore: ["node_modules/**", "dist/**", ".git"] }, + skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"], + instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"], + references: { + local: { path: "../library" }, + sdk: { repository: "github.com/example/sdk", branch: "main" }, + shorthand: "github.com/example/docs", + }, + plugins: [ + "opencode-helicone-session", + { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } }, + ], }), ), ) @@ -180,6 +191,29 @@ describe("Config", () => { expect(documents).toHaveLength(1) expect(documents[0]?.info.shell).toBe("/bin/bash") + expect(documents[0]?.info.model).toBe("anthropic/claude") + expect(documents[0]?.info.autoupdate).toBe("notify") + expect(documents[0]?.info.snapshots).toBe(false) + expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] }) + expect(documents[0]?.info.skills).toEqual([ + "./skills", + "~/shared-skills", + "https://example.com/.well-known/skills/", + ]) + expect(documents[0]?.info.instructions).toEqual([ + "CONTRIBUTING.md", + ".cursor/rules/*.md", + "https://example.com/shared-rules.md", + ]) + expect(documents[0]?.info.references).toEqual({ + local: { path: "../library" }, + sdk: { repository: "github.com/example/sdk", branch: "main" }, + shorthand: "github.com/example/docs", + }) + expect(documents[0]?.info.plugins).toEqual([ + "opencode-helicone-session", + { package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } }, + ]) }).pipe(Effect.provide(testLayer(tmp.path))) }), ), diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 6f9b3181f2..ed1f18b7d7 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -1,9 +1,8 @@ import { describe, expect } from "bun:test" import { Effect, Schema } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { Config } from "@opencode-ai/core/config/config" +import { Config } from "@opencode-ai/core/config" import { ConfigProvider } from "@opencode-ai/core/config/provider" -import { ConfigV2 } from "@opencode-ai/core/config/schema" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -21,7 +20,7 @@ function options(headers: Record, variant?: string) { } } -const decode = Schema.decodeUnknownSync(ConfigV2.Info) +const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigProvider.Plugin", () => { it.effect("loads configured providers and applies later model overrides", () => @@ -34,8 +33,8 @@ describe("ConfigProvider.Plugin", () => { directories: () => Effect.succeed([]), get: () => Effect.succeed([ - new ConfigV2.Loaded({ - source: new ConfigV2.MemorySource({ type: "memory" }), + new Config.Loaded({ + source: new Config.MemorySource({ type: "memory" }), info: decode({ providers: { custom: { @@ -63,8 +62,8 @@ describe("ConfigProvider.Plugin", () => { }, }), }), - new ConfigV2.Loaded({ - source: new ConfigV2.MemorySource({ type: "memory" }), + new Config.Loaded({ + source: new Config.MemorySource({ type: "memory" }), info: decode({ providers: { custom: { @@ -95,8 +94,8 @@ describe("ConfigProvider.Plugin", () => { }, }), }), - new ConfigV2.Loaded({ - source: new ConfigV2.MemorySource({ type: "memory" }), + new Config.Loaded({ + source: new Config.MemorySource({ type: "memory" }), info: decode({ providers: { custom: { name: "Renamed" }, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 0bf23b3c68..8dc8c6ee54 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -43,7 +43,7 @@ import { ConfigSkills } from "./skills" import { ConfigVariable } from "./variable" import { Npm } from "@opencode-ai/core/npm" import { withTransientReadRetry } from "@/util/effect-http-client" -import { ConfigV2 } from "@opencode-ai/core/config/schema" +import { ConfigExperimental } from "@opencode-ai/core/config/experimental" const log = Log.create({ service: "config" }) @@ -302,7 +302,7 @@ export const Info = Schema.Struct({ mcp_timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in milliseconds for model context protocol (MCP) requests", }), - policies: Schema.optional(Schema.mutable(Schema.Array(ConfigV2.Policy))).annotate({ + policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({ description: "Policy statements applied to supported resources, such as provider access", }), }), diff --git a/specs/v2/config.md b/specs/v2/config.md index 74e896dbdf..196c3fc4b3 100644 --- a/specs/v2/config.md +++ b/specs/v2/config.md @@ -36,20 +36,71 @@ Settings that affect process startup, shell execution, or network serving. Revie Configuration that introduces location-scoped project resources or discoverable content. -| Field | Current Purpose | Status | Notes | -| -------------- | --------------------------------------- | ------- | ----- | -| `command` | User-defined commands | pending | | -| `skills` | Additional skill locations | pending | | -| `reference` | Named git or local directory references | pending | | -| `instructions` | Additional instruction file patterns | pending | | +| Field | Current Purpose | Status | Notes | +| -------------- | --------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------- | +| `command` | User-defined commands | remove | Do not port as v2 config; named reusable user workflows belong to skills. | +| `skills` | Additional skill locations | redesign | Replace `{ paths?, urls? }` with a single array of local path or remote URL discovery sources. | +| `reference` | Named git or local directory references | redesign | Rename to plural `references`; retain named local path and Git repository external-context entries. | +| `instructions` | Additional ambient instruction sources | keep | Keep as one array of local paths, glob patterns, or remote URLs supplying automatically included context. | + +V2 does not expose separate user-authored command configuration. Skills should cover named reusable prompt workflows, whether invoked directly by the user or loaded by an agent. Internal command routing and built-in commands may remain runtime concerns without creating a `command` or `commands` config field. + +This intentionally does not port legacy command-only behavior such as per-command `model`, `agent`, `subtask`, prompt shell expansion, or positional/template substitution. If a related capability is needed in v2, it should be designed in the owning domain rather than preserved through a second workflow definition system. + +Keep `skills` as discovery-source configuration rather than inline workflow definitions. Skill content remains owned by `SKILL.md`; each `skills` entry is either a local search root or a remote discovery URL. Direct invocation behavior can be designed separately without expanding the config shape. + +```jsonc +{ + "skills": ["./team-skills", "~/shared-skills", "https://example.com/.well-known/skills/"], +} +``` + +Keep ambient instructions separate from skills. Instructions are automatically included as model context, while skills are loaded or invoked intentionally. Each source is unambiguously either a local path/glob or a URL, so v2 keeps the simple array shape: + +```jsonc +{ + "instructions": ["CONTRIBUTING.md", "docs/guidelines.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"], +} +``` + +Keep named external context references as a v2 configuration capability, renamed to plural `references` because it is a collection keyed by alias. References declare local directories or Git repositories that can later be addressed as `@alias` or `@alias/path` when the v2 runtime implements this behavior. + +```jsonc +{ + "references": { + "design-system": { "path": "../ui-library" }, + "sdk": { "repository": "github.com/example/sdk", "branch": "main" }, + }, +} +``` + +Retain the compact string entry form as well: values starting with `.`, `/`, or `~` represent local paths, and other strings represent Git repositories. ## Group 4: Plugins Plugin loading has source-path and scope-sensitive behavior, so it should be reviewed separately from other project resources. -| Field | Current Purpose | Status | Notes | -| -------- | ----------------------------- | ------- | ------------------------------------------------------ | -| `plugin` | User-specified plugin modules | pending | Existing loader records origin and global/local scope. | +| Field | Current Purpose | Status | Notes | +| -------- | ----------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------- | +| `plugin` | User-specified plugin modules | redesign | Rename to plural `plugins`; retain ordered loading with package strings or `{ package, options? }` entries. | + +Plugin order remains part of the v2 configuration contract because hook registration and execution can depend on load order. Replace legacy option tuples with readable object entries: + +```jsonc +{ + "plugins": [ + "opencode-helicone-session", + { + "package": "@my-org/audit-plugin", + "options": { + "endpoint": "https://audit.example.com", + }, + }, + ], +} +``` + +The configured `plugins` list represents package-loaded plugins only. Local plugin code remains discovered from plugin directories such as `.opencode/plugins/`; v2 does not port arbitrary configured local paths or file URLs into this field. ## Group 5: Filesystem And Tool Runtime @@ -57,8 +108,8 @@ Settings controlling local file observation, snapshots, language tooling, and to | Field | Current Purpose | Status | Notes | | ------------- | --------------------------------------- | ------- | ----- | -| `watcher` | Ignore patterns for filesystem watching | pending | | -| `snapshot` | Enable filesystem snapshot tracking | pending | | +| `watcher` | Ignore patterns for filesystem watching | keep | Keep `{ ignore?: string[] }`; this configures the filesystem watcher subsystem. | +| `snapshot` | Enable filesystem snapshot tracking | redesign | Rename to plural `snapshots`; controls creation of snapshots used for undo and revert behavior. | | `formatter` | Configure formatters | pending | | | `lsp` | Configure language servers | pending | | | `attachment` | Configure attachment/image processing | pending | | @@ -79,13 +130,13 @@ Settings affecting sharing behavior or user/account identity rather than model e Provider catalog customization and model-choice configuration. The new core work has started here. -| Field | Current Purpose | Status | Notes | -| -------------------- | ------------------------------------------------- | -------- | --------------------------------------------------------------------------------------- | -| `provider` | Custom provider configuration and model overrides | pending | New core schema currently uses `providers`; decide public key compatibility. | -| `disabled_providers` | Disable automatically loaded providers | redesign | Replace with `experimental.policies: [{ effect: "deny", action: "provider.use", resource: "..." }]`. | -| `enabled_providers` | Restrict enabled providers to an allowlist | redesign | Replace with ordered `provider.use` allow/deny statements and wildcard resources. | -| `model` | Default model selection | pending | | -| `small_model` | Small/utility model selection | pending | | +| Field | Current Purpose | Status | Notes | +| -------------------- | ------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | +| `provider` | Custom provider configuration and model overrides | redesign | Rename to plural `providers` in v2; do not preserve the legacy singular key. Review nested provider/model fields separately. | +| `disabled_providers` | Disable automatically loaded providers | redesign | Replace with `experimental.policies: [{ effect: "deny", action: "provider.use", resource: "..." }]`. | +| `enabled_providers` | Restrict enabled providers to an allowlist | redesign | Replace with ordered `provider.use` allow/deny statements and wildcard resources. | +| `model` | Default model selection | keep | Keep as the fallback model when an active session or agent does not specify a model. | +| `small_model` | Small/utility model selection | remove | Do not port; its only runtime consumer is title generation, which can use an explicit `title` agent model override. | Provider selection rules belong in `experimental.policies` rather than provider entries or repeated top-level provider fields. Initial proposed shape: @@ -112,6 +163,12 @@ See [provider-policy.md](./provider-policy.md) for the provider policy semantics Policy evaluation will consume authored config documents in reverse order while preserving statement order inside each document. The precedence of `.opencode` policy sources remains open until `.opencode` configuration is reviewed. +Provider configuration uses the plural `providers` key in v2. This intentionally differs from the legacy singular `provider` key; v2 does not add a compatibility alias while its configuration surface is still being defined. + +Keep `model` as the default model fallback. It is application-wide behavior used when an active session or agent has no explicit model selection, so it does not belong inside any individual provider configuration. + +Do not port `small_model`. In the current runtime it is only consulted while generating a session title: the `title` agent model wins first, then `small_model`, then automatic/current-model fallback. In v2, users who need a specific title model should configure the `title` agent directly rather than use a separate top-level model setting. + ## Group 8: Agents And Permissions Agent behavior and tool-access policy. Review together because agent configuration can contain permissions and model choices.