Bläddra i källkod

Rename v2 auth service to account (#28260)

Dax 2 månader sedan
förälder
incheckning
8643c0721e
67 ändrade filer med 2544 tillägg och 1382 borttagningar
  1. 319 0
      packages/core/src/account.ts
  2. 147 0
      packages/core/src/agent.ts
  3. 0 264
      packages/core/src/auth.ts
  4. 139 53
      packages/core/src/catalog.ts
  5. 2 2
      packages/core/src/event.ts
  6. 5 4
      packages/core/src/location-layer.ts
  7. 12 1
      packages/core/src/models-dev.ts
  8. 45 0
      packages/core/src/permission.ts
  9. 63 18
      packages/core/src/plugin.ts
  10. 41 0
      packages/core/src/plugin/account.ts
  11. 0 27
      packages/core/src/plugin/auth.ts
  12. 51 41
      packages/core/src/plugin/boot.ts
  13. 10 6
      packages/core/src/plugin/env.ts
  14. 56 44
      packages/core/src/plugin/models-dev.ts
  15. 13 8
      packages/core/src/plugin/provider/amazon-bedrock.ts
  16. 9 5
      packages/core/src/plugin/provider/anthropic.ts
  17. 22 10
      packages/core/src/plugin/provider/azure.ts
  18. 8 4
      packages/core/src/plugin/provider/cerebras.ts
  19. 1 1
      packages/core/src/plugin/provider/cloudflare-ai-gateway.ts
  20. 9 7
      packages/core/src/plugin/provider/cloudflare-workers-ai.ts
  21. 8 8
      packages/core/src/plugin/provider/github-copilot.ts
  22. 35 28
      packages/core/src/plugin/provider/google-vertex.ts
  23. 10 5
      packages/core/src/plugin/provider/kilo.ts
  24. 12 7
      packages/core/src/plugin/provider/llmgateway.ts
  25. 11 6
      packages/core/src/plugin/provider/nvidia.ts
  26. 11 5
      packages/core/src/plugin/provider/openai.ts
  27. 15 10
      packages/core/src/plugin/provider/opencode.ts
  28. 17 12
      packages/core/src/plugin/provider/openrouter.ts
  29. 9 4
      packages/core/src/plugin/provider/vercel.ts
  30. 10 5
      packages/core/src/plugin/provider/zenmux.ts
  31. 1 1
      packages/core/src/provider.ts
  32. 14 0
      packages/core/src/util/wildcard.ts
  33. 284 0
      packages/core/test/account.test.ts
  34. 101 121
      packages/core/test/catalog.test.ts
  35. 2 0
      packages/core/test/models.test.ts
  36. 22 17
      packages/core/test/plugin/provider-amazon-bedrock.test.ts
  37. 20 14
      packages/core/test/plugin/provider-anthropic.test.ts
  38. 35 21
      packages/core/test/plugin/provider-azure-cognitive-services.test.ts
  39. 89 50
      packages/core/test/plugin/provider-azure.test.ts
  40. 15 13
      packages/core/test/plugin/provider-cerebras.test.ts
  41. 63 46
      packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts
  42. 18 14
      packages/core/test/plugin/provider-github-copilot.test.ts
  43. 55 27
      packages/core/test/plugin/provider-gitlab.test.ts
  44. 23 22
      packages/core/test/plugin/provider-google-vertex-anthropic.test.ts
  45. 56 64
      packages/core/test/plugin/provider-google-vertex.test.ts
  46. 32 1
      packages/core/test/plugin/provider-helper.ts
  47. 48 40
      packages/core/test/plugin/provider-kilo.test.ts
  48. 34 26
      packages/core/test/plugin/provider-llmgateway.test.ts
  49. 47 40
      packages/core/test/plugin/provider-nvidia.test.ts
  50. 25 17
      packages/core/test/plugin/provider-openai.test.ts
  51. 126 98
      packages/core/test/plugin/provider-opencode.test.ts
  52. 48 38
      packages/core/test/plugin/provider-openrouter.test.ts
  53. 29 16
      packages/core/test/plugin/provider-vercel.test.ts
  54. 58 45
      packages/core/test/plugin/provider-zenmux.test.ts
  55. 11 12
      packages/opencode/src/cli/cmd/debug/v2.ts
  56. 2 2
      packages/opencode/src/cli/cmd/run.ts
  57. 1 1
      packages/opencode/src/event-v2-bridge.ts
  58. 1 15
      packages/opencode/src/permission/evaluate.ts
  59. 11 20
      packages/opencode/src/permission/index.ts
  60. 7 1
      packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
  61. 1 1
      packages/opencode/src/session/prompt.ts
  62. 3 3
      packages/opencode/src/session/session.ts
  63. 1 1
      packages/opencode/src/share/session.ts
  64. 1 1
      packages/opencode/test/server/httpapi-event-diagnostics.test.ts
  65. 1 1
      packages/sdk/js/src/v2/gen/types.gen.ts
  66. 121 0
      specs/v2/instructions.md
  67. 48 8
      specs/v2/provider-model.md

+ 319 - 0
packages/core/src/account.ts

@@ -0,0 +1,319 @@
+import path from "path"
+import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
+import { Identifier } from "./util/identifier"
+import { NonNegativeInt, withStatics } from "./schema"
+import { Global } from "./global"
+import { AppFileSystem } from "./filesystem"
+import { EventV2 } from "./event"
+
+export const ID = Schema.String.pipe(
+  Schema.brand("AccountV2.ID"),
+  withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
+)
+export type ID = typeof ID.Type
+
+export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
+export type ServiceID = typeof ServiceID.Type
+
+export class OAuthCredential extends Schema.Class<OAuthCredential>("AccountV2.OAuthCredential")({
+  type: Schema.Literal("oauth"),
+  refresh: Schema.String,
+  access: Schema.String,
+  expires: NonNegativeInt,
+}) {}
+
+export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AccountV2.ApiKeyCredential")({
+  type: Schema.Literal("api"),
+  key: Schema.String,
+  metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+}) {}
+
+export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
+  .pipe(Schema.toTaggedUnion("type"))
+  .annotate({
+    identifier: "AccountV2.Credential",
+  })
+export type Credential = Schema.Schema.Type<typeof Credential>
+
+export class Info extends Schema.Class<Info>("AccountV2.Info")({
+  id: ID,
+  serviceID: ServiceID,
+  description: Schema.String,
+  credential: Credential,
+}) {}
+
+export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("AccountV2.FileWriteError", {
+  operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
+  cause: Schema.Defect,
+}) {}
+
+export type Error = FileWriteError
+
+export const Event = {
+  Added: EventV2.define({
+    type: "account.added",
+    schema: {
+      account: Info,
+    },
+  }),
+  Removed: EventV2.define({
+    type: "account.removed",
+    schema: {
+      account: Info,
+    },
+  }),
+  Switched: EventV2.define({
+    type: "account.switched",
+    schema: {
+      serviceID: ServiceID,
+      from: Schema.optional(ID),
+      to: Schema.optional(ID),
+    },
+  }),
+}
+
+interface Writable {
+  version: 2
+  accounts: Record<string, Info>
+  active: Record<string, ID>
+}
+
+const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
+
+function migrate(old: Record<string, unknown>): Writable {
+  const accounts: Record<string, Info> = {}
+  const active: Record<string, ID> = {}
+  for (const [serviceID, value] of Object.entries(old)) {
+    const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
+    const parsed = (decoded as Record<string, Credential>)[serviceID]
+    if (!parsed) continue
+    const id = Identifier.ascending()
+    const account = ID.make(id)
+    const brandedServiceID = ServiceID.make(serviceID)
+    accounts[id] = new Info({
+      id: account,
+      serviceID: brandedServiceID,
+      description: "default",
+      credential: parsed,
+    })
+    active[brandedServiceID] = account
+  }
+  return { version: 2, accounts, active }
+}
+
+export interface Interface {
+  readonly get: (id: ID) => Effect.Effect<Info | undefined, Error>
+  readonly all: () => Effect.Effect<Info[], Error>
+  readonly create: (input: {
+    serviceID: ServiceID
+    credential: Credential
+    description?: string
+  }) => Effect.Effect<Info | undefined, Error>
+  readonly update: (id: ID, updates: Partial<Pick<Info, "description" | "credential">>) => Effect.Effect<void, Error>
+  readonly remove: (id: ID) => Effect.Effect<void, Error>
+  readonly activate: (id: ID) => Effect.Effect<void, Error>
+  readonly active: (serviceID: ServiceID) => Effect.Effect<Info | undefined, Error>
+  readonly forService: (serviceID: ServiceID) => Effect.Effect<Info[], Error>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Account") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const fsys = yield* AppFileSystem.Service
+    const global = yield* Global.Service
+    const events = yield* EventV2.Service
+    const file = path.join(global.data, "account.json")
+    const legacyFile = path.join(global.data, "auth.json")
+
+    const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
+      const migrated = migrate(raw)
+      yield* fsys
+        .writeJson(file, migrated, 0o600)
+        .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause })))
+      return migrated
+    })
+
+    const parseAuthContent = () => {
+      try {
+        return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "")
+      } catch {}
+    }
+
+    const load: () => Effect.Effect<Writable, Error> = Effect.fnUntraced(function* () {
+      if (process.env.OPENCODE_AUTH_CONTENT) {
+        const raw = parseAuthContent()
+        if (raw && typeof raw === "object") {
+          if ("version" in raw && raw.version === 2) return raw as Writable
+          return yield* writeMigrated(raw as Record<string, unknown>)
+        }
+        return { version: 2, accounts: {}, active: {} }
+      }
+
+      const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
+      if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
+
+      const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
+
+      if (raw && typeof raw === "object") {
+        if ("version" in raw && raw.version === 2) return raw as Writable
+        return yield* writeMigrated(raw as Record<string, unknown>)
+      }
+
+      return { version: 2, accounts: {}, active: {} }
+    })
+
+    const write = (data: Writable) =>
+      fsys
+        .writeJson(file, data, 0o600)
+        .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause })))
+
+    const state = SynchronizedRef.makeUnsafe(
+      yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))),
+    )
+
+    const activate = Effect.fn("AccountV2.activate")(function* (id: ID) {
+      const data = yield* SynchronizedRef.get(state)
+      const account = data.accounts[id]
+      if (!account) return
+      const activated = yield* SynchronizedRef.modifyEffect(
+        state,
+        Effect.fnUntraced(function* (data) {
+          const nextAccount = data.accounts[id]
+          if (!nextAccount) return [undefined, data] as const
+
+          const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } }
+          yield* write(next)
+          return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const
+        }),
+      )
+      if (activated) yield* events.publish(Event.Switched, activated)
+    })
+
+    const result: Interface = {
+      get: Effect.fn("AccountV2.get")(function* (id) {
+        return (yield* SynchronizedRef.get(state)).accounts[id]
+      }),
+
+      all: Effect.fn("AccountV2.all")(function* () {
+        return Object.values((yield* SynchronizedRef.get(state)).accounts)
+      }),
+
+      active: Effect.fn("AccountV2.active")(function* (serviceID) {
+        const data = yield* SynchronizedRef.get(state)
+        return (
+          data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
+        )
+      }),
+
+      forService: Effect.fn("AccountV2.list")(function* (serviceID) {
+        return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
+      }),
+
+      create: Effect.fn("AccountV2.add")(function* (input) {
+        const id = ID.make(Identifier.ascending())
+        const account = new Info({
+          id,
+          serviceID: input.serviceID,
+          description: input.description ?? "default",
+          credential: input.credential,
+        })
+        const added = yield* SynchronizedRef.modifyEffect(
+          state,
+          Effect.fnUntraced(function* (data) {
+            const next = {
+              ...data,
+              accounts: { ...data.accounts, [account.id]: account },
+              active: { ...data.active, [account.serviceID]: account.id },
+            }
+
+            yield* write(next)
+            return [
+              {
+                account,
+                switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id },
+              },
+              next,
+            ] as const
+          }),
+        )
+        yield* events.publish(Event.Added, { account: added.account })
+        yield* events.publish(Event.Switched, added.switched)
+        return added.account
+      }),
+
+      update: Effect.fn("AccountV2.update")(function* (id, updates) {
+        const existing = (yield* SynchronizedRef.get(state)).accounts[id]
+        if (!existing) return
+        yield* SynchronizedRef.modifyEffect(
+          state,
+          Effect.fnUntraced(function* (data) {
+            if (!data.accounts[id]) return [undefined, data] as const
+
+            const next = {
+              ...data,
+              accounts: {
+                ...data.accounts,
+                [id]: new Info({
+                  id,
+                  serviceID: existing.serviceID,
+                  description: updates.description ?? existing.description,
+                  credential: updates.credential ?? existing.credential,
+                }),
+              },
+            }
+
+            yield* write(next)
+            return [undefined, next] as const
+          }),
+        )
+      }),
+
+      remove: Effect.fn("AccountV2.remove")(function* (id) {
+        const removed = yield* SynchronizedRef.modifyEffect(
+          state,
+          Effect.fnUntraced(function* (data) {
+            const accounts = { ...data.accounts }
+            const active = { ...data.active }
+            const removed = accounts[id]
+            if (!removed) return [undefined, data] as const
+            const wasActive = active[removed.serviceID] === id
+            delete accounts[id]
+            const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID)
+            if (wasActive) {
+              if (replacement) active[removed.serviceID] = replacement.id
+              else delete active[removed.serviceID]
+            }
+
+            const next = { ...data, accounts, active }
+            yield* write(next)
+            return [
+              {
+                account: removed,
+                switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined,
+              },
+              next,
+            ] as const
+          }),
+        )
+        if (removed) {
+          yield* events.publish(Event.Removed, { account: removed.account })
+          if (removed.switched) yield* events.publish(Event.Switched, removed.switched)
+        }
+      }),
+
+      activate,
+    }
+
+    return Service.of(result)
+  }),
+)
+
+export const defaultLayer = layer.pipe(
+  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(Global.defaultLayer),
+  Layer.provide(EventV2.defaultLayer),
+)
+
+export * as AccountV2 from "./account"

+ 147 - 0
packages/core/src/agent.ts

@@ -0,0 +1,147 @@
+export * as AgentV2 from "./agent"
+
+import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect"
+import { produce, type Draft } from "immer"
+import { ModelV2 } from "./model"
+import { PermissionV2 } from "./permission"
+import { PluginV2 } from "./plugin"
+import { ProviderV2 } from "./provider"
+
+export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID"))
+export type ID = typeof ID.Type
+
+export const Mode = Schema.Literals(["subagent", "primary", "all"]).annotate({ identifier: "AgentV2.Mode" })
+export type Mode = typeof Mode.Type
+
+export const Info = Schema.Struct({
+  name: ID,
+  description: Schema.optional(Schema.String),
+  mode: Mode,
+  hidden: Schema.Boolean.pipe(Schema.optional),
+  color: Schema.String.pipe(Schema.optional),
+  permission: PermissionV2.Ruleset,
+  model: ModelV2.Ref.pipe(Schema.optional),
+  system: Schema.String.pipe(Schema.optional),
+  options: ProviderV2.Options.pipe(Schema.optional),
+  steps: Schema.Int.pipe(Schema.optional),
+}).annotate({ identifier: "AgentV2.Info" })
+export type Info = typeof Info.Type
+
+export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("AgentV2.NotFound", {
+  agent: ID,
+}) {}
+
+export class InvalidDefaultError extends Schema.TaggedErrorClass<InvalidDefaultError>()("AgentV2.InvalidDefault", {
+  agent: ID,
+  reason: Schema.Literals(["missing", "subagent", "hidden"]),
+}) {}
+
+export class NoDefaultError extends Schema.TaggedErrorClass<NoDefaultError>()("AgentV2.NoDefault", {}) {}
+
+export interface Interface {
+  readonly get: (agent: ID) => Effect.Effect<Info, NotFoundError>
+  readonly list: () => Effect.Effect<Info[]>
+  readonly update: (agent: ID, fn: (agent: Draft<Info>) => void) => Effect.Effect<void>
+  readonly remove: (agent: ID) => Effect.Effect<void>
+  readonly defaultInfo: () => Effect.Effect<Info, InvalidDefaultError | NoDefaultError>
+  readonly defaultAgent: () => Effect.Effect<ID, InvalidDefaultError | NoDefaultError>
+  readonly setDefault: (agent: ID) => Effect.Effect<void, NotFoundError>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const plugin = yield* PluginV2.Service
+    let agents = HashMap.empty<ID, Info>()
+    let defaultAgent: ID | undefined
+
+    const result: Interface = {
+      get: Effect.fn("AgentV2.get")(function* (agent) {
+        const match = HashMap.get(agents, agent)
+        if (!match.valueOrUndefined) return yield* new NotFoundError({ agent })
+        return match.value
+      }),
+
+      list: Effect.fn("AgentV2.list")(function* () {
+        return pipe(
+          HashMap.toValues(agents),
+          Array.sortWith((agent) => agent.name, Order.String),
+        )
+      }),
+
+      update: Effect.fnUntraced(function* (agent, fn) {
+        const next = produce(
+          HashMap.get(agents, agent).pipe(
+            Option.getOrElse(
+              () =>
+                ({
+                  name: agent,
+                  mode: "all",
+                  permission: [],
+                  options: {
+                    headers: {},
+                    body: {},
+                    aisdk: {
+                      provider: {},
+                      request: {},
+                    },
+                  },
+                }) satisfies Info,
+            ),
+          ),
+          fn,
+        )
+        const updated = yield* plugin.trigger("agent.update", {}, { agent: next, cancel: false })
+        if (updated.cancel) return
+        agents = HashMap.set(agents, agent, { ...updated.agent, name: agent })
+      }),
+
+      remove: Effect.fn("AgentV2.remove")(function* (agent) {
+        const existing = Option.getOrUndefined(HashMap.get(agents, agent))
+        if (!existing) return
+        if ((yield* plugin.trigger("agent.remove", { agent: existing }, { cancel: false })).cancel) return
+        agents = HashMap.remove(agents, agent)
+        if (defaultAgent === agent) defaultAgent = undefined
+      }),
+
+      defaultInfo: Effect.fn("AgentV2.defaultInfo")(function* () {
+        const updated = yield* plugin.trigger("agent.default", {}, { agent: defaultAgent })
+        const selected = updated.agent
+        if (selected) {
+          const agent = yield* result
+            .get(selected)
+            .pipe(
+              Effect.catchTag("AgentV2.NotFound", () =>
+                Effect.fail(new InvalidDefaultError({ agent: selected, reason: "missing" })),
+              ),
+            )
+          if (agent.mode === "subagent") return yield* new InvalidDefaultError({ agent: selected, reason: "subagent" })
+          if (agent.hidden === true) return yield* new InvalidDefaultError({ agent: selected, reason: "hidden" })
+          return agent
+        }
+
+        const visible = pipe(
+          yield* result.list(),
+          Array.findFirst((agent) => agent.mode !== "subagent" && agent.hidden !== true),
+        )
+        if (Option.isSome(visible)) return visible.value
+        return yield* new NoDefaultError()
+      }),
+
+      defaultAgent: Effect.fn("AgentV2.defaultAgent")(function* () {
+        return (yield* result.defaultInfo()).name
+      }),
+
+      setDefault: Effect.fn("AgentV2.setDefault")(function* (agent) {
+        yield* result.get(agent)
+        defaultAgent = agent
+      }),
+    }
+
+    return Service.of(result)
+  }),
+)
+
+export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer))

+ 0 - 264
packages/core/src/auth.ts

@@ -1,264 +0,0 @@
-import path from "path"
-import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
-import { Identifier } from "./util/identifier"
-import { NonNegativeInt, withStatics } from "./schema"
-import { Global } from "./global"
-import { AppFileSystem } from "./filesystem"
-
-export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
-
-const AccountID = Schema.String.pipe(
-  Schema.brand("AccountID"),
-  withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
-)
-export type AccountID = typeof AccountID.Type
-
-export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
-export type ServiceID = typeof ServiceID.Type
-
-export class OAuthCredential extends Schema.Class<OAuthCredential>("AuthV2.OAuthCredential")({
-  type: Schema.Literal("oauth"),
-  refresh: Schema.String,
-  access: Schema.String,
-  expires: NonNegativeInt,
-}) {}
-
-export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AuthV2.ApiKeyCredential")({
-  type: Schema.Literal("api"),
-  key: Schema.String,
-  metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
-}) {}
-
-export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
-  .pipe(Schema.toTaggedUnion("type"))
-  .annotate({
-    identifier: "AuthV2.Credential",
-  })
-export type Credential = Schema.Schema.Type<typeof Credential>
-
-export class Account extends Schema.Class<Account>("AuthV2.Account")({
-  id: AccountID,
-  serviceID: ServiceID,
-  description: Schema.String,
-  credential: Credential,
-}) {}
-
-export class AuthFileWriteError extends Schema.TaggedErrorClass<AuthFileWriteError>()("AuthV2.FileWriteError", {
-  operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
-  cause: Schema.Defect,
-}) {}
-
-export type AuthError = AuthFileWriteError
-
-interface Writable {
-  version: 2
-  accounts: Record<string, Account>
-  active: Record<string, AccountID>
-}
-
-const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
-
-function migrate(old: Record<string, unknown>): Writable {
-  const accounts: Record<string, Account> = {}
-  const active: Record<string, AccountID> = {}
-  for (const [serviceID, value] of Object.entries(old)) {
-    const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
-    const parsed = (decoded as Record<string, Credential>)[serviceID]
-    if (!parsed) continue
-    const id = Identifier.ascending()
-    const accountID = AccountID.make(id)
-    const brandedServiceID = ServiceID.make(serviceID)
-    accounts[id] = new Account({
-      id: accountID,
-      serviceID: brandedServiceID,
-      description: "default",
-      credential: parsed,
-    })
-    active[brandedServiceID] = accountID
-  }
-  return { version: 2, accounts, active }
-}
-
-export interface Interface {
-  readonly get: (accountID: AccountID) => Effect.Effect<Account | undefined, AuthError>
-  readonly all: () => Effect.Effect<Account[], AuthError>
-  readonly create: (input: {
-    serviceID: ServiceID
-    credential: Credential
-    description?: string
-    active?: boolean
-  }) => Effect.Effect<Account, AuthError>
-  readonly update: (
-    accountID: AccountID,
-    updates: Partial<Pick<Account, "description" | "credential">>,
-  ) => Effect.Effect<void, AuthError>
-  readonly remove: (accountID: AccountID) => Effect.Effect<void, AuthError>
-  readonly activate: (accountID: AccountID) => Effect.Effect<void, AuthError>
-  readonly active: (serviceID: ServiceID) => Effect.Effect<Account | undefined, AuthError>
-  readonly forService: (serviceID: ServiceID) => Effect.Effect<Account[], AuthError>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Auth") {}
-
-export const layer = Layer.effect(
-  Service,
-  Effect.gen(function* () {
-    const fsys = yield* AppFileSystem.Service
-    const global = yield* Global.Service
-    const file = path.join(global.data, "auth-v2.json")
-    const legacyFile = path.join(global.data, "auth.json")
-
-    const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
-      const migrated = migrate(raw)
-      yield* fsys
-        .writeJson(file, migrated, 0o600)
-        .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause })))
-      return migrated
-    })
-
-    const parseAuthContent = () => {
-      try {
-        return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "")
-      } catch {}
-    }
-
-    const load: () => Effect.Effect<Writable, AuthError> = Effect.fnUntraced(function* () {
-      if (process.env.OPENCODE_AUTH_CONTENT) {
-        const raw = parseAuthContent()
-        if (raw && typeof raw === "object") {
-          if ("version" in raw && raw.version === 2) return raw as Writable
-          return yield* writeMigrated(raw as Record<string, unknown>)
-        }
-        return { version: 2, accounts: {}, active: {} }
-      }
-
-      const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
-      if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
-
-      const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
-
-      if (raw && typeof raw === "object") {
-        if ("version" in raw && raw.version === 2) return raw as Writable
-        return yield* writeMigrated(raw as Record<string, unknown>)
-      }
-
-      return { version: 2, accounts: {}, active: {} }
-    })
-
-    const write = (data: Writable) =>
-      fsys
-        .writeJson(file, data, 0o600)
-        .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "write", cause })))
-
-    const state = SynchronizedRef.makeUnsafe(yield* load())
-
-    const result: Interface = {
-      get: Effect.fn("AuthV2.get")(function* (accountID) {
-        return (yield* SynchronizedRef.get(state)).accounts[accountID]
-      }),
-
-      all: Effect.fn("AuthV2.all")(function* () {
-        return Object.values((yield* SynchronizedRef.get(state)).accounts)
-      }),
-
-      active: Effect.fn("AuthV2.active")(function* (serviceID) {
-        const data = yield* SynchronizedRef.get(state)
-        return (
-          data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
-        )
-      }),
-
-      forService: Effect.fn("AuthV2.list")(function* (serviceID) {
-        return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
-      }),
-
-      create: Effect.fn("AuthV2.add")(function* (input) {
-        return yield* SynchronizedRef.modifyEffect(
-          state,
-          Effect.fnUntraced(function* (data) {
-            const account = new Account({
-              id: AccountID.make(Identifier.ascending()),
-              serviceID: input.serviceID,
-              description: input.description ?? "default",
-              credential: input.credential,
-            })
-            const next = {
-              ...data,
-              accounts: { ...data.accounts, [account.id]: account },
-              active:
-                (input.active ?? Object.values(data.accounts).every((a) => a.serviceID !== input.serviceID))
-                  ? { ...data.active, [input.serviceID]: account.id }
-                  : data.active,
-            }
-
-            yield* write(next)
-            return [account, next] as const
-          }),
-        )
-      }),
-
-      update: Effect.fn("AuthV2.update")(function* (accountID, updates) {
-        yield* SynchronizedRef.modifyEffect(
-          state,
-          Effect.fnUntraced(function* (data) {
-            const existing = data.accounts[accountID]
-            if (!existing) return [undefined, data] as const
-
-            const next = {
-              ...data,
-              accounts: {
-                ...data.accounts,
-                [accountID]: new Account({
-                  id: accountID,
-                  serviceID: existing.serviceID,
-                  description: updates.description ?? existing.description,
-                  credential: updates.credential ?? existing.credential,
-                }),
-              },
-            }
-
-            yield* write(next)
-            return [undefined, next] as const
-          }),
-        )
-      }),
-
-      remove: Effect.fn("AuthV2.remove")(function* (accountID) {
-        yield* SynchronizedRef.modifyEffect(
-          state,
-          Effect.fnUntraced(function* (data) {
-            const accounts = { ...data.accounts }
-            const active = { ...data.active }
-            if (accounts[accountID] && active[accounts[accountID].serviceID] === accountID)
-              delete active[accounts[accountID].serviceID]
-            delete accounts[accountID]
-
-            const next = { ...data, accounts, active }
-            yield* write(next)
-            return [undefined, next] as const
-          }),
-        )
-      }),
-
-      activate: Effect.fn("AuthV2.activate")(function* (accountID) {
-        yield* SynchronizedRef.modifyEffect(
-          state,
-          Effect.fnUntraced(function* (data) {
-            const account = data.accounts[accountID]
-            if (!account) return [undefined, data] as const
-
-            const next = { ...data, active: { ...data.active, [account.serviceID]: accountID } }
-            yield* write(next)
-            return [undefined, next] as const
-          }),
-        )
-      }),
-    }
-
-    return Service.of(result)
-  }),
-)
-
-export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer))
-
-export * as AuthV2 from "./auth"

+ 139 - 53
packages/core/src/catalog.ts

@@ -1,6 +1,6 @@
 export * as Catalog from "./catalog"
 
-import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect"
+import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect"
 import { produce, type Draft } from "immer"
 import { ModelV2 } from "./model"
 import { PluginV2 } from "./plugin"
@@ -8,9 +8,9 @@ import { ProviderV2 } from "./provider"
 import { Location } from "./location"
 import { EventV2 } from "./event"
 
-type ProviderRecord = {
+export type ProviderRecord = {
   provider: ProviderV2.Info
-  models: HashMap.HashMap<ModelV2.ID, ModelV2.Info>
+  models: Map<ModelV2.ID, ModelV2.Info>
 }
 
 export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
@@ -34,10 +34,26 @@ export const Event = {
   }),
 }
 
+export type Context = {
+  data: readonly ProviderRecord[]
+  updateProvider: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
+  updateModel: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
+  provider: {
+    update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
+    remove: (providerID: ProviderV2.ID) => void
+  }
+  model: {
+    update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
+    remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
+  }
+}
+
+export type Loader = (update: (ctx: Context) => void) => Effect.Effect<void>
+
 export interface Interface {
+  readonly loader: () => Effect.Effect<Loader, never, Scope.Scope>
   readonly provider: {
     readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
-    readonly update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => Effect.Effect<void>
     readonly all: () => Effect.Effect<ProviderV2.Info[]>
     readonly available: () => Effect.Effect<ProviderV2.Info[]>
   }
@@ -46,11 +62,6 @@ export interface Interface {
       providerID: ProviderV2.ID,
       modelID: ModelV2.ID,
     ) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
-    readonly update: (
-      providerID: ProviderV2.ID,
-      modelID: ModelV2.ID,
-      fn: (model: Draft<ModelV2.Info>) => void,
-    ) => Effect.Effect<void, ProviderNotFoundError>
     readonly all: () => Effect.Effect<ModelV2.Info[]>
     readonly available: () => Effect.Effect<ModelV2.Info[]>
     readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
@@ -69,9 +80,11 @@ export const layer = Layer.effect(
   Effect.gen(function* () {
     yield* Location.Service
     let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
+    let loaders: { update: (ctx: Context) => void }[] = []
     let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
     const plugin = yield* PluginV2.Service
     const events = yield* EventV2.Service
+    const scope = yield* Scope.Scope
 
     const resolve = (model: ModelV2.Info) => {
       const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider
@@ -112,29 +125,122 @@ export const layer = Layer.effect(
       return match.value
     }
 
+    const normalizeEndpoint = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
+      if (item.endpoint.type !== "aisdk" || typeof item.options.aisdk.provider.baseURL !== "string") return
+      item.endpoint.url = item.options.aisdk.provider.baseURL
+      delete item.options.aisdk.provider.baseURL
+    }
+
+    const clone = (input: HashMap.HashMap<ProviderV2.ID, ProviderRecord>) =>
+      HashMap.fromIterable(HashMap.toEntries(input).map(([key, value]) => [key, { ...value, models: new Map(value.models) }] as const))
+
+    const context = (draft: { records: HashMap.HashMap<ProviderV2.ID, ProviderRecord>; data: ProviderRecord[] }): Context => {
+      const result: Context = {
+        data: draft.data,
+        updateProvider: (providerID, fn) => result.provider.update(providerID, fn),
+        updateModel: (providerID, modelID, fn) => result.model.update(providerID, modelID, fn),
+        provider: {
+          update: (providerID, fn) => {
+            const current = Option.getOrUndefined(HashMap.get(draft.records, providerID))
+            const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => {
+              fn(draft)
+              normalizeEndpoint(draft)
+            })
+            const next = {
+              provider,
+              models: current?.models ?? new Map<ModelV2.ID, ModelV2.Info>(),
+            }
+            draft.records = HashMap.set(draft.records, providerID, next)
+            const index = draft.data.findIndex((item) => item.provider.id === providerID)
+            if (index === -1) draft.data.push(next)
+            else draft.data[index] = next
+          },
+          remove: (providerID) => {
+            draft.records = HashMap.remove(draft.records, providerID)
+            const index = draft.data.findIndex((item) => item.provider.id === providerID)
+            if (index !== -1) draft.data.splice(index, 1)
+          },
+        },
+        model: {
+          update: (providerID, modelID, fn) => {
+            const current = Option.getOrThrow(HashMap.get(draft.records, providerID))
+            const model = produce(
+              current.models.get(modelID) ?? ModelV2.Info.empty(providerID, modelID),
+              (draft) => {
+                fn(draft)
+                normalizeEndpoint(draft)
+              },
+            )
+            const next = {
+              provider: current.provider,
+              models: new Map(current.models).set(modelID, new ModelV2.Info({ ...model, id: modelID, providerID })),
+            }
+            draft.records = HashMap.set(draft.records, providerID, next)
+            const index = draft.data.findIndex((item) => item.provider.id === providerID)
+            if (index === -1) draft.data.push(next)
+            else draft.data[index] = next
+          },
+          remove: (providerID, modelID) => {
+            const current = Option.getOrUndefined(HashMap.get(draft.records, providerID))
+            if (!current) return
+            const next = {
+              provider: current.provider,
+              models: new Map(current.models),
+            }
+            next.models.delete(modelID)
+            draft.records = HashMap.set(draft.records, providerID, next)
+            const index = draft.data.findIndex((item) => item.provider.id === providerID)
+            if (index !== -1) draft.data[index] = next
+          },
+        },
+      }
+      return result
+    }
+
+    const transform = Effect.fn("CatalogV2.transform")(function* () {
+      const draft = { records: clone(records), data: HashMap.toValues(records) }
+      yield* plugin.trigger("catalog.transform", context(draft), {})
+      records = draft.records
+    })
+
+    const rebuild = Effect.fn("CatalogV2.rebuild")(function* () {
+      const draft = { records: HashMap.empty<ProviderV2.ID, ProviderRecord>(), data: [] as ProviderRecord[] }
+      for (const loader of loaders) loader.update(context(draft))
+      yield* plugin.trigger("catalog.transform", context(draft), {})
+      records = draft.records
+    })
+
+    yield* plugin.added().pipe(
+      Stream.runForEach((id) =>
+        Effect.gen(function* () {
+          const draft = { records: clone(records), data: HashMap.toValues(records) }
+          yield* plugin.triggerFor(id, "catalog.transform", context(draft), {})
+          records = draft.records
+        }),
+      ),
+      Effect.forkIn(scope, { startImmediately: true }),
+    )
+
     const result: Interface = {
+      loader: Effect.fn("CatalogV2.loader")(function* () {
+        const loader = { update: (_ctx: Context) => {} }
+        loaders = [...loaders, loader]
+        const scope = yield* Scope.Scope
+        yield* Scope.addFinalizer(scope, Effect.sync(() => {
+          loaders = loaders.filter((item) => item !== loader)
+        }).pipe(Effect.andThen(rebuild())))
+        return Effect.fnUntraced(function* (update) {
+          loader.update = update
+          yield* rebuild()
+        })
+      }),
+
       provider: {
         get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
           const record = yield* getRecord(providerID)
           return record.provider
         }),
 
-        update: Effect.fnUntraced(function* (providerID, fn) {
-          const current = Option.getOrUndefined(HashMap.get(records, providerID))
-          const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => {
-            fn(draft)
-            if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") {
-              draft.endpoint.url = draft.options.aisdk.provider.baseURL
-              delete draft.options.aisdk.provider.baseURL
-            }
-          })
-          const updated = yield* plugin.trigger("provider.update", {}, { provider, cancel: false })
-          records = HashMap.set(records, providerID, {
-            provider: updated.provider,
-            models: current?.models ?? HashMap.empty<ModelV2.ID, ModelV2.Info>(),
-          })
-        }),
-
         all: Effect.fn("CatalogV2.provider.all")(function* () {
           return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider)
         }),
@@ -149,39 +255,16 @@ export const layer = Layer.effect(
       model: {
         get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
           const record = yield* getRecord(providerID)
-          const model = Option.getOrUndefined(HashMap.get(record.models, modelID))
+          const model = record.models.get(modelID)
           if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
           return resolve(model)
         }),
 
-        update: Effect.fnUntraced(function* (providerID, modelID, fn) {
-          const record = yield* getRecord(providerID)
-          const model = produce(
-            HashMap.get(record.models, modelID).pipe(Option.getOrElse(() => ModelV2.Info.empty(providerID, modelID))),
-            (draft) => {
-              fn(draft)
-              if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") {
-                draft.endpoint.url = draft.options.aisdk.provider.baseURL
-                delete draft.options.aisdk.provider.baseURL
-              }
-            },
-          )
-          const updated = yield* plugin.trigger("model.update", {}, { model, cancel: false })
-          if (updated.cancel) return
-          const next = new ModelV2.Info({ ...updated.model, id: modelID, providerID })
-          records = HashMap.set(records, providerID, {
-            provider: record.provider,
-            models: HashMap.set(record.models, modelID, next),
-          })
-          yield* events.publish(Event.ModelUpdated, { model: resolve(next) })
-          return
-        }),
-
         all: Effect.fn("CatalogV2.model.all")(function* () {
           return pipe(
             records,
             HashMap.toValues,
-            Array.flatMap((record) => HashMap.toValues(record.models)),
+            Array.flatMap((record) => globalThis.Array.from(record.models.values())),
             Array.map(resolve),
             Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
           )
@@ -217,12 +300,12 @@ export const layer = Layer.effect(
           if (!record) return Option.none<ModelV2.Info>()
 
           if (providerID === ProviderV2.ID.opencode) {
-            const gpt5Nano = Option.getOrUndefined(HashMap.get(record.models, ModelV2.ID.make("gpt-5-nano")))
+            const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
             if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano))
           }
 
           const candidates = pipe(
-            HashMap.toValues(record.models),
+            globalThis.Array.from(record.models.values()),
             Array.filter(
               (model) =>
                 model.providerID === providerID &&
@@ -266,4 +349,7 @@ export const layer = Layer.effect(
 
 const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
 
-export const defaultLayer = layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer))
+export const defaultLayer = layer.pipe(
+  Layer.provide(EventV2.defaultLayer),
+  Layer.provide(PluginV2.defaultLayer),
+)

+ 2 - 2
packages/core/src/event.ts

@@ -1,3 +1,5 @@
+export * as EventV2 from "./event"
+
 import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
 import { Location } from "./location"
 import { withStatics } from "./schema"
@@ -153,5 +155,3 @@ export const layer = Layer.effect(
 )
 
 export const defaultLayer = layer
-
-export * as EventV2 from "./event"

+ 5 - 4
packages/core/src/location-layer.ts

@@ -4,9 +4,10 @@ import { Catalog } from "./catalog"
 import { PluginBoot } from "./plugin/boot"
 
 export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
-  lookup: (ref: Location.Ref) => {
-    const location = Layer.succeed(Location.Service, Location.Service.of(ref))
-    return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(location))
-  },
+  lookup: (ref: Location.Ref) =>
+    Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(
+      Layer.provide([Layer.succeed(Location.Service, Location.Service.of(ref))]),
+    ),
   idleTimeToLive: "5 minutes",
+  dependencies: [],
 }) {}

+ 12 - 1
packages/core/src/models-dev.ts

@@ -7,6 +7,7 @@ import { Flock } from "./util/flock"
 import { Hash } from "./util/hash"
 import { AppFileSystem } from "./filesystem"
 import { InstallationChannel, InstallationVersion } from "./installation/version"
+import { EventV2 } from "./event"
 
 export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
 export type CatalogModelStatus = typeof CatalogModelStatus.Type
@@ -105,6 +106,13 @@ export const Provider = Schema.Struct({
 
 export type Provider = Schema.Schema.Type<typeof Provider>
 
+export const Event = {
+  Refreshed: EventV2.define({
+    type: "models-dev.refreshed",
+    schema: {},
+  }),
+}
+
 declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined
 
 export interface Interface {
@@ -118,6 +126,7 @@ export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
     const fs = yield* AppFileSystem.Service
+    const events = yield* EventV2.Service
     const http = HttpClient.filterStatusOk(
       (yield* HttpClient.HttpClient).pipe(
         HttpClient.retryTransient({
@@ -197,6 +206,7 @@ export const layer = Layer.effect(
           if (!force && (yield* fresh())) return
           yield* fetchAndWrite()
           yield* invalidate
+          yield* events.publish(Event.Refreshed, {})
         }),
       ).pipe(
         Effect.tapCause((cause) =>
@@ -215,9 +225,10 @@ export const layer = Layer.effect(
   }),
 )
 
-export const defaultLayer: Layer.Layer<Service> = layer.pipe(
+export const defaultLayer = layer.pipe(
   Layer.provide(FetchHttpClient.layer),
   Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(EventV2.defaultLayer),
 )
 
 export * as ModelsDev from "./models-dev"

+ 45 - 0
packages/core/src/permission.ts

@@ -0,0 +1,45 @@
+export * as PermissionV2 from "./permission"
+
+import { Schema } from "effect"
+import { Wildcard } from "./util/wildcard"
+
+export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Action" })
+export type Action = typeof Action.Type
+
+export const Rule = Schema.Struct({
+  permission: Schema.String,
+  pattern: Schema.String,
+  action: Action,
+}).annotate({ identifier: "PermissionV2.Rule" })
+export type Rule = typeof Rule.Type
+
+export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" })
+export type Ruleset = typeof Ruleset.Type
+
+const EDIT_TOOLS = ["edit", "write", "apply_patch"]
+
+export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
+  return (
+    rulesets
+      .flat()
+      .findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
+      action: "ask",
+      permission,
+      pattern: "*",
+    }
+  )
+}
+
+export function merge(...rulesets: Ruleset[]): Ruleset {
+  return rulesets.flat()
+}
+
+export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
+  return new Set(
+    tools.filter((tool) => {
+      const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
+      const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
+      return rule?.pattern === "*" && rule.action === "deny"
+    }),
+  )
+}

+ 63 - 18
packages/core/src/plugin.ts

@@ -2,27 +2,26 @@ export * as PluginV2 from "./plugin"
 
 import { createDraft, finishDraft, type Draft } from "immer"
 import type { LanguageModelV3 } from "@ai-sdk/provider"
-import { type ProviderV2 } from "./provider"
-import { Context, Effect, Layer, Schema } from "effect"
+import { Context, Effect, Exit, Layer, PubSub, Schema, Scope, Stream } from "effect"
 import type { ModelV2 } from "./model"
+import type { AgentV2 } from "./agent"
+import type { Catalog } from "./catalog"
 
 export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
 export type ID = typeof ID.Type
 
 type HookSpec = {
-  "provider.update": {
-    input: {}
-    output: {
-      provider: ProviderV2.Info
-      cancel: boolean
-    }
+  "catalog.transform": {
+    input: Catalog.Context
+    output: {}
   }
-  "model.update": {
-    input: {}
-    output: {
-      model: ModelV2.Info
-      cancel: boolean
+  "account.switched": {
+    input: {
+      serviceID: import("./account").AccountV2.ServiceID
+      from?: import("./account").AccountV2.ID
+      to?: import("./account").AccountV2.ID
     }
+    output: {}
   }
   "aisdk.language": {
     input: {
@@ -44,6 +43,27 @@ type HookSpec = {
       sdk?: any
     }
   }
+  "agent.update": {
+    input: {}
+    output: {
+      agent: AgentV2.Info
+      cancel: boolean
+    }
+  }
+  "agent.remove": {
+    input: {
+      agent: AgentV2.Info
+    }
+    output: {
+      cancel: boolean
+    }
+  }
+  "agent.default": {
+    input: {}
+    output: {
+      agent?: AgentV2.ID
+    }
+  }
 }
 
 export type Hooks = {
@@ -61,15 +81,25 @@ export type HookFunctions = {
 export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"]
 export type HookOutput<Name extends keyof Hooks> = HookSpec[Name]["output"]
 
-export type Effect = Effect.Effect<HookFunctions | void, never, never>
+export type Effect<R = never> = Effect.Effect<HookFunctions | void, never, R | Scope.Scope>
 
 export function define<R>(input: { id: ID; effect: Effect.Effect<HookFunctions | void, never, R> }) {
   return input
 }
 
 export interface Interface {
-  readonly add: (input: { id: ID; effect: Effect }) => Effect.Effect<void>
+  readonly add: (input: {
+    id: ID
+    effect: Effect.Effect<void | HookFunctions, never, Scope.Scope>
+  }) => Effect.Effect<void, never, never>
   readonly remove: (id: ID) => Effect.Effect<void>
+  readonly added: () => Stream.Stream<ID>
+  readonly triggerFor: <Name extends keyof Hooks>(
+    id: ID,
+    name: Name,
+    input: HookInput<Name>,
+    output: HookOutput<Name>,
+  ) => Effect.Effect<HookInput<Name> & HookOutput<Name>>
   readonly trigger: <Name extends keyof Hooks>(
     name: Name,
     input: HookInput<Name>,
@@ -85,21 +115,33 @@ export const layer = Layer.effect(
     let hooks: {
       id: ID
       hooks: HookFunctions
+      scope: Scope.Closeable
     }[] = []
+    const added = yield* PubSub.unbounded<ID>()
+
+    yield* Effect.addFinalizer(() => PubSub.shutdown(added))
 
     const svc = Service.of({
       add: Effect.fn("Plugin.add")(function* (input) {
-        const result = yield* input.effect
-        if (!result) return
+        const existing = hooks.find((item) => item.id === input.id)
+        if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
+        const scope = yield* Scope.make()
+        const result = yield* input.effect.pipe(Scope.provide(scope))
         hooks = [
           ...hooks.filter((item) => item.id !== input.id),
           {
             id: input.id,
-            hooks: result,
+            hooks: result ?? {},
+            scope,
           },
         ]
+        yield* PubSub.publish(added, input.id)
       }),
+      added: () => Stream.fromPubSub(added),
       trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) {
+        return yield* svc.triggerFor(ID.make("*"), name, input, output)
+      }),
+      triggerFor: Effect.fn("Plugin.triggerFor")(function* (id, name, input, output) {
         const draftEntries = new Map<string, ReturnType<typeof createDraft>>()
         const event = {
           ...input,
@@ -114,6 +156,7 @@ export const layer = Layer.effect(
         }
 
         for (const item of hooks) {
+          if (id !== ID.make("*") && item.id !== id) continue
           const match = item.hooks[name]
           if (!match) continue
           yield* match(event as any).pipe(
@@ -133,7 +176,9 @@ export const layer = Layer.effect(
         return event as any
       }),
       remove: Effect.fn("Plugin.remove")(function* (id) {
+        const existing = hooks.find((item) => item.id === id)
         hooks = hooks.filter((item) => item.id !== id)
+        if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
       }),
     })
     return svc

+ 41 - 0
packages/core/src/plugin/account.ts

@@ -0,0 +1,41 @@
+import { Effect, Scope, Stream } from "effect"
+import { AccountV2 } from "../account"
+import { EventV2 } from "../event"
+import { PluginV2 } from "../plugin"
+
+export const AccountPlugin = PluginV2.define({
+  id: PluginV2.ID.make("account"),
+  effect: Effect.gen(function* () {
+    const accounts = yield* AccountV2.Service
+    const events = yield* EventV2.Service
+    const scope = yield* Scope.Scope
+
+    yield* events.subscribe(AccountV2.Event.Switched).pipe(
+      Stream.runForEach((event) =>
+        PluginV2.Service.use((plugin) => plugin.trigger("account.switched", event.data, {})).pipe(Effect.asVoid),
+      ),
+      Effect.forkIn(scope, { startImmediately: true }),
+    )
+
+    return {
+      "catalog.transform": Effect.fn(function* (evt) {
+        for (const item of evt.data) {
+          const account = yield* accounts.active(AccountV2.ServiceID.make(item.provider.id)).pipe(Effect.orDie)
+          if (!account) continue
+          evt.provider.update(item.provider.id, (provider) => {
+            provider.enabled = {
+              via: "account",
+              service: account.serviceID,
+            }
+            if (account.credential.type === "api") {
+              provider.options.aisdk.provider.apiKey = account.credential.key
+              Object.assign(provider.options.aisdk.provider, account.credential.metadata ?? {})
+            }
+            if (account.credential.type === "oauth") provider.options.aisdk.provider.apiKey = account.credential.access
+          })
+        }
+      }),
+      "account.switched": Effect.fn(function* () {}),
+    }
+  }),
+})

+ 0 - 27
packages/core/src/plugin/auth.ts

@@ -1,27 +0,0 @@
-import { Effect } from "effect"
-import { AuthV2 } from "../auth"
-import { PluginV2 } from "../plugin"
-
-export const AuthPlugin = PluginV2.define({
-  id: PluginV2.ID.make("auth"),
-  effect: Effect.gen(function* () {
-    const auth = yield* AuthV2.Service
-    return {
-      "provider.update": Effect.fn(function* (evt) {
-        const account = yield* auth.active(AuthV2.ServiceID.make(evt.provider.id)).pipe(Effect.orDie)
-        if (!account) return
-        evt.provider.enabled = {
-          via: "auth",
-          service: account.serviceID,
-        }
-        if (account.credential.type === "api") {
-          evt.provider.options.aisdk.provider.apiKey = account.credential.key
-          Object.assign(evt.provider.options.aisdk.provider, account.credential.metadata ?? {})
-        }
-        if (account.credential.type === "oauth") {
-          evt.provider.options.aisdk.provider.apiKey = account.credential.access
-        }
-      }),
-    }
-  }),
-})

+ 51 - 41
packages/core/src/plugin/boot.ts

@@ -1,18 +1,22 @@
 export * as PluginBoot from "./boot"
 
-import { Context, Deferred, Effect, Layer } from "effect"
-import { AuthV2 } from "../auth"
+import { Context, Deferred, Effect, Layer, Scope } from "effect"
+import { AccountV2 } from "../account"
+import { AgentV2 } from "../agent"
 import { Catalog } from "../catalog"
+import { EventV2 } from "../event"
 import { Npm } from "../npm"
 import { PluginV2 } from "../plugin"
-import { AuthPlugin } from "./auth"
+import { AccountPlugin } from "./account"
 import { EnvPlugin } from "./env"
 import { ModelsDevPlugin } from "./models-dev"
 import { ProviderPlugins } from "./provider"
 
 type Plugin = {
   id: PluginV2.ID
-  effect: Effect.Effect<PluginV2.HookFunctions | void, never, Catalog.Service | AuthV2.Service | Npm.Service>
+  effect: PluginV2.Effect<
+    Catalog.Service | AgentV2.Service | AccountV2.Service | Npm.Service | EventV2.Service | PluginV2.Service
+  >
 }
 
 export interface Interface {
@@ -21,51 +25,57 @@ export interface Interface {
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginBoot") {}
 
-export const layer: Layer.Layer<Service, never, Catalog.Service | PluginV2.Service | AuthV2.Service | Npm.Service> =
-  Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const catalog = yield* Catalog.Service
-      const plugin = yield* PluginV2.Service
-      const auth = yield* AuthV2.Service
-      const npm = yield* Npm.Service
-      const done = yield* Deferred.make<void>()
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const agent = yield* AgentV2.Service
+    const catalog = yield* Catalog.Service
+    const plugin = yield* PluginV2.Service
+    const accounts = yield* AccountV2.Service
+    const npm = yield* Npm.Service
+    const events = yield* EventV2.Service
+    const done = yield* Deferred.make<void>()
 
-      const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
-        yield* plugin.add({
-          id: input.id,
-          effect: input.effect.pipe(
-            Effect.provideService(Catalog.Service, catalog),
-            Effect.provideService(AuthV2.Service, auth),
-            Effect.provideService(Npm.Service, npm),
-          ),
-        })
+    const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
+      yield* plugin.add({
+        id: input.id,
+        effect: input.effect.pipe(
+          Effect.provideService(Catalog.Service, catalog),
+          Effect.provideService(AgentV2.Service, agent),
+          Effect.provideService(AccountV2.Service, accounts),
+          Effect.provideService(Npm.Service, npm),
+          Effect.provideService(EventV2.Service, events),
+          Effect.provideService(PluginV2.Service, plugin),
+        ),
       })
+    })
 
-      const boot = Effect.gen(function* () {
-        yield* add(EnvPlugin)
-        yield* add(AuthPlugin)
-        for (const item of ProviderPlugins) {
-          yield* add(item)
-        }
-        yield* add(ModelsDevPlugin)
-      }).pipe(Effect.withSpan("PluginBoot.boot"))
+    const boot = Effect.gen(function* () {
+      yield* add(EnvPlugin)
+      yield* add(AccountPlugin)
+      for (const item of ProviderPlugins) {
+        yield* add(item)
+      }
+      yield* add(ModelsDevPlugin)
+    }).pipe(Effect.withSpan("PluginBoot.boot"))
 
-      yield* boot.pipe(
-        Effect.exit,
-        Effect.flatMap((exit) => Deferred.done(done, exit)),
-        Effect.forkScoped,
-      )
+    yield* boot.pipe(
+      Effect.exit,
+      Effect.flatMap((exit) => Deferred.done(done, exit)),
+      Effect.forkScoped,
+    )
 
-      return Service.of({
-        wait: () => Deferred.await(done),
-      })
-    }),
-  )
+    return Service.of({
+      wait: () => Deferred.await(done),
+    })
+  }),
+)
 
 export const defaultLayer = layer.pipe(
+  Layer.provide(AgentV2.defaultLayer),
   Layer.provide(Catalog.defaultLayer),
+  Layer.provide(EventV2.defaultLayer),
   Layer.provide(PluginV2.defaultLayer),
-  Layer.provide(Layer.orDie(AuthV2.defaultLayer)),
+  Layer.provide(AccountV2.defaultLayer),
   Layer.provide(Npm.defaultLayer),
 )

+ 10 - 6
packages/core/src/plugin/env.ts

@@ -5,12 +5,16 @@ export const EnvPlugin = PluginV2.define({
   id: PluginV2.ID.make("env"),
   effect: Effect.gen(function* () {
     return {
-      "provider.update": Effect.fn(function* (evt) {
-        const key = evt.provider.env.find((item) => process.env[item])
-        if (!key) return
-        evt.provider.enabled = {
-          via: "env",
-          name: key,
+      "catalog.transform": Effect.fn(function* (evt) {
+        for (const item of evt.data) {
+          const key = item.provider.env.find((env) => process.env[env])
+          if (!key) continue
+          evt.provider.update(item.provider.id, (provider) => {
+            provider.enabled = {
+              via: "env",
+              name: key,
+            }
+          })
         }
       }),
     }

+ 56 - 44
packages/core/src/plugin/models-dev.ts

@@ -1,5 +1,6 @@
-import { DateTime, Effect } from "effect"
+import { DateTime, Effect, Scope, Stream } from "effect"
 import { Catalog } from "../catalog"
+import { EventV2 } from "../event"
 import { ModelV2 } from "../model"
 import { ModelsDev } from "../models-dev"
 import { PluginV2 } from "../plugin"
@@ -54,55 +55,66 @@ export const ModelsDevPlugin = PluginV2.define({
   effect: Effect.gen(function* () {
     const catalog = yield* Catalog.Service
     const modelsDev = yield* ModelsDev.Service
-    for (const item of Object.values(yield* modelsDev.get())) {
-      const providerID = ProviderV2.ID.make(item.id)
-      yield* catalog.provider.update(providerID, (provider) => {
-        provider.name = item.name
-        provider.env = [...item.env]
-        provider.endpoint = item.npm
-          ? {
-              type: "aisdk",
-              package: item.npm,
-              url: item.api,
-            }
-          : {
-              type: "unknown",
-            }
-      })
-
-      for (const model of Object.values(item.models)) {
-        const modelID = ModelV2.ID.make(model.id)
-        yield* catalog.model
-          .update(providerID, modelID, (draft) => {
-            draft.name = model.name
-            draft.family = model.family ? ModelV2.Family.make(model.family) : undefined
-            draft.endpoint = model.provider?.npm
+    const events = yield* EventV2.Service
+    const scope = yield* Scope.Scope
+    const load = yield* catalog.loader()
+    const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () {
+      const data = yield* modelsDev.get()
+      yield* load((catalog) => {
+        for (const item of Object.values(data)) {
+          const providerID = ProviderV2.ID.make(item.id)
+          catalog.provider.update(providerID, (provider) => {
+            provider.name = item.name
+            provider.env = [...item.env]
+            provider.endpoint = item.npm
               ? {
                   type: "aisdk",
-                  package: model.provider?.npm,
-                  url: model.provider.api,
+                  package: item.npm,
+                  url: item.api,
                 }
               : {
                   type: "unknown",
                 }
-            draft.capabilities = {
-              tools: model.tool_call,
-              input: [...(model.modalities?.input ?? [])],
-              output: [...(model.modalities?.output ?? [])],
-            }
-            draft.variants = variants(model)
-            draft.time.released = released(model.release_date)
-            draft.cost = cost(model.cost)
-            draft.status = model.status ?? "active"
-            draft.enabled = true
-            draft.limit = {
-              context: model.limit.context,
-              input: model.limit.input,
-              output: model.limit.output,
-            }
           })
-          .pipe(Effect.orDie)
-      }
-    }
+
+          for (const model of Object.values(item.models)) {
+            const modelID = ModelV2.ID.make(model.id)
+            catalog.model.update(providerID, modelID, (draft) => {
+              draft.name = model.name
+              draft.family = model.family ? ModelV2.Family.make(model.family) : undefined
+              draft.endpoint = model.provider?.npm
+                ? {
+                    type: "aisdk",
+                    package: model.provider?.npm,
+                    url: model.provider.api,
+                  }
+                : {
+                    type: "unknown",
+                  }
+              draft.capabilities = {
+                tools: model.tool_call,
+                input: [...(model.modalities?.input ?? [])],
+                output: [...(model.modalities?.output ?? [])],
+              }
+              draft.variants = variants(model)
+              draft.time.released = released(model.release_date)
+              draft.cost = cost(model.cost)
+              draft.status = model.status ?? "active"
+              draft.enabled = true
+              draft.limit = {
+                context: model.limit.context,
+                input: model.limit.input,
+                output: model.limit.output,
+              }
+            })
+          }
+        }
+      })
+    })
+    yield* refresh()
+    yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
+      Stream.runForEach(() => refresh()),
+      Effect.forkIn(scope, { startImmediately: true }),
+    )
   }).pipe(Effect.provide(ModelsDev.defaultLayer)),
 })

+ 13 - 8
packages/core/src/plugin/provider/amazon-bedrock.ts

@@ -50,14 +50,19 @@ export const AmazonBedrockPlugin = PluginV2.define({
   id: PluginV2.ID.make("amazon-bedrock"),
   effect: Effect.gen(function* () {
     return {
-      "provider.update": Effect.fn(function* (evt) {
-        if (evt.provider.id !== ProviderV2.ID.amazonBedrock) return
-        if (evt.provider.endpoint.type !== "aisdk") return
-        if (typeof evt.provider.options.aisdk.provider.endpoint !== "string") return
-        // The AI SDK expects a base URL, but users configure Bedrock private/VPC
-        // endpoints as `endpoint`; move it into the catalog endpoint URL once.
-        evt.provider.endpoint.url = evt.provider.options.aisdk.provider.endpoint
-        delete evt.provider.options.aisdk.provider.endpoint
+      "catalog.transform": Effect.fn(function* (evt) {
+        for (const item of evt.data) {
+          if (item.provider.endpoint.type !== "aisdk") continue
+          if (item.provider.endpoint.package !== "@ai-sdk/amazon-bedrock") continue
+          evt.provider.update(item.provider.id, (provider) => {
+            if (provider.endpoint.type !== "aisdk") return
+            if (typeof provider.options.aisdk.provider.endpoint !== "string") return
+            // The AI SDK expects a base URL, but users configure Bedrock private/VPC
+            // endpoints as `endpoint`; move it into the catalog endpoint URL once.
+            provider.endpoint.url = provider.options.aisdk.provider.endpoint
+            delete provider.options.aisdk.provider.endpoint
+          })
+        }
       }),
       "aisdk.sdk": Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/amazon-bedrock") return

+ 9 - 5
packages/core/src/plugin/provider/anthropic.ts

@@ -1,15 +1,19 @@
 import { Effect } from "effect"
 import { PluginV2 } from "../../plugin"
-import { ProviderV2 } from "../../provider"
 
 export const AnthropicPlugin = PluginV2.define({
   id: PluginV2.ID.make("anthropic"),
   effect: Effect.gen(function* () {
     return {
-      "provider.update": Effect.fn(function* (evt) {
-        if (evt.provider.id !== ProviderV2.ID.anthropic) return
-        evt.provider.options.headers["anthropic-beta"] =
-          "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
+      "catalog.transform": Effect.fn(function* (evt) {
+        for (const item of evt.data) {
+          if (item.provider.endpoint.type !== "aisdk") continue
+          if (item.provider.endpoint.package !== "@ai-sdk/anthropic") continue
+          evt.provider.update(item.provider.id, (provider) => {
+          provider.options.headers["anthropic-beta"] =
+            "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
+          })
+        }
       }),
       "aisdk.sdk": Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/anthropic") return

+ 22 - 10
packages/core/src/plugin/provider/azure.ts

@@ -14,12 +14,18 @@ export const AzurePlugin = PluginV2.define({
   id: PluginV2.ID.make("azure"),
   effect: Effect.gen(function* () {
     return {
-      "provider.update": Effect.fn(function* (evt) {
-        if (evt.provider.id !== ProviderV2.ID.azure) return
-        const configured = evt.provider.options.aisdk.provider.resourceName
-        const resourceName =
-          typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
-        if (resourceName) evt.provider.options.aisdk.provider.resourceName = resourceName
+      "catalog.transform": Effect.fn(function* (evt) {
+        for (const item of evt.data) {
+          if (item.provider.endpoint.type !== "aisdk") continue
+          if (item.provider.endpoint.package !== "@ai-sdk/azure") continue
+          const configured = item.provider.options.aisdk.provider.resourceName
+          const resourceName =
+            typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
+          if (!resourceName) continue
+          evt.provider.update(item.provider.id, (provider) => {
+            provider.options.aisdk.provider.resourceName = resourceName
+          })
+        }
       }),
       "aisdk.sdk": Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/azure") return
@@ -49,11 +55,17 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({
   id: PluginV2.ID.make("azure-cognitive-services"),
   effect: Effect.gen(function* () {
     return {
-      "provider.update": Effect.fn(function* (evt) {
-        if (evt.provider.id !== ProviderV2.ID.make("azure-cognitive-services")) return
+      "catalog.transform": Effect.fn(function* (evt) {
         const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
-        if (resourceName)
-          evt.provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
+        if (!resourceName) return
+        for (const item of evt.data) {
+          if (item.provider.endpoint.type !== "aisdk") continue
+          if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
+          if (!item.provider.id.includes("azure-cognitive-services")) continue
+          evt.provider.update(item.provider.id, (provider) => {
+            provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
+          })
+        }
       }),
       "aisdk.language": Effect.fn(function* (evt) {
         if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return

+ 8 - 4
packages/core/src/plugin/provider/cerebras.ts

@@ -1,14 +1,18 @@
 import { Effect } from "effect"
 import { PluginV2 } from "../../plugin"
-import { ProviderV2 } from "../../provider"
 
 export const CerebrasPlugin = PluginV2.define({
   id: PluginV2.ID.make("cerebras"),
   effect: Effect.gen(function* () {
     return {
-      "provider.update": Effect.fn(function* (evt) {
-        if (evt.provider.id !== ProviderV2.ID.make("cerebras")) return
-        evt.provider.options.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
+      "catalog.transform": Effect.fn(function* (ctx) {
+        for (const item of ctx.data) {
+          if (item.provider.endpoint.type !== "aisdk") continue
+          if (item.provider.endpoint.package !== "@ai-sdk/cerebras") continue
+          ctx.provider.update(item.provider.id, (provider) => {
+            provider.options.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
+          })
+        }
       }),
       "aisdk.sdk": Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/cerebras") return

+ 1 - 1
packages/core/src/plugin/provider/cloudflare-ai-gateway.ts

@@ -45,7 +45,7 @@ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
 
 function gatewayConfig(options: Record<string, unknown>): GatewayConfig | undefined {
   const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
-  // AuthPlugin copies CLI prompt metadata into options. The prompt stores the
+  // AccountPlugin copies CLI prompt metadata into options. The prompt stores the
   // gateway as gatewayId, while older config examples may use gateway.
   const gatewayId =
     process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway")

+ 9 - 7
packages/core/src/plugin/provider/cloudflare-workers-ai.ts

@@ -10,13 +10,15 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({
   id: PluginV2.ID.make("cloudflare-workers-ai"),
   effect: Effect.gen(function* () {
     return {
-      "provider.update": Effect.fn(function* (evt) {
-        if (evt.provider.id !== providerID) return
-        if (evt.provider.endpoint.type !== "aisdk") return
-        if (evt.provider.endpoint.url) return
-
-        const accountId = resolveAccountId(evt.provider.options.aisdk.provider)
-        if (accountId) evt.provider.endpoint.url = workersEndpoint(accountId)
+      "catalog.transform": Effect.fn(function* (evt) {
+        const item = evt.data.find((record) => record.provider.id === providerID)
+        if (!item) return
+        evt.provider.update(item.provider.id, (provider) => {
+          if (provider.endpoint.type !== "aisdk") return
+          if (provider.endpoint.url) return
+          const accountId = resolveAccountId(provider.options.aisdk.provider)
+          if (accountId) provider.endpoint.url = workersEndpoint(accountId)
+        })
       }),
       "aisdk.sdk": Effect.fn(function* (evt) {
         if (evt.model.providerID !== providerID) return

+ 8 - 8
packages/core/src/plugin/provider/github-copilot.ts

@@ -15,9 +15,6 @@ export const GithubCopilotPlugin = PluginV2.define({
   id: PluginV2.ID.make("github-copilot"),
   effect: Effect.gen(function* () {
     return {
-      "provider.update": Effect.fn(function* (evt) {
-        if (evt.provider.id !== ProviderV2.ID.githubCopilot) return
-      }),
       "aisdk.sdk": Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/github-copilot") return
         const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
@@ -33,11 +30,14 @@ export const GithubCopilotPlugin = PluginV2.define({
           ? evt.sdk.responses(evt.model.apiID)
           : evt.sdk.chat(evt.model.apiID)
       }),
-      "model.update": Effect.fn(function* (evt) {
-        if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
-        // This chat-only alias conflicts with the Copilot GPT-5 Responses route,
-        // so hide it only for Copilot rather than for every provider catalog.
-        if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true
+      "catalog.transform": Effect.fn(function* (evt) {
+        const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.githubCopilot)
+        if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
+        evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
+          // This chat-only alias conflicts with the Copilot GPT-5 Responses route,
+          // so hide it only for Copilot rather than for every provider catalog.
+          model.enabled = false
+        })
       }),
     }
   }),

+ 35 - 28
packages/core/src/plugin/provider/google-vertex.ts

@@ -57,20 +57,22 @@ export const GoogleVertexPlugin = PluginV2.define({
   id: PluginV2.ID.make("google-vertex"),
   effect: Effect.gen(function* () {
     return {
-      "provider.update": Effect.fn(function* (evt) {
-        if (evt.provider.id !== ProviderV2.ID.googleVertex) return
-        const project = resolveProject(evt.provider.options.aisdk.provider)
-        const location = String(resolveLocation(evt.provider.options.aisdk.provider))
-        if (project) evt.provider.options.aisdk.provider.project = project
-        evt.provider.options.aisdk.provider.location = location
-        if (evt.provider.endpoint.type === "aisdk" && evt.provider.endpoint.url) {
-          evt.provider.endpoint.url = replaceVertexVars(evt.provider.endpoint.url, project, location)
-        }
-        if (
-          evt.provider.endpoint.type === "aisdk" &&
-          evt.provider.endpoint.package.includes("@ai-sdk/openai-compatible")
-        ) {
-          evt.provider.options.aisdk.provider.fetch = authFetch(evt.provider.options.aisdk.provider.fetch)
+      "catalog.transform": Effect.fn(function* (evt) {
+        for (const item of evt.data) {
+          if (item.provider.endpoint.type !== "aisdk") continue
+          if (item.provider.endpoint.package !== "@ai-sdk/google-vertex" && !item.provider.endpoint.package.includes("@ai-sdk/openai-compatible")) continue
+          const project = resolveProject(item.provider.options.aisdk.provider)
+          const location = String(resolveLocation(item.provider.options.aisdk.provider))
+          evt.provider.update(item.provider.id, (provider) => {
+            if (project) provider.options.aisdk.provider.project = project
+            provider.options.aisdk.provider.location = location
+            if (provider.endpoint.type === "aisdk" && provider.endpoint.url) {
+              provider.endpoint.url = replaceVertexVars(provider.endpoint.url, project, location)
+            }
+            if (provider.endpoint.type === "aisdk" && provider.endpoint.package.includes("@ai-sdk/openai-compatible")) {
+              provider.options.aisdk.provider.fetch = authFetch(provider.options.aisdk.provider.fetch)
+            }
+          })
         }
       }),
       "aisdk.sdk": Effect.fn(function* (evt) {
@@ -102,20 +104,25 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
   id: PluginV2.ID.make("google-vertex-anthropic"),
   effect: Effect.gen(function* () {
     return {
-      "provider.update": Effect.fn(function* (evt) {
-        if (evt.provider.id !== ProviderV2.ID.make("google-vertex-anthropic")) return
-        const project =
-          evt.provider.options.aisdk.provider.project ??
-          process.env.GOOGLE_CLOUD_PROJECT ??
-          process.env.GCP_PROJECT ??
-          process.env.GCLOUD_PROJECT
-        const location =
-          evt.provider.options.aisdk.provider.location ??
-          process.env.GOOGLE_CLOUD_LOCATION ??
-          process.env.VERTEX_LOCATION ??
-          "global"
-        if (project) evt.provider.options.aisdk.provider.project = project
-        evt.provider.options.aisdk.provider.location = location
+      "catalog.transform": Effect.fn(function* (evt) {
+        for (const item of evt.data) {
+          if (item.provider.endpoint.type !== "aisdk") continue
+          if (item.provider.endpoint.package !== "@ai-sdk/google-vertex/anthropic") continue
+          const project =
+            item.provider.options.aisdk.provider.project ??
+            process.env.GOOGLE_CLOUD_PROJECT ??
+            process.env.GCP_PROJECT ??
+            process.env.GCLOUD_PROJECT
+          const location =
+            item.provider.options.aisdk.provider.location ??
+            process.env.GOOGLE_CLOUD_LOCATION ??
+            process.env.VERTEX_LOCATION ??
+            "global"
+          evt.provider.update(item.provider.id, (provider) => {
+            if (project) provider.options.aisdk.provider.project = project
+            provider.options.aisdk.provider.location = location
+          })
+        }
       }),
       "aisdk.sdk": Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/google-vertex/anthropic") return

+ 10 - 5
packages/core/src/plugin/provider/kilo.ts

@@ -1,15 +1,20 @@
 import { Effect } from "effect"
 import { PluginV2 } from "../../plugin"
-import { ProviderV2 } from "../../provider"
 
 export const KiloPlugin = PluginV2.define({
   id: PluginV2.ID.make("kilo"),
   effect: Effect.gen(function* () {
     return {
-      "provider.update": Effect.fn(function* (evt) {
-        if (evt.provider.id !== ProviderV2.ID.make("kilo")) return
-        evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
-        evt.provider.options.headers["X-Title"] = "opencode"
+      "catalog.transform": Effect.fn(function* (evt) {
+        for (const item of evt.data) {
+          if (item.provider.endpoint.type !== "aisdk") continue
+          if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
+          if (item.provider.endpoint.url !== "https://api.kilo.ai/api/gateway") continue
+          evt.provider.update(item.provider.id, (provider) => {
+            provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
+            provider.options.headers["X-Title"] = "opencode"
+          })
+        }
       }),
     }
   }),

+ 12 - 7
packages/core/src/plugin/provider/llmgateway.ts

@@ -1,17 +1,22 @@
 import { Effect } from "effect"
 import { PluginV2 } from "../../plugin"
-import { ProviderV2 } from "../../provider"
 
 export const LLMGatewayPlugin = PluginV2.define({
   id: PluginV2.ID.make("llmgateway"),
   effect: Effect.gen(function* () {
     return {
-      "provider.update": Effect.fn(function* (evt) {
-        if (evt.provider.id !== ProviderV2.ID.make("llmgateway")) return
-        if (evt.provider.enabled === false) return
-        evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
-        evt.provider.options.headers["X-Title"] = "opencode"
-        evt.provider.options.headers["X-Source"] = "opencode"
+      "catalog.transform": Effect.fn(function* (evt) {
+        for (const item of evt.data) {
+          if (item.provider.enabled === false) continue
+          if (item.provider.endpoint.type !== "aisdk") continue
+          if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
+          if (item.provider.endpoint.url !== "https://api.llmgateway.io/v1") continue
+          evt.provider.update(item.provider.id, (provider) => {
+            provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
+            provider.options.headers["X-Title"] = "opencode"
+            provider.options.headers["X-Source"] = "opencode"
+          })
+        }
       }),
     }
   }),

+ 11 - 6
packages/core/src/plugin/provider/nvidia.ts

@@ -1,16 +1,21 @@
 import { Effect } from "effect"
 import { PluginV2 } from "../../plugin"
-import { ProviderV2 } from "../../provider"
 
 export const NvidiaPlugin = PluginV2.define({
   id: PluginV2.ID.make("nvidia"),
   effect: Effect.gen(function* () {
     return {
-      "provider.update": Effect.fn(function* (evt) {
-        if (evt.provider.id !== ProviderV2.ID.make("nvidia")) return
-        evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
-        evt.provider.options.headers["X-Title"] = "opencode"
-        evt.provider.options.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
+      "catalog.transform": Effect.fn(function* (evt) {
+        for (const item of evt.data) {
+          if (item.provider.endpoint.type !== "aisdk") continue
+          if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
+          if (item.provider.endpoint.url !== "https://integrate.api.nvidia.com/v1") continue
+          evt.provider.update(item.provider.id, (provider) => {
+          provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
+          provider.options.headers["X-Title"] = "opencode"
+          provider.options.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
+          })
+        }
       }),
     }
   }),

+ 11 - 5
packages/core/src/plugin/provider/openai.ts

@@ -16,11 +16,17 @@ export const OpenAIPlugin = PluginV2.define({
         if (evt.model.providerID !== ProviderV2.ID.openai) return
         evt.language = evt.sdk.responses(evt.model.apiID)
       }),
-      "model.update": Effect.fn(function* (evt) {
-        if (evt.model.providerID !== ProviderV2.ID.openai) return
-        // OpenAIPlugin sends OpenAI models through Responses; this alias is a
-        // chat-completions-only model, so remove it only from OpenAI's catalog.
-        if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true
+      "catalog.transform": Effect.fn(function* (evt) {
+        for (const item of evt.data) {
+          if (item.provider.endpoint.type !== "aisdk") continue
+          if (item.provider.endpoint.package !== "@ai-sdk/openai") continue
+          if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue
+          evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
+            // OpenAIPlugin sends OpenAI models through Responses; this alias is a
+            // chat-completions-only model, so hide it only from OpenAI's catalog.
+            model.enabled = false
+          })
+        }
       }),
     }
   }),

+ 15 - 10
packages/core/src/plugin/provider/opencode.ts

@@ -7,20 +7,25 @@ export const OpencodePlugin = PluginV2.define({
   effect: Effect.gen(function* () {
     let hasKey = false
     return {
-      "provider.update": Effect.fn(function* (evt) {
-        if (evt.provider.id !== ProviderV2.ID.opencode) return
+      "catalog.transform": Effect.fn(function* (evt) {
+        const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.opencode)
+        if (!item) return
         hasKey = Boolean(
           process.env.OPENCODE_API_KEY ||
-            evt.provider.env.some((item) => process.env[item]) ||
-            evt.provider.options.aisdk.provider.apiKey ||
-            (evt.provider.enabled && evt.provider.enabled.via === "auth"),
+            item.provider.env.some((env) => process.env[env]) ||
+            item.provider.options.aisdk.provider.apiKey ||
+            (item.provider.enabled && item.provider.enabled.via === "account"),
         )
-        if (!hasKey) evt.provider.options.aisdk.provider.apiKey = "public"
-      }),
-      "model.update": Effect.fn(function* (evt) {
-        if (evt.model.providerID !== ProviderV2.ID.opencode) return
+        evt.provider.update(item.provider.id, (provider) => {
+          if (!hasKey) provider.options.aisdk.provider.apiKey = "public"
+        })
         if (hasKey) return
-        if (evt.model.cost.some((item) => item.input > 0)) evt.cancel = true
+        for (const model of item.models.values()) {
+          if (!model.cost.some((cost) => cost.input > 0)) continue
+          evt.model.update(item.provider.id, model.id, (draft) => {
+            draft.enabled = false
+          })
+        }
       }),
     }
   }),

+ 17 - 12
packages/core/src/plugin/provider/openrouter.ts

@@ -1,29 +1,34 @@
 import { Effect } from "effect"
 import { ModelV2 } from "../../model"
 import { PluginV2 } from "../../plugin"
-import { ProviderV2 } from "../../provider"
 
 export const OpenRouterPlugin = PluginV2.define({
   id: PluginV2.ID.make("openrouter"),
   effect: Effect.gen(function* () {
     return {
-      "provider.update": Effect.fn(function* (evt) {
-        if (evt.provider.id !== ProviderV2.ID.openrouter) return
-        evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
-        evt.provider.options.headers["X-Title"] = "opencode"
+      "catalog.transform": Effect.fn(function* (evt) {
+        for (const item of evt.data) {
+          if (item.provider.endpoint.type !== "aisdk") continue
+          if (item.provider.endpoint.package !== "@openrouter/ai-sdk-provider") continue
+          evt.provider.update(item.provider.id, (provider) => {
+            provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
+            provider.options.headers["X-Title"] = "opencode"
+          })
+          for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) {
+            if (!item.models.has(modelID)) continue
+            evt.model.update(item.provider.id, modelID, (model) => {
+              // These are OpenRouter-specific OpenAI chat aliases that do not work
+              // on the generic path. Keep custom providers with matching IDs untouched.
+              model.enabled = false
+            })
+          }
+        }
       }),
       "aisdk.sdk": Effect.fn(function* (evt) {
         if (evt.package !== "@openrouter/ai-sdk-provider") return
         const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))
         evt.sdk = mod.createOpenRouter(evt.options)
       }),
-      "model.update": Effect.fn(function* (evt) {
-        if (evt.model.providerID !== ProviderV2.ID.openrouter) return
-        // These are OpenRouter-specific OpenAI chat aliases that do not work on
-        // the generic path. Keep custom providers with matching IDs untouched.
-        if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true
-        if (evt.model.id === ModelV2.ID.make("openai/gpt-5-chat")) evt.cancel = true
-      }),
     }
   }),
 })

+ 9 - 4
packages/core/src/plugin/provider/vercel.ts

@@ -6,10 +6,15 @@ export const VercelPlugin = PluginV2.define({
   id: PluginV2.ID.make("vercel"),
   effect: Effect.gen(function* () {
     return {
-      "provider.update": Effect.fn(function* (evt) {
-        if (evt.provider.id !== ProviderV2.ID.make("vercel")) return
-        evt.provider.options.headers["http-referer"] = "https://opencode.ai/"
-        evt.provider.options.headers["x-title"] = "opencode"
+      "catalog.transform": Effect.fn(function* (evt) {
+        for (const item of evt.data) {
+          if (item.provider.endpoint.type !== "aisdk") continue
+          if (item.provider.endpoint.package !== "@ai-sdk/vercel") continue
+          evt.provider.update(item.provider.id, (provider) => {
+            provider.options.headers["http-referer"] = "https://opencode.ai/"
+            provider.options.headers["x-title"] = "opencode"
+          })
+        }
       }),
       "aisdk.sdk": Effect.fn(function* (evt) {
         if (evt.package !== "@ai-sdk/vercel") return

+ 10 - 5
packages/core/src/plugin/provider/zenmux.ts

@@ -1,15 +1,20 @@
 import { Effect } from "effect"
 import { PluginV2 } from "../../plugin"
-import { ProviderV2 } from "../../provider"
 
 export const ZenmuxPlugin = PluginV2.define({
   id: PluginV2.ID.make("zenmux"),
   effect: Effect.gen(function* () {
     return {
-      "provider.update": Effect.fn(function* (evt) {
-        if (evt.provider.id !== ProviderV2.ID.make("zenmux")) return
-        evt.provider.options.headers["HTTP-Referer"] ??= "https://opencode.ai/"
-        evt.provider.options.headers["X-Title"] ??= "opencode"
+      "catalog.transform": Effect.fn(function* (evt) {
+        for (const item of evt.data) {
+          if (item.provider.endpoint.type !== "aisdk") continue
+          if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
+          if (item.provider.endpoint.url !== "https://zenmux.ai/api/v1") continue
+          evt.provider.update(item.provider.id, (provider) => {
+            provider.options.headers["HTTP-Referer"] ??= "https://opencode.ai/"
+            provider.options.headers["X-Title"] ??= "opencode"
+          })
+        }
       }),
     }
   }),

+ 1 - 1
packages/core/src/provider.ts

@@ -86,7 +86,7 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
       name: Schema.String,
     }),
     Schema.Struct({
-      via: Schema.Literal("auth"),
+      via: Schema.Literal("account"),
       service: Schema.String,
     }),
     Schema.Struct({

+ 14 - 0
packages/core/src/util/wildcard.ts

@@ -0,0 +1,14 @@
+export * as Wildcard from "./wildcard"
+
+export function match(input: string, pattern: string) {
+  const normalized = input.replaceAll("\\", "/")
+  let escaped = pattern
+    .replaceAll("\\", "/")
+    .replace(/[.+^${}()|[\]\\]/g, "\\$&")
+    .replace(/\*/g, ".*")
+    .replace(/\?/g, ".")
+
+  if (escaped.endsWith(" .*")) escaped = escaped.slice(0, -3) + "( .*)?"
+
+  return new RegExp("^" + escaped + "$", process.platform === "win32" ? "si" : "s").test(normalized)
+}

+ 284 - 0
packages/core/test/account.test.ts

@@ -0,0 +1,284 @@
+import path from "path"
+import { describe, expect } from "bun:test"
+import { produce } from "immer"
+import { Effect, Fiber, Layer, Option, Stream } from "effect"
+import { AccountV2 } from "@opencode-ai/core/account"
+import { Catalog } from "@opencode-ai/core/catalog"
+import { EventV2 } from "@opencode-ai/core/event"
+import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { Global } from "@opencode-ai/core/global"
+import { PluginV2 } from "@opencode-ai/core/plugin"
+import { AccountPlugin } from "@opencode-ai/core/plugin/account"
+import { ModelV2 } from "@opencode-ai/core/model"
+import { ProviderV2 } from "@opencode-ai/core/provider"
+import { tmpdir } from "./fixture/tmpdir"
+import { testEffect } from "./lib/effect"
+
+const it = testEffect(PluginV2.defaultLayer)
+
+function context(
+  records: { provider: ProviderV2.Info; models: Map<ModelV2.ID, ModelV2.Info> }[],
+  updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }>,
+): Catalog.Context {
+  return {
+    data: records,
+    updateProvider: (providerID, fn) => context(records, updates).provider.update(providerID, fn),
+    updateModel: (providerID, modelID, fn) => context(records, updates).model.update(providerID, modelID, fn),
+    provider: {
+      update: (providerID, fn) => {
+        const record = records.find((item) => item.provider.id === providerID)
+        const provider = produce(record?.provider ?? ProviderV2.Info.empty(providerID), fn)
+        if (record) record.provider = provider
+        else records.push({ provider, models: new Map<ModelV2.ID, ModelV2.Info>() })
+        updates.push({
+          id: providerID,
+          enabled: provider.enabled,
+          apiKey:
+            typeof provider.options.aisdk.provider.apiKey === "string"
+              ? provider.options.aisdk.provider.apiKey
+              : undefined,
+        })
+      },
+      remove: (providerID) => {
+        const index = records.findIndex((item) => item.provider.id === providerID)
+        if (index !== -1) records.splice(index, 1)
+      },
+    },
+    model: {
+      update: () => {},
+      remove: () => {},
+    },
+  }
+}
+
+function testLayer(dir: string) {
+  return AccountV2.layer.pipe(
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provideMerge(EventV2.defaultLayer),
+    Layer.provide(
+      Global.layerWith({
+        data: dir,
+        cache: path.join(dir, "cache"),
+        config: path.join(dir, "config"),
+        state: path.join(dir, "state"),
+        tmp: path.join(dir, "tmp"),
+        bin: path.join(dir, "bin"),
+        log: path.join(dir, "log"),
+        repos: path.join(dir, "repos"),
+      }),
+    ),
+  )
+}
+
+describe("AccountV2", () => {
+  it.live("emits account lifecycle events", () =>
+    Effect.acquireRelease(
+      Effect.promise(() => tmpdir()),
+      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+    ).pipe(
+      Effect.flatMap((tmp) =>
+        Effect.gen(function* () {
+          const accounts = yield* AccountV2.Service
+          const eventSvc = yield* EventV2.Service
+          const addedFiber = yield* eventSvc
+            .subscribe(AccountV2.Event.Added)
+            .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
+          const switchedFiber = yield* eventSvc
+            .subscribe(AccountV2.Event.Switched)
+            .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
+          const removedFiber = yield* eventSvc
+            .subscribe(AccountV2.Event.Removed)
+            .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
+
+          yield* Effect.yieldNow
+
+          const first = yield* accounts.create({
+            serviceID: AccountV2.ServiceID.make("provider"),
+            credential: new AccountV2.ApiKeyCredential({ type: "api", key: "raw-key" }),
+          })
+          expect(first).toBeDefined()
+          if (!first) return
+          expect(first.description).toBe("default")
+          expect(first.credential.type).toBe("api")
+          if (first.credential.type === "api") expect(first.credential.key).toBe("raw-key")
+
+          yield* accounts.update(first.id, { description: "keep" })
+          const updated = yield* accounts.get(first.id)
+          expect(updated?.description).toBe("keep")
+          expect(updated?.credential.type).toBe("api")
+          if (updated?.credential.type === "api") expect(updated.credential.key).toBe("raw-key")
+
+          const second = yield* accounts.create({
+            serviceID: AccountV2.ServiceID.make("provider"),
+            credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }),
+          })
+          expect(second).toBeDefined()
+          if (!second) return
+
+          yield* accounts.remove(second.id)
+          const added = Array.from(yield* Fiber.join(addedFiber))
+          const switched = Array.from(yield* Fiber.join(switchedFiber))
+          const removed = Array.from(yield* Fiber.join(removedFiber))
+          expect(added.map((event) => event.data.account.id)).toEqual([first.id, second.id])
+          expect(switched.map((event) => event.data)).toEqual([
+            { serviceID: AccountV2.ServiceID.make("provider"), from: undefined, to: first.id },
+            { serviceID: AccountV2.ServiceID.make("provider"), from: first.id, to: second.id },
+            { serviceID: AccountV2.ServiceID.make("provider"), from: second.id, to: first.id },
+          ])
+          expect(removed[0]?.data.account.id).toBe(second.id)
+        }).pipe(Effect.provide(testLayer(tmp.path))),
+      ),
+    ),
+  )
+
+  it.live("always switches to newly created accounts", () =>
+    Effect.acquireRelease(
+      Effect.promise(() => tmpdir()),
+      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+    ).pipe(
+      Effect.flatMap((tmp) =>
+        Effect.gen(function* () {
+          const accounts = yield* AccountV2.Service
+          const eventSvc = yield* EventV2.Service
+          const switchedFiber = yield* eventSvc
+            .subscribe(AccountV2.Event.Switched)
+            .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
+
+          yield* Effect.yieldNow
+
+          const first = yield* accounts.create({
+            serviceID: AccountV2.ServiceID.make("provider"),
+            credential: new AccountV2.ApiKeyCredential({ type: "api", key: "first-key" }),
+          })
+          const second = yield* accounts.create({
+            serviceID: AccountV2.ServiceID.make("provider"),
+            credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }),
+          })
+          const third = yield* accounts.create({
+            serviceID: AccountV2.ServiceID.make("provider"),
+            credential: new AccountV2.ApiKeyCredential({ type: "api", key: "third-key" }),
+          })
+
+          expect(first).toBeDefined()
+          expect(second).toBeDefined()
+          expect(third).toBeDefined()
+          if (!first || !second || !third) return
+
+          expect((yield* accounts.active(AccountV2.ServiceID.make("provider")))?.id).toBe(third.id)
+          expect(Array.from(yield* Fiber.join(switchedFiber)).map((event) => event.data)).toEqual([
+            { serviceID: AccountV2.ServiceID.make("provider"), from: undefined, to: first.id },
+            { serviceID: AccountV2.ServiceID.make("provider"), from: first.id, to: second.id },
+            { serviceID: AccountV2.ServiceID.make("provider"), from: second.id, to: third.id },
+          ])
+        }).pipe(Effect.provide(testLayer(tmp.path))),
+      ),
+    ),
+  )
+
+  it.live("account plugin refreshes providers on account lifecycle events", () =>
+    Effect.acquireRelease(
+      Effect.promise(() => tmpdir()),
+      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+    ).pipe(
+      Effect.flatMap((tmp) =>
+        Effect.gen(function* () {
+          const accounts = yield* AccountV2.Service
+          const plugin = yield* PluginV2.Service
+          const records = [
+            {
+              provider: ProviderV2.Info.empty(ProviderV2.ID.make("provider")),
+              models: new Map<ModelV2.ID, ModelV2.Info>(),
+            },
+          ]
+          const updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }> = []
+          const catalog = Catalog.Service.of({
+            loader: () => Effect.die("unexpected catalog.loader"),
+            provider: {
+              get: () => Effect.die("unexpected provider.get"),
+              all: () => Effect.succeed([]),
+              available: () => Effect.succeed([]),
+            },
+            model: {
+              get: () => Effect.die("unexpected model.get"),
+              all: () => Effect.succeed([]),
+              available: () => Effect.succeed([]),
+              default: () => Effect.succeed(Option.none<ModelV2.Info>()),
+              setDefault: () => Effect.die("unexpected model.setDefault"),
+              small: () => Effect.succeed(Option.none<ModelV2.Info>()),
+            },
+          })
+
+          const eventSvc = yield* EventV2.Service
+          yield* plugin.add({
+            ...AccountPlugin,
+            effect: AccountPlugin.effect.pipe(
+              Effect.provideService(AccountV2.Service, accounts),
+              Effect.provideService(Catalog.Service, catalog),
+              Effect.provideService(EventV2.Service, eventSvc),
+              Effect.provideService(PluginV2.Service, plugin),
+            ),
+          })
+          yield* Effect.yieldNow
+
+          const first = yield* accounts.create({
+            serviceID: AccountV2.ServiceID.make("provider"),
+            credential: new AccountV2.ApiKeyCredential({ type: "api", key: "first-key" }),
+          })
+          expect(first).toBeDefined()
+          if (!first) return
+          yield* plugin.trigger("catalog.transform", context(records, updates), {})
+          expect(updates).toEqual([
+            {
+              id: ProviderV2.ID.make("provider"),
+              enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
+              apiKey: "first-key",
+            },
+          ])
+
+          updates.length = 0
+          const second = yield* accounts.create({
+            serviceID: AccountV2.ServiceID.make("provider"),
+            credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }),
+          })
+          expect(second).toBeDefined()
+          if (!second) return
+          yield* plugin.trigger("catalog.transform", context(records, updates), {})
+          expect(updates).toEqual([
+            {
+              id: ProviderV2.ID.make("provider"),
+              enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
+              apiKey: "second-key",
+            },
+          ])
+
+          updates.length = 0
+          yield* accounts.activate(first.id)
+          yield* plugin.trigger("catalog.transform", context(records, updates), {})
+          expect(updates).toEqual([
+            {
+              id: ProviderV2.ID.make("provider"),
+              enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
+              apiKey: "first-key",
+            },
+          ])
+
+          updates.length = 0
+          yield* accounts.remove(first.id)
+          yield* plugin.trigger("catalog.transform", context(records, updates), {})
+          expect(updates).toEqual([
+            {
+              id: ProviderV2.ID.make("provider"),
+              enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
+              apiKey: "second-key",
+            },
+          ])
+
+          updates.length = 0
+          yield* accounts.remove(second.id)
+          yield* plugin.trigger("catalog.transform", context(records, updates), {})
+          expect(updates).toEqual([])
+        }).pipe(Effect.provide(testLayer(tmp.path))),
+      ),
+    ),
+  )
+})

+ 101 - 121
packages/core/test/catalog.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect } from "bun:test"
-import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect"
+import { DateTime, Effect, Layer, Option } from "effect"
 import { Catalog } from "@opencode-ai/core/catalog"
 import { EventV2 } from "@opencode-ai/core/event"
 import { Location } from "@opencode-ai/core/location"
@@ -22,24 +22,20 @@ describe("CatalogV2", () => {
     Effect.gen(function* () {
       const catalog = yield* Catalog.Service
       const providerID = ProviderV2.ID.make("test")
+      const load = yield* catalog.loader()
 
-      yield* catalog.provider.update(providerID, (provider) => {
-        provider.endpoint = {
-          type: "aisdk",
-          package: "@ai-sdk/openai-compatible",
-          url: "https://default.example.com",
-        }
-        provider.options.aisdk.provider.baseURL = "https://override.example.com"
-      })
-
-      const provider = yield* catalog.provider.get(providerID)
+      yield* load((catalog) =>
+        catalog.provider.update(providerID, (provider) => {
+          provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://default.example.com" }
+          provider.options.aisdk.provider.baseURL = "https://override.example.com"
+        }),
+      )
 
-      expect(provider.endpoint).toEqual({
+      expect((yield* catalog.provider.get(providerID)).endpoint).toEqual({
         type: "aisdk",
         package: "@ai-sdk/openai-compatible",
         url: "https://override.example.com",
       })
-      expect(provider.options.aisdk.provider.baseURL).toBeUndefined()
     }),
   )
 
@@ -48,56 +44,23 @@ describe("CatalogV2", () => {
       const catalog = yield* Catalog.Service
       const providerID = ProviderV2.ID.make("test")
       const modelID = ModelV2.ID.make("model")
+      const load = yield* catalog.loader()
 
-      yield* catalog.provider.update(providerID, (provider) => {
-        provider.endpoint = {
-          type: "aisdk",
-          package: "@ai-sdk/openai-compatible",
-          url: "https://provider.example.com",
-        }
+      yield* load((catalog) => {
+        catalog.provider.update(providerID, (provider) => {
+          provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://provider.example.com" }
+        })
+        catalog.model.update(providerID, modelID, (model) => {
+          model.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://model.example.com" }
+          model.options.aisdk.provider.baseURL = "https://override.example.com"
+        })
       })
-      yield* catalog.model.update(providerID, modelID, (model) => {
-        model.endpoint = {
-          type: "aisdk",
-          package: "@ai-sdk/openai-compatible",
-          url: "https://model.example.com",
-        }
-        model.options.aisdk.provider.baseURL = "https://override.example.com"
-      })
-
-      const model = yield* catalog.model.get(providerID, modelID)
 
-      expect(model.endpoint).toEqual({
+      expect((yield* catalog.model.get(providerID, modelID)).endpoint).toEqual({
         type: "aisdk",
         package: "@ai-sdk/openai-compatible",
         url: "https://override.example.com",
       })
-      expect(model.options.aisdk.provider.baseURL).toBeUndefined()
-    }),
-  )
-
-  it.effect("publishes model updated events", () =>
-    Effect.gen(function* () {
-      const catalog = yield* Catalog.Service
-      const events = yield* EventV2.Service
-      const providerID = ProviderV2.ID.make("test")
-      const modelID = ModelV2.ID.make("model")
-      const fiber = yield* events
-        .subscribe(Catalog.Event.ModelUpdated)
-        .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
-
-      yield* Effect.yieldNow
-      yield* catalog.provider.update(providerID, () => {})
-      yield* catalog.model.update(providerID, modelID, (model) => {
-        model.name = "Updated Model"
-      })
-      const event = Array.from(yield* Fiber.join(fiber))[0]
-
-      expect(event?.type).toBe("catalog.model.updated")
-      expect(event?.data.model.providerID).toBe(providerID)
-      expect(event?.data.model.id).toBe(modelID)
-      expect(event?.data.model.name).toBe("Updated Model")
-      expect(event?.location).toEqual({ directory: "test" })
     }),
   )
 
@@ -106,19 +69,16 @@ describe("CatalogV2", () => {
       const catalog = yield* Catalog.Service
       const providerID = ProviderV2.ID.make("test")
       const modelID = ModelV2.ID.make("model")
+      const load = yield* catalog.loader()
 
-      yield* catalog.provider.update(providerID, (provider) => {
-        provider.endpoint = {
-          type: "aisdk",
-          package: "@ai-sdk/openai-compatible",
-          url: "https://provider.example.com",
-        }
+      yield* load((catalog) => {
+        catalog.provider.update(providerID, (provider) => {
+          provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://provider.example.com" }
+        })
+        catalog.model.update(providerID, modelID, () => {})
       })
-      yield* catalog.model.update(providerID, modelID, () => {})
 
-      const model = yield* catalog.model.get(providerID, modelID)
-
-      expect(model.endpoint).toEqual({
+      expect((yield* catalog.model.get(providerID, modelID)).endpoint).toEqual({
         type: "aisdk",
         package: "@ai-sdk/openai-compatible",
         url: "https://provider.example.com",
@@ -126,58 +86,82 @@ describe("CatalogV2", () => {
     }),
   )
 
-  it.effect("runs provider hooks after baseURL is normalized", () =>
+  it.effect("runs catalog transform hooks after baseURL is normalized", () =>
     Effect.gen(function* () {
       const catalog = yield* Catalog.Service
       const plugin = yield* PluginV2.Service
       const providerID = ProviderV2.ID.make("test")
       const seen: unknown[] = []
+      const load = yield* catalog.loader()
 
       yield* plugin.add({
         id: PluginV2.ID.make("test"),
         effect: Effect.succeed({
-          "provider.update": (evt) =>
+          "catalog.transform": (evt) =>
             Effect.sync(() => {
-              seen.push(evt.provider.endpoint.type)
-              if (evt.provider.endpoint.type === "aisdk") seen.push(evt.provider.endpoint.url)
-              seen.push(evt.provider.options.aisdk.provider.baseURL)
+              const item = evt.data.find((record) => record.provider.id === providerID)
+              if (!item) return
+              seen.push(item.provider.endpoint.type)
+              if (item?.provider.endpoint.type === "aisdk") seen.push(item.provider.endpoint.url)
+              seen.push(item?.provider.options.aisdk.provider.baseURL)
             }),
         }),
       })
-      yield* catalog.provider.update(providerID, (provider) => {
-        provider.endpoint = {
-          type: "aisdk",
-          package: "@ai-sdk/openai-compatible",
-        }
-        provider.options.aisdk.provider.baseURL = "https://provider.example.com"
-      })
+      yield* load((catalog) =>
+        catalog.provider.update(providerID, (provider) => {
+          provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
+          provider.options.aisdk.provider.baseURL = "https://provider.example.com"
+        }),
+      )
 
       expect(seen).toEqual(["aisdk", "https://provider.example.com", undefined])
     }),
   )
 
-  it.effect("resolves provider and model option merges", () =>
+  it.effect("runs catalog transform when a plugin is added", () =>
     Effect.gen(function* () {
       const catalog = yield* Catalog.Service
+      const plugin = yield* PluginV2.Service
       const providerID = ProviderV2.ID.make("test")
-      const modelID = ModelV2.ID.make("model")
+      const load = yield* catalog.loader()
 
-      yield* catalog.provider.update(providerID, (provider) => {
-        provider.options.headers.provider = "provider"
-        provider.options.headers.shared = "provider"
-        provider.options.body.provider = true
-        provider.options.aisdk.provider.provider = true
+      yield* load((catalog) => catalog.provider.update(providerID, (provider) => { provider.name = "Before" }))
+      yield* plugin.add({
+        id: PluginV2.ID.make("test-transform"),
+        effect: Effect.succeed({
+          "catalog.transform": (evt) => Effect.sync(() => evt.provider.update(providerID, (provider) => { provider.name = "After" })),
+        }),
       })
-      yield* catalog.model.update(providerID, modelID, (model) => {
-        model.options.headers.model = "model"
-        model.options.headers.shared = "model"
-        model.options.body.model = true
-        model.options.aisdk.provider.model = true
-        model.options.aisdk.request.request = true
+      yield* Effect.yieldNow
+
+      expect((yield* catalog.provider.get(providerID)).name).toBe("After")
+    }),
+  )
+
+  it.effect("resolves provider and model option merges", () =>
+    Effect.gen(function* () {
+      const catalog = yield* Catalog.Service
+      const providerID = ProviderV2.ID.make("test")
+      const modelID = ModelV2.ID.make("model")
+      const load = yield* catalog.loader()
+
+      yield* load((catalog) => {
+        catalog.provider.update(providerID, (provider) => {
+          provider.options.headers.provider = "provider"
+          provider.options.headers.shared = "provider"
+          provider.options.body.provider = true
+          provider.options.aisdk.provider.provider = true
+        })
+        catalog.model.update(providerID, modelID, (model) => {
+          model.options.headers.model = "model"
+          model.options.headers.shared = "model"
+          model.options.body.model = true
+          model.options.aisdk.provider.model = true
+          model.options.aisdk.request.request = true
+        })
       })
 
       const model = yield* catalog.model.get(providerID, modelID)
-
       expect(model.options.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
       expect(model.options.body).toEqual({ provider: true, model: true })
       expect(model.options.aisdk.provider).toEqual({ provider: true, model: true })
@@ -189,20 +173,15 @@ describe("CatalogV2", () => {
     Effect.gen(function* () {
       const catalog = yield* Catalog.Service
       const providerID = ProviderV2.ID.make("test")
+      const load = yield* catalog.loader()
 
-      yield* catalog.provider.update(providerID, (provider) => {
-        provider.enabled = { via: "custom", data: {} }
+      yield* load((catalog) => {
+        catalog.provider.update(providerID, (provider) => { provider.enabled = { via: "custom", data: {} } })
+        catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => { model.time.released = DateTime.makeUnsafe(1000) })
+        catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => { model.time.released = DateTime.makeUnsafe(2000) })
       })
-      yield* catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
-        model.time.released = DateTime.makeUnsafe(1000)
-      })
-      yield* catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => {
-        model.time.released = DateTime.makeUnsafe(2000)
-      })
-
-      const model = yield* catalog.model.default()
 
-      expect(Option.getOrUndefined(model)?.id).toMatch("new")
+      expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toMatch("new")
     }),
   )
 
@@ -210,24 +189,25 @@ describe("CatalogV2", () => {
     Effect.gen(function* () {
       const catalog = yield* Catalog.Service
       const providerID = ProviderV2.ID.make("test")
-
-      yield* catalog.provider.update(providerID, () => {})
-      yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
-        model.capabilities.input = ["text"]
-        model.capabilities.output = ["text"]
-        model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
-        model.time.released = DateTime.makeUnsafe(Date.now())
-      })
-      yield* catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
-        model.capabilities.input = ["text"]
-        model.capabilities.output = ["text"]
-        model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
-        model.time.released = DateTime.makeUnsafe(Date.now())
-      })
-
-      const model = yield* catalog.model.small(providerID)
-
-      expect(Option.getOrUndefined(model)?.id).toMatch("expensive-mini")
+      const load = yield* catalog.loader()
+
+      yield* load((catalog) => {
+        catalog.provider.update(providerID, () => {})
+        catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
+          model.capabilities.input = ["text"]
+          model.capabilities.output = ["text"]
+          model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
+          model.time.released = DateTime.makeUnsafe(Date.now())
+        })
+        catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
+          model.capabilities.input = ["text"]
+          model.capabilities.output = ["text"]
+          model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
+          model.time.released = DateTime.makeUnsafe(Date.now())
+        })
+      })
+
+      expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
     }),
   )
 })

+ 2 - 0
packages/core/test/models.test.ts

@@ -5,6 +5,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Global } from "@opencode-ai/core/global"
 import { ModelsDev } from "@opencode-ai/core/models-dev"
+import { EventV2 } from "@opencode-ai/core/event"
 import { it } from "./lib/effect"
 import { rm, writeFile, utimes, mkdir } from "fs/promises"
 import path from "path"
@@ -92,6 +93,7 @@ const buildLayer = (state: Ref.Ref<MockState>) =>
   Layer.fresh(ModelsDev.layer).pipe(
     Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
     Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(EventV2.defaultLayer),
   )
 
 const writeCache = (data: object, mtimeMs?: number) =>

+ 22 - 17
packages/core/test/plugin/provider-amazon-bedrock.test.ts

@@ -1,7 +1,9 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
+import { Catalog } from "@opencode-ai/core/catalog"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock"
+import { ProviderV2 } from "@opencode-ai/core/provider"
 import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
 
 function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
@@ -20,27 +22,30 @@ describe("AmazonBedrockPlugin", () => {
   it.effect("moves endpoint option to endpoint URL", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(AmazonBedrockPlugin)
-      const result = yield* plugin.trigger(
-        "provider.update",
-        {},
-        {
-          provider: provider("amazon-bedrock", {
-            options: {
-              headers: {},
-              body: {},
-              aisdk: { provider: { endpoint: "https://bedrock.example" }, request: {} },
-            },
-          }),
-          cancel: false,
-        },
-      )
-      expect(result.provider.endpoint).toEqual({
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const bedrock = provider("amazon-bedrock", {
+          endpoint: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" },
+          options: {
+            headers: {},
+            body: {},
+            aisdk: { provider: { endpoint: "https://bedrock.example" }, request: {} },
+          },
+        })
+        catalog.provider.update(bedrock.id, (item) => {
+          item.endpoint = bedrock.endpoint
+          item.options = bedrock.options
+        })
+      })
+      const result = yield* catalog.provider.get(ProviderV2.ID.amazonBedrock)
+      expect(result.endpoint).toEqual({
         type: "aisdk",
-        package: "test-provider",
+        package: "@ai-sdk/amazon-bedrock",
         url: "https://bedrock.example",
       })
-      expect(result.provider.options.aisdk.provider.endpoint).toBeUndefined()
+      expect(result.options.aisdk.provider.endpoint).toBeUndefined()
     }),
   )
 

+ 20 - 14
packages/core/test/plugin/provider-anthropic.test.ts

@@ -1,37 +1,43 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
+import { Catalog } from "@opencode-ai/core/catalog"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic"
+import { ProviderV2 } from "@opencode-ai/core/provider"
 import { it, model, provider } from "./provider-helper"
 
 describe("AnthropicPlugin", () => {
   it.effect("applies legacy beta headers", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(AnthropicPlugin)
-      const result = yield* plugin.trigger(
-        "provider.update",
-        {},
-        {
-          provider: provider("anthropic", {
-            options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
-          }),
-          cancel: false,
-        },
-      )
-      expect(result.provider.options.headers["anthropic-beta"]).toBe(
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const item = provider("anthropic", {
+          endpoint: { type: "aisdk", package: "@ai-sdk/anthropic" },
+          options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
+        })
+        catalog.provider.update(item.id, (draft) => {
+          draft.endpoint = item.endpoint
+          draft.options = item.options
+        })
+      })
+      expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).options.headers["anthropic-beta"]).toBe(
         "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
       )
-      expect(result.provider.options.headers.Existing).toBe("1")
+      expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).options.headers.Existing).toBe("1")
     }),
   )
 
   it.effect("ignores non-Anthropic providers", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(AnthropicPlugin)
-      const result = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false })
-      expect(result.provider.options.headers["anthropic-beta"]).toBeUndefined()
+      const load = yield* catalog.loader()
+      yield* load((catalog) => catalog.provider.update(provider("openai").id, () => {}))
+      expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.headers["anthropic-beta"]).toBeUndefined()
     }),
   )
 

+ 35 - 21
packages/core/test/plugin/provider-azure-cognitive-services.test.ts

@@ -1,7 +1,9 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
+import { Catalog } from "@opencode-ai/core/catalog"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure"
+import { ProviderV2 } from "@opencode-ai/core/provider"
 import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
 
 describe("AzureCognitiveServicesPlugin", () => {
@@ -9,20 +11,22 @@ describe("AzureCognitiveServicesPlugin", () => {
     withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () =>
       Effect.gen(function* () {
         const plugin = yield* PluginV2.Service
+        const catalog = yield* Catalog.Service
         yield* plugin.add(AzureCognitiveServicesPlugin)
-        const result = yield* plugin.trigger(
-          "provider.update",
-          {},
-          { provider: provider("azure-cognitive-services"), cancel: false },
-        )
-        expect(result.provider.endpoint).toEqual({
+        const load = yield* catalog.loader()
+        yield* load((catalog) => {
+          catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => {
+            item.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
+          })
+        })
+        const result = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))
+        expect(result.endpoint).toEqual({
           type: "aisdk",
-          package: "test-provider",
+          package: "@ai-sdk/openai-compatible",
+          url: "https://cognitive.cognitiveservices.azure.com/openai",
         })
-        expect(result.provider.options.aisdk.provider.baseURL).toBe(
-          "https://cognitive.cognitiveservices.azure.com/openai",
-        )
-        expect(result.provider.options.aisdk.provider.resourceName).toBeUndefined()
+        expect(result.options.aisdk.provider.baseURL).toBeUndefined()
+        expect(result.options.aisdk.provider.resourceName).toBeUndefined()
       }),
     ),
   )
@@ -31,17 +35,27 @@ describe("AzureCognitiveServicesPlugin", () => {
     withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
       Effect.gen(function* () {
         const plugin = yield* PluginV2.Service
+        const catalog = yield* Catalog.Service
         yield* plugin.add(AzureCognitiveServicesPlugin)
-        const azure = yield* plugin.trigger(
-          "provider.update",
-          {},
-          { provider: provider("azure-cognitive-services"), cancel: false },
-        )
-        const other = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false })
-        expect(azure.provider.options.aisdk.provider.baseURL).toBeUndefined()
-        expect(azure.provider.endpoint).toEqual({ type: "aisdk", package: "test-provider" })
-        expect(other.provider.options.aisdk.provider.baseURL).toBeUndefined()
-        expect(other.provider.endpoint).toEqual({ type: "aisdk", package: "test-provider" })
+        const load = yield* catalog.loader()
+        yield* load((catalog) => {
+          const azure = provider("azure-cognitive-services", {
+            endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
+          })
+          const openai = provider("openai")
+          catalog.provider.update(azure.id, (item) => {
+            item.endpoint = azure.endpoint
+          })
+          catalog.provider.update(openai.id, (item) => {
+            item.endpoint = openai.endpoint
+          })
+        })
+        const azure = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))
+        const openai = yield* catalog.provider.get(ProviderV2.ID.openai)
+        expect(azure.options.aisdk.provider.baseURL).toBeUndefined()
+        expect(azure.endpoint).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible" })
+        expect(openai.options.aisdk.provider.baseURL).toBeUndefined()
+        expect(openai.endpoint).toEqual({ type: "aisdk", package: "test-provider" })
       }),
     ),
   )

+ 89 - 50
packages/core/test/plugin/provider-azure.test.ts

@@ -1,22 +1,40 @@
 import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
-import { AuthV2 } from "@opencode-ai/core/auth"
+import { AccountV2 } from "@opencode-ai/core/account"
+import { Catalog } from "@opencode-ai/core/catalog"
+import { EventV2 } from "@opencode-ai/core/event"
+import { Location } from "@opencode-ai/core/location"
 import { PluginV2 } from "@opencode-ai/core/plugin"
-import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
+import { AccountPlugin } from "@opencode-ai/core/plugin/account"
 import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
+import { ProviderV2 } from "@opencode-ai/core/provider"
 import { testEffect } from "../lib/effect"
 import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
 
-const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))
+const itWithAccount = testEffect(
+  Catalog.layer.pipe(
+    Layer.provideMerge(PluginV2.defaultLayer),
+    Layer.provideMerge(AccountV2.defaultLayer),
+    Layer.provideMerge(EventV2.defaultLayer),
+    Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
+    Layer.provideMerge(npmLayer),
+  ),
+)
 
 describe("AzurePlugin", () => {
   it.effect("resolves resourceName from env", () =>
     withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
       Effect.gen(function* () {
         const plugin = yield* PluginV2.Service
+        const catalog = yield* Catalog.Service
         yield* plugin.add(AzurePlugin)
-        const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false })
-        expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env")
+        const load = yield* catalog.loader()
+        yield* load((catalog) => {
+          catalog.provider.update(ProviderV2.ID.azure, (item) => {
+            item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" }
+          })
+        })
+        expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe("from-env")
       }),
     ),
   )
@@ -25,25 +43,29 @@ describe("AzurePlugin", () => {
     withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
       Effect.gen(function* () {
         const plugin = yield* PluginV2.Service
+        const catalog = yield* Catalog.Service
         yield* plugin.add(AzurePlugin)
-        const azure = yield* plugin.trigger(
-          "provider.update",
-          {},
-          {
-            provider: provider("azure", {
-              options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } },
-            }),
-            cancel: false,
-          },
+        const load = yield* catalog.loader()
+        yield* load((catalog) => {
+          const azure = provider("azure", {
+            endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
+            options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } },
+          })
+          catalog.provider.update(azure.id, (item) => {
+            item.endpoint = azure.endpoint
+            item.options = azure.options
+          })
+          catalog.provider.update(ProviderV2.ID.openai, () => {})
+        })
+        expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe(
+          "from-config",
         )
-        const other = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false })
-        expect(azure.provider.options.aisdk.provider.resourceName).toBe("from-config")
-        expect(other.provider.options.aisdk.provider.resourceName).toBeUndefined()
+        expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.aisdk.provider.resourceName).toBeUndefined()
       }),
     ),
   )
 
-  itWithAuth.effect("prefers auth resourceName over env", () =>
+  itWithAccount.effect("prefers account resourceName over env", () =>
     withEnv(
       {
         AZURE_RESOURCE_NAME: "from-env",
@@ -51,23 +73,36 @@ describe("AzurePlugin", () => {
       () =>
         Effect.gen(function* () {
           const plugin = yield* PluginV2.Service
-          const auth = yield* AuthV2.Service
-          yield* auth.create({
-            serviceID: AuthV2.ServiceID.make("azure"),
-            credential: new AuthV2.ApiKeyCredential({
+          const accounts = yield* AccountV2.Service
+          const catalog = yield* Catalog.Service
+          const events = yield* EventV2.Service
+          yield* accounts.create({
+            serviceID: AccountV2.ServiceID.make("azure"),
+            credential: new AccountV2.ApiKeyCredential({
               type: "api",
               key: "key",
-              metadata: { resourceName: "from-auth" },
+              metadata: { resourceName: "from-account" },
             }),
-            active: true,
           })
           yield* plugin.add({
-            ...AuthPlugin,
-            effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
+            ...AccountPlugin,
+            effect: AccountPlugin.effect.pipe(
+              Effect.provideService(AccountV2.Service, accounts),
+              Effect.provideService(Catalog.Service, catalog),
+              Effect.provideService(EventV2.Service, events),
+              Effect.provideService(PluginV2.Service, plugin),
+            ),
           })
           yield* plugin.add(AzurePlugin)
-          const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false })
-          expect(result.provider.options.aisdk.provider.resourceName).toBe("from-auth")
+          const load = yield* catalog.loader()
+          yield* load((catalog) => {
+            catalog.provider.update(ProviderV2.ID.azure, (item) => {
+              item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" }
+            })
+          })
+          expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe(
+            "from-account",
+          )
         }),
     ),
   )
@@ -76,18 +111,20 @@ describe("AzurePlugin", () => {
     withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
       Effect.gen(function* () {
         const plugin = yield* PluginV2.Service
+        const catalog = yield* Catalog.Service
         yield* plugin.add(AzurePlugin)
-        const result = yield* plugin.trigger(
-          "provider.update",
-          {},
-          {
-            provider: provider("azure", {
-              options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } },
-            }),
-            cancel: false,
-          },
-        )
-        expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env")
+        const load = yield* catalog.loader()
+        yield* load((catalog) => {
+          const azure = provider("azure", {
+            endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
+            options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } },
+          })
+          catalog.provider.update(azure.id, (item) => {
+            item.endpoint = azure.endpoint
+            item.options = azure.options
+          })
+        })
+        expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe("from-env")
       }),
     ),
   )
@@ -96,18 +133,20 @@ describe("AzurePlugin", () => {
     withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
       Effect.gen(function* () {
         const plugin = yield* PluginV2.Service
+        const catalog = yield* Catalog.Service
         yield* plugin.add(AzurePlugin)
-        const result = yield* plugin.trigger(
-          "provider.update",
-          {},
-          {
-            provider: provider("azure", {
-              options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "   " }, request: {} } },
-            }),
-            cancel: false,
-          },
-        )
-        expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env")
+        const load = yield* catalog.loader()
+        yield* load((catalog) => {
+          const azure = provider("azure", {
+            endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
+            options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "   " }, request: {} } },
+          })
+          catalog.provider.update(azure.id, (item) => {
+            item.endpoint = azure.endpoint
+            item.options = azure.options
+          })
+        })
+        expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe("from-env")
       }),
     ),
   )

+ 15 - 13
packages/core/test/plugin/provider-cerebras.test.ts

@@ -1,7 +1,9 @@
 import { describe, expect, mock } from "bun:test"
 import { Effect } from "effect"
+import { Catalog } from "@opencode-ai/core/catalog"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras"
+import { ProviderV2 } from "@opencode-ai/core/provider"
 import { it, model, provider } from "./provider-helper"
 
 const cerebrasOptions: Record<string, unknown>[] = []
@@ -20,27 +22,27 @@ describe("CerebrasPlugin", () => {
   it.effect("applies the legacy integration header", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(CerebrasPlugin)
-      const result = yield* plugin.trigger(
-        "provider.update",
-        {},
-        {
-          provider: provider("cerebras", {
-            options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
-          }),
-          cancel: false,
-        },
-      )
-      expect(result.provider.options.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode" })
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => {
+          item.endpoint = { type: "aisdk", package: "@ai-sdk/cerebras" }
+          item.options.headers.Existing = "1"
+        })
+      })
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).options.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode" })
     }),
   )
 
   it.effect("ignores non-Cerebras providers", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(CerebrasPlugin)
-      const result = yield* plugin.trigger("provider.update", {}, { provider: provider("groq"), cancel: false })
-      expect(result.provider.options.headers).toEqual({})
+      const load = yield* catalog.loader()
+      yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("groq"))).options.headers).toEqual({})
     }),
   )
 

+ 63 - 46
packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts

@@ -1,14 +1,26 @@
 import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
-import { AuthV2 } from "@opencode-ai/core/auth"
+import { AccountV2 } from "@opencode-ai/core/account"
+import { Catalog } from "@opencode-ai/core/catalog"
+import { Location } from "@opencode-ai/core/location"
+import { EventV2 } from "@opencode-ai/core/event"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { PluginV2 } from "@opencode-ai/core/plugin"
-import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
+import { AccountPlugin } from "@opencode-ai/core/plugin/account"
 import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
+import { ProviderV2 } from "@opencode-ai/core/provider"
 import { testEffect } from "../lib/effect"
-import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
+import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper"
 
-const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))
+const itWithAccount = testEffect(
+  Catalog.layer.pipe(
+    Layer.provideMerge(PluginV2.defaultLayer),
+    Layer.provideMerge(AccountV2.defaultLayer),
+    Layer.provideMerge(EventV2.defaultLayer),
+    Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
+    Layer.provideMerge(npmLayer),
+  ),
+)
 
 function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
   return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
@@ -34,22 +46,25 @@ describe("CloudflareWorkersAIPlugin", () => {
     withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
       Effect.gen(function* () {
         const plugin = yield* PluginV2.Service
+        const catalog = yield* Catalog.Service
         yield* plugin.add(CloudflareWorkersAIPlugin)
-        const updated = yield* plugin.trigger(
-          "provider.update",
-          {},
-          { provider: provider("cloudflare-workers-ai"), cancel: false },
+        const load = yield* catalog.loader()
+        yield* load((catalog) =>
+          catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
+            provider.endpoint = { type: "aisdk", package: "test-provider" }
+          }),
         )
+        const provider = yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))
         const sdk = yield* plugin.trigger(
           "aisdk.sdk",
           {
-            model: model("cloudflare-workers-ai", "@cf/model", { endpoint: updated.provider.endpoint }),
+            model: model("cloudflare-workers-ai", "@cf/model", { endpoint: provider.endpoint }),
             package: "@ai-sdk/openai-compatible",
             options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
           },
           {},
         )
-        expect(updated.provider.endpoint).toEqual({
+        expect(provider.endpoint).toEqual({
           type: "aisdk",
           package: "test-provider",
           url: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1",
@@ -63,18 +78,15 @@ describe("CloudflareWorkersAIPlugin", () => {
     withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
       Effect.gen(function* () {
         const plugin = yield* PluginV2.Service
+        const catalog = yield* Catalog.Service
         yield* plugin.add(CloudflareWorkersAIPlugin)
-        const result = yield* plugin.trigger(
-          "provider.update",
-          {},
-          {
-            provider: provider("cloudflare-workers-ai", {
-              endpoint: { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" },
-            }),
-            cancel: false,
-          },
+        const load = yield* catalog.loader()
+        yield* load((catalog) =>
+          catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
+            provider.endpoint = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }
+          }),
         )
-        expect(result.provider.endpoint).toEqual({
+        expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({
           type: "aisdk",
           package: "test-provider",
           url: "https://proxy.example/v1",
@@ -104,7 +116,7 @@ describe("CloudflareWorkersAIPlugin", () => {
     ),
   )
 
-  itWithAuth.effect("falls back to auth account metadata when account env is absent", () =>
+  itWithAccount.effect("falls back to account metadata when account env is absent", () =>
     withEnv(
       {
         CLOUDFLARE_ACCOUNT_ID: undefined,
@@ -113,30 +125,37 @@ describe("CloudflareWorkersAIPlugin", () => {
       () =>
         Effect.gen(function* () {
           const plugin = yield* PluginV2.Service
-          const auth = yield* AuthV2.Service
-          yield* auth.create({
-            serviceID: AuthV2.ServiceID.make("cloudflare-workers-ai"),
-            credential: new AuthV2.ApiKeyCredential({
+          const accounts = yield* AccountV2.Service
+          const catalog = yield* Catalog.Service
+          const events = yield* EventV2.Service
+          yield* accounts.create({
+            serviceID: AccountV2.ServiceID.make("cloudflare-workers-ai"),
+            credential: new AccountV2.ApiKeyCredential({
               type: "api",
-              key: "auth-key",
-              metadata: { accountId: "auth-acct" },
+              key: "account-key",
+              metadata: { accountId: "account-acct" },
             }),
-            active: true,
           })
           yield* plugin.add({
-            ...AuthPlugin,
-            effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
+            ...AccountPlugin,
+            effect: AccountPlugin.effect.pipe(
+              Effect.provideService(AccountV2.Service, accounts),
+              Effect.provideService(Catalog.Service, catalog),
+              Effect.provideService(EventV2.Service, events),
+              Effect.provideService(PluginV2.Service, plugin),
+            ),
           })
           yield* plugin.add(CloudflareWorkersAIPlugin)
-          const updated = yield* plugin.trigger(
-            "provider.update",
-            {},
-            { provider: provider("cloudflare-workers-ai"), cancel: false },
+          const load = yield* catalog.loader()
+          yield* load((catalog) =>
+            catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
+              provider.endpoint = { type: "aisdk", package: "test-provider" }
+            }),
           )
-          expect(updated.provider.endpoint).toEqual({
+          expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({
             type: "aisdk",
             package: "test-provider",
-            url: "https://api.cloudflare.com/client/v4/accounts/auth-acct/ai/v1",
+            url: "https://api.cloudflare.com/client/v4/accounts/account-acct/ai/v1",
           })
         }),
     ),
@@ -146,18 +165,16 @@ describe("CloudflareWorkersAIPlugin", () => {
     withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
       Effect.gen(function* () {
         const plugin = yield* PluginV2.Service
+        const catalog = yield* Catalog.Service
         yield* plugin.add(CloudflareWorkersAIPlugin)
-        const result = yield* plugin.trigger(
-          "provider.update",
-          {},
-          {
-            provider: provider("cloudflare-workers-ai", {
-              options: { headers: {}, body: {}, aisdk: { provider: { accountId: "configured-acct" }, request: {} } },
-            }),
-            cancel: false,
-          },
+        const load = yield* catalog.loader()
+        yield* load((catalog) =>
+          catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
+            provider.endpoint = { type: "aisdk", package: "test-provider" }
+            provider.options.aisdk.provider.accountId = "configured-acct"
+          }),
         )
-        expect(result.provider.endpoint).toEqual({
+        expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({
           type: "aisdk",
           package: "test-provider",
           url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1",

+ 18 - 14
packages/core/test/plugin/provider-github-copilot.test.ts

@@ -1,8 +1,10 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
+import { Catalog } from "@opencode-ai/core/catalog"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
+import { ProviderV2 } from "@opencode-ai/core/provider"
 import { fakeSelectorSdk, it, model } from "./provider-helper"
 
 describe("GithubCopilotPlugin", () => {
@@ -145,29 +147,31 @@ describe("GithubCopilotPlugin", () => {
     }),
   )
 
-  it.effect("filters gpt-5-chat-latest before Copilot language selection", () =>
+  it.effect("disables gpt-5-chat-latest before Copilot language selection", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(GithubCopilotPlugin)
-      const result = yield* plugin.trigger(
-        "model.update",
-        {},
-        { model: model("github-copilot", "gpt-5-chat-latest"), cancel: false },
-      )
-      expect(result.cancel).toBe(true)
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {})
+        catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
+      })
+      expect((yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(false)
     }),
   )
 
-  it.effect("does not filter gpt-5-chat-latest for non-Copilot providers", () =>
+  it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(GithubCopilotPlugin)
-      const result = yield* plugin.trigger(
-        "model.update",
-        {},
-        { model: model("custom-copilot", "gpt-5-chat-latest"), cancel: false },
-      )
-      expect(result.cancel).toBe(false)
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {})
+        catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
+      })
+      expect((yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(true)
     }),
   )
 

+ 55 - 27
packages/core/test/plugin/provider-gitlab.test.ts

@@ -1,11 +1,15 @@
 import { describe, expect, mock } from "bun:test"
 import { Effect, Layer } from "effect"
-import { AuthV2 } from "@opencode-ai/core/auth"
+import { AccountV2 } from "@opencode-ai/core/account"
+import { Catalog } from "@opencode-ai/core/catalog"
+import { EventV2 } from "@opencode-ai/core/event"
+import { Location } from "@opencode-ai/core/location"
 import { PluginV2 } from "@opencode-ai/core/plugin"
-import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
+import { AccountPlugin } from "@opencode-ai/core/plugin/account"
 import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
+import { ProviderV2 } from "@opencode-ai/core/provider"
 import { testEffect } from "../lib/effect"
-import { it, model, npmLayer, provider, withEnv } from "./provider-helper"
+import { it, model, npmLayer, withEnv } from "./provider-helper"
 
 const gitlabSDKOptions: Record<string, unknown>[] = []
 
@@ -22,7 +26,15 @@ void mock.module("gitlab-ai-provider", () => ({
   isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact",
 }))
 
-const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))
+const itWithAccount = testEffect(
+  Catalog.layer.pipe(
+    Layer.provideMerge(PluginV2.defaultLayer),
+    Layer.provideMerge(AccountV2.defaultLayer),
+    Layer.provideMerge(EventV2.defaultLayer),
+    Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
+    Layer.provideMerge(npmLayer),
+  ),
+)
 
 describe("GitLabPlugin", () => {
   it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () =>
@@ -141,7 +153,7 @@ describe("GitLabPlugin", () => {
     }),
   )
 
-  itWithAuth.effect("uses active API auth token over GITLAB_TOKEN", () =>
+  itWithAccount.effect("uses active account API token over GITLAB_TOKEN", () =>
     withEnv(
       {
         GITLAB_TOKEN: "env-token",
@@ -150,33 +162,41 @@ describe("GitLabPlugin", () => {
         Effect.gen(function* () {
           gitlabSDKOptions.length = 0
           const plugin = yield* PluginV2.Service
-          const auth = yield* AuthV2.Service
-          yield* auth.create({
-            serviceID: AuthV2.ServiceID.make("gitlab"),
-            credential: new AuthV2.ApiKeyCredential({ type: "api", key: "auth-token" }),
-            active: true,
+          const accounts = yield* AccountV2.Service
+          const catalog = yield* Catalog.Service
+          const events = yield* EventV2.Service
+          yield* accounts.create({
+            serviceID: AccountV2.ServiceID.make("gitlab"),
+            credential: new AccountV2.ApiKeyCredential({ type: "api", key: "account-token" }),
           })
           yield* plugin.add({
-            ...AuthPlugin,
-            effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
+            ...AccountPlugin,
+            effect: AccountPlugin.effect.pipe(
+              Effect.provideService(AccountV2.Service, accounts),
+              Effect.provideService(Catalog.Service, catalog),
+              Effect.provideService(EventV2.Service, events),
+              Effect.provideService(PluginV2.Service, plugin),
+            ),
           })
           yield* plugin.add(GitLabPlugin)
-          const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("gitlab"), cancel: false })
+          const load = yield* catalog.loader()
+          yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
+          const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
           yield* plugin.trigger(
             "aisdk.sdk",
             {
               model: model("gitlab", "claude"),
               package: "gitlab-ai-provider",
-              options: updated.provider.options.aisdk.provider,
+              options: provider.options.aisdk.provider,
             },
             {},
           )
-          expect(gitlabSDKOptions[0].apiKey).toBe("auth-token")
+          expect(gitlabSDKOptions[0].apiKey).toBe("account-token")
         }),
     ),
   )
 
-  itWithAuth.effect("uses active OAuth access token when no API auth exists", () =>
+  itWithAccount.effect("uses active account OAuth access token when no API token exists", () =>
     withEnv(
       {
         GITLAB_TOKEN: undefined,
@@ -185,33 +205,41 @@ describe("GitLabPlugin", () => {
         Effect.gen(function* () {
           gitlabSDKOptions.length = 0
           const plugin = yield* PluginV2.Service
-          const auth = yield* AuthV2.Service
-          yield* auth.create({
-            serviceID: AuthV2.ServiceID.make("gitlab"),
-            credential: new AuthV2.OAuthCredential({
+          const accounts = yield* AccountV2.Service
+          const catalog = yield* Catalog.Service
+          const events = yield* EventV2.Service
+          yield* accounts.create({
+            serviceID: AccountV2.ServiceID.make("gitlab"),
+            credential: new AccountV2.OAuthCredential({
               type: "oauth",
               refresh: "refresh-token",
-              access: "oauth-token",
+              access: "account-oauth-token",
               expires: 9999999999999,
             }),
-            active: true,
           })
           yield* plugin.add({
-            ...AuthPlugin,
-            effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
+            ...AccountPlugin,
+            effect: AccountPlugin.effect.pipe(
+              Effect.provideService(AccountV2.Service, accounts),
+              Effect.provideService(Catalog.Service, catalog),
+              Effect.provideService(EventV2.Service, events),
+              Effect.provideService(PluginV2.Service, plugin),
+            ),
           })
           yield* plugin.add(GitLabPlugin)
-          const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("gitlab"), cancel: false })
+          const load = yield* catalog.loader()
+          yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
+          const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
           yield* plugin.trigger(
             "aisdk.sdk",
             {
               model: model("gitlab", "claude"),
               package: "gitlab-ai-provider",
-              options: updated.provider.options.aisdk.provider,
+              options: provider.options.aisdk.provider,
             },
             {},
           )
-          expect(gitlabSDKOptions[0].apiKey).toBe("oauth-token")
+          expect(gitlabSDKOptions[0].apiKey).toBe("account-oauth-token")
         }),
     ),
   )

+ 23 - 22
packages/core/test/plugin/provider-google-vertex-anthropic.test.ts

@@ -1,8 +1,10 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
+import { Catalog } from "@opencode-ai/core/catalog"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { GoogleVertexAnthropicPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
-import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
+import { ProviderV2 } from "@opencode-ai/core/provider"
+import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper"
 
 describe("GoogleVertexAnthropicPlugin", () => {
   it.effect("resolves legacy project and location env on provider update", () =>
@@ -18,14 +20,17 @@ describe("GoogleVertexAnthropicPlugin", () => {
       () =>
         Effect.gen(function* () {
           const plugin = yield* PluginV2.Service
+          const catalog = yield* Catalog.Service
           yield* plugin.add(GoogleVertexAnthropicPlugin)
-          const result = yield* plugin.trigger(
-            "provider.update",
-            {},
-            { provider: provider("google-vertex-anthropic"), cancel: false },
+          const load = yield* catalog.loader()
+          yield* load((catalog) =>
+            catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
+              provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
+            }),
           )
-          expect(result.provider.options.aisdk.provider.project).toBe("cloud-project")
-          expect(result.provider.options.aisdk.provider.location).toBe("cloud-location")
+          const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))
+          expect(provider.options.aisdk.provider.project).toBe("cloud-project")
+          expect(provider.options.aisdk.provider.location).toBe("cloud-location")
         }),
     ),
   )
@@ -34,23 +39,19 @@ describe("GoogleVertexAnthropicPlugin", () => {
     withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () =>
       Effect.gen(function* () {
         const plugin = yield* PluginV2.Service
+        const catalog = yield* Catalog.Service
         yield* plugin.add(GoogleVertexAnthropicPlugin)
-        const result = yield* plugin.trigger(
-          "provider.update",
-          {},
-          {
-            provider: provider("google-vertex-anthropic", {
-              options: {
-                headers: {},
-                body: {},
-                aisdk: { provider: { project: "configured-project", location: "configured-location" }, request: {} },
-              },
-            }),
-            cancel: false,
-          },
+        const load = yield* catalog.loader()
+        yield* load((catalog) =>
+          catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
+            provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
+            provider.options.aisdk.provider.project = "configured-project"
+            provider.options.aisdk.provider.location = "configured-location"
+          }),
         )
-        expect(result.provider.options.aisdk.provider.project).toBe("configured-project")
-        expect(result.provider.options.aisdk.provider.location).toBe("configured-location")
+        const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))
+        expect(provider.options.aisdk.provider.project).toBe("configured-project")
+        expect(provider.options.aisdk.provider.location).toBe("configured-location")
       }),
     ),
   )

+ 56 - 64
packages/core/test/plugin/provider-google-vertex.test.ts

@@ -1,8 +1,10 @@
 import { describe, expect, mock } from "bun:test"
 import { Effect } from "effect"
+import { Catalog } from "@opencode-ai/core/catalog"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
-import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
+import { ProviderV2 } from "@opencode-ai/core/provider"
+import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper"
 
 const vertexOptions: Record<string, any>[] = []
 
@@ -43,24 +45,22 @@ describe("GoogleVertexPlugin", () => {
       () =>
         Effect.gen(function* () {
           const plugin = yield* PluginV2.Service
+          const catalog = yield* Catalog.Service
           yield* plugin.add(GoogleVertexPlugin)
-          const result = yield* plugin.trigger(
-            "provider.update",
-            {},
-            {
-              provider: provider("google-vertex", {
-                endpoint: {
-                  type: "aisdk",
-                  package: "@ai-sdk/openai-compatible",
-                  url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
-                },
-              }),
-              cancel: false,
-            },
+          const load = yield* catalog.loader()
+          yield* load((catalog) =>
+            catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
+              provider.endpoint = {
+                type: "aisdk",
+                package: "@ai-sdk/openai-compatible",
+                url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
+              }
+            }),
           )
-          expect(result.provider.options.aisdk.provider.project).toBe("google-cloud-project")
-          expect(result.provider.options.aisdk.provider.location).toBe("google-vertex-location")
-          expect(result.provider.endpoint).toEqual({
+          const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
+          expect(provider.options.aisdk.provider.project).toBe("google-cloud-project")
+          expect(provider.options.aisdk.provider.location).toBe("google-vertex-location")
+          expect(provider.endpoint).toEqual({
             type: "aisdk",
             package: "@ai-sdk/openai-compatible",
             url: "https://google-vertex-location-aiplatform.googleapis.com/v1/projects/google-cloud-project/locations/google-vertex-location",
@@ -84,21 +84,19 @@ describe("GoogleVertexPlugin", () => {
         Effect.gen(function* () {
           vertexOptions.length = 0
           const plugin = yield* PluginV2.Service
+          const catalog = yield* Catalog.Service
           yield* plugin.add(GoogleVertexPlugin)
-          const updated = yield* plugin.trigger(
-            "provider.update",
-            {},
-            {
-              provider: provider("google-vertex", {
-                endpoint: {
-                  type: "aisdk",
-                  package: "@ai-sdk/openai-compatible",
-                  url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
-                },
-              }),
-              cancel: false,
-            },
+          const load = yield* catalog.loader()
+          yield* load((catalog) =>
+            catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
+              provider.endpoint = {
+                type: "aisdk",
+                package: "@ai-sdk/openai-compatible",
+                url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
+              }
+            }),
           )
+          const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
           yield* plugin.trigger(
             "aisdk.sdk",
             {
@@ -111,8 +109,8 @@ describe("GoogleVertexPlugin", () => {
             {},
           )
 
-          expect(updated.provider.options.aisdk.provider.project).toBe("vertex-project")
-          expect(updated.provider.endpoint).toEqual({
+          expect(provider.options.aisdk.provider.project).toBe("vertex-project")
+          expect(provider.endpoint).toEqual({
             type: "aisdk",
             package: "@ai-sdk/openai-compatible",
             url: "https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4",
@@ -136,29 +134,24 @@ describe("GoogleVertexPlugin", () => {
       () =>
         Effect.gen(function* () {
           const plugin = yield* PluginV2.Service
+          const catalog = yield* Catalog.Service
           yield* plugin.add(GoogleVertexPlugin)
-          const result = yield* plugin.trigger(
-            "provider.update",
-            {},
-            {
-              provider: provider("google-vertex", {
-                endpoint: {
-                  type: "aisdk",
-                  package: "@ai-sdk/openai-compatible",
-                  url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
-                },
-                options: {
-                  headers: {},
-                  body: {},
-                  aisdk: { provider: { project: "config-project", location: "global" }, request: {} },
-                },
-              }),
-              cancel: false,
-            },
+          const load = yield* catalog.loader()
+          yield* load((catalog) =>
+            catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
+              provider.endpoint = {
+                type: "aisdk",
+                package: "@ai-sdk/openai-compatible",
+                url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
+              }
+              provider.options.aisdk.provider.project = "config-project"
+              provider.options.aisdk.provider.location = "global"
+            }),
           )
-          expect(result.provider.options.aisdk.provider.project).toBe("config-project")
-          expect(result.provider.options.aisdk.provider.location).toBe("global")
-          expect(result.provider.endpoint).toEqual({
+          const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
+          expect(provider.options.aisdk.provider.project).toBe("config-project")
+          expect(provider.options.aisdk.provider.location).toBe("global")
+          expect(provider.endpoint).toEqual({
             type: "aisdk",
             package: "@ai-sdk/openai-compatible",
             url: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global",
@@ -180,19 +173,18 @@ describe("GoogleVertexPlugin", () => {
       () =>
         Effect.gen(function* () {
           const plugin = yield* PluginV2.Service
+          const catalog = yield* Catalog.Service
           yield* plugin.add(GoogleVertexPlugin)
-          const result = yield* plugin.trigger(
-            "provider.update",
-            {},
-            {
-              provider: provider("google-vertex", {
-                options: { headers: {}, body: {}, aisdk: { provider: { project: "config-project" }, request: {} } },
-              }),
-              cancel: false,
-            },
+          const load = yield* catalog.loader()
+          yield* load((catalog) =>
+            catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
+              provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex" }
+              provider.options.aisdk.provider.project = "config-project"
+            }),
           )
-          expect(result.provider.options.aisdk.provider.project).toBe("config-project")
-          expect(result.provider.options.aisdk.provider.location).toBe("us-central1")
+          const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
+          expect(provider.options.aisdk.provider.project).toBe("config-project")
+          expect(provider.options.aisdk.provider.location).toBe("us-central1")
         }),
     ),
   )

+ 32 - 1
packages/core/test/plugin/provider-helper.ts

@@ -2,12 +2,16 @@ import { Npm } from "@opencode-ai/core/npm"
 import type { LanguageModelV3 } from "@ai-sdk/provider"
 import { expect } from "bun:test"
 import { Effect, Layer, Option } from "effect"
+import { Catalog } from "@opencode-ai/core/catalog"
+import { EventV2 } from "@opencode-ai/core/event"
+import { Location } from "@opencode-ai/core/location"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { testEffect } from "../lib/effect"
 
 export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
+const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
 
 export const npmLayer = Layer.succeed(
   Npm.Service,
@@ -18,7 +22,34 @@ export const npmLayer = Layer.succeed(
   }),
 )
 
-export const it = testEffect(Layer.mergeAll(PluginV2.defaultLayer, npmLayer))
+export const catalogLayer = Layer.succeed(
+  Catalog.Service,
+  Catalog.Service.of({
+    loader: () => Effect.die("unexpected catalog.loader"),
+    provider: {
+      get: () => Effect.die("unexpected provider.get"),
+      all: () => Effect.succeed([]),
+      available: () => Effect.succeed([]),
+    },
+    model: {
+      get: () => Effect.die("unexpected model.get"),
+      all: () => Effect.succeed([]),
+      available: () => Effect.succeed([]),
+      default: () => Effect.succeed(Option.none<ModelV2.Info>()),
+      setDefault: () => Effect.die("unexpected model.setDefault"),
+      small: () => Effect.succeed(Option.none<ModelV2.Info>()),
+    },
+  }),
+)
+
+export const it = testEffect(
+  Catalog.layer.pipe(
+    Layer.provideMerge(PluginV2.defaultLayer),
+    Layer.provideMerge(EventV2.defaultLayer),
+    Layer.provideMerge(locationLayer),
+    Layer.provideMerge(npmLayer),
+  ),
+)
 
 export function provider(providerID: string, options?: Partial<ProviderV2.Info>) {
   return new ProviderV2.Info({

+ 48 - 40
packages/core/test/plugin/provider-kilo.test.ts

@@ -1,8 +1,10 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
+import { Catalog } from "@opencode-ai/core/catalog"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
 import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo"
+import { ProviderV2 } from "@opencode-ai/core/provider"
 import { expectPluginRegistered, it, provider } from "./provider-helper"
 
 describe("KiloPlugin", () => {
@@ -18,73 +20,79 @@ describe("KiloPlugin", () => {
   it.effect("applies legacy referer headers only to kilo", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(KiloPlugin)
-      const result = yield* plugin.trigger(
-        "provider.update",
-        {},
-        {
-          provider: provider("kilo", {
-            options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
-          }),
-          cancel: false,
-        },
-      )
-      const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false })
-      expect(result.provider.options.headers).toEqual({
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const kilo = provider("kilo", {
+          endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
+          options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
+        })
+        catalog.provider.update(kilo.id, (draft) => {
+          draft.endpoint = kilo.endpoint
+          draft.options = kilo.options
+        })
+        catalog.provider.update(provider("openrouter").id, () => {})
+      })
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).options.headers).toEqual({
         Existing: "value",
         "HTTP-Referer": "https://opencode.ai/",
         "X-Title": "opencode",
       })
-      expect(ignored.provider.options.headers).toEqual({})
+      expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({})
     }),
   )
 
   it.effect("uses the exact legacy Kilo header casing and set", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(KiloPlugin)
-      const result = yield* plugin.trigger("provider.update", {}, { provider: provider("kilo"), cancel: false })
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const item = provider("kilo", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" } })
+        catalog.provider.update(item.id, (draft) => {
+          draft.endpoint = item.endpoint
+        })
+      })
 
-      expect(result.provider.options.headers).toEqual({
+      const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo"))
+      expect(result.options.headers).toEqual({
         "HTTP-Referer": "https://opencode.ai/",
         "X-Title": "opencode",
       })
-      expect(result.provider.options.headers).not.toHaveProperty("http-referer")
-      expect(result.provider.options.headers).not.toHaveProperty("x-title")
-      expect(result.provider.options.headers).not.toHaveProperty("X-Source")
+      expect(result.options.headers).not.toHaveProperty("http-referer")
+      expect(result.options.headers).not.toHaveProperty("x-title")
+      expect(result.options.headers).not.toHaveProperty("X-Source")
     }),
   )
 
   it.effect("uses the legacy provider-id guard instead of endpoint package matching", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(KiloPlugin)
-      const matchingID = yield* plugin.trigger(
-        "provider.update",
-        {},
-        {
-          provider: provider("kilo", {
-            endpoint: { type: "aisdk", package: "not-kilo" },
-          }),
-          cancel: false,
-        },
-      )
-      const matchingPackage = yield* plugin.trigger(
-        "provider.update",
-        {},
-        {
-          provider: provider("custom-kilo", {
-            endpoint: { type: "aisdk", package: "kilo" },
-          }),
-          cancel: false,
-        },
-      )
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const kilo = provider("kilo", {
+          endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
+        })
+        catalog.provider.update(kilo.id, (draft) => {
+          draft.endpoint = kilo.endpoint
+        })
+        const custom = provider("custom-kilo", {
+          endpoint: { type: "aisdk", package: "kilo" },
+        })
+        catalog.provider.update(custom.id, (draft) => {
+          draft.endpoint = custom.endpoint
+        })
+      })
 
-      expect(matchingID.provider.options.headers).toEqual({
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).options.headers).toEqual({
         "HTTP-Referer": "https://opencode.ai/",
         "X-Title": "opencode",
       })
-      expect(matchingPackage.provider.options.headers).toEqual({})
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo"))).options.headers).toEqual({})
     }),
   )
 })

+ 34 - 26
packages/core/test/plugin/provider-llmgateway.test.ts

@@ -1,8 +1,10 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
+import { Catalog } from "@opencode-ai/core/catalog"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
 import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
+import { ProviderV2 } from "@opencode-ai/core/provider"
 import { expectPluginRegistered, it, provider } from "./provider-helper"
 
 describe("LLMGatewayPlugin", () => {
@@ -18,46 +20,52 @@ describe("LLMGatewayPlugin", () => {
   it.effect("applies legacy referer headers only to enabled llmgateway", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(LLMGatewayPlugin)
-      const result = yield* plugin.trigger(
-        "provider.update",
-        {},
-        {
-          provider: provider("llmgateway", {
-            enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
-            options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
-          }),
-          cancel: false,
-        },
-      )
-      const ignored = yield* plugin.trigger(
-        "provider.update",
-        {},
-        {
-          provider: provider("openrouter", {
-            enabled: { via: "env", name: "OPENROUTER_API_KEY" },
-          }),
-          cancel: false,
-        },
-      )
-      expect(result.provider.options.headers).toEqual({
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const llmgateway = provider("llmgateway", {
+          enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
+          endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
+          options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
+        })
+        catalog.provider.update(llmgateway.id, (draft) => {
+          draft.enabled = llmgateway.enabled
+          draft.endpoint = llmgateway.endpoint
+          draft.options = llmgateway.options
+        })
+        const openrouter = provider("openrouter", {
+          enabled: { via: "env", name: "OPENROUTER_API_KEY" },
+        })
+        catalog.provider.update(openrouter.id, (draft) => {
+          draft.enabled = openrouter.enabled
+        })
+      })
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).options.headers).toEqual({
         Existing: "value",
         "HTTP-Referer": "https://opencode.ai/",
         "X-Title": "opencode",
         "X-Source": "opencode",
       })
-      expect(ignored.provider.options.headers).toEqual({})
+      expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({})
     }),
   )
 
   it.effect("does not apply legacy headers to a disabled llmgateway provider", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(LLMGatewayPlugin)
-      const result = yield* plugin.trigger("provider.update", {}, { provider: provider("llmgateway"), cancel: false })
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const item = provider("llmgateway", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" } })
+        catalog.provider.update(item.id, (draft) => {
+          draft.endpoint = item.endpoint
+        })
+      })
 
-      expect(result.provider.enabled).toBe(false)
-      expect(result.provider.options.headers).toEqual({})
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).enabled).toBe(false)
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).options.headers).toEqual({})
     }),
   )
 })

+ 47 - 40
packages/core/test/plugin/provider-nvidia.test.ts

@@ -1,8 +1,10 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
+import { Catalog } from "@opencode-ai/core/catalog"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
 import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia"
+import { ProviderV2 } from "@opencode-ai/core/provider"
 import { expectPluginRegistered, it, provider } from "./provider-helper"
 
 describe("NvidiaPlugin", () => {
@@ -18,45 +20,48 @@ describe("NvidiaPlugin", () => {
   it.effect("applies NVIDIA tracking headers only to nvidia", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(NvidiaPlugin)
-      const result = yield* plugin.trigger(
-        "provider.update",
-        {},
-        {
-          provider: provider("nvidia", {
-            options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
-          }),
-          cancel: false,
-        },
-      )
-      const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false })
-      expect(result.provider.options.headers).toEqual({
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const nvidia = provider("nvidia", {
+          endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
+          options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
+        })
+        catalog.provider.update(nvidia.id, (draft) => {
+          draft.endpoint = nvidia.endpoint
+          draft.options = nvidia.options
+        })
+        catalog.provider.update(provider("openrouter").id, () => {})
+      })
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({
         Existing: "value",
         "HTTP-Referer": "https://opencode.ai/",
         "X-Title": "opencode",
         "X-BILLING-INVOKE-ORIGIN": "OpenCode",
       })
-      expect(ignored.provider.options.headers).toEqual({})
+      expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({})
     }),
   )
 
   it.effect("adds billing origin for custom NVIDIA endpoints", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(NvidiaPlugin)
-      const result = yield* plugin.trigger(
-        "provider.update",
-        {},
-        {
-          provider: provider("nvidia", {
-            endpoint: { type: "aisdk", package: "test-provider", url: "http://localhost:8000/v1" },
-            options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } },
-          }),
-          cancel: false,
-        },
-      )
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const item = provider("nvidia", {
+          endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
+          options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } },
+        })
+        catalog.provider.update(item.id, (draft) => {
+          draft.endpoint = item.endpoint
+          draft.options = item.options
+        })
+      })
 
-      expect(result.provider.options.headers).toEqual({
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({
         "HTTP-Referer": "https://opencode.ai/",
         "X-Title": "opencode",
         "X-BILLING-INVOKE-ORIGIN": "OpenCode",
@@ -67,23 +72,25 @@ describe("NvidiaPlugin", () => {
   it.effect("preserves an explicit NVIDIA billing origin header", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(NvidiaPlugin)
-      const result = yield* plugin.trigger(
-        "provider.update",
-        {},
-        {
-          provider: provider("nvidia", {
-            options: {
-              headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
-              body: {},
-              aisdk: { provider: { baseURL: "https://integrate.api.nvidia.com/v1" }, request: {} },
-            },
-          }),
-          cancel: false,
-        },
-      )
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const item = provider("nvidia", {
+          endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
+          options: {
+            headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
+            body: {},
+            aisdk: { provider: { baseURL: "https://integrate.api.nvidia.com/v1" }, request: {} },
+          },
+        })
+        catalog.provider.update(item.id, (draft) => {
+          draft.endpoint = item.endpoint
+          draft.options = item.options
+        })
+      })
 
-      expect(result.provider.options.headers).toEqual({
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({
         "HTTP-Referer": "https://opencode.ai/",
         "X-Title": "opencode",
         "X-BILLING-INVOKE-ORIGIN": "CustomOrigin",

+ 25 - 17
packages/core/test/plugin/provider-openai.test.ts

@@ -1,9 +1,11 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
+import { Catalog } from "@opencode-ai/core/catalog"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
-import { fakeSelectorSdk, it, model } from "./provider-helper"
+import { ProviderV2 } from "@opencode-ai/core/provider"
+import { fakeSelectorSdk, it, model, provider } from "./provider-helper"
 
 describe("OpenAIPlugin", () => {
   it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
@@ -70,31 +72,37 @@ describe("OpenAIPlugin", () => {
     }),
   )
 
-  it.effect("cancels gpt-5-chat-latest during model updates", () =>
+  it.effect("disables gpt-5-chat-latest during catalog transforms", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(OpenAIPlugin)
-      const normal = yield* plugin.trigger("model.update", {}, { model: model("openai", "gpt-5"), cancel: false })
-      const filtered = yield* plugin.trigger(
-        "model.update",
-        {},
-        { model: model("openai", "gpt-5-chat-latest"), cancel: false },
-      )
-      expect(normal.cancel).toBe(false)
-      expect(filtered.cancel).toBe(true)
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const item = provider("openai", { endpoint: { type: "aisdk", package: "@ai-sdk/openai" } })
+        catalog.provider.update(item.id, (draft) => {
+          draft.endpoint = item.endpoint
+        })
+        catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {})
+        catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
+      })
+      expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true)
+      expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(false)
     }),
   )
 
-  it.effect("does not cancel gpt-5-chat-latest for non-OpenAI providers", () =>
+  it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(OpenAIPlugin)
-      const result = yield* plugin.trigger(
-        "model.update",
-        {},
-        { model: model("custom-openai", "gpt-5-chat-latest"), cancel: false },
-      )
-      expect(result.cancel).toBe(false)
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const item = provider("custom-openai")
+        catalog.provider.update(item.id, () => {})
+        catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
+      })
+      expect((yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(true)
     }),
   )
 })

+ 126 - 98
packages/core/test/plugin/provider-opencode.test.ts

@@ -12,19 +12,23 @@ const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0,
 const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
 
 describe("OpencodePlugin", () => {
-  it.effect("uses a public key and cancels paid models without credentials", () =>
+  it.effect("uses a public key and disables paid models without credentials", () =>
     withEnv({ OPENCODE_API_KEY: undefined }, () =>
       Effect.gen(function* () {
         const plugin = yield* PluginV2.Service
+        const catalog = yield* Catalog.Service
         yield* plugin.add(OpencodePlugin)
-        const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
-        const paid = yield* plugin.trigger(
-          "model.update",
-          {},
-          { model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
-        )
-        expect(updated.provider.options.aisdk.provider.apiKey).toBe("public")
-        expect(paid.cancel).toBe(true)
+        const load = yield* catalog.loader()
+        yield* load((catalog) => {
+          const item = provider("opencode")
+          catalog.provider.update(item.id, () => {})
+          const paid = model("opencode", "paid", { cost: cost(1) })
+          catalog.model.update(item.id, paid.id, (draft) => {
+            draft.cost = [...paid.cost]
+          })
+        })
+        expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("public")
+        expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
       }),
     ),
   )
@@ -33,14 +37,19 @@ describe("OpencodePlugin", () => {
     withEnv({ OPENCODE_API_KEY: undefined }, () =>
       Effect.gen(function* () {
         const plugin = yield* PluginV2.Service
+        const catalog = yield* Catalog.Service
         yield* plugin.add(OpencodePlugin)
-        yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
-        const free = yield* plugin.trigger(
-          "model.update",
-          {},
-          { model: model("opencode", "free", { cost: cost(0) }), cancel: false },
-        )
-        expect(free.cancel).toBe(false)
+        const load = yield* catalog.loader()
+        yield* load((catalog) => {
+          const item = provider("opencode")
+          catalog.provider.update(item.id, () => {})
+          const free = model("opencode", "free", { cost: cost(0) })
+          catalog.model.update(item.id, free.id, (draft) => {
+            draft.cost = [...free.cost]
+          })
+        })
+        expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("public")
+        expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
       }),
     ),
   )
@@ -49,14 +58,19 @@ describe("OpencodePlugin", () => {
     withEnv({ OPENCODE_API_KEY: undefined }, () =>
       Effect.gen(function* () {
         const plugin = yield* PluginV2.Service
+        const catalog = yield* Catalog.Service
         yield* plugin.add(OpencodePlugin)
-        yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
-        const outputOnly = yield* plugin.trigger(
-          "model.update",
-          {},
-          { model: model("opencode", "output-only", { cost: cost(0, 1) }), cancel: false },
-        )
-        expect(outputOnly.cancel).toBe(false)
+        const load = yield* catalog.loader()
+        yield* load((catalog) => {
+          const item = provider("opencode")
+          catalog.provider.update(item.id, () => {})
+          const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) })
+          catalog.model.update(item.id, outputOnly.id, (draft) => {
+            draft.cost = [...outputOnly.cost]
+          })
+        })
+        expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("public")
+        expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(true)
       }),
     ),
   )
@@ -65,15 +79,19 @@ describe("OpencodePlugin", () => {
     withEnv({ OPENCODE_API_KEY: "secret" }, () =>
       Effect.gen(function* () {
         const plugin = yield* PluginV2.Service
+        const catalog = yield* Catalog.Service
         yield* plugin.add(OpencodePlugin)
-        const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
-        const paid = yield* plugin.trigger(
-          "model.update",
-          {},
-          { model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
-        )
-        expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
-        expect(paid.cancel).toBe(false)
+        const load = yield* catalog.loader()
+        yield* load((catalog) => {
+          const item = provider("opencode")
+          catalog.provider.update(item.id, () => {})
+          const paid = model("opencode", "paid", { cost: cost(1) })
+          catalog.model.update(item.id, paid.id, (draft) => {
+            draft.cost = [...paid.cost]
+          })
+        })
+        expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined()
+        expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
       }),
     ),
   )
@@ -82,19 +100,21 @@ describe("OpencodePlugin", () => {
     withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
       Effect.gen(function* () {
         const plugin = yield* PluginV2.Service
+        const catalog = yield* Catalog.Service
         yield* plugin.add(OpencodePlugin)
-        const updated = yield* plugin.trigger(
-          "provider.update",
-          {},
-          { provider: provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] }), cancel: false },
-        )
-        const paid = yield* plugin.trigger(
-          "model.update",
-          {},
-          { model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
-        )
-        expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
-        expect(paid.cancel).toBe(false)
+        const load = yield* catalog.loader()
+        yield* load((catalog) => {
+          const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] })
+          catalog.provider.update(item.id, (draft) => {
+            draft.env = [...item.env]
+          })
+          const paid = model("opencode", "paid", { cost: cost(1) })
+          catalog.model.update(item.id, paid.id, (draft) => {
+            draft.cost = [...paid.cost]
+          })
+        })
+        expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined()
+        expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
       }),
     ),
   )
@@ -103,31 +123,30 @@ describe("OpencodePlugin", () => {
     withEnv({ OPENCODE_API_KEY: undefined }, () =>
       Effect.gen(function* () {
         const plugin = yield* PluginV2.Service
+        const catalog = yield* Catalog.Service
         yield* plugin.add(OpencodePlugin)
-        const updated = yield* plugin.trigger(
-          "provider.update",
-          {},
-          {
-            provider: provider("opencode", {
-              options: {
-                headers: {},
-                body: {},
-                aisdk: {
-                  provider: { apiKey: "configured" },
-                  request: {},
-                },
+        const load = yield* catalog.loader()
+        yield* load((catalog) => {
+          const item = provider("opencode", {
+            options: {
+              headers: {},
+              body: {},
+              aisdk: {
+                provider: { apiKey: "configured" },
+                request: {},
               },
-            }),
-            cancel: false,
-          },
-        )
-        const paid = yield* plugin.trigger(
-          "model.update",
-          {},
-          { model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
-        )
-        expect(updated.provider.options.aisdk.provider.apiKey).toBe("configured")
-        expect(paid.cancel).toBe(false)
+            },
+          })
+          catalog.provider.update(item.id, (draft) => {
+            draft.options = item.options
+          })
+          const paid = model("opencode", "paid", { cost: cost(1) })
+          catalog.model.update(item.id, paid.id, (draft) => {
+            draft.cost = [...paid.cost]
+          })
+        })
+        expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("configured")
+        expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
       }),
     ),
   )
@@ -136,19 +155,21 @@ describe("OpencodePlugin", () => {
     withEnv({ OPENCODE_API_KEY: undefined }, () =>
       Effect.gen(function* () {
         const plugin = yield* PluginV2.Service
+        const catalog = yield* Catalog.Service
         yield* plugin.add(OpencodePlugin)
-        const updated = yield* plugin.trigger(
-          "provider.update",
-          {},
-          { provider: provider("opencode", { enabled: { via: "auth", service: "opencode" } }), cancel: false },
-        )
-        const paid = yield* plugin.trigger(
-          "model.update",
-          {},
-          { model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
-        )
-        expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
-        expect(paid.cancel).toBe(false)
+        const load = yield* catalog.loader()
+        yield* load((catalog) => {
+          const item = provider("opencode", { enabled: { via: "account", service: "opencode" } })
+          catalog.provider.update(item.id, (draft) => {
+            draft.enabled = item.enabled
+          })
+          const paid = model("opencode", "paid", { cost: cost(1) })
+          catalog.model.update(item.id, paid.id, (draft) => {
+            draft.cost = [...paid.cost]
+          })
+        })
+        expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined()
+        expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
       }),
     ),
   )
@@ -157,15 +178,19 @@ describe("OpencodePlugin", () => {
     withEnv({ OPENCODE_API_KEY: undefined }, () =>
       Effect.gen(function* () {
         const plugin = yield* PluginV2.Service
+        const catalog = yield* Catalog.Service
         yield* plugin.add(OpencodePlugin)
-        const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false })
-        const paid = yield* plugin.trigger(
-          "model.update",
-          {},
-          { model: model("openai", "paid", { cost: cost(1) }), cancel: false },
-        )
-        expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
-        expect(paid.cancel).toBe(false)
+        const load = yield* catalog.loader()
+        yield* load((catalog) => {
+          const item = provider("openai")
+          catalog.provider.update(item.id, () => {})
+          const paid = model("openai", "paid", { cost: cost(1) })
+          catalog.model.update(item.id, paid.id, (draft) => {
+            draft.cost = [...paid.cost]
+          })
+        })
+        expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.aisdk.provider.apiKey).toBeUndefined()
+        expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
       }),
     ),
   )
@@ -175,18 +200,21 @@ describe("OpencodePlugin", () => {
       const catalog = yield* Catalog.Service
       const providerID = ProviderV2.ID.opencode
 
-      yield* catalog.provider.update(providerID, () => {})
-      yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
-        model.capabilities.input = ["text"]
-        model.capabilities.output = ["text"]
-        model.cost = cost(1, 1)
-        model.time.released = DateTime.makeUnsafe(Date.now())
-      })
-      yield* catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
-        model.capabilities.input = ["text"]
-        model.capabilities.output = ["text"]
-        model.cost = cost(10, 10)
-        model.time.released = DateTime.makeUnsafe(Date.now())
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        catalog.provider.update(providerID, () => {})
+        catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
+          model.capabilities.input = ["text"]
+          model.capabilities.output = ["text"]
+          model.cost = [...cost(1, 1)]
+          model.time.released = DateTime.makeUnsafe(Date.now())
+        })
+        catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
+          model.capabilities.input = ["text"]
+          model.capabilities.output = ["text"]
+          model.cost = [...cost(10, 10)]
+          model.time.released = DateTime.makeUnsafe(Date.now())
+        })
       })
 
       const selected = yield* catalog.model.small(providerID)

+ 48 - 38
packages/core/test/plugin/provider-openrouter.test.ts

@@ -1,8 +1,11 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
+import { Catalog } from "@opencode-ai/core/catalog"
+import { ModelV2 } from "@opencode-ai/core/model"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
 import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter"
+import { ProviderV2 } from "@opencode-ai/core/provider"
 import { expectPluginRegistered, it, model, provider } from "./provider-helper"
 
 describe("OpenRouterPlugin", () => {
@@ -18,24 +21,27 @@ describe("OpenRouterPlugin", () => {
   it.effect("applies legacy referer headers only to openrouter", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(OpenRouterPlugin)
-      const result = yield* plugin.trigger(
-        "provider.update",
-        {},
-        {
-          provider: provider("openrouter", {
-            options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
-          }),
-          cancel: false,
-        },
-      )
-      const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("nvidia"), cancel: false })
-      expect(result.provider.options.headers).toEqual({
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const openrouter = provider("openrouter", {
+          endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
+          options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
+        })
+        catalog.provider.update(openrouter.id, (item) => {
+          item.endpoint = openrouter.endpoint
+          item.options = openrouter.options
+        })
+        catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
+      })
+
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).options.headers).toEqual({
         Existing: "value",
         "HTTP-Referer": "https://opencode.ai/",
         "X-Title": "opencode",
       })
-      expect(ignored.provider.options.headers).toEqual({})
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({})
     }),
   )
 
@@ -67,39 +73,43 @@ describe("OpenRouterPlugin", () => {
   it.effect("filters OpenRouter's gpt-5 chat alias", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(OpenRouterPlugin)
-      const result = yield* plugin.trigger(
-        "model.update",
-        {},
-        { model: model("openrouter", "openai/gpt-5-chat"), cancel: false },
-      )
-      const regular = yield* plugin.trigger(
-        "model.update",
-        {},
-        { model: model("openrouter", "openai/gpt-5"), cancel: false },
-      )
-      const ignored = yield* plugin.trigger(
-        "model.update",
-        {},
-        { model: model("openai", "openai/gpt-5-chat"), cancel: false },
-      )
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const openrouter = provider("openrouter", { endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" } })
+        catalog.provider.update(openrouter.id, (item) => {
+          item.endpoint = openrouter.endpoint
+        })
+        catalog.provider.update(ProviderV2.ID.openai, () => {})
+        for (const item of [
+          model("openrouter", "openai/gpt-5-chat"),
+          model("openrouter", "openai/gpt-5"),
+          model("openai", "openai/gpt-5-chat"),
+        ]) {
+          catalog.model.update(item.providerID, item.id, () => {})
+        }
+      })
 
-      expect(result.cancel).toBe(true)
-      expect(regular.cancel).toBe(false)
-      expect(ignored.cancel).toBe(false)
+      expect((yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat"))).enabled).toBe(false)
+      expect((yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5"))).enabled).toBe(true)
+      expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"))).enabled).toBe(true)
     }),
   )
 
-  it.effect("does not filter gpt-5-chat-latest for non-OpenRouter providers", () =>
+  it.effect("does not disable gpt-5-chat-latest for non-OpenRouter providers", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(OpenRouterPlugin)
-      const result = yield* plugin.trigger(
-        "model.update",
-        {},
-        { model: model("custom-openrouter", "gpt-5-chat-latest"), cancel: false },
-      )
-      expect(result.cancel).toBe(false)
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {})
+        catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
+      })
+      expect(
+        (yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled,
+      ).toBe(true)
     }),
   )
 })

+ 29 - 16
packages/core/test/plugin/provider-vercel.test.ts

@@ -1,25 +1,29 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
+import { Catalog } from "@opencode-ai/core/catalog"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel"
+import { ProviderV2 } from "@opencode-ai/core/provider"
 import { it, model, provider } from "./provider-helper"
 
 describe("VercelPlugin", () => {
   it.effect("applies legacy lower-case referer headers", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(VercelPlugin)
-      const result = yield* plugin.trigger(
-        "provider.update",
-        {},
-        {
-          provider: provider("vercel", {
-            options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
-          }),
-          cancel: false,
-        },
-      )
-      expect(result.provider.options.headers).toEqual({
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const item = provider("vercel", {
+          endpoint: { type: "aisdk", package: "@ai-sdk/vercel" },
+          options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
+        })
+        catalog.provider.update(item.id, (draft) => {
+          draft.endpoint = item.endpoint
+          draft.options = item.options
+        })
+      })
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).toEqual({
         Existing: "1",
         "http-referer": "https://opencode.ai/",
         "x-title": "opencode",
@@ -30,10 +34,17 @@ describe("VercelPlugin", () => {
   it.effect("does not add legacy upper-case referer headers", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(VercelPlugin)
-      const result = yield* plugin.trigger("provider.update", {}, { provider: provider("vercel"), cancel: false })
-      expect(result.provider.options.headers).not.toHaveProperty("HTTP-Referer")
-      expect(result.provider.options.headers).not.toHaveProperty("X-Title")
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const item = provider("vercel", { endpoint: { type: "aisdk", package: "@ai-sdk/vercel" } })
+        catalog.provider.update(item.id, (draft) => {
+          draft.endpoint = item.endpoint
+        })
+      })
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).not.toHaveProperty("HTTP-Referer")
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).not.toHaveProperty("X-Title")
     }),
   )
 
@@ -54,9 +65,11 @@ describe("VercelPlugin", () => {
   it.effect("ignores non-Vercel providers", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(VercelPlugin)
-      const result = yield* plugin.trigger("provider.update", {}, { provider: provider("gateway"), cancel: false })
-      expect(result.provider.options.headers).toEqual({})
+      const load = yield* catalog.loader()
+      yield* load((catalog) => catalog.provider.update(provider("gateway").id, () => {}))
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).options.headers).toEqual({})
     }),
   )
 })

+ 58 - 45
packages/core/test/plugin/provider-zenmux.test.ts

@@ -1,8 +1,10 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
+import { Catalog } from "@opencode-ai/core/catalog"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
 import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux"
+import { ProviderV2 } from "@opencode-ai/core/provider"
 import { expectPluginRegistered, it, provider } from "./provider-helper"
 
 describe("ZenmuxPlugin", () => {
@@ -18,30 +20,39 @@ describe("ZenmuxPlugin", () => {
   it.effect("applies the exact legacy Zenmux headers", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(ZenmuxPlugin)
-      const result = yield* plugin.trigger("provider.update", {}, { provider: provider("zenmux"), cancel: false })
-      expect(result.provider.options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
-      expect(Object.keys(result.provider.options.headers).sort()).toEqual(["HTTP-Referer", "X-Title"])
-      expect(result.cancel).toBe(false)
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const item = provider("zenmux", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" } })
+        catalog.provider.update(item.id, (draft) => {
+          draft.endpoint = item.endpoint
+        })
+      })
+      const result = yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))
+      expect(result.options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
+      expect(Object.keys(result.options.headers).sort()).toEqual(["HTTP-Referer", "X-Title"])
     }),
   )
 
   it.effect("merges legacy Zenmux headers with existing headers", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(ZenmuxPlugin)
-      const result = yield* plugin.trigger(
-        "provider.update",
-        {},
-        {
-          provider: provider("zenmux", {
-            options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
-          }),
-          cancel: false,
-        },
-      )
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const item = provider("zenmux", {
+          endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
+          options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
+        })
+        catalog.provider.update(item.id, (draft) => {
+          draft.endpoint = item.endpoint
+          draft.options = item.options
+        })
+      })
 
-      expect(result.provider.options.headers).toEqual({
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).options.headers).toEqual({
         Existing: "value",
         "HTTP-Referer": "https://opencode.ai/",
         "X-Title": "opencode",
@@ -52,23 +63,25 @@ describe("ZenmuxPlugin", () => {
   it.effect("lets configured Zenmux legacy headers override defaults", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(ZenmuxPlugin)
-      const result = yield* plugin.trigger(
-        "provider.update",
-        {},
-        {
-          provider: provider("zenmux", {
-            options: {
-              headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
-              body: {},
-              aisdk: { provider: {}, request: {} },
-            },
-          }),
-          cancel: false,
-        },
-      )
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const item = provider("zenmux", {
+          endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
+          options: {
+            headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
+            body: {},
+            aisdk: { provider: {}, request: {} },
+          },
+        })
+        catalog.provider.update(item.id, (draft) => {
+          draft.endpoint = item.endpoint
+          draft.options = item.options
+        })
+      })
 
-      expect(result.provider.options.headers).toEqual({
+      expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).options.headers).toEqual({
         "HTTP-Referer": "https://example.com/",
         "X-Title": "custom-title",
       })
@@ -78,23 +91,23 @@ describe("ZenmuxPlugin", () => {
   it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service
+      const catalog = yield* Catalog.Service
       yield* plugin.add(ZenmuxPlugin)
-      const ignored = yield* plugin.trigger(
-        "provider.update",
-        {},
-        {
-          provider: provider("openrouter", {
-            options: {
-              headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
-              body: {},
-              aisdk: { provider: {}, request: {} },
-            },
-          }),
-          cancel: false,
-        },
-      )
+      const load = yield* catalog.loader()
+      yield* load((catalog) => {
+        const item = provider("openrouter", {
+          options: {
+            headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
+            body: {},
+            aisdk: { provider: {}, request: {} },
+          },
+        })
+        catalog.provider.update(item.id, (draft) => {
+          draft.options = item.options
+        })
+      })
 
-      expect(ignored.provider.options.headers).toEqual({
+      expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({
         "HTTP-Referer": "https://example.com/",
         "X-Title": "custom-title",
       })

+ 11 - 12
packages/opencode/src/cli/cmd/debug/v2.ts

@@ -1,18 +1,16 @@
 import { EOL } from "os"
-import { Effect, Layer, Option } from "effect"
+import { Effect, Option } from "effect"
 import { Catalog } from "@opencode-ai/core/catalog"
 import { LocationServiceMap } from "@opencode-ai/core/location-layer"
 import { PluginBoot } from "@opencode-ai/core/plugin/boot"
 import { effectCmd } from "../../effect-cmd"
 
-const Runtime = Layer.mergeAll(LocationServiceMap.layer)
-
 export const V2Command = effectCmd({
   command: "v2",
   describe: "debug v2 catalog and built-in plugins",
   instance: false,
-  handler: Effect.fn("Cli.debug.v2")(
-    function* () {
+  handler: () =>
+    Effect.gen(function* () {
       yield* PluginBoot.Service.use((service) => service.wait())
       const catalog = yield* Catalog.Service
       const providers = (yield* catalog.provider.available()).sort((a, b) => a.id.localeCompare(b.id))
@@ -35,12 +33,13 @@ export const V2Command = effectCmd({
         ),
       }
       process.stdout.write(JSON.stringify(result, null, 2) + EOL)
-    },
-    Effect.provide(
-      LocationServiceMap.get({
-        directory: process.cwd(),
-      }),
+    }).pipe(
+      Effect.withSpan("Cli.debug.v2"),
+      Effect.provide(
+        LocationServiceMap.get({
+          directory: process.cwd(),
+        }),
+      ),
+      Effect.provide(LocationServiceMap.layer),
     ),
-    Effect.provide(Runtime),
-  ),
 })

+ 2 - 2
packages/opencode/src/cli/cmd/run.ts

@@ -458,7 +458,7 @@ export const RunCommand = effectCmd({
         const name = title()
         const result = await sdk.session.create({
           title: name,
-          permission: rules,
+          permission: [...rules],
         })
         const id = result.data?.id
         if (!id) {
@@ -501,7 +501,7 @@ export const RunCommand = effectCmd({
                 variant: input.variant,
               }
             : undefined,
-          permission: rules,
+          permission: [...rules],
         })
         const id = result.data?.id
         if (!id) {

+ 1 - 1
packages/opencode/src/event-v2-bridge.ts

@@ -81,7 +81,7 @@ export const layer = Layer.effect(
 )
 
 export const defaultLayer = layer.pipe(
-  Layer.provideMerge(EventV2.defaultLayer),
+  Layer.provide(EventV2.defaultLayer),
   Layer.provide(SyncEvent.defaultLayer),
   Layer.provide(ProjectBus.defaultLayer),
 )

+ 1 - 15
packages/opencode/src/permission/evaluate.ts

@@ -1,15 +1 @@
-import { Wildcard } from "@/util/wildcard"
-
-type Rule = {
-  permission: string
-  pattern: string
-  action: "allow" | "deny" | "ask"
-}
-
-export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule {
-  const rules = rulesets.flat()
-  const match = rules.findLast(
-    (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
-  )
-  return match ?? { action: "ask", permission, pattern: "*" }
-}
+export { evaluate } from "@opencode-ai/core/permission"

+ 11 - 20
packages/opencode/src/permission/index.ts

@@ -8,15 +8,15 @@ import { PermissionTable } from "@/session/session.sql"
 import { Database } from "@/storage/db"
 import { eq } from "drizzle-orm"
 import * as Log from "@opencode-ai/core/util/log"
-import { Wildcard } from "@/util/wildcard"
+import { Wildcard } from "@opencode-ai/core/util/wildcard"
 import { Deferred, Effect, Layer, Schema, Context } from "effect"
 import os from "os"
-import { evaluate as evalRule } from "./evaluate"
+import { PermissionV2 } from "@opencode-ai/core/permission"
 import { PermissionID } from "./schema"
 
 const log = Log.create({ service: "permission" })
 
-export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" })
+export const Action = PermissionV2.Action.annotate({ identifier: "PermissionAction" })
 export type Action = Schema.Schema.Type<typeof Action>
 
 export const Rule = Schema.Struct({
@@ -26,7 +26,7 @@ export const Rule = Schema.Struct({
 }).annotate({ identifier: "PermissionRule" })
 export type Rule = Schema.Schema.Type<typeof Rule>
 
-export const Ruleset = Schema.mutable(Schema.Array(Rule)).annotate({ identifier: "PermissionRuleset" })
+export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" })
 export type Ruleset = Schema.Schema.Type<typeof Ruleset>
 
 export class Request extends Schema.Class<Request>("PermissionRequest")({
@@ -122,11 +122,11 @@ interface PendingEntry {
 
 interface State {
   pending: Map<PermissionID, PendingEntry>
-  approved: Ruleset
+  approved: Rule[]
 }
 
 export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
-  return evalRule(permission, pattern, ...rulesets)
+  return PermissionV2.evaluate(permission, pattern, ...rulesets)
 }
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
@@ -142,7 +142,7 @@ export const layer = Layer.effect(
         )
         const state = {
           pending: new Map<PermissionID, PendingEntry>(),
-          approved: row?.data ?? [],
+          approved: [...(row?.data ?? [])],
         }
 
         yield* Effect.addFinalizer(() =>
@@ -271,7 +271,7 @@ function expand(pattern: string): string {
 }
 
 export function fromConfig(permission: ConfigPermission.Info) {
-  const ruleset: Ruleset = []
+  const ruleset: Rule[] = []
   for (const [key, value] of Object.entries(permission)) {
     if (typeof value === "string") {
       ruleset.push({ permission: key, action: value, pattern: "*" })
@@ -284,21 +284,12 @@ export function fromConfig(permission: ConfigPermission.Info) {
   return ruleset
 }
 
-export function merge(...rulesets: Ruleset[]): Ruleset {
-  return rulesets.flat()
+export function merge(...rulesets: Ruleset[]): Rule[] {
+  return [...PermissionV2.merge(...rulesets)]
 }
 
-const EDIT_TOOLS = ["edit", "write", "apply_patch"]
-
 export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
-  const result = new Set<string>()
-  for (const tool of tools) {
-    const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
-    const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
-    if (!rule) continue
-    if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
-  }
-  return result
+  return PermissionV2.disabled(tools, ruleset)
 }
 
 export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))

+ 7 - 1
packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts

@@ -159,9 +159,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
       if (body.trim().length === 0) return yield* create({})
 
       const json = yield* tryParseJson(body)
-      const payload = yield* Schema.decodeUnknownEffect(Session.CreateInput)(json).pipe(
+      const decoded = yield* Schema.decodeUnknownEffect(Session.CreateInput)(json).pipe(
         Effect.mapError(() => new HttpApiError.BadRequest({})),
       )
+      const payload = decoded
+        ? {
+            ...decoded,
+            permission: decoded.permission ? [...decoded.permission] : undefined,
+          }
+        : decoded
       return yield* create({ payload })
     })
 

+ 1 - 1
packages/opencode/src/session/prompt.ts

@@ -1216,7 +1216,7 @@ export const layer = Layer.effect(
       const message = yield* createUserMessage(input)
       yield* sessions.touch(input.sessionID)
 
-      const permissions: Permission.Ruleset = []
+      const permissions: Permission.Rule[] = []
       for (const [t, enabled] of Object.entries(input.tools ?? {})) {
         permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
       }

+ 3 - 3
packages/opencode/src/session/session.ts

@@ -101,7 +101,7 @@ export function fromRow(row: SessionRow): Info {
     },
     share,
     revert,
-    permission: row.permission ?? undefined,
+    permission: row.permission ? [...row.permission] : undefined,
     time: {
       created: row.time_created,
       updated: row.time_updated,
@@ -542,7 +542,7 @@ export const layer: Layer.Layer<
         title: input.title ?? createDefaultTitle(!!input.parentID),
         agent: input.agent,
         model: input.model,
-        permission: input.permission,
+        permission: input.permission ? [...input.permission] : undefined,
         cost: 0,
         tokens: EmptyTokens,
         time: {
@@ -734,7 +734,7 @@ export const layer: Layer.Layer<
       sessionID: SessionID
       permission: Permission.Ruleset
     }) {
-      yield* patch(input.sessionID, { permission: input.permission, time: { updated: Date.now() } })
+      yield* patch(input.sessionID, { permission: [...input.permission], time: { updated: Date.now() } })
     })
 
     const setRevert = Effect.fn("Session.setRevert")(function* (input: {

+ 1 - 1
packages/opencode/src/share/session.ts

@@ -4,7 +4,7 @@ import { SyncEvent } from "@/sync"
 import { Effect, Layer, Scope, Context } from "effect"
 import { Config } from "@/config/config"
 import { RuntimeFlags } from "@/effect/runtime-flags"
-import * as ShareNext from "./share-next"
+import { ShareNext } from "./share-next"
 
 export interface Interface {
   readonly create: (input?: Session.CreateInput) => Effect.Effect<Session.Info>

+ 1 - 1
packages/opencode/test/server/httpapi-event-diagnostics.test.ts

@@ -77,7 +77,7 @@ const publishPartUpdated = (partID: ReturnType<typeof PartID.ascending>) => {
 
 const subscribeAllCallback = (handler: (event: BusEvent) => void) =>
   Effect.acquireRelease(inApp(Bus.Service.use((svc) => svc.subscribeAllCallback(handler))), (dispose) =>
-    Effect.sync(dispose),
+    Effect.sync(() => dispose()),
   )
 
 const openEventStream = (directory: string) =>

+ 1 - 1
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -3529,7 +3529,7 @@ export type ProviderV2Info = {
         name: string
       }
     | {
-        via: "auth"
+        via: "account"
         service: string
       }
     | {

+ 121 - 0
specs/v2/instructions.md

@@ -0,0 +1,121 @@
+# V2 Core Instructions
+
+These notes describe how to work on `packages/core` during the v2 port.
+
+## Direction
+
+Move behavior out of large application services and into plugins. Core services should become small, typed containers that own state, expose simple operations, and trigger hooks where policy or integration-specific logic belongs.
+
+The target shape is:
+
+- `packages/core` contains domain schemas, typed errors, state containers, events, and plugin hook contracts.
+- Plugins implement provider-specific, config-specific, auth-specific, model-discovery, and generation behavior.
+- Services are hot-reloadable by design: updates are granular, observable, and do not require tearing down the whole process.
+- `packages/opencode` becomes thinner over time: UI, server routes, CLI, storage glue, and legacy compatibility should call the core services instead of owning domain logic directly.
+
+## Service Shape
+
+Core services should look like `Catalog`, `AccountV2`, and `AgentV2`:
+
+- define schemas and branded ids at the top of the module
+- define typed `Schema.TaggedErrorClass` errors for expected failures
+- define an `Interface` with small operations
+- expose a `Context.Service`
+- implement `layer` with private in-memory state
+- expose `defaultLayer` with explicit dependencies
+- self-export with `export * as Name from "./file"`
+
+Prefer a dumb container API:
+
+- `get`, `all`, `available`, `default`, `update`, `remove`, `activate`, or other small domain verbs
+- `update(id, draft => ...)` for registration and mutation
+- hook calls before committing mutations when plugins need to enrich, cancel, or validate changes
+- events after committing mutations when other services or frontends need to react
+
+Avoid putting application policy directly in core services unless it is a domain invariant. For example, resolving model endpoint inheritance is catalog-owned; deciding which providers to register is plugin-owned.
+
+## Plugin Hooks
+
+Plugins are the extension boundary for v2. Add hooks to `PluginV2.HookSpec` when logic should be provided by integrations instead of the container itself.
+
+Hook conventions:
+
+- hooks receive immutable input plus mutable output
+- mutable object outputs are exposed as Immer drafts
+- include `cancel: boolean` when plugins can prevent a mutation
+- trigger hooks sequentially so ordering remains deterministic
+- keep hook names domain-oriented, like `provider.update`, `model.update`, `account.activate`, `agent.generate`
+- keep hook payloads small and typed with core schemas
+
+Use hooks for:
+
+- registering providers and models
+- applying env/account/config-derived enablement
+- transforming SDK/provider options
+- implementing generated behavior such as agent generation
+- choosing defaults when the choice is policy rather than state
+
+Do not use hooks as a dumping ground for transport concerns, UI behavior, or compatibility shims.
+
+## Plugin Boot
+
+Built-in core plugins are registered by `packages/core/src/plugin/boot.ts`.
+
+When a new core service is intended to be available to plugins:
+
+- add the service to the boot layer dependency type
+- yield the service inside the layer
+- provide it to each plugin effect in `add`
+- add its default layer to `PluginBoot.defaultLayer` only when that does not create a cycle
+
+Keep boot as composition only. It should not contain provider, account, agent, or model policy itself.
+
+## Boundaries
+
+Core should not import from `packages/opencode`. If a type or concept is needed by core, move or remodel the domain shape in core first.
+
+Avoid moving legacy services over wholesale. Port the domain shape and the container API, then leave specific behavior behind hooks for plugins to implement.
+
+When porting an opencode service:
+
+- identify the state it owns
+- identify the operations callers actually need
+- identify which branches are policy or integration behavior
+- model state and operations in `packages/core`
+- add hooks for the policy/integration branches
+- keep old package code working until callers can migrate incrementally
+
+## Schemas And Types
+
+Use Effect schemas as the public contract:
+
+- branded schemas for ids
+- `Schema.Class` or `Schema.Struct` for domain data
+- `Schema.TaggedErrorClass` for expected errors
+- existing core helpers like `DeepMutable`, `withStatics`, and integer schemas where appropriate
+
+Prefer `Info` objects as the stored domain records. Add static `empty(...)` constructors when update APIs need to create records on first mutation.
+
+Keep schemas stable and explicit. Do not rely on opencode config shapes as core domain shapes unless the config shape is actually the domain model.
+
+## State And Events
+
+Keep state private to the service layer. Use immutable replacement or Effect refs when persistence/concurrency requires it.
+
+Publish events for committed domain changes, not for attempted mutations. Event names should describe domain facts, for example `catalog.model.updated`.
+
+The v2 goal is granular reconfiguration. A model update should let dependents react to that model update; it should not require global reloads.
+
+## Style
+
+Follow the local core style:
+
+- `Effect.gen(function* () { ... })` for composition
+- `Effect.fn("Domain.method")` for public service methods
+- `Effect.fnUntraced` for small internal mutation helpers
+- `yield* new ErrorClass(...)` for typed failures
+- minimal helpers unless they name a real concept
+- no `any` unless an existing plugin boundary requires it
+- no compatibility code without a concrete persisted or external-consumer need
+
+Prefer the smallest correct port. The goal is to make services easier to replace and reason about, not to recreate the old architecture in a new package.

+ 48 - 8
specs/v2/provider-model.md

@@ -232,7 +232,7 @@ export interface Interface {
 }
 ```
 
-`ProviderV2.Info.enabled` is stored provider state. Provider plugins set this field after checking env, auth, config, or provider-specific availability.
+`ProviderV2.Info.enabled` is stored provider state. Provider plugins set this field after checking env, account, config, or provider-specific availability.
 
 `ProviderV2.Endpoint` includes `{ type: "unknown" }`. `CatalogV2.model.get()` and `CatalogV2.model.all()` resolve `unknown` endpoints from the provider before returning models.
 
@@ -256,6 +256,46 @@ const available = provider.enabled && model.status !== "deprecated"
 ## Plugin Interface
 
 ```ts
+type HookSpec = {
+  "account.update": {
+    input: {
+      id: AccountV2.ID
+      serviceID: AccountV2.ServiceID
+    }
+    output: {
+      description: string
+      credential: AccountV2.Credential
+      cancel: boolean
+    }
+  }
+
+  "account.remove": {
+    input: {
+      account: AccountV2.Info
+    }
+    output: {
+      cancel: boolean
+    }
+  }
+
+  "account.activate": {
+    input: {}
+    output: {
+      from?: AccountV2.ID
+      to: AccountV2.ID
+      cancel: boolean
+    }
+  }
+
+  "account.activated": {
+    input: {
+      from?: AccountV2.ID
+      to: AccountV2.ID
+    }
+    output: {}
+  }
+}
+
 export type Definition<R = never> = Effect.Effect<
   {
     readonly order: number
@@ -280,7 +320,7 @@ export interface Interface {
 export const Order = {
   modelsDev: 0,
   env: 10,
-  auth: 20,
+  account: 20,
   provider: 30,
   config: 40,
   discovery: 50,
@@ -294,21 +334,21 @@ export const ModelsDevPlugin: PluginV2.Definition<ProviderV2.Service | ModelV2.S
 
 export const EnvPlugin: PluginV2.Definition<ProviderV2.Service | Env.Service>
 
-export const AuthPlugin: PluginV2.Definition<ProviderV2.Service | AuthV2.Service>
+export const AccountPlugin: PluginV2.Definition<ProviderV2.Service | AccountV2.Service>
 
 export const ConfigPlugin: PluginV2.Definition<ProviderV2.Service | ModelV2.Service | Config.Service>
 
-export const AnthropicPlugin: PluginV2.Definition<ProviderV2.Service | AuthV2.Service>
+export const AnthropicPlugin: PluginV2.Definition<ProviderV2.Service | AccountV2.Service>
 
 export const OpenRouterPlugin: PluginV2.Definition<ProviderV2.Service>
 
-export const AmazonBedrockPlugin: PluginV2.Definition<ProviderV2.Service | AuthV2.Service | Env.Service>
+export const AmazonBedrockPlugin: PluginV2.Definition<ProviderV2.Service | AccountV2.Service | Env.Service>
 
-export const GoogleVertexPlugin: PluginV2.Definition<ProviderV2.Service | AuthV2.Service | Env.Service>
+export const GoogleVertexPlugin: PluginV2.Definition<ProviderV2.Service | AccountV2.Service | Env.Service>
 
-export const GitLabPlugin: PluginV2.Definition<ProviderV2.Service | AuthV2.Service | Env.Service>
+export const GitLabPlugin: PluginV2.Definition<ProviderV2.Service | AccountV2.Service | Env.Service>
 
-export const GitLabDiscoveryPlugin: PluginV2.Definition<ProviderV2.Service | ModelV2.Service | AuthV2.Service>
+export const GitLabDiscoveryPlugin: PluginV2.Definition<ProviderV2.Service | ModelV2.Service | AccountV2.Service>
 ```
 
 ## Plugin Hooks