feat(plugin): add namespaced hook API (#33416)

This commit is contained in:
Dax 2026-06-22 19:06:57 -04:00 committed by GitHub
commit 909a1a6d78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
150 changed files with 3276 additions and 3906 deletions

View file

@ -108,7 +108,7 @@ export const layer = Layer.effect(
return Service.of({
transform: state.transform,
rebuild: state.rebuild,
reload: state.reload,
get: Effect.fn("AgentV2.get")(function* (id) {
return state.get().agents.get(id)
}),

View file

@ -1,14 +1,27 @@
export * as AISDK from "./aisdk"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Cause, Context, Effect, Layer, Schema } from "effect"
import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
import { ModelV2 } from "./model"
import { EventV2 } from "./event"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
import { State } from "./state"
type SDK = any
export interface SDKEvent {
readonly model: ModelV2.Info
readonly package: string
readonly options: Record<string, any>
sdk?: SDK
}
export interface LanguageEvent {
readonly model: ModelV2.Info
readonly sdk: SDK
readonly options: Record<string, any>
language?: LanguageModelV3
}
function wrapSSE(res: Response, ms: number, ctl: AbortController) {
if (typeof ms !== "number" || ms <= 0) return res
if (!res.body) return res
@ -117,19 +130,70 @@ function initError(providerID: ProviderV2.ID) {
}
export interface Interface {
readonly hook: {
readonly sdk: (
callback: (event: SDKEvent) => Effect.Effect<void> | void,
) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly language: (
callback: (event: LanguageEvent) => Effect.Effect<void> | void,
) => Effect.Effect<State.Registration, never, Scope.Scope>
}
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
readonly language: (model: ModelV2.Info) => Effect.Effect<LanguageModelV3, InitError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AISDK") {}
export const layer = Layer.effect(
export const locationLayer = Layer.effect(
Service,
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
let sdkHooks: ((event: SDKEvent) => Effect.Effect<void> | void)[] = []
let languageHooks: ((event: LanguageEvent) => Effect.Effect<void> | void)[] = []
const languages = new Map<string, LanguageModelV3>()
const sdks = new Map<string, SDK>()
return Service.of({
const register = <Event>(
hooks: () => ((event: Event) => Effect.Effect<void> | void)[],
update: (hooks: ((event: Event) => Effect.Effect<void> | void)[]) => void,
) =>
Effect.fn("AISDK.hook")(function* (callback: (event: Event) => Effect.Effect<void> | void) {
const scope = yield* Scope.Scope
let active = true
update([...hooks(), callback])
const dispose = Effect.sync(() => {
if (!active) return
active = false
update(hooks().filter((item) => item !== callback))
})
yield* Scope.addFinalizer(scope, dispose)
return { dispose }
})
const run = Effect.fnUntraced(function* <Event>(
hooks: readonly ((event: Event) => Effect.Effect<void> | void)[],
event: Event,
) {
for (const hook of hooks) {
const result = hook(event)
if (Effect.isEffect(result)) yield* result
}
return event
})
const service = Service.of({
hook: {
sdk: register(
() => sdkHooks,
(next) => (sdkHooks = next),
),
language: register(
() => languageHooks,
(next) => (languageHooks = next),
),
},
runSDK: (event) => run(sdkHooks, event),
runLanguage: (event) => run(languageHooks, event),
language: Effect.fn("AISDK.language")(function* (model) {
const key = `${model.providerID}/${model.id}/${model.request.variant ?? "default"}`
const existing = languages.get(key)
@ -148,26 +212,14 @@ export const layer = Layer.effect(
})
const sdk =
sdks.get(sdkKey) ??
(yield* plugin
.trigger("aisdk.sdk", { model, package: model.api.package, options }, {})
.pipe(initError(model.providerID))).sdk
(yield* service.runSDK({ model, package: model.api.package, options }).pipe(initError(model.providerID))).sdk
if (!sdk)
return yield* new InitError({
providerID: model.providerID,
cause: new Error("No AISDK provider plugin returned an SDK"),
})
sdks.set(sdkKey, sdk)
const result = yield* plugin
.trigger(
"aisdk.language",
{
model,
sdk,
options,
},
{},
)
.pipe(initError(model.providerID))
const result = yield* service.runLanguage({ model, sdk, options }).pipe(initError(model.providerID))
const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.api.id)).pipe(
initError(model.providerID),
)
@ -175,7 +227,8 @@ export const layer = Layer.effect(
return language
}),
})
return service
}),
)
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))))
export const defaultLayer = locationLayer

View file

@ -170,7 +170,7 @@ export const layer = Layer.effect(
})
const result: Interface = {
transform: state.transform,
rebuild: state.rebuild,
reload: state.reload,
provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {

View file

@ -52,7 +52,7 @@ export const layer = Layer.effect(
})
return Service.of({
rebuild: state.rebuild,
reload: state.reload,
transform: state.transform,
get: Effect.fn("CommandV2.get")(function* (name) {
return state.get().commands.get(name)

View file

@ -1,6 +1,6 @@
export * as ConfigAgentPlugin from "./agent"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../../plugin/internal"
import path from "path"
import { Effect, Option, Schema } from "effect"
import { AgentV2 } from "../../agent"

View file

@ -1,6 +1,6 @@
export * as ConfigCommandPlugin from "./command"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../../plugin/internal"
import path from "path"
import { Effect, Option, Schema } from "effect"
import { CommandV2 } from "../../command"

View file

@ -0,0 +1,99 @@
export * as ConfigExternalPlugin from "./external"
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
import { Effect, Schema } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../../config"
import { FSUtil } from "../../fs-util"
import { Location } from "../../location"
import { Npm } from "../../npm"
import { define } from "../../plugin/internal"
import { PluginPromise } from "../../plugin/promise"
const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
effect: Schema.declare<EffectPlugin["effect"]>(
(input): input is EffectPlugin["effect"] => typeof input === "function",
),
}),
Schema.Struct({
id: Schema.String,
setup: Schema.declare<PromisePlugin["setup"]>(
(input): input is PromisePlugin["setup"] => typeof input === "function",
),
}),
]),
})
export const Plugin = define({
id: "config-plugin",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const loaded: EffectPlugin[] = []
yield* ctx.plugin.transform((plugins) => {
for (const plugin of loaded) plugins.add(plugin)
})
yield* Effect.gen(function* () {
const configured: { package: string; options?: Record<string, any> }[] = []
for (const entry of yield* config.entries()) {
if (entry.type === "document") {
const directory = entry.path ? path.dirname(entry.path) : location.directory
for (const item of entry.info.plugins ?? []) {
const ref = typeof item === "string" ? { package: item } : item
const packageName = (() => {
if (ref.package.startsWith("file://")) return fileURLToPath(ref.package)
if (ref.package.startsWith("./") || ref.package.startsWith("../")) {
return path.resolve(directory, ref.package)
}
return ref.package
})()
configured.push({ package: packageName, options: ref.options })
}
}
if (entry.type === "directory") {
const files = yield* fs
.glob("{plugin,plugins}/*.{ts,js}", {
cwd: entry.path,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
files.sort()
for (const file of files) configured.push({ package: file })
}
}
for (const ref of configured) {
yield* Effect.gen(function* () {
const entrypoint = path.isAbsolute(ref.package)
? pathToFileURL(ref.package).href
: (yield* npm.add(ref.package)).entrypoint
if (!entrypoint) return
const mod = yield* Effect.promise(() => import(entrypoint))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
loaded.push({
id: plugin.id,
effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }),
})
}).pipe(Effect.ignoreCause)
}
yield* ctx.plugin.reload()
}).pipe(Effect.forkScoped({ startImmediately: true }))
}),
})

View file

@ -1,6 +1,6 @@
export * as ConfigProviderPlugin from "./provider"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../../plugin/internal"
import { Effect } from "effect"
import { Config } from "../../config"
import { ModelV2 } from "../../model"

View file

@ -1,24 +1,28 @@
export * as ConfigReferencePlugin from "./reference"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../../plugin/internal"
import path from "path"
import { Effect } from "effect"
import { Config } from "../../config"
import { ConfigReference } from "../reference"
import { Reference } from "../../reference"
import { AbsolutePath } from "../../schema"
import { Global } from "../../global"
import { Location } from "../../location"
export const Plugin = define({
id: "core/config-reference",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const location = yield* Location.Service
const global = yield* Global.Service
yield* ctx.reference.transform(
Effect.fn(function* (draft) {
const entries = new Map<string, Reference.Source>()
for (const doc of (yield* config.entries()).filter(
(entry): entry is Config.Document => entry.type === "document",
)) {
const directory = doc.path ? path.dirname(doc.path) : ctx.location.directory
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
entries.set(
@ -27,7 +31,7 @@ export const Plugin = define({
? new Reference.LocalSource({
type: "local",
path: AbsolutePath.make(
localPath(directory, ctx.path.home, typeof entry === "string" ? entry : entry.path),
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
),
description: typeof entry === "string" ? undefined : entry.description,
hidden: typeof entry === "string" ? undefined : entry.hidden,

View file

@ -1,16 +1,20 @@
export * as ConfigSkillPlugin from "./skill"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../../plugin/internal"
import path from "path"
import { Effect } from "effect"
import { Config } from "../../config"
import { AbsolutePath } from "../../schema"
import { SkillV2 } from "../../skill"
import { Global } from "../../global"
import { Location } from "../../location"
export const Plugin = define({
id: "config-skill",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const global = yield* Global.Service
const location = yield* Location.Service
yield* ctx.skill.transform(
Effect.fn(function* (draft) {
const entries = yield* config.entries()
@ -29,13 +33,11 @@ export const Plugin = define({
draft.source(new SkillV2.UrlSource({ type: "url", url: item }))
continue
}
const expanded = item.startsWith("~/") ? path.join(ctx.path.home, item.slice(2)) : item
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
draft.source(
new SkillV2.DirectorySource({
type: "directory",
path: AbsolutePath.make(
path.isAbsolute(expanded) ? expanded : path.join(ctx.location.directory, expanded),
),
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
)
}

View file

@ -432,7 +432,7 @@ export const locationLayer = Layer.effect(
return Service.of({
transform: state.transform,
rebuild: state.rebuild,
reload: state.reload,
get: Effect.fn("Integration.get")(function* (id) {
const entry = state.get().integrations.get(id)
if (!entry) return undefined

View file

@ -1,12 +1,17 @@
export * as PluginV2 from "./plugin"
import { createDraft, finishDraft, type Draft } from "immer"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Context, Effect, Exit, Layer, Schema, Scope } from "effect"
import type { ModelV2 } from "./model"
import type { Catalog } from "./catalog"
import type { Plugin, PluginDraft } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "./agent"
import { AISDK } from "./aisdk"
import { Catalog } from "./catalog"
import { CommandV2 } from "./command"
import { EventV2 } from "./event"
import { Integration } from "./integration"
import { KeyedMutex } from "./effect/keyed-mutex"
import { PluginHost } from "./plugin/host"
import { Reference } from "./reference"
import { SkillV2 } from "./skill"
import { State } from "./state"
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
@ -21,69 +26,9 @@ export const Event = {
}),
}
type HookSpec = {
"catalog.transform": {
input: Catalog.Draft
output: {}
}
"aisdk.language": {
input: {
model: ModelV2.Info
sdk: any
options: Record<string, any>
}
output: {
language?: LanguageModelV3
}
}
"aisdk.sdk": {
input: {
model: ModelV2.Info
package: string
options: Record<string, any>
}
output: {
sdk?: any
}
}
}
export type Hooks = {
[Name in keyof HookSpec]: Readonly<HookSpec[Name]["input"]> & {
-readonly [Field in keyof HookSpec[Name]["output"]]: HookSpec[Name]["output"][Field] extends object
? Draft<HookSpec[Name]["output"][Field]>
: HookSpec[Name]["output"][Field]
}
}
export type HookFunctions = {
[key in keyof Hooks]?: (input: Hooks[key]) => Effect.Effect<void>
}
export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"]
export type HookOutput<Name extends keyof Hooks> = HookSpec[Name]["output"]
export interface Interface {
readonly add: (input: {
id: string
effect: Effect.Effect<void | HookFunctions, never, Scope.Scope>
}) => Effect.Effect<void, never, never>
readonly remove: (id: ID) => Effect.Effect<void>
readonly hook: <Name extends keyof Hooks>(
name: Name,
callback: (input: Hooks[Name]) => Effect.Effect<void> | void,
) => Effect.Effect<State.Registration, never, Scope.Scope>
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>,
output: HookOutput<Name>,
) => Effect.Effect<HookInput<Name> & HookOutput<Name>>
readonly transform: State.Transform<PluginDraft>
readonly reload: State.Reload
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {}
@ -91,127 +36,85 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
let hooks: {
id: ID
hooks: HookFunctions
scope: Scope.Closeable
}[] = []
let registrations: {
[Name in keyof Hooks]: {
name: Name
callback: (input: Hooks[Name]) => Effect.Effect<void> | void
}
}[keyof Hooks][] = []
const events = yield* EventV2.Service
const locks = KeyedMutex.makeUnsafe<ID>()
const scope = yield* Scope.make()
const active = new Map<ID, Scope.Closeable>()
let host: Parameters<Plugin["effect"]>[0]
const attach = Effect.fn("Plugin.attach")(function* (plugin: Plugin, host: Parameters<Plugin["effect"]>[0]) {
const id = ID.make(plugin.id)
yield* locks.withLock(id)(
Effect.gen(function* () {
const existing = active.get(id)
if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore)
const child = yield* Scope.fork(scope)
yield* plugin.effect(host).pipe(
Scope.provide(child),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
)
active.set(id, child)
yield* events.publish(Event.Added, { id })
}),
)
})
const detach = Effect.fn("Plugin.detach")(function* (id: ID) {
yield* locks.withLock(id)(
Effect.gen(function* () {
const current = active.get(id)
active.delete(id)
if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore)
}),
)
})
const state = State.create<Map<ID, Plugin>, PluginDraft>({
initial: () => new Map(),
draft: (draft) => ({
list: () => Array.from(draft.values()),
add: (plugin) => draft.set(ID.make(plugin.id), plugin),
remove: (id) => draft.delete(ID.make(id)),
}),
finalize: (draft) =>
State.batch(
Effect.gen(function* () {
const desired = new Set<ID>()
for (const plugin of draft.list()) desired.add(ID.make(plugin.id))
for (const id of active.keys()) {
if (!desired.has(id)) yield* detach(id)
}
for (const plugin of draft.list()) yield* attach(plugin, host)
}).pipe(Effect.withSpan("Plugin.reconcile")),
),
})
// One registry-owned scope lets shutdown remove every plugin transform in one batch.
yield* Effect.addFinalizer((exit) =>
Effect.gen(function* () {
hooks = []
active.clear()
yield* State.batch(Scope.close(scope, exit))
}),
)
const svc = Service.of({
add: Effect.fn("Plugin.add")(function* (input) {
const id = ID.make(input.id)
yield* locks.withLock(id)(
Effect.gen(function* () {
const existing = hooks.find((item) => item.id === id)
if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore)
const childScope = yield* Scope.fork(scope)
const result = yield* input.effect.pipe(
Scope.provide(childScope),
Effect.withSpan("Plugin.load", {
attributes: {
"plugin.id": id,
},
}),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)),
)
const next = {
id,
hooks: result ?? {},
scope: childScope,
}
hooks = existing ? hooks.with(hooks.indexOf(existing), next) : [...hooks, next]
yield* events.publish(Event.Added, { id })
}),
)
}),
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,
...output,
} as Record<string, unknown>
for (const [field, value] of Object.entries(output)) {
if (value && typeof value === "object") {
draftEntries.set(field, createDraft(value))
event[field] = draftEntries.get(field)
}
}
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(
Effect.withSpan(`Plugin.hook.${name}`, {
attributes: {
plugin: item.id,
hook: name,
},
}),
)
}
for (const item of registrations) {
if (item.name !== name) continue
const result = item.callback(event as never)
if (Effect.isEffect(result)) yield* result
}
for (const [field, draft] of draftEntries) {
event[field] = finishDraft(draft)
}
return event as any
}),
remove: Effect.fn("Plugin.remove")(function* (id) {
yield* locks.withLock(id)(
Effect.gen(function* () {
const existing = hooks.find((item) => item.id === id)
hooks = hooks.filter((item) => item.id !== id)
if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore)
}),
)
}),
hook: Effect.fn("Plugin.hook")(function* (name, callback) {
const scope = yield* Scope.Scope
const registration = { name, callback } as (typeof registrations)[number]
let active = true
registrations = [...registrations, registration]
const dispose = Effect.sync(() => {
if (!active) return
active = false
registrations = registrations.filter((item) => item !== registration)
})
yield* Scope.addFinalizer(scope, dispose)
return { dispose }
}),
const service = Service.of({
transform: state.transform,
reload: state.reload,
})
return svc
host = yield* PluginHost.make(service)
return service
}),
)
export const locationLayer = layer
// opencode
// sdcok
export const locationLayer = layer.pipe(
Layer.provideMerge(AgentV2.locationLayer),
Layer.provideMerge(AISDK.locationLayer),
Layer.provideMerge(Catalog.locationLayer),
Layer.provideMerge(CommandV2.locationLayer),
Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(Reference.locationLayer),
Layer.provideMerge(SkillV2.locationLayer),
)

View file

@ -1,10 +1,11 @@
export * as AgentPlugin from "./agent"
import path from "path"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "./internal"
import { Effect } from "effect"
import { AgentV2 } from "../agent"
import { Global } from "../global"
import { Location } from "../location"
import { PermissionV2 } from "../permission"
const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*")
@ -99,7 +100,8 @@ Rules:
export const Plugin = define({
id: "agent",
effect: Effect.fn(function* (ctx) {
const worktree = ctx.location.directory
const location = yield* Location.Service
const worktree = location.directory
const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")]
const readonlyExternalDirectory: PermissionV2.Ruleset = [
{ action: "external_directory", resource: "*", effect: "ask" },

View file

@ -1,9 +1,9 @@
export * as PluginBoot from "./boot"
import type { Plugin as PublicPlugin } from "@opencode-ai/plugin/v2/effect"
import { Context, Deferred, Effect, Layer } from "effect"
import { Effect, Layer } from "effect"
import { Integration } from "../integration"
import { AgentV2 } from "../agent"
import { AISDK } from "../aisdk"
import { Catalog } from "../catalog"
import { CommandV2 } from "../command"
import { Config } from "../config"
@ -11,6 +11,7 @@ import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigExternalPlugin } from "../config/plugin/external"
import { EventV2 } from "../event"
import { FSUtil } from "../fs-util"
import { FileSystem } from "../filesystem"
@ -29,18 +30,9 @@ import { SkillV2 } from "../skill"
import { Reference } from "../reference"
import { State } from "../state"
import { PluginHost } from "./host"
import { PluginInternal } from "./internal"
type InternalPlugin = PublicPlugin<any>
export interface Interface {
readonly add: (plugin: PublicPlugin<any>) => Effect.Effect<void>
readonly wait: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginBoot") {}
export const layer = Layer.effect(
Service,
export const locationLayer = Layer.effectDiscard(
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
@ -57,75 +49,47 @@ export const layer = Layer.effect(
const global = yield* Global.Service
const skill = yield* SkillV2.Service
const reference = yield* Reference.Service
const host = yield* PluginHost.make()
const done = yield* Deferred.make<void>()
const host = yield* PluginHost.make(plugin)
const add = Effect.fn("PluginBoot.add")(function* (input: InternalPlugin) {
yield* plugin.add({
id: input.id,
effect: input
.effect(host)
.pipe(
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(CommandV2.Service, commands),
Effect.provideService(Integration.Service, integration),
Effect.provideService(AgentV2.Service, agents),
Effect.provideService(Config.Service, config),
Effect.provideService(Location.Service, location),
Effect.provideService(ModelsDev.Service, modelsDev),
Effect.provideService(Npm.Service, npm),
Effect.provideService(EventV2.Service, events),
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(FileSystem.Service, filesystem),
Effect.provideService(Global.Service, global),
Effect.provideService(SkillV2.Service, skill),
Effect.provideService(Reference.Service, reference),
),
})
})
const add = <R>(input: PluginInternal.Plugin<R>) =>
input
.effect({ ...host, options: {} })
.pipe(
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(CommandV2.Service, commands),
Effect.provideService(Integration.Service, integration),
Effect.provideService(AgentV2.Service, agents),
Effect.provideService(Config.Service, config),
Effect.provideService(Location.Service, location),
Effect.provideService(ModelsDev.Service, modelsDev),
Effect.provideService(Npm.Service, npm),
Effect.provideService(EventV2.Service, events),
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(FileSystem.Service, filesystem),
Effect.provideService(Global.Service, global),
Effect.provideService(SkillV2.Service, skill),
Effect.provideService(Reference.Service, reference),
)
const boot = Effect.gen(function* () {
yield* State.batch(
Effect.gen(function* () {
yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)
yield* add(ModelsDevPlugin)
yield* add(ConfigProviderPlugin.Plugin)
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
yield* add(ConfigReferencePlugin.Plugin)
for (const item of ProviderPlugins) {
yield* add(item)
}
}),
)
}).pipe(Effect.withSpan("PluginBoot.boot"))
yield* boot.pipe(
Effect.exit,
Effect.flatMap((exit) => Deferred.done(done, exit)),
Effect.forkScoped,
)
return Service.of({
add: (input) =>
Deferred.await(done).pipe(
Effect.andThen(
plugin.add({
id: input.id,
effect: input.effect(host),
}),
),
),
wait: () => Deferred.await(done),
})
yield* State.batch(
Effect.gen(function* () {
yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)
yield* add(ModelsDevPlugin)
yield* add(ConfigProviderPlugin.Plugin)
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
yield* add(ConfigReferencePlugin.Plugin)
for (const item of ProviderPlugins) yield* add(item)
yield* add(ConfigExternalPlugin.Plugin)
}),
).pipe(Effect.withSpan("PluginBoot.boot"))
}),
)
export const locationLayer = layer.pipe(
).pipe(
Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(AISDK.locationLayer),
Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(Catalog.locationLayer),
Layer.provideMerge(CommandV2.locationLayer),

View file

@ -1,20 +1,22 @@
export * as CommandPlugin from "./command"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "./internal"
import { Effect } from "effect"
import { Location } from "../location"
import PROMPT_INITIALIZE from "./command/initialize.txt"
import PROMPT_REVIEW from "./command/review.txt"
export const Plugin = define({
id: "command",
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
yield* ctx.command.transform((draft) => {
draft.update("init", (command) => {
command.template = PROMPT_INITIALIZE.replace("${path}", ctx.location.project.directory)
command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory)
command.description = "guided AGENTS.md setup"
})
draft.update("review", (command) => {
command.template = PROMPT_REVIEW.replace("${path}", ctx.location.project.directory)
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
command.description = "review changes [commit|branch|pr], defaults to uncommitted"
command.subtask = true
})

View file

@ -1,58 +1,31 @@
export * as PluginHost from "./host"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import type { PluginHost as Interface } from "@opencode-ai/plugin/v2/effect"
import type { Event as SDKEvent, ModelV2Info } from "@opencode-ai/sdk/v2/types"
import { Effect, Schema, Stream } from "effect"
import type { PluginContext as Interface } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema } from "effect"
import { AgentV2 } from "../agent"
import { AISDK } from "../aisdk"
import { Catalog } from "../catalog"
import { CommandV2 } from "../command"
import { EventV2 } from "../event"
import { FileSystem } from "../filesystem"
import { Global } from "../global"
import { Integration } from "../integration"
import { Location } from "../location"
import { ModelV2 } from "../model"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import type { PluginV2 } from "../plugin"
import { ProviderV2 } from "../provider"
import { Reference } from "../reference"
import { SkillV2 } from "../skill"
type EventMap = { [Item in SDKEvent as Item["type"]]: Item }
type SDKHook = (event: {
readonly model: ModelV2Info
readonly package: string
readonly options: Record<string, any>
sdk?: any
}) => Effect.Effect<void> | void
type LanguageHook = (event: {
readonly model: ModelV2Info
readonly sdk: any
readonly options: Record<string, any>
language?: LanguageModelV3
}) => Effect.Effect<void> | void
export const make = Effect.fn("PluginHost.make")(function* () {
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
const agents = yield* AgentV2.Service
const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const events = yield* EventV2.Service
const filesystem = yield* FileSystem.Service
const global = yield* Global.Service
const integration = yield* Integration.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const plugin = yield* PluginV2.Service
const reference = yield* Reference.Service
const skill = yield* SkillV2.Service
return {
options: {},
agent: {
get: (id) => agents.get(AgentV2.ID.make(id)),
default: agents.default,
list: agents.all,
rebuild: agents.rebuild,
reload: agents.reload,
transform: (callback) =>
agents.transform((draft) =>
callback({
@ -65,51 +38,35 @@ export const make = Effect.fn("PluginHost.make")(function* () {
),
},
aisdk: {
hook: (name, callback) => {
if (name === "sdk") {
const run = callback as SDKHook
return plugin.hook("aisdk.sdk", (event) => {
const output = {
model: event.model,
package: event.package,
options: event.options,
sdk: event.sdk,
}
const result = run(output)
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))),
)
})
}
const run = callback as LanguageHook
return plugin.hook("aisdk.language", (event) => {
sdk: (callback) =>
aisdk.hook.sdk((event) => {
const output = {
model: event.model,
package: event.package,
options: event.options,
sdk: event.sdk,
}
const result = callback(output)
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))),
)
}),
language: (callback) =>
aisdk.hook.language((event) => {
const output = {
model: event.model,
sdk: event.sdk,
options: event.options,
language: event.language,
}
const result = run(output)
const result = callback(output)
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
Effect.tap(() => Effect.sync(() => (event.language = output.language))),
)
})
},
}),
},
catalog: {
provider: {
get: (id) => catalog.provider.get(ProviderV2.ID.make(id)),
list: catalog.provider.all,
available: catalog.provider.available,
},
model: {
get: (providerID, modelID) => catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
list: catalog.model.all,
available: catalog.model.available,
default: catalog.model.default,
small: (providerID) => catalog.model.small(ProviderV2.ID.make(providerID)),
},
rebuild: catalog.rebuild,
reload: catalog.reload,
transform: (callback) =>
catalog.transform((draft) =>
callback({
@ -135,41 +92,11 @@ export const make = Effect.fn("PluginHost.make")(function* () {
),
},
command: {
get: commands.get,
list: commands.list,
rebuild: commands.rebuild,
reload: commands.reload,
transform: commands.transform,
},
event: {
subscribe: <Type extends keyof EventMap>(type: Type): Stream.Stream<EventMap[Type]> =>
Stream.unwrap(
Effect.sync(() => {
const definition = EventV2.registry.get(type)
if (!definition) throw new Error(`Unknown event type: ${type}`)
const encode = Schema.encodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)
return events.subscribe(definition).pipe(
Stream.map(
(event) =>
({
id: event.id,
type: event.type,
properties: encode(event.data),
}) as unknown as EventMap[Type],
),
)
}),
),
},
filesystem: {
read: (input) => filesystem.read(Schema.decodeUnknownSync(FileSystem.ReadInput)(input)),
list: (input) => filesystem.list(Schema.decodeUnknownSync(FileSystem.ListInput)(input ?? {})),
find: (input) => filesystem.find(Schema.decodeUnknownSync(FileSystem.FindInput)(input)),
glob: (input) => filesystem.glob(Schema.decodeUnknownSync(FileSystem.GlobInput)(input)),
},
integration: {
get: (id) => integration.get(Integration.ID.make(id)),
list: integration.list,
rebuild: integration.rebuild,
reload: integration.reload,
transform: (callback) =>
integration.transform((draft) =>
callback({
@ -198,19 +125,12 @@ export const make = Effect.fn("PluginHost.make")(function* () {
}),
),
},
location,
npm,
path: {
home: global.home,
data: global.data,
cache: global.cache,
config: global.config,
state: global.state,
temp: global.tmp,
plugin: {
reload: plugin.reload,
transform: plugin.transform,
},
reference: {
list: reference.list,
rebuild: reference.rebuild,
reload: reference.reload,
transform: (callback) =>
reference.transform((draft) =>
callback({
@ -221,9 +141,7 @@ export const make = Effect.fn("PluginHost.make")(function* () {
),
},
skill: {
sources: skill.sources,
list: skill.list,
rebuild: skill.rebuild,
reload: skill.reload,
transform: (callback) =>
skill.transform((draft) =>
callback({

View file

@ -0,0 +1,43 @@
export * as PluginInternal from "./internal"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import type { Effect, Scope } from "effect"
import type { AgentV2 } from "../agent"
import type { Catalog } from "../catalog"
import type { CommandV2 } from "../command"
import type { Config } from "../config"
import type { EventV2 } from "../event"
import type { FileSystem } from "../filesystem"
import type { FSUtil } from "../fs-util"
import type { Global } from "../global"
import type { Integration } from "../integration"
import type { Location } from "../location"
import type { ModelsDev } from "../models-dev"
import type { Npm } from "../npm"
import type { Reference } from "../reference"
import type { SkillV2 } from "../skill"
export type Requirements =
| AgentV2.Service
| Catalog.Service
| CommandV2.Service
| Config.Service
| EventV2.Service
| FileSystem.Service
| FSUtil.Service
| Global.Service
| Integration.Service
| Location.Service
| ModelsDev.Service
| Npm.Service
| Reference.Service
| SkillV2.Service
export interface Plugin<R = never> {
readonly id: string
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R | Scope.Scope>
}
export function define<R>(plugin: Plugin<R>) {
return plugin
}

View file

@ -1,5 +1,6 @@
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "./internal"
import { Effect, Stream } from "effect"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { ModelRequest } from "../model-request"
import { ModelsDev } from "../models-dev"
@ -52,6 +53,7 @@ export const ModelsDevPlugin = define({
id: "models-dev",
effect: Effect.fn(function* (ctx) {
const modelsDev = yield* ModelsDev.Service
const events = yield* EventV2.Service
yield* ctx.integration.transform(
Effect.fn(function* (integrations) {
const data = yield* modelsDev.get()
@ -128,8 +130,8 @@ export const ModelsDevPlugin = define({
}
}),
)
yield* ctx.event.subscribe("models-dev.refreshed").pipe(
Stream.runForEach(() => ctx.integration.rebuild().pipe(Effect.andThen(ctx.catalog.rebuild()))),
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.runForEach(() => ctx.integration.reload().pipe(Effect.andThen(ctx.catalog.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
}),

View file

@ -0,0 +1,86 @@
export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/v2/effect"
import type { Plugin, PluginContext, Registration } from "@opencode-ai/plugin/v2/promise"
import { Effect, Scope } from "effect"
// The Effect host hands back this registration shape; mirror it structurally so
// we do not have to alias the Effect package's `Registration` against the Promise one.
type HostRegistration = { readonly dispose: Effect.Effect<void> }
/**
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only
* loader (`PluginV2` / `PluginBoot`) can run it unchanged.
*
* Hook registrations created during the async `setup` attach to the plugin's
* scope, so unloading the plugin disposes them. The captured fiber context
* preserves boot-time batching, so Promise-plugin transforms still coalesce
* into one reload per domain.
*/
export function fromPromise(plugin: Plugin) {
return define({
id: plugin.id,
effect: (host) =>
Effect.gen(function* () {
const scope = yield* Scope.Scope
const context = yield* Effect.context<Scope.Scope>()
// Run a hook registration on the plugin scope and resolve once it is registered.
const register = (effect: Effect.Effect<HostRegistration, never, Scope.Scope>): Promise<Registration> =>
Effect.runPromiseWith(context)(Scope.provide(scope)(effect)).then((registration) => ({
dispose: () => Effect.runPromiseWith(context)(registration.dispose),
}))
const run = (effect: Effect.Effect<void>) => Effect.runPromiseWith(context)(effect)
const transform =
<Draft>(domain: {
transform: (
callback: (draft: Draft) => Effect.Effect<void> | void,
) => Effect.Effect<HostRegistration, never, Scope.Scope>
}) =>
(callback: (draft: Draft) => Promise<void> | void) =>
register(domain.transform((draft) => Effect.promise(() => Promise.resolve(callback(draft)))))
const context2: PluginContext = {
options: host.options,
agent: {
transform: transform(host.agent),
reload: () => run(host.agent.reload()),
},
aisdk: {
sdk: (callback) =>
register(host.aisdk.sdk((event) => Effect.promise(() => Promise.resolve(callback(event))))),
language: (callback) =>
register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
catalog: {
transform: transform(host.catalog),
reload: () => run(host.catalog.reload()),
},
command: {
transform: transform(host.command),
reload: () => run(host.command.reload()),
},
integration: {
transform: transform(host.integration),
reload: () => run(host.integration.reload()),
},
plugin: {
transform: transform(host.plugin),
reload: () => run(host.plugin.reload()),
},
reference: {
transform: transform(host.reference),
reload: () => run(host.reference.reload()),
},
skill: {
transform: transform(host.skill),
reload: () => run(host.skill.reload()),
},
}
yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
}),
})
}

View file

@ -30,8 +30,10 @@ import { VercelPlugin } from "./provider/vercel"
import { VenicePlugin } from "./provider/venice"
import { XAIPlugin } from "./provider/xai"
import { ZenmuxPlugin } from "./provider/zenmux"
import type { PluginInternal } from "./internal"
import type { Scope } from "effect"
export const ProviderPlugins = [
export const ProviderPlugins: PluginInternal.Plugin<PluginInternal.Requirements | Scope.Scope>[] = [
AlibabaPlugin,
AmazonBedrockPlugin,
AnthropicPlugin,

View file

@ -1,11 +1,10 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const AlibabaPlugin = define({
id: "alibaba",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/alibaba") return
const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba"))

View file

@ -1,6 +1,6 @@
import { Effect } from "effect"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
type MantleSDK = {
@ -78,8 +78,7 @@ export const AmazonBedrockPlugin = define({
}
}),
)
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return
const options = { ...evt.options }
@ -112,8 +111,7 @@ export const AmazonBedrockPlugin = define({
evt.sdk = mod.createAmazonBedrock(options)
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return
if (evt.model.api.type === "aisdk" && evt.model.api.package === "@ai-sdk/amazon-bedrock/mantle") {

View file

@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const AnthropicPlugin = define({
id: "anthropic",
@ -16,8 +16,7 @@ export const AnthropicPlugin = define({
}
}),
)
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/anthropic") return
const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic"))

View file

@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
@ -28,8 +28,7 @@ export const AzurePlugin = define({
}
}),
)
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/azure") return
if (evt.model.providerID === ProviderV2.ID.azure) {
@ -47,8 +46,7 @@ export const AzurePlugin = define({
evt.sdk = mod.createAzure(evt.options)
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.azure) return
evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls))
@ -74,8 +72,7 @@ export const AzureCognitiveServicesPlugin = define({
}
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls))

View file

@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const CerebrasPlugin = define({
id: "cerebras",
@ -15,8 +15,7 @@ export const CerebrasPlugin = define({
}
}),
)
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cerebras") return
const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras"))

View file

@ -1,13 +1,12 @@
import os from "os"
import { InstallationVersion } from "../../installation/version"
import { Effect, Option, Schema } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const CloudflareAIGatewayPlugin = define({
id: "cloudflare-ai-gateway",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "ai-gateway-provider") return
if (evt.options.baseURL) return

View file

@ -1,7 +1,7 @@
import os from "os"
import { InstallationVersion } from "../../installation/version"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
@ -21,8 +21,7 @@ export const CloudflareWorkersAIPlugin = define({
})
}),
)
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return
if (evt.package !== "@ai-sdk/openai-compatible") return
@ -38,8 +37,7 @@ export const CloudflareWorkersAIPlugin = define({
)
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return
evt.language = evt.sdk.languageModel(evt.model.api.id)

View file

@ -1,11 +1,10 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const CoherePlugin = define({
id: "cohere",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cohere") return
const mod = yield* Effect.promise(() => import("@ai-sdk/cohere"))

View file

@ -1,11 +1,10 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const DeepInfraPlugin = define({
id: "deepinfra",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/deepinfra") return
const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra"))

View file

@ -1,18 +1,19 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
import { Npm } from "../../npm"
export const DynamicProviderPlugin = define({
id: "dynamic-provider",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
const npm = yield* Npm.Service
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.sdk) return
const installedPath = evt.package.startsWith("file://")
? evt.package
: (yield* ctx.npm.add(evt.package).pipe(Effect.orDie)).entrypoint
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
const mod = yield* Effect.promise(async () => {

View file

@ -1,11 +1,10 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const GatewayPlugin = define({
id: "gateway",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/gateway") return
const mod = yield* Effect.promise(() => import("@ai-sdk/gateway"))

View file

@ -1,6 +1,6 @@
import { Effect } from "effect"
import { ModelV2 } from "../../model"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
function shouldUseResponses(modelID: string) {
@ -25,16 +25,14 @@ export const GithubCopilotPlugin = define({
})
}),
)
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/github-copilot") return
const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
evt.sdk = mod.createOpenaiCompatible(evt.options)
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) {

View file

@ -1,14 +1,13 @@
import os from "os"
import { InstallationVersion } from "../../installation/version"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
export const GitLabPlugin = define({
id: "gitlab",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "gitlab-ai-provider") return
const mod = yield* Effect.promise(() => import("gitlab-ai-provider"))
@ -32,8 +31,7 @@ export const GitLabPlugin = define({
})
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.gitlab) return
const featureFlags =

View file

@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
function resolveProject(options: Record<string, any>) {
@ -84,8 +84,7 @@ export const GoogleVertexPlugin = define({
}
}),
)
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) {
evt.options.fetch = authFetch(evt.options.fetch)
@ -104,8 +103,7 @@ export const GoogleVertexPlugin = define({
})
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.googleVertex) return
evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim())
@ -139,8 +137,7 @@ export const GoogleVertexAnthropicPlugin = define({
}
}),
)
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic"))
@ -166,8 +163,7 @@ export const GoogleVertexAnthropicPlugin = define({
})
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return
evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim())

View file

@ -1,11 +1,10 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const GooglePlugin = define({
id: "google",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google") return
const mod = yield* Effect.promise(() => import("@ai-sdk/google"))

View file

@ -1,11 +1,10 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const GroqPlugin = define({
id: "groq",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/groq") return
const mod = yield* Effect.promise(() => import("@ai-sdk/groq"))

View file

@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const KiloPlugin = define({
id: "kilo",

View file

@ -1,9 +1,11 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
import { Integration } from "../../integration"
export const LLMGatewayPlugin = define({
id: "llmgateway",
effect: Effect.fn(function* (ctx) {
const integrations = yield* Integration.Service
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
@ -11,7 +13,7 @@ export const LLMGatewayPlugin = define({
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
if (!(yield* ctx.integration.get(item.provider.id))) continue
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"

View file

@ -1,11 +1,10 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const MistralPlugin = define({
id: "mistral",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/mistral") return
const mod = yield* Effect.promise(() => import("@ai-sdk/mistral"))

View file

@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const NvidiaPlugin = define({
id: "nvidia",

View file

@ -1,11 +1,10 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const OpenAICompatiblePlugin = define({
id: "openai-compatible",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.sdk) return
if (!evt.package.includes("@ai-sdk/openai-compatible")) return

View file

@ -1,6 +1,6 @@
import { Effect } from "effect"
import { ModelV2 } from "../../model"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
import { Integration } from "../../integration"
import { browser, headless } from "./openai-auth"
@ -27,16 +27,14 @@ export const OpenAIPlugin = define({
}
}),
)
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/openai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
evt.sdk = mod.createOpenAI(evt.options)
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.openai) return
evt.language = evt.sdk.responses(evt.model.api.id)

View file

@ -1,16 +1,18 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
import { Integration } from "../../integration"
export const OpencodePlugin = define({
id: "opencode",
effect: Effect.fn(function* (ctx) {
const integrations = yield* Integration.Service
let hasKey = false
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
const item = evt.provider.get(ProviderV2.ID.opencode)
if (!item) return
const integration = yield* ctx.integration.get(item.provider.id)
const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
hasKey = Boolean(
process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey,
)

View file

@ -1,6 +1,6 @@
import { Effect } from "effect"
import { ModelV2 } from "../../model"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const OpenRouterPlugin = define({
id: "openrouter",
@ -25,8 +25,7 @@ export const OpenRouterPlugin = define({
}
}),
)
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@openrouter/ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))

View file

@ -1,11 +1,10 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const PerplexityPlugin = define({
id: "perplexity",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/perplexity") return
const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity"))

View file

@ -1,13 +1,14 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
import { Npm } from "../../npm"
import { ProviderV2 } from "../../provider"
export const SapAICorePlugin = define({
id: "sap-ai-core",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
const npm = yield* Npm.Service
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
const serviceKey =
@ -17,7 +18,7 @@ export const SapAICorePlugin = define({
const installedPath = evt.package.startsWith("file://")
? evt.package
: (yield* ctx.npm.add(evt.package).pipe(Effect.orDie)).entrypoint
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
const mod = yield* Effect.promise(async () => {
@ -35,8 +36,7 @@ export const SapAICorePlugin = define({
)
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
evt.language = evt.sdk(evt.model.api.id)

View file

@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
@ -67,8 +67,7 @@ export function cortexFetch(upstream: FetchLike = fetch) {
export const SnowflakeCortexPlugin = define({
id: "snowflake-cortex",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
const token =

View file

@ -1,11 +1,10 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const TogetherAIPlugin = define({
id: "togetherai",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/togetherai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai"))

View file

@ -1,11 +1,10 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const VenicePlugin = define({
id: "venice",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "venice-ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider"))

View file

@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const VercelPlugin = define({
id: "vercel",
@ -16,8 +16,7 @@ export const VercelPlugin = define({
}
}),
)
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/vercel") return
const mod = yield* Effect.promise(() => import("@ai-sdk/vercel"))

View file

@ -1,20 +1,18 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
export const XAIPlugin = define({
id: "xai",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/xai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/xai"))
evt.sdk = mod.createXai(evt.options)
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("xai")) return
evt.language = evt.sdk.responses(evt.model.api.id)

View file

@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "../internal"
export const ZenmuxPlugin = define({
id: "zenmux",

View file

@ -2,7 +2,7 @@
export * as SkillPlugin from "./skill"
import { define } from "@opencode-ai/plugin/v2/effect"
import { define } from "./internal"
import { Effect } from "effect"
import { AbsolutePath } from "../schema"
import { SkillV2 } from "../skill"

View file

@ -13,7 +13,6 @@ import { Slug } from "../util/slug"
import { EventV2 } from "../event"
import { Database } from "../database/database"
import { Location } from "../location"
import { PluginBoot } from "../plugin/boot"
export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID"))
export type StrategyID = typeof StrategyID.Type
@ -125,10 +124,8 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pr
export const refreshAfterBoot = Effect.gen(function* () {
const location = yield* Location.Service
const boot = yield* PluginBoot.Service
const copies = yield* Service
yield* Effect.gen(function* () {
yield* boot.wait()
yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id })
const result = yield* copies.refresh({ projectID: location.project.id })
yield* Effect.logInfo("project copy refresh done", {

View file

@ -126,7 +126,7 @@ export const layer = Layer.effect(
return Service.of({
transform: state.transform,
rebuild: state.rebuild,
reload: state.reload,
list: Effect.fn("Reference.list")(function* () {
return Array.from(materialized.values())
}),

View file

@ -1,7 +1,6 @@
export * as ReferenceGuidance from "./guidance"
import { Context, Effect, Layer, Schema } from "effect"
import { PluginBoot } from "../plugin/boot"
import { Reference } from "../reference"
import { SystemContext } from "../system-context/index"
@ -34,12 +33,10 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const boot = yield* PluginBoot.Service
const references = yield* Reference.Service
return Service.of({
load: Effect.fn("ReferenceGuidance.load")(function* () {
yield* boot.wait()
const available = (yield* references.list())
.filter((reference) => reference.description !== undefined)
.map((reference) => ({

View file

@ -13,7 +13,6 @@ import { Integration } from "../../integration"
import { IntegrationConnection } from "../../integration/connection"
import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request"
import { PluginBoot } from "../../plugin/boot"
import { ProviderV2 } from "../../provider"
import { SessionSchema } from "../schema"
@ -178,11 +177,9 @@ export const locationLayer = Layer.effect(
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const boot = yield* PluginBoot.Service
return Service.of({
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
// Location plugins populate and filter the catalog asynchronously during layer startup.
yield* boot.wait()
const defaultModel = session.model ? undefined : yield* catalog.model.default()
const selected = session.model
? (yield* catalog.model.available()).find(

View file

@ -148,7 +148,7 @@ export const layer = Layer.effect(
return Service.of({
transform: state.transform,
rebuild: state.rebuild,
reload: state.reload,
sources: Effect.fn("SkillV2.sources")(function* () {
return state.get().sources
}),

View file

@ -3,7 +3,6 @@ export * as SkillGuidance from "./guidance"
import { Context, Effect, Layer, Schema } from "effect"
import { AgentV2 } from "../agent"
import { PermissionV2 } from "../permission"
import { PluginBoot } from "../plugin/boot"
import { SkillV2 } from "../skill"
import { SystemContext } from "../system-context/index"
@ -40,12 +39,10 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const boot = yield* PluginBoot.Service
const skills = yield* SkillV2.Service
return Service.of({
load: Effect.fn("SkillGuidance.load")(function* (selection) {
yield* boot.wait()
const agent = selection.info
if (!agent) return SystemContext.empty
const permitted = SkillV2.available(yield* skills.list(), agent)

View file

@ -3,7 +3,7 @@ export * as State from "./state"
import { Context, Effect, Scope, Semaphore } from "effect"
/**
* A replayable transform applied to a draft during rebuild.
* A replayable transform applied to a draft during reload.
*
* Domain drafts expose readable and writable state while preserving concise
* plugin/config code. Transforms may perform Effects before returning.
@ -19,14 +19,14 @@ export type Transform<DraftApi> = (
transform: TransformCallback<DraftApi>,
) => Effect.Effect<Registration, never, Scope.Scope>
export type Rebuild = () => Effect.Effect<void>
export type Reload = () => Effect.Effect<void>
export interface Transformable<DraftApi> {
readonly transform: Transform<DraftApi>
readonly rebuild: Rebuild
readonly reload: Reload
}
const CurrentBatch = Context.Reference<Set<Rebuild> | undefined>("@opencode/State/CurrentBatch", {
const CurrentBatch = Context.Reference<Set<Reload> | undefined>("@opencode/State/CurrentBatch", {
defaultValue: () => undefined,
})
@ -34,15 +34,15 @@ export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const current = yield* CurrentBatch
if (current) return yield* effect
const rebuilds = new Set<Rebuild>()
const result = yield* effect.pipe(Effect.provideService(CurrentBatch, rebuilds))
yield* Effect.forEach(rebuilds, (rebuild) => rebuild(), { discard: true })
const reloads = new Set<Reload>()
const result = yield* effect.pipe(Effect.provideService(CurrentBatch, reloads))
yield* Effect.forEach(reloads, (reload) => reload(), { discard: true })
return result
})
}
export interface Options<State, DraftApi> {
/** Creates the base value for initial state and every scoped-transform rebuild. */
/** Creates the base value for initial state and every scoped-transform reload. */
readonly initial: () => State
/** Wraps mutable state in a domain-specific draft API. */
readonly draft: MakeDraft<State, DraftApi>
@ -54,7 +54,7 @@ export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
readonly get: () => State
/**
* Registers and applies a scoped transform. Closing the owning Scope removes
* the transform and rebuilds the materialized state.
* the transform and reloads the materialized state.
*/
}
@ -78,11 +78,11 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
const materialize = Effect.fnUntraced(function* () {
const next = options.initial()
const api = options.draft(next)
for (const transform of transforms) yield* apply(transform.run, api).pipe(Effect.withSpan("State.rebuild.update"))
for (const transform of transforms) yield* apply(transform.run, api).pipe(Effect.withSpan("State.reload.update"))
yield* commit(next)
})
const rebuild = () => semaphore.withPermit(materialize())
const reload = () => semaphore.withPermit(materialize())
const result: Interface<State, DraftApi> = {
get: () => state,
@ -101,7 +101,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
return Effect.gen(function* () {
const batch = yield* CurrentBatch
if (batch) {
batch.add(rebuild)
batch.add(reload)
return
}
yield* materialize()
@ -116,13 +116,13 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
)
yield* Scope.addFinalizer(scope, dispose)
const batch = yield* CurrentBatch
if (batch) batch.add(rebuild)
else yield* rebuild()
if (batch) batch.add(reload)
else yield* reload()
return { dispose }
}),
)
}),
rebuild,
reload,
}
return result
}

View file

@ -5,7 +5,6 @@ import { pathToFileURL } from "url"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FSUtil } from "../fs-util"
import { PluginBoot } from "../plugin/boot"
import { SkillV2 } from "../skill"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
@ -58,10 +57,8 @@ export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const fs = yield* FSUtil.Service
const boot = yield* PluginBoot.Service
const skills = yield* SkillV2.Service
const permission = yield* PermissionV2.Service
yield* boot.wait()
yield* tools
.register({
[name]: Tool.make({