opencode/specs/v2/catalog-config-plugin-lifecycle.md

10 KiB

Catalog / Config / Plugin Lifecycle Options

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.
  • Config owns 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.
  • Catalog depends on provider/model config semantics and is part of that full service reload.
  • One reload produces at most one Catalog.Event.Updated notification.
  • 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.Updated notification.
  • 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.