Download specs/v2/catalog-config-plugin-lifecycle.md from SaylorTwift/opencode: direct link, hf CLI and curl.
- Browser
- Download file 10.9 kB
-
https://huggingface.co/SaylorTwift/opencode/resolve/main/specs/v2/catalog-config-plugin-lifecycle.md
- Command line
-
hf download hf://SaylorTwift/opencode/specs/v2/catalog-config-plugin-lifecycle.md
-
curl -L -o catalog-config-plugin-lifecycle.md https://huggingface.co/SaylorTwift/opencode/resolve/main/specs/v2/catalog-config-plugin-lifecycle.md
Catalog / Config / Plugin Lifecycle Options
Status: current core has selected replayable Location-scoped Catalog transforms, aligned with option B. Reload/watch behavior and deferred external plugin activation remain design work; the option comparison below is retained as historical context.
We need to choose where provider/model inputs live and how visible catalog state changes after boot. The designs below compare config, models.dev, auth, plugin activation/disablement, config edits, and policy changes under each option.
Scenarios
- Initial load: a location opens, built-in/configured plugins activate, and the first visible catalog is constructed.
- Config: authored provider/model definitions and overrides.
- models.dev: remote provider/model data refreshed on a timer.
- Auth: active credentials enable/configure providers and can later disappear.
- Plugin activation: a plugin starts contributing while the location is open.
- Plugin disablement: a plugin stops contributing and its influence must disappear.
- Config edit: authored configuration changes while the location is open.
- Policy: allowed/denied provider selection changes after providers exist.
A. Config Transforms, Service Reload
Config merges its ordered documents and then runs ordered, replayable plugin transforms. Each transform is a callback receiving Draft<Config.Info> and may mutate any config field.
type ConfigTransform = (config: Draft<Config.Info>) => void
const transform = yield * Config.transform()
yield *
transform((config) => {
config.providers ??= {}
config.providers.acme = {
/* ... */
}
config.model = "acme/code"
config.permissions = [
/* ... */
]
})
Because a transform can mutate any part of config, a transform change cannot safely trigger only Catalog.reload() or any other granular subset. Every service derived from config must reload in place from the newly transformed config.
const transform = yield* Config.transform()
yield* transform((draft) => mutateAnyConfigField(draft))
β Reload.all()
β Policy.reload()
β Catalog.reload()
β Agent.reload()
β MCP.reload()
β other config-consuming services reload
Initial Load
Configured plugin installation/updates should not block location readiness. Build an initial snapshot from authored config and fast built-ins, then activate slow plugins in the background and coalesce their resulting reload requests.
LocationServiceMap.get(ref)
β build location layer
β Config.layer reads authored documents
β merge authored documents
β run currently active Config transforms
β Policy.layer reads transformed Config
β Catalog.layer reads transformed Config
β materialize baseline provider/model catalog
β PluginBoot baseline ready
β Frontend.fetchCatalog()
PluginBoot background fiber
β install/update plugin packages concurrently
β activate completed plugins
β Config.transform()
β transform(updateConfig)
β ReloadScheduler.request()
β debounce short burst of completed activations
β Reload.all()
β Config.get()
β run newly active Config transforms
β Catalog.reload()
β Catalog.Event.Updated
β Frontend.refetchCatalog()
The initial layer build is not a reload. Reload.all() only runs after the live location changes, such as a background plugin becoming active or a config source changing. Debouncing reduces repeated full-service reloads when multiple plugins complete near each other; each batch still reloads every config-consuming service because a config transform may mutate any field.
Config
config file loaded
β config source/watch trigger records new documents
β Reload.all()
β Policy.reload()
β Catalog.reload()
β Catalog.Event.Updated
β Frontend.refetchCatalog()
models.dev
timer fires
β ModelsDevPlugin.refresh()
β ModelsDev.get()
β transform(applyModelsDevToConfig)
β Reload.all()
β Policy.reload()
β Catalog.reload()
β Catalog.Event.Updated
β Frontend.refetchCatalog()
Catalog does not know about ModelsDev; the plugin transforms config before catalog reads it.
Auth
Account.switched(providerID)
β AuthPlugin.refresh(providerID)
β Account.active(providerID)
β transform(applyAuthToConfig)
β Reload.all()
β Policy.reload()
β Catalog.reload()
β Catalog.Event.Updated
β Frontend.refetchCatalog()
Plugin Activation
Plugin.activate("acme-models")
β Config.transform()
β transform(applyAcmeConfig)
β Reload.all()
β Policy.reload()
β Catalog.reload()
β Catalog.Event.Updated
β Frontend.refetchCatalog()
Plugin Disablement
Plugin.disable("company-naming")
β close plugin scope
β Config internally unregisters transform in finalizer
β Reload.all()
β Policy.reload()
β Catalog.reload()
β sonnet.name = "Sonnet"
β Catalog.Event.Updated
β Frontend.refetchCatalog()
Config Edit
file watcher sees edit
β config source/watch trigger records updated documents
β Reload.all()
β Policy.reload()
β Catalog.reload()
β Catalog.Event.Updated
β Frontend.refetchCatalog()
Policy
policy config changes
β config source/watch trigger records updated documents
β Reload.all()
β Policy.reload()
β Catalog.reload()
β apply updated policy
β Catalog.Event.Updated
β Frontend.refetchCatalog()
Tradeoffs
- A plugin receives
Draft<Config.Info>, can inspect preceding config state, and can mutate arbitrary config fields through a replayable transform. - Plugin disablement removes its config transform and lets services rematerialize without manual undo.
- models.dev and auth become config transforms rather than catalog dependencies.
Configowns merge/order semantics for fields visible to transforms.- Granular service reload is not safe because a config transform can mutate anything; every config-consuming service reloads after any transform change.
Catalogdepends on provider/model config semantics and is part of that full service reload.- One reload produces at most one
Catalog.Event.Updatednotification. - Deferred plugin activation avoids blocking readiness, but plugin completions may cause repeated full-service reload batches during startup.
B. Catalog Transforms
Plugins register replayable catalog transforms. Each transform receives a Catalog.Editor whose helper methods mutate a private catalog draft; Catalog rematerializes visible records from its active transforms.
interface Catalog {
transform(): Effect.Effect<(update: (catalog: Catalog.Editor) => void) => Effect.Effect<void>, never, Scope.Scope>
}
const transform = yield* Catalog.transform()
yield* transform(update)
β replace this transform callback
β apply active transforms in registration order
β apply policy
β commit diff
β Event.publish(Catalog.Event.Updated)
β Frontend.refetchCatalog()
Initial Load
Configured plugin installation/updates should not block location readiness. Build an initial catalog from immediately available sources, then activate slow plugins in the background and coalesce refresh requests.
LocationServiceMap.get(ref)
β build location layer
β Catalog.layer creates empty catalog state
β PluginBoot.layer activates immediately available plugins
β ConfigProviderPlugin installs Catalog.transform()
β ModelsDevPlugin installs Catalog.transform()
β AuthPlugin installs Catalog.transform()
β Catalog.layer applies active transforms during boot
β apply policy
β materialize baseline provider/model catalog
β PluginBoot baseline ready
β Frontend.fetchCatalog()
PluginBoot background fiber
β install/update plugin packages concurrently
β activate completed plugins
β Catalog.transform()
β transform(updateCatalog)
β Catalog internally rebuilds
β Catalog.Event.Updated
β Frontend.refetchCatalog()
Each completed plugin activation rebuilds catalog when it calls its transform. Debouncing plugin completions would require adding an explicit batch/suspend-rebuild mechanism; it does not arise from the transform interface itself.
Config
config file loaded
β ConfigProviderAdapter.load()
β transform(applyConfigToCatalog)
β Catalog internally rebuilds
models.dev
timer fires
β ModelsDevPlugin.refresh()
β ModelsDev.get()
β transform(applyModelsDevToCatalog)
β Catalog internally rebuilds
β commit diff
Auth
Account.switched(providerID)
β AuthPlugin.refresh()
β transform(applyAuthToCatalog)
β Catalog internally rebuilds
β replay active transforms including current auth
β apply policy
β commit diff
Plugin Activation
Plugin.activate("acme-models")
β Catalog.transform()
β transform(applyAcmeToCatalog)
β Catalog internally rebuilds
β commit diff
Plugin Disablement
Plugin.disable("company-naming")
β close plugin scope
β Catalog internally unregisters transform in finalizer
β Catalog internally rebuilds
β sonnet.name = "Sonnet"
β commit diff
Config Edit
file watcher sees edit
β ConfigProviderAdapter.load()
β transform(applyUpdatedConfigToCatalog)
β Catalog internally rebuilds
Policy
policy changes
β Catalog rebuild trigger
β replay all active transforms
β apply updated policy last
β commit diff
Tradeoffs
- Disablement, source refresh, and policy re-evaluation are transform replay operations.
- Auth does not need to be represented as config.
- Config remains one catalog source rather than a catalog dependency.
- The API shape matches A, but the mutable draft is catalog state instead of configuration state.
- Catalog needs transform ordering and internal rebuild behavior in addition to reads.
- Recompute ordering, serialization, and diff events must be specified.
- One internal rebuild produces at most one
Catalog.Event.Updatednotification. - Deferred plugin activation avoids blocking readiness and only rebuilds catalog for catalog transform changes.
- Debouncing those rebuilds needs an additional batching interface or an activation coordinator that installs multiple transforms before exposing updates.