@opencode/plugin/effect 是 OpenCode 插件 API 的 Effect 原生版本。其上下文操作返回 Effect 或 Stream,回调返回 Effect,插件生命周期由 Scope 表示。
在加载插件及其 node_modules 依赖时,OpenCode 会在运行时提供 effect(包括导出的 effect/* 子路径)和 @opencode/plugin。请将它们作为 peer/dev 依赖安装,用于本地类型检查和测试,而不是将其打包进插件。
bun add --peer @opencode/plugin effect@4.0.1
从 .opencode/plugins/ 导出 Effect 插件即可自动加载。
import { Plugin } from "@opencode/plugin/effect"
import { Effect } from "effect"

export default Plugin.define({
  id: "example",
  effect: (ctx) =>
    Effect.gen(function* () {
      const storage = ctx.storage
      yield* storage.set("loaded", true)
    }),
})
已发布的包以及 .opencode/plugins/ 之外的插件目录,使用与其他服务端插件相同的 plugins 配置。
{
  "$schema": "https://opencode.ai/config.json",
  "plugins": [
    "opencode-acme-effect-plugin",
    "opencode-acme-effect-plugin@1.2.0",
    "@acme/opencode-effect-plugin",
    "./plugins/local-effect",
    {
      "package": "@acme/opencode-effect-plugin",
      "options": { "agent": "reviewer", "strict": true },
    },
  ],
}
参见配置插件,了解启用方式、包解析与配置优先级。
import { Plugin } from "@opencode/plugin/effect"
import { Effect } from "effect"

export default Plugin.define({
  id: "local-effect",
  effect: (ctx) =>
    Effect.gen(function* () {
      yield* Effect.logInfo("Effect plugin loaded", { version: ctx.app.version })
    }),
})

生命周期

effect 在插件加载时运行。其作用域会在插件重新加载或卸载时关闭,因此注册、有作用域的 fiber 与终结器会一并释放。
import { Plugin } from "@opencode/plugin/effect"
import { Effect } from "effect"

export default Plugin.define({
  id: "lifecycle",
  effect: (ctx) =>
    Effect.gen(function* () {
      yield* Effect.logInfo("loaded", { version: ctx.app.version })
      yield* Effect.addFinalizer(() => Effect.logInfo("unloaded"))
    }),
})
后台任务请使用有作用域的 fiber。插件作用域在清理过程中会中断该 fiber。
effect: (ctx) =>
  Effect.gen(function* () {
    const storage = ctx.storage
    yield* Effect.repeat(
      storage.set("heartbeat", { time: Date.now() }),
      { schedule: Schedule.spaced("1 minute") },
    ).pipe(Effect.forkScoped)
  }),

上下文

上下文暴露已连接 OpenCode 服务端的 Effect 客户端,以及仅限插件使用的转换、钩子、存储、重载与选项。它不暴露 OpenCode 的私有 Core 服务。
effect: (ctx) =>
  Effect.gen(function* () {
    const plugins = ctx.plugin
    const active = yield* plugins.list().pipe(Effect.orDie)
    yield* Effect.logInfo("active plugins", { count: active.data.length })
  }),
插件定义与完整上下文声明如下。
interface Context {
  readonly app: App
  readonly location: Location.Info
  readonly options: PluginOptions
  readonly agent: AgentDomain
  readonly aisdk: AISDKDomain
  readonly command: CommandDomain
  readonly event: EventDomain
  readonly experimental: {
    readonly terminal: Pick<ExperimentalApi<unknown>["persistentPty"], "read">
  }
  readonly integration: IntegrationDomain
  readonly mcp: MCPDomain
  readonly model: ModelDomain
  readonly generate: GenerateApi<unknown>
  readonly permission: PermissionDomain
  readonly plugin: Pick<PluginApi<unknown>, "list">
  readonly provider: ProviderDomain
  readonly reference: ReferenceDomain
  readonly rpc: RpcDomain
  readonly session: SessionDomain
  readonly shell: ShellDomain
  readonly skill: SkillDomain
  readonly storage: StorageDomain
  readonly tool: ToolDomain
  readonly vcs: VcsDomain
  readonly websearch: WebSearchDomain
  readonly worktree: WorktreeDomain
}

interface Plugin<R = Scope.Scope> {
  readonly id: string
  readonly effect: (context: Context) => Effect.Effect<void, never, R>
}

选项

在 opencode.json(c) 中使用对象形式传入选项。
{
  "plugins": [
    {
      "package": "./plugins/company-effect",
      "options": { "strict": true },
    },
  ],
}
从 ctx.options 读取选项。使用前请先收窄 unknown 值。
import { Plugin } from "@opencode/plugin/effect"
import { Effect } from "effect"

export default Plugin.define({
  id: "company",
  effect: (ctx) =>
    Effect.gen(function* () {
      const strict = ctx.options.strict === true
      yield* Effect.logInfo("company plugin configured", { strict })
    }),
})

转换

转换通过编辑器同步修改状态。OpenCode 在每个域内按注册顺序应用转换。来源首先贡献提供商与不可变的模型定义;随后模型转换编辑活跃提供商的候选项。yield 注册可使其保留在插件作用域内。
import { Model, Plugin, Provider } from "@opencode/plugin/effect"
import { Effect } from "effect"

export default Plugin.define({
  id: "company.models",
  effect: (ctx) =>
    Effect.gen(function* () {
      const providerID = Provider.ID.make("acme")
      const models = [{ ...Model.Info.default(providerID, Model.ID.make("reasoner")), name: "Acme Reasoner" }]
      yield* ctx.provider.transform((editor) => {
        editor.add({
          info: {
            ...Provider.Info.empty(providerID),
            name: "Acme",
            activation: "enabled",
            package: "@opencode/ai/providers/openai-compatible",
            settings: { baseURL: "http://127.0.0.1:8000/v1" },
          },
          models,
        })
      })
    }),
})
模型转换可以在完整的活跃提供商候选项集合上强制执行策略。
effect: (ctx) =>
  Effect.gen(function* () {
    yield* ctx.model.transform((editor) => {
      editor
        .list()
        .filter((model) => model.cost.some((tier) => tier.output > 20))
        .forEach((model) => {
          editor.remove(model.providerID, model.id)
        })
    })
  }),
当转换所用的外部状态发生变化时调用 reload。此处 loadFromSource() 返回 { info: Provider.Info, models: readonly Model.Info[] } 条目;提供商重载也会使整个模型结果失效。
effect: (ctx) =>
  Effect.gen(function* () {
    const provider = ctx.provider
    const source = { providers: yield* loadFromSource() }

    yield* provider.transform((editor) => {
      source.providers.forEach((provider) => editor.add(provider))
    })

    yield* Effect.repeat(
      Effect.gen(function* () {
        source.providers = yield* loadFromSource()
        yield* provider.reload()
      }),
      { schedule: Schedule.spaced("1 minute") },
    ).pipe(Effect.forkScoped)
  }),

API

智能体

读取全部智能体,或按 ID 获取单个智能体。客户端响应包含已解析的位置与 schema 数据。
effect: (ctx) =>
  Effect.gen(function* () {
    const agent = ctx.agent
    const agents = yield* agent.list().pipe(Effect.orDie)
    const build = yield* agent.get({ agentID: Agent.ID.make("build") }).pipe(Effect.orDie)
    yield* Effect.logInfo("agents", { count: agents.data.length, build: build.data.name })
  }),
转换或重载智能体。
effect: (ctx) =>
  Effect.gen(function* () {
    const agent = ctx.agent
    yield* agent.transform((editor) => {
      editor.default("build")
      editor.update("build", (item) => (item.description = "Builds features and fixes bugs"))
      editor.remove("legacy")
    })
    yield* agent.reload()
  }),
Schema:Agent.Info。
interface AgentEditor {
  list(): readonly Types.DeepMutable<Agent.Info>[]
  get(id: string): Types.DeepMutable<Agent.Info> | undefined
  default(id: string | undefined): void
  update(id: string, update: (agent: Types.DeepMutable<Agent.Info>) => void): void
  remove(id: string): void
}

interface AgentDomain extends AgentApi<unknown> {
  readonly transform: Transform<AgentEditor>
  readonly reload: () => Effect.Effect<void>
}

提供商

读取可用的提供商,或按 ID 查看单个提供商的元数据。
effect: (ctx) =>
  Effect.gen(function* () {
    const provider = ctx.provider
    const providers = yield* provider.list().pipe(Effect.orDie)
    const anthropic = yield* provider.get({ providerID: Provider.ID.make("anthropic") }).pipe(Effect.orDie)
    yield* Effect.logInfo("providers", {
      providers: providers.data.length,
      anthropic: anthropic.data.name,
    })
  }),
提供商转换编辑提供商设置与源定义。编辑器的读取包含未激活的提供商,并暴露不可变定义;models.update 编辑源模型的自有副本。
effect: (ctx) =>
  Effect.gen(function* () {
    yield* ctx.provider.transform((editor) => {
      editor.update("anthropic", (provider) => {
        provider.headers = { ...provider.headers, "x-company": "engineering" }
      })
      editor.models.update("anthropic", "claude-sonnet-5-5", (model) => (model.name = "Team Sonnet"))
      editor.models.remove("anthropic", "legacy-model")
      editor.remove("legacy-provider")
    })
  }),
用 models.set 替换源清单,然后在其捕获的定义发生变化后重载。
effect: (ctx) =>
  Effect.gen(function* () {
    const provider = ctx.provider
    const source = { models: yield* loadModels() }
    yield* provider.transform((editor) => editor.models.set("acme", source.models))
    source.models = yield* loadModels()
    yield* provider.reload()
  }),
对于账户相关的发现,请将用于加载清单的连接作为 sourceConnection 传入。当选择了其他连接时,OpenCode 会排除该提供商,直到你的插件发布刷新后的清单。
const connection = yield* ctx.integration.connection.active("acme")
if (!connection) return
const inventory = yield* loadInventory(connection)
yield* ctx.provider.transform((editor) => {
  editor.add({ info: inventory.provider, models: inventory.models, sourceConnection: connection })
})
interface ProviderRecord {
  readonly provider: Provider.Info
  readonly models: ReadonlyMap<string, Model.Info>
  readonly sourceConnection?: ConnectionInfo
}

interface ProviderEditor {
  list(): readonly ProviderRecord[]
  get(providerID: string): ProviderRecord | undefined
  add(input: { info: Provider.Info; models: readonly Model.Info[]; sourceConnection?: ConnectionInfo }): void
  update(providerID: string, update: (provider: Types.DeepMutable<Provider.Info>) => void): void
  remove(providerID: string): void
  readonly models: {
    set(providerID: string, models: readonly Model.Info[]): void
    update(providerID: string, modelID: string, update: (model: Types.DeepMutable<Model.Info>) => void): void
    remove(providerID: string, modelID: string): void
  }
}

interface ProviderDomain extends ProviderApi<unknown> {
  readonly transform: Transform<ProviderEditor>
  readonly reload: () => Effect.Effect<void>
}

模型

读取可用模型与默认选择。
effect: (ctx) =>
  Effect.gen(function* () {
    const model = ctx.model
    const models = yield* model.list().pipe(Effect.orDie)
    const selected = yield* model.default().pipe(Effect.orDie)
    yield* Effect.logInfo("models", { count: models.data.length, selected: selected.data?.name })
  }),
模型转换编辑完整的活跃提供商候选项集合。禁用的候选在所有转换完成前仍可编辑;update 只能在可用提供商下添加模型。
effect: (ctx) =>
  Effect.gen(function* () {
    yield* ctx.model.transform((editor) => {
      editor.list("anthropic").forEach((model) => {
        editor.update(model.providerID, model.id, (draft) => {
          draft.enabled = draft.capabilities.tools
        })
      })
      editor.default.set("anthropic", "claude-sonnet-5-5")
      editor.remove("anthropic", "legacy-model")
    })
  }),
回调编辑原始的模型覆盖。OpenCode 提交结果时仅合并一次提供商默认值;未更改的读取会复用该结果。editor.provider 读取不可变的源定义(包括未激活的模板),而非模型转换的输出。
effect: (ctx) =>
  Effect.gen(function* () {
    const model = ctx.model
    yield* model.transform((editor) => {
      const source = editor.provider.get("anthropic")?.models.get("claude-sonnet-5-5")
      if (!source) return
      editor.update("anthropic", source.id, (draft) => {
        draft.limit.context = Math.min(draft.limit.context, source.limit.context)
      })
    })
    yield* model.reload()
  }),
Schema:Model.Info。
interface ModelEditor {
  list(providerID?: string): readonly Types.DeepMutable<Model.Info>[]
  get(providerID: string, modelID: string): Types.DeepMutable<Model.Info> | undefined
  update(providerID: string, modelID: string, update: (model: Types.DeepMutable<Model.Info>) => void): void
  remove(providerID: string, modelID: string): void
  readonly default: {
    get(): { providerID: string; modelID: string } | undefined
    set(providerID: string, modelID: string): void
  }
  readonly provider: {
    list(): readonly ProviderRecord[]
    get(providerID: string): ProviderRecord | undefined
  }
}

interface ModelDomain extends ModelApi<unknown> {
  readonly transform: Transform<ModelEditor>
  readonly reload: () => Effect.Effect<void>
}

命令

读取当前位置可用的命令。
effect: (ctx) =>
  Effect.gen(function* () {
    const command = ctx.command
    const commands = yield* command.list().pipe(Effect.orDie)
    yield* Effect.logInfo("commands", { names: commands.data.map((item) => item.name) })
  }),
当前的命令转换仅支持添加。执行器接收会话、提示附件与投递模式。
effect: (ctx) =>
  Effect.gen(function* () {
    const command = ctx.command
    const session = ctx.session
    yield* command.transform((editor) => {
      editor.add({
        name: "security-review",
        description: "Review changes for security issues",
        execute: (input) =>
          session.prompt({
            ...input.prompt,
            sessionID: input.sessionID,
            text: `Review these changes for security issues.\n\n${input.prompt.text}`,
            delivery: input.delivery,
          }).pipe(Effect.asVoid),
      })
    })
    yield* command.reload()
  }),
interface CommandInvocation {
  readonly sessionID: Session.ID
  readonly prompt: PromptInput.Prompt
  readonly delivery: SessionInbox.Delivery
}

interface CommandDefinition {
  readonly name: string
  readonly description?: string
  readonly execute: (input: CommandInvocation) => Effect.Effect<void, unknown>
}

interface CommandEditor {
  add(definition: CommandDefinition): void
}

interface CommandDomain extends Pick<CommandApi<unknown>, "list"> {
  readonly transform: Transform<CommandEditor>
  readonly reload: () => Effect.Effect<void>
}

集成

读取集成,并在存在活跃凭据时解析该凭据。
effect: (ctx) =>
  Effect.gen(function* () {
    const integration = ctx.integration
    const integrations = yield* integration.list().pipe(Effect.orDie)
    const github = yield* integration.get({ integrationID: Integration.ID.make("github") }).pipe(Effect.orDie)
    const connection = yield* integration.connection.active("github")
    const credential = connection ? yield* integration.connection.resolve(connection).pipe(Effect.orDie) : undefined
    yield* Effect.logInfo("integration", {
      count: integrations.data.length,
      github: github.data.name,
      credential: credential?.type,
    })
  }),
使用密钥连接,或通过已连接的 API 驱动 OAuth 流程。
effect: (ctx) =>
  Effect.gen(function* () {
    const integration = ctx.integration
    const token = yield* Config.Redacted("GITHUB_TOKEN").pipe(Effect.orDie)
    yield* integration.connect
      .key({ integrationID: Integration.ID.make("github"), key: Redacted.value(token) })
      .pipe(Effect.orDie)

    const attempt = yield* integration.oauth
      .connect({
        integrationID: Integration.ID.make("acme"),
        methodID: Integration.MethodID.make("oauth"),
      })
      .pipe(Effect.orDie)
    const status = yield* integration.oauth
      .status({
        integrationID: Integration.ID.make("acme"),
        attemptID: attempt.data.attemptID,
      })
      .pipe(Effect.orDie)
    yield* Effect.logInfo("OAuth status", { status: status.data })
  }),
转换集成与认证方式。OAuth 方式回调返回 Effect,并可获取有作用域的资源。
effect: (ctx) =>
  Effect.gen(function* () {
    const integration = ctx.integration
    yield* integration.transform((editor) => {
      editor.update("acme", (item) => (item.name = "Acme"))
      editor.method.update({
        integrationID: "acme",
        method: { id: "device", type: "oauth", label: "Sign in with Acme" },
        authorize: () =>
          Effect.succeed({
            mode: "code",
            url: "https://acme.example/device",
            instructions: "Enter the displayed code",
            callback: (code) => exchangeCode(code),
          }),
      })
    })
    yield* integration.reload()
  }),
type IntegrationOAuthAuthorization = {
  readonly url: string
  readonly instructions: string
  readonly expiresAt?: number
} & (
  | { readonly mode: "auto"; readonly callback: Effect.Effect<Credential.OAuth, unknown> }
  | { readonly mode: "code"; readonly callback: (code: string) => Effect.Effect<Credential.OAuth, unknown> }
)

type IntegrationOAuthMethodRegistration = {
  readonly integrationID: string
  readonly method: IntegrationOAuthMethod
  readonly authorize: (answer: Form.Answer) => Effect.Effect<IntegrationOAuthAuthorization, unknown, Scope.Scope>
  readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
  readonly label?: (credential: Credential.OAuth) => string | undefined
}

interface IntegrationEditor {
  list(): readonly IntegrationRef[]
  get(id: string): IntegrationRef | undefined
  update(id: string, update: (integration: IntegrationRef) => void): void
  remove(id: string): void
  readonly method: {
    list(integrationID: string): readonly IntegrationMethod[]
    update(input: IntegrationMethodRegistration): void
    remove(integrationID: string, method: IntegrationMethod): void
  }
}

interface IntegrationDomain extends Omit<IntegrationApi<unknown>, "wellknown"> {
  readonly transform: Transform<IntegrationEditor>
  readonly reload: () => Effect.Effect<void>
  readonly connection: {
    readonly active: (integrationID: string) => Effect.Effect<ConnectionInfo | undefined>
    readonly resolve: (connection: ConnectionInfo) => Effect.Effect<Credential.Value | undefined, unknown>
  }
}

MCP

列出 MCP 服务器及其当前连接状态。
effect: (ctx) =>
  Effect.gen(function* () {
    const mcp = ctx.mcp
    const servers = yield* mcp.list().pipe(Effect.orDie)
    yield* Effect.logInfo("MCP servers", { count: servers.data.length })
  }),
插件仅通过转换管理 MCP 服务器。使用 editor.set 添加或替换服务器,editor.update 更改其配置,editor.remove 移除它。使用 editor.list 与 editor.get 检查配置。
effect: (ctx) =>
  Effect.gen(function* () {
    const mcp = ctx.mcp
    yield* mcp.transform((editor) => {
      const servers = editor.list()
      const docs = editor.get("docs")
      editor.set("docs", { type: "remote", url: "https://mcp.example.com" })
      editor.update("docs", (server) => (server.disabled = false))
      editor.remove("legacy")
    })
  }),
在转换中设置 disabled: true 可禁用服务器并断开连接,设置 disabled: false 可启用它并允许 OpenCode 连接。OpenCode 根据转换后的配置协调服务器生命周期。
当转换所用的外部状态变化后,调用 reload() 以重新应用转换并协调服务器。
effect: (ctx) =>
  Effect.gen(function* () {
    const mcp = ctx.mcp
    yield* mcp.reload()
  }),
interface MCPEditor {
  list(): readonly [string, Types.DeepMutable<Mcp.ServerConfig>][]
  get(name: string): Types.DeepMutable<Mcp.ServerConfig> | undefined
  set(name: string, config: Mcp.ServerConfig): void
  update(name: string, update: (config: Types.DeepMutable<Mcp.ServerConfig>) => void): void
  remove(name: string): void
}

interface MCPDomain extends Pick<McpApi<unknown>, "list"> {
  readonly transform: Transform<MCPEditor>
  readonly reload: () => Effect.Effect<void>
}

插件

列出当前位置的活跃、失败与已解析的插件。
effect: (ctx) =>
  Effect.gen(function* () {
    const plugin = ctx.plugin
    const plugins = yield* plugin.list().pipe(Effect.orDie)
    yield* Effect.forEach(plugins.data, (item) => Effect.logInfo("plugin", item), { discard: true })
  }),
当只关心活跃插件时,可在 Effect 管道中过滤 schema 值。
effect: (ctx) =>
  Effect.gen(function* () {
    const plugin = ctx.plugin
    const active = yield* plugin.list().pipe(
      Effect.orDie,
      Effect.map((result) => result.data.filter((item) => item.state.status === "active")),
    )
    yield* Effect.logInfo("active plugin count", { count: active.length })
  }),
interface PluginApi<E = never> {
  readonly list: PluginListOperation<E>
}

interface Context {
  readonly plugin: PluginApi<unknown>
}

引用

读取当前位置可用的引用。
effect: (ctx) =>
  Effect.gen(function* () {
    const reference = ctx.reference
    const references = yield* reference.list().pipe(Effect.orDie)
    yield* Effect.logInfo("references", { count: references.data.length })
  }),
添加或移除本地与 Git 引用,然后在外部状态变化后重载。get(name) 返回当前配置的来源;若该名称不存在则返回 undefined。
effect: (ctx) =>
  Effect.gen(function* () {
    const reference = ctx.reference
    yield* reference.transform((editor) => {
      editor.add("handbook", { type: "local", path: "/workspace/docs/handbook" })
      editor.add("standards", { type: "git", repository: "https://github.com/acme/standards", branch: "main" })
      const handbook = editor.get("handbook")
      editor.remove("legacy")
    })
    yield* reference.reload()
  }),
interface ReferenceEditor {
  add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void
  remove(name: string): void
  list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[]
  get(name: string): ReferenceLocalSource | ReferenceGitSource | undefined
}

interface ReferenceDomain extends ReferenceApi<unknown> {
  readonly transform: Transform<ReferenceEditor>
  readonly reload: () => Effect.Effect<void>
}

会话

创建或读取会话,然后选择后续工作所用的智能体与模型。
effect: (ctx) =>
  Effect.gen(function* () {
    const session = ctx.session
    const created = yield* session.create({ title: "Review" }).pipe(Effect.orDie)
    const current = yield* session.get({ sessionID: created.id }).pipe(Effect.orDie)
    yield* session.switchAgent({ sessionID: current.id, agent: Agent.ID.make("build") }).pipe(Effect.orDie)
    yield* session
      .switchModel({
        sessionID: current.id,
        model: { providerID: Provider.ID.make("anthropic"), id: Model.ID.make("claude-sonnet-5-5") },
      })
      .pipe(Effect.orDie)
  }),
发送提示、瞬时生成请求、命令或合成消息。
effect: (ctx) =>
  Effect.gen(function* () {
    const session = ctx.session
    const created = yield* session.create({ title: "Automation" }).pipe(Effect.orDie)
    yield* session.prompt({ sessionID: created.id, text: "Review the current changes" }).pipe(Effect.orDie)
    const summary = yield* session
      .generate({ sessionID: created.id, prompt: "Summarize this project" })
      .pipe(Effect.orDie)
    yield* session.command({ sessionID: created.id, name: "review", text: "--staged" }).pipe(Effect.orDie)
    yield* session
      .synthetic({ sessionID: created.id, text: `Summary generated: ${summary.text}`, resume: false })
      .pipe(Effect.orDie)
  }),
按页列出会话。要获取下一页,请将 cursor.next 作为 cursor 传回。游标会记住你的过滤条件,因此无需重复传入。
effect: (ctx) =>
  Effect.gen(function* () {
    const page = yield* ctx.session.list({ directory: ctx.location.directory, limit: 20 }).pipe(Effect.orDie)
    const next = page.cursor.next
      ? yield* ctx.session.list({ cursor: page.cursor.next, limit: 20 }).pipe(Effect.orDie)
      : undefined
  }),
使用 remove 删除临时会话。删除会停止活跃执行,并递归删除子会话。
effect: (ctx) =>
  Effect.gen(function* () {
    const created = yield* ctx.session.create({ title: "Temporary worker" }).pipe(Effect.orDie)
    yield* ctx.session.remove({ sessionID: created.id }).pipe(Effect.orDie)
  }),
使用 compact 请求上下文压缩。它返回的是被接纳的收件箱条目,而非已完成的摘要;上下文压缩默认在下一个步骤边界运行。改为传入 delivery: "queue" 可保留队列顺序。
effect: (ctx) =>
  Effect.gen(function* () {
    const created = yield* ctx.session.create({ title: "Review" }).pipe(Effect.orDie)
    const compaction = yield* ctx.session.compact({ sessionID: created.id }).pipe(Effect.orDie)
  }),
type SessionDomain = Pick<
  SessionApi<unknown>,
  | "list"
  | "create"
  | "get"
  | "remove"
  | "switchAgent"
  | "switchModel"
  | "prompt"
  | "generate"
  | "command"
  | "compact"
  | "synthetic"
  | "interrupt"
  | "update"
  | "move"
  | "wait"
  | "context"
>

智能体技能

读取当前位置的智能体技能。
effect: (ctx) =>
  Effect.gen(function* () {
    const skill = ctx.skill
    const skills = yield* skill.list().pipe(Effect.orDie)
    yield* Effect.logInfo("skills", { ids: skills.data.map((item) => item.id) })
  }),
转换并重载智能体技能。对带品牌标记的值请使用再导出的 Effect schema 构造器。get(id) 返回当前编辑器条目;若该技能不存在则返回 undefined。
effect: (ctx) =>
  Effect.gen(function* () {
    const skill = ctx.skill
    yield* skill.transform((editor) => {
      editor.add(Skill.Info.make({
        id: Skill.ID.make("review"),
        name: Skill.Name.make("Review"),
        description: "Review the current changes",
        path: Skill.Info.fields.path.make("/workspace/.opencode/skills/review/SKILL.md"),
        content: "Review the current changes for correctness and missing tests.",
      }))
      const review = editor.get("review")
      editor.update("review", (item) => (item.autoinvoke = true))
      editor.remove("legacy")
    })
    yield* skill.reload()
  }),
Schema:Skill.Info。
interface SkillEditor {
  list(): readonly Types.DeepMutable<Skill.Info>[]
  get(id: string): Types.DeepMutable<Skill.Info> | undefined
  add(skill: Skill.Info): void
  update(id: string, update: (skill: Types.DeepMutable<Skill.Info>) => void): void
  remove(id: string): void
}

interface SkillDomain extends SkillApi<unknown> {
  readonly transform: Transform<SkillEditor>
  readonly reload: () => Effect.Effect<void>
}

存储

存储、读取和删除作用域限定于插件 ID 的持久化 JSON 值。
effect: (ctx) =>
  Effect.gen(function* () {
    const storage = ctx.storage
    yield* storage.set("settings", { strict: true })
    const settings = yield* storage.get("settings")
    yield* Effect.logInfo("settings", { settings })
    yield* storage.remove("settings")
  }),
按前缀扫描键,支持游标分页。
effect: (ctx) =>
  Effect.gen(function* () {
    const storage = ctx.storage
    const first = yield* storage.scan({ prefix: "cache/", limit: 100 })
    const second = first.next
      ? yield* storage.scan({ prefix: "cache/", after: first.next, limit: 100 })
      : { entries: [] }
    yield* Effect.logInfo("cache entries", { count: first.entries.length + second.entries.length })
  }),
存储接受 Effect 的 JSON 类型,并直接返回 Effect。
interface StorageDomain {
  readonly get: (key: string) => Effect.Effect<Schema.Json | undefined>
  readonly set: (key: string, value: Schema.Json) => Effect.Effect<void>
  readonly remove: (key: string) => Effect.Effect<void>
  readonly scan: (options: StorageScanOptions) => Effect.Effect<StorageScanResult>
}

工具

使用 Effect Schema 注册带类型的工具。执行器接收解码后的输入,并返回包含类型化输出、显示内容或元数据的 Effect。
编辑器支持 add、update 与 remove。转换回调是同步的:不得返回 Effect 或 Promise。请在注册或重载前加载外部数据。OpenCode 会在全新编辑器上按注册顺序重放活跃的转换;对于相同的有效工具名称,后一个有效注册会覆盖前一个。
effect: (ctx) =>
  Effect.gen(function* () {
    const tool = ctx.tool
    yield* tool.transform((editor) => {
      editor.namespace({
        name: "acme",
        description: "Customer account tools",
      })
      editor.add({
        name: "greeting",
        description: "Create a greeting",
        input: Schema.Struct({ name: Schema.String }),
        output: Schema.Struct({ greeting: Schema.String }),
        options: { namespace: "acme", codemode: true },
        execute: ({ name }, context) =>
          Effect.gen(function* () {
            yield* context.progress({ status: "greeting" })
            return { output: { greeting: `Hello ${name}!` } }
          }),
      })
    })
  }),
在修改回调捕获的源数据后调用 yield* ctx.tool.reload()。重载会按原顺序重放活跃的转换;它不会重新运行插件 effect。
工具的 id 包含其有效名称;当该 ID 不存在时 get() 返回 undefined。请使用有效工具名称调用 update 与 remove,包括其命名空间(上例中的 acme_greeting)。命名空间中的点以及工具名称中不支持的字符会变为 _。不存在的名称会被忽略;创建工具需要使用 add 并提供完整定义。更新会保留名称与命名空间。请赋值新的 schema 或选项以替换它们,而不是就地修改嵌套值。无效的更新会被记录,并保持先前的定义不变。
yield* ctx.tool.transform((editor) => {
  editor.update("acme_greeting", (tool) => {
    tool.description = "Greet the user by name"
  })
  editor.remove("acme_obsolete")
})
更新与移除会与添加一起按顺序重放,包括在 MCP 目录刷新之后。
transform 返回一个有作用域的注册。运行 yield* registration.dispose 可移除其转换并根据其余转换重建,从而显露出它曾覆盖的更早定义。释放是幂等的,关闭插件作用域也会释放其注册。
每个模型请求都会捕获工具快照。重载与释放影响的是未来的快照,而非已有请求已捕获的定义或执行器。闭包捕获可变插件数据的执行器仍会观察到该数据;若某个值必须与该定义绑定,请在转换内部捕获它。
interface ToolEditor {
  list(): readonly (Tool.Info & { readonly id: string })[]
  get(id: string): (Tool.Info & { readonly id: string }) | undefined
  namespace(namespace: { name: string; description: string }): void
  add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
    tool: Tool.Info<Input, Output>,
  ): void
  update(id: string, update: (tool: Types.Mutable<Tool.Info>) => void): void
  remove(id: string): void
}

interface ToolDomain {
  readonly list: () => Effect.Effect<readonly (Tool.Info & { readonly id: string })[]>
  readonly transform: Transform<ToolEditor>
  readonly reload: () => Effect.Effect<void>
}

VCS

读取仓库信息、工作副本状态或文件差异。
effect: (ctx) =>
  Effect.gen(function* () {
    const vcs = ctx.vcs
    const info = yield* vcs.get().pipe(Effect.orDie)
    const branches = yield* vcs.branch.list({ search: "feature", limit: 10 }).pipe(Effect.orDie)
    const changes = yield* vcs.status().pipe(Effect.orDie)
    const diff = yield* vcs.diff({ mode: "working", context: 3 }).pipe(Effect.orDie)
    yield* Effect.logInfo("vcs", { branch: info.data.branch.current, files: changes.data.length })
  }),
通过有作用域的转换注册位置作用域的提供商。与检测到的仓库类型匹配的提供商会被自动选中;使用 editor.default.set 可选择其他提供商。
effect: (ctx) =>
  Effect.gen(function* () {
    const vcs = ctx.vcs
    yield* vcs.transform((editor) => {
      editor.add({
        id: "custom",
        name: "Custom VCS",
        info: () => Effect.succeed({ branch: { current: "feature", default: "main" } }),
        branches: (input) => readBranches(input),
        status: (scope) => readStatus(scope.worktree),
        diff: (input) => readDiff(input),
      })
      editor.default.set("custom")
    })
  }),
提供商回调接收当前位置、工作副本根目录、规范项目根目录以及可选的仓库存储。差异回调还会接收所选模式、请求的上下文以及输出字节预算。仓库发现仍使用 OpenCode 内置的 Git 与 Mercurial 检测器。
提供商可以选择实现 init(scope) 以在 scope.worktree 中创建仓库。初始化按 ID 选择提供商(默认为 Git),而非按检测到的仓库类型。即使该位置尚无仓库,也应注册带 init 的提供商;OpenCode 在调用前会检查目录存在且不含仓库,然后重新发现项目。新的仓库格式仍需此回调之外的发现支持。
interface VcsEditor {
  add(definition: VcsDefinition): void
  readonly default: {
    get(): string | undefined
    set(selection: string): void
  }
}

interface VcsDomain extends Omit<VcsApi<unknown>, "init"> {
  readonly transform: Transform<VcsEditor>
  readonly reload: () => Effect.Effect<void>
}

interface VcsDefinition {
  readonly init?: (scope: VcsScope) => Effect.Effect<void, unknown>
  // Existing info, branches, status, and diff callbacks are required.
}

网络搜索

列出提供商,或通过所选提供商执行查询。
effect: (ctx) =>
  Effect.gen(function* () {
    const websearch = ctx.websearch
    const providers = yield* websearch.providers().pipe(Effect.orDie)
    const results = yield* websearch
      .query({ query: "OpenCode plugins", providerID: WebSearch.ID.make("internal") })
      .pipe(Effect.orDie)
    yield* Effect.logInfo("websearch", { providers: providers.data.length, results: results.data.results.length })
  }),
注册 Effect 执行器并选择默认提供商。将默认值设为 false 可禁用网络搜索。
effect: (ctx) =>
  Effect.gen(function* () {
    const websearch = ctx.websearch
    yield* websearch.transform((editor) => {
      editor.add({
        id: "internal",
        name: "Internal search",
        execute: ({ query }) => searchInternal(query),
      })
      editor.default.set("internal")
    })
    yield* websearch.reload()
  }),
interface WebSearchDefinition {
  readonly id: string
  readonly name: string
  readonly execute: (input: WebSearch.ProviderInput) => Effect.Effect<readonly WebSearch.Result[], unknown>
}

interface WebSearchEditor {
  add(definition: WebSearchDefinition): void
  readonly default: {
    get(): string | false | undefined
    set(selection: string | false): void
  }
}

interface WebSearchDomain extends WebsearchApi<unknown> {
  readonly transform: Transform<WebSearchEditor>
  readonly reload: () => Effect.Effect<void>
}

事件

公开的服务端事件订阅是一个 Effect Stream。在插件作用域中分叉其消费者即可自动中断。
effect: (ctx) =>
  Effect.gen(function* () {
    const event = ctx.event
    yield* event.subscribe().pipe(
      Stream.tap((item) => Effect.logDebug("OpenCode event", { type: item.type })),
      Stream.runDrain,
      Effect.forkScoped,
    )
  }),
使用 Stream 算子选择并处理事件类型。
effect: (ctx) =>
  Effect.gen(function* () {
    const event = ctx.event
    yield* event.subscribe().pipe(
      Stream.filter((item) => item.type === "config.updated"),
      Stream.runForEach(() => Effect.logInfo("configuration changed")),
      Effect.forkScoped,
    )
  }),
Schema:V2EventEncoded。
type EventSubscribeOutput = OpenCodeEvent
type EventSubscribeOperation<E = never> = () => Stream.Stream<EventSubscribeOutput, E>

interface EventApi<E = never> {
  readonly subscribe: EventSubscribeOperation<E>
}

interface EventDomain extends Pick<EventApi<unknown>, "subscribe"> {}

钩子

钩子拦截实时操作。多个插件可以注册同一个钩子;OpenCode 按插件顺序运行它们,因此后注册的钩子能看到先前的更改。注册在插件作用域内保持活跃。
effect: (ctx) =>
  Effect.gen(function* () {
    const session = ctx.session
    yield* session.hook("context", () => Effect.void)
  }),

会话

在模型派发前立即修改组装好的系统指令、消息或工具。context 针对智能体循环运行;compaction、generate 与 title 针对相应的辅助请求运行。compaction 与 title 接受一个 result,用于跳过模型调用。
effect: (ctx) =>
  Effect.gen(function* () {
    const session = ctx.session
    yield* session.hook("context", (event) =>
      Effect.sync(() => {
        event.system.push({ type: "text", text: "Keep the review focused on correctness." })
        delete event.tools.write
      }),
    )
    yield* session.hook("compaction", (event) =>
      Effect.map(summarize(event.messages), (summary) => {
        event.result = { summary }
      }),
    )
  }),
修改模型请求设置,并可选择将钩子限定于某个提供商。该事件携带与下述 HTTP 钩子相同的 kind。
effect: (ctx) =>
  Effect.gen(function* () {
    const session = ctx.session
    yield* session.hook(
      "model.request",
      (event) => Effect.sync(() => (event.headers["x-plugin"] = "review")),
      { providerID: "anthropic" },
    )
  }),
修改原生提供商请求或响应。其请求/响应体是一次性流;在读取前请先克隆或替换该 body。两个钩子都会针对会话发出的每个请求运行;event.kind 为 "primary"、"compaction"、"title" 或 "generate",取决于由哪条流程发出。
effect: (ctx) =>
  Effect.gen(function* () {
    const session = ctx.session
    yield* session.hook("http.request", (event) =>
      Effect.sync(() => {
        event.request.headers.set("x-session-id", event.sessionID)
        if (event.kind === "title") event.request.headers.set("x-priority", "background")
      }),
    )
    yield* session.hook("http.response", (event) =>
      Effect.sync(() => {
        event.response = new Response(event.response.body, {
          status: event.response.status,
          headers: { ...Object.fromEntries(event.response.headers), "x-plugin": "review" },
        })
      }),
    )
  }),
通过 WebSocket 流式传输的提供商每个会话复用一个连接,因此 HTTP 钩子看不到该流量。实验性的 experimental.ws.handshake 钩子在每个模型调用中、连接被选中之前运行一次,并暴露连接的 url 与 headers;修改其中任一项都会重新打开套接字。对于 WebSocket 路由回退到的任何请求,HTTP 钩子仍会运行。
effect: (ctx) =>
  Effect.gen(function* () {
    yield* ctx.session.hook(
      "experimental.ws.handshake",
      (event) =>
        Effect.gen(function* () {
          event.headers.authorization = `Bearer ${yield* mintToken(event.url)}`
          delete event.headers["api-key"]
        }),
      { providerID: "azure" },
    )
  }),
experimental.ws.send 与 experimental.ws.receive 暴露帧本身:send 在提供商驱动构建出站帧后运行,receive 在每个入站帧被驱动观察到之前运行。钩子返回时 frame 中的内容即为实际发送到线路或传递给驱动的内容;OpenCode 不对其进行校验。
effect: (ctx) =>
  Effect.gen(function* () {
    yield* ctx.session.hook(
      "experimental.ws.send",
      (event) =>
        Effect.sync(() => {
          const body = JSON.parse(event.frame)
          if (body.type === "response.create") body.metadata = { ...body.metadata, session: event.sessionID }
          event.frame = JSON.stringify(body)
        }),
      { providerID: "openai" },
    )
  }),
覆盖提供商失败的重试决策,或替换其毫秒级延迟。该钩子在 OpenCode 对失败分类并提出其策略之后、任何重试被调度之前运行。它不暴露 OpenCode 内部如何执行下一次尝试。
effect: (ctx) =>
  Effect.gen(function* () {
    yield* ctx.session.hook("retry", (event) =>
      Effect.sync(() => {
        if (event.error.status === 429) {
          event.decision = { retry: true, delay: 10_000 }
          return
        }
        if (event.error.type === "provider.invalid-request" && event.attempt === 2) {
          event.decision = { retry: true, delay: 0 }
          return
        }
        if (event.attempt >= 3) event.decision = { retry: false }
      }),
    )
  }),
初始的 decision 是 OpenCode 的策略,因此钩子可以使原本终止性的提供商失败变为可重试,或否决已提议的重试。多个钩子按注册顺序运行,后运行的钩子能看到当前决策。内置的最大尝试次数仍是硬性上限。attempt 是被提议的实际尝试次数;初始请求为尝试 1,因此第一次重试为尝试 2。无效延迟(NaN、无穷大或负值)会回退到计算出的延迟。上下文溢出恢复仍然独立处理,因为它通过上下文压缩对话来替代重试同一请求。

参考

interface SessionHooks {
  readonly context: SessionContext
  readonly compaction: SessionCompaction
  readonly generate: SessionGenerate
  readonly title: SessionTitle
  readonly "model.request": SessionModelRequest
  readonly "http.request": SessionHttpRequest
  readonly "http.response": SessionHttpResponse
  readonly "experimental.ws.handshake": SessionWebSocketHandshake
  readonly "experimental.ws.send": SessionWebSocketSend
  readonly "experimental.ws.receive": SessionWebSocketReceive
  readonly retry: SessionRetry
}

type RetryDecision = { retry: false } | { retry: true; delay: number }

interface SessionRetry {
  readonly sessionID: string
  readonly agent: string
  readonly model: { providerID: string; id: string; variant?: string }
  readonly error: { type: string; message: string; status?: number }
  readonly attempt: number
  decision: RetryDecision
}

interface SessionHookDomain {
  readonly hook: ModelHooks<SessionHooks>
}

Shell

在执行前修改 shell 命令、工作目录、超时、可执行文件或环境变量。
effect: (ctx) =>
  Effect.gen(function* () {
    const shell = ctx.shell
    yield* shell.hook("create.before", (event) =>
      Effect.sync(() => {
        if (event.command === "npm") event.command = "bun"
        event.timeout = Math.min(event.timeout, 60_000)
        event.env.COMPANY_ENV = "development"
      }),
    )
  }),

参考

interface ShellCreateBefore {
  command: string
  cwd: string
  timeout: number
  shell: string
  env: Record<string, string | undefined>
}

interface ShellHooks {
  readonly "create.before": ShellCreateBefore
}

interface ShellHookDomain {
  readonly hook: Hooks<ShellHooks>
}

工具

before 钩子可以替换输入,或以 Tool.Error 失败。
effect: (ctx) =>
  Effect.gen(function* () {
    const tool = ctx.tool
    yield* tool.hook("execute.before", (event) =>
      event.tool === "write"
        ? Effect.fail(new Tool.Error({ message: "Writes are disabled" }))
        : Effect.logDebug("tool input", { tool: event.tool, input: event.input }),
    )
  }),
after 钩子可以检查或替换成功结果与失败。
effect: (ctx) =>
  Effect.gen(function* () {
    const tool = ctx.tool
    yield* tool.hook("execute.after", (event) => {
      if (event.status === "error") return Effect.logWarning("tool failed", { message: event.error.message })
      return Effect.sync(() => {
        event.result = { ...event.result, metadata: { observed: true } }
      })
    })
  }),

参考

interface ToolHooks {
  readonly "execute.before": ToolExecuteBefore
  readonly "execute.after": ToolExecuteAfter
}

interface ToolFailures extends Record<keyof ToolHooks, unknown> {
  readonly "execute.before": Tool.Error
  readonly "execute.after": never
}

interface ToolHookDomain {
  readonly hook: Hooks<ToolHooks, ToolFailures>
}
共享的注册类型说明了哪些操作需要插件作用域。
interface Registration {
  readonly dispose: Effect.Effect<void>
}

type Hooks<Spec, Failures extends Record<keyof Spec, unknown> = Record<keyof Spec, never>> = <Name extends keyof Spec>(
  name: Name,
  callback: (input: Spec[Name]) => Effect.Effect<void, Failures[Name]>,
) => Effect.Effect<Registration, never, Scope.Scope>

type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Registration, never, Scope.Scope>

发布

包插件使用与本地 Effect 插件相同的默认导出。从主入口导出 Effect 实现,并将 effect 声明为 peerDependency。在 devDependencies 下固定 @opencode/plugin 所使用的版本,使开发环境只安装一份副本。
{
  "name": "opencode-acme-effect-plugin",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": "./src/index.ts"
  },
  "dependencies": {
    "@opencode/plugin": "latest"
  },
  "peerDependencies": {
    "effect": "^4.0.1"
  },
  "devDependencies": {
    "effect": "4.0.1"
  }
}
OpenCode 会将 effect、导出的 effect/* 子路径以及 @opencode/plugin 的导入(包括来自插件 node_modules 中依赖的导入)重定向到宿主的运行时实例,使 fiber、日志记录器与 Schema 解析器共享同一份运行时副本。
  • 不要将 effect 打包进已发布的插件产物。如果使用打包器构建,请将 effect 与 effect/* 标记为 external。
  • 插件及其 node_modules 依赖始终针对宿主 OpenCode 版本的 effect 实例执行。请使用与目标 OpenCode 版本兼容的 effect API 与模块路径;基于另一个 effect 主版本(如 Effect 3)构建的依赖同样会收到宿主的 effect,且不受支持。
  • 仅提供公开的 effect 子路径。导入 Effect 私有 internal 模块的插件会加载失败,并报出标明该路径的错误。
  • 编译后的 OpenCode 二进制文件不包含 Effect 的 HTTP API 文档页面所使用的 Scalar 与 Swagger UI 资源;从插件提供这些页面会显示提示信息。
包入口导出带有 effect 函数的 Plugin.define。
import { Plugin } from "@opencode/plugin/effect"
import { Effect } from "effect"

export default Plugin.define({
  id: "acme.published",
  effect: (ctx) =>
    Effect.gen(function* () {
      const storage = ctx.storage
      yield* storage.set("installed", true)
    }),
})
请使用与目标 OpenCode 版本兼容的版本,并测试已安装的包,而不仅仅是工作区链接的副本。
bun pm pack
bun add ./opencode-acme-effect-plugin-1.0.0.tgz