@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 })
})
Schema:
Provider.Info,Model.Info。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()
}),
Schema:
Command.Info,
Session.Inbox.Delivery。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 })
}),
Schema:
Plugin.Info,Plugin.Source。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()
}),
Schema:
WebSearch.Provider,
WebSearch.Result。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 版本兼容的effectAPI 与模块路径;基于另一个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