mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 03:18:31 +00:00
feat(core): manage configurable plugin generations
This commit is contained in:
parent
f9d1d3b259
commit
a9b7bd9e2f
83 changed files with 1127 additions and 830 deletions
|
|
@ -102,7 +102,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
|||
description: "Named local directories or Git repositories available as external context",
|
||||
}),
|
||||
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
||||
description: "Ordered external plugin packages to load",
|
||||
description: "Ordered plugin enablement directives and external package declarations",
|
||||
}),
|
||||
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * as ConfigAgentPlugin from "./agent"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
|
|
@ -34,7 +34,7 @@ const agentKeys = new Set([
|
|||
])
|
||||
|
||||
export const Plugin = define({
|
||||
id: "config-agent",
|
||||
id: "opencode.config.agent",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * as ConfigCommandPlugin from "./command"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { CommandV2 } from "../../command"
|
||||
|
|
@ -13,7 +13,7 @@ import { ConfigMarkdown } from "../markdown"
|
|||
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
|
||||
|
||||
export const Plugin = define({
|
||||
id: "config-command",
|
||||
id: "opencode.config.command",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
|
|
|
|||
|
|
@ -1,136 +0,0 @@
|
|||
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",
|
||||
),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
const PluginPackage = Schema.Struct({
|
||||
exports: Schema.optional(Schema.Unknown),
|
||||
main: Schema.optional(Schema.String),
|
||||
module: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
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 load = Effect.fn("ConfigExternalPlugin.load")(function* () {
|
||||
const configured: { package: string; options?: Record<string, unknown> }[] = []
|
||||
|
||||
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(() => []))
|
||||
const directories = yield* fs
|
||||
.glob("{plugin,plugins}/*", {
|
||||
cwd: entry.path,
|
||||
absolute: true,
|
||||
include: "all",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(
|
||||
Effect.flatMap((items) =>
|
||||
Effect.filter(items, (item) => fs.isDir(item), {
|
||||
concurrency: "unbounded",
|
||||
}),
|
||||
),
|
||||
Effect.orElseSucceed(() => []),
|
||||
)
|
||||
const packages = yield* Effect.forEach(
|
||||
directories.sort(),
|
||||
(directory) => resolvePackageEntrypoint(fs, directory),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined)))
|
||||
files.sort()
|
||||
for (const file of files) configured.push({ package: file })
|
||||
for (const file of packages) configured.push({ package: file })
|
||||
}
|
||||
}
|
||||
|
||||
return yield* Effect.forEach(configured, (ref) =>
|
||||
Effect.gen(function* () {
|
||||
const entrypoint = path.isAbsolute(ref.package)
|
||||
? pathToFileURL(ref.package).href
|
||||
: (yield* npm.add(ref.package)).entrypoint
|
||||
if (!entrypoint) return
|
||||
yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint })
|
||||
const mod = yield* Effect.promise(() => import(entrypoint))
|
||||
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
return {
|
||||
id: plugin.id,
|
||||
effect: (host: Parameters<typeof plugin.effect>[0]) =>
|
||||
plugin.effect({ ...host, options: ref.options ?? {} }),
|
||||
}
|
||||
}).pipe(Effect.catchCause(() => Effect.succeed(undefined))),
|
||||
).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
|
||||
})
|
||||
for (const plugin of yield* load()) yield* ctx.plugin.add(plugin)
|
||||
}),
|
||||
})
|
||||
|
||||
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
|
||||
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
|
||||
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined
|
||||
const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"]
|
||||
|
||||
return yield* Effect.forEach(entries, (entry) => {
|
||||
if (!entry) return Effect.succeed(undefined)
|
||||
const file = path.resolve(directory, entry)
|
||||
return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined)))
|
||||
}).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined)))
|
||||
})
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
export * as ConfigProviderPlugin from "./provider"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ModelV2 } from "../../model"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "config-provider",
|
||||
id: "opencode.config.provider",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * as ConfigReferencePlugin from "./reference"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import path from "path"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
|
|
@ -11,7 +11,7 @@ import { Global } from "../../global"
|
|||
import { Location } from "../../location"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "core/config-reference",
|
||||
id: "opencode.config.reference",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * as ConfigSkillPlugin from "./skill"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import path from "path"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
|
|
@ -10,7 +10,7 @@ import { Global } from "../../global"
|
|||
import { Location } from "../../location"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "config-skill",
|
||||
id: "opencode.config.skill",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
|
|
|
|||
|
|
@ -583,12 +583,32 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
const local = <A extends Payload>(stream: Stream.Stream<A>) =>
|
||||
Stream.unwrap(
|
||||
Effect.serviceOption(Location.Service).pipe(
|
||||
Effect.map((location) =>
|
||||
Option.match(location, {
|
||||
onNone: () => stream,
|
||||
onSome: (location) =>
|
||||
stream.pipe(
|
||||
Stream.filter(
|
||||
(event) =>
|
||||
!event.location ||
|
||||
(event.location.directory === location.directory &&
|
||||
event.location.workspaceID === location.workspaceID),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
|
||||
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
|
||||
local(Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))).pipe(
|
||||
Stream.map((event) => event as Payload<D>),
|
||||
)
|
||||
|
||||
const streamLive = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.live)
|
||||
const streamLive = (): Stream.Stream<Payload> => local(Stream.fromPubSub(pubsub.live))
|
||||
|
||||
const readAfter = (
|
||||
aggregateID: string,
|
||||
|
|
|
|||
|
|
@ -5,12 +5,16 @@ import { Catalog } from "./catalog"
|
|||
import { CommandV2 } from "./command"
|
||||
import { Config } from "./config"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { Node } from "./effect/app-node"
|
||||
import { makeLocationNode, Node } from "./effect/app-node"
|
||||
import { httpClient } from "./effect/app-node-platform"
|
||||
import { EventV2 } from "./event"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Generate } from "./generate"
|
||||
import { Form } from "./form"
|
||||
import { Global } from "./global"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher"
|
||||
import { Image } from "./image"
|
||||
import { Integration } from "./integration"
|
||||
|
|
@ -18,15 +22,20 @@ import { Location } from "./location"
|
|||
import { LocationMutation } from "./location-mutation"
|
||||
import { LocationServiceMap } from "./location-service-map"
|
||||
import { MCP } from "./mcp/index"
|
||||
import { ModelsDev } from "./models-dev"
|
||||
import { Npm } from "./npm"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { PluginInternal } from "./plugin/internal"
|
||||
import { PluginRuntime } from "./plugin/runtime"
|
||||
import { SdkPlugins } from "./plugin/sdk"
|
||||
import { PluginSupervisor } from "./plugin/supervisor"
|
||||
import { ProjectCopy } from "./project/copy"
|
||||
import { Pty } from "./pty"
|
||||
import { QuestionV2 } from "./question"
|
||||
import { Shell } from "./shell"
|
||||
import { Reference } from "./reference"
|
||||
import { ReferenceGuidance } from "./reference/guidance"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import { SessionRunnerLLM } from "./session/runner/llm"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
import { SessionCompaction } from "./session/compaction"
|
||||
|
|
@ -42,11 +51,49 @@ import { SessionInstructions } from "./session/instructions"
|
|||
import { McpTool } from "./tool/mcp"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
import { WebSearchTool } from "./tool/websearch"
|
||||
import { ToolOutputStore } from "./tool-output-store"
|
||||
import { Vcs } from "./vcs"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map"
|
||||
|
||||
const pluginSupervisorNode = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: PluginSupervisor.layer,
|
||||
deps: [
|
||||
PluginV2.node,
|
||||
SdkPlugins.node,
|
||||
AgentV2.node,
|
||||
Catalog.node,
|
||||
CommandV2.node,
|
||||
Config.node,
|
||||
EventV2.node,
|
||||
FileMutation.node,
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
Image.node,
|
||||
Integration.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
Npm.node,
|
||||
PermissionV2.node,
|
||||
PluginRuntime.node,
|
||||
QuestionV2.node,
|
||||
ReadToolFileSystem.node,
|
||||
Reference.node,
|
||||
Ripgrep.node,
|
||||
SessionInstructions.node,
|
||||
SessionTodo.node,
|
||||
Shell.node,
|
||||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
WebSearchTool.configNode,
|
||||
],
|
||||
})
|
||||
|
||||
const locationServiceNodes = [
|
||||
Location.node,
|
||||
Config.node,
|
||||
|
|
@ -57,12 +104,11 @@ const locationServiceNodes = [
|
|||
Catalog.node,
|
||||
AISDK.node,
|
||||
PluginV2.node,
|
||||
PluginInternal.node,
|
||||
pluginSupervisorNode,
|
||||
ProjectCopy.node,
|
||||
ProjectCopy.refreshNode,
|
||||
FileSystemSearch.node,
|
||||
FileSystem.node,
|
||||
LocationWatcher.node,
|
||||
Pty.node,
|
||||
Shell.node,
|
||||
SkillV2.node,
|
||||
|
|
@ -92,6 +138,8 @@ const locationServiceNodes = [
|
|||
Snapshot.node,
|
||||
SessionRunnerLLM.node,
|
||||
Vcs.node,
|
||||
// Start repository watches only after boot-critical filesystem and Git work.
|
||||
LocationWatcher.node,
|
||||
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
|
||||
|
||||
export const locationServices = LayerNode.group<typeof locationServiceNodes>(locationServiceNodes)
|
||||
|
|
@ -106,6 +154,7 @@ export function buildLocationServiceMap(
|
|||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
(ref: Location.Ref) => {
|
||||
const startedAt = performance.now()
|
||||
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
|
||||
// Apply replacements during hoist, not afterward: replacements can
|
||||
// introduce new tagged dependencies (Location.boundNode depends on
|
||||
|
|
@ -116,9 +165,10 @@ export function buildLocationServiceMap(
|
|||
return LayerNode.compile(location.node).pipe(
|
||||
Layer.fresh,
|
||||
Layer.tap(() =>
|
||||
Effect.logInfo("booting location services", {
|
||||
Effect.logInfo("location services booted", {
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
}),
|
||||
),
|
||||
Layer.provide(LayerNode.compile(location.hoisted)),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
export * as PluginV2 from "./plugin"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { AISDK } from "./aisdk"
|
||||
|
|
@ -10,7 +9,6 @@ import { Catalog } from "./catalog"
|
|||
import { CommandV2 } from "./command"
|
||||
import { EventV2 } from "./event"
|
||||
import { Integration } from "./integration"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { Location } from "./location"
|
||||
import { PluginHost } from "./plugin/host"
|
||||
import { PluginRuntime } from "./plugin/runtime"
|
||||
|
|
@ -27,9 +25,7 @@ export type Info = Plugin.Info
|
|||
export const Event = Plugin.Event
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (id: ID, effect: PluginDefinition["effect"]) => Effect.Effect<void>
|
||||
readonly remove: (id: ID) => Effect.Effect<void>
|
||||
readonly wait: (id: ID) => Effect.Effect<void>
|
||||
readonly activate: (plugins: readonly import("@opencode-ai/plugin/v2/effect").Plugin[]) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
|
|
@ -39,110 +35,73 @@ const layer = Layer.effect(
|
|||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const locks = KeyedMutex.makeUnsafe<ID>()
|
||||
const scope = yield* Scope.make()
|
||||
const active = new Map<ID, Scope.Closeable>()
|
||||
const loading = new Set<ID>()
|
||||
const waiters = new Map<ID, Set<Deferred.Deferred<void>>>()
|
||||
const failures = new Map<ID, Exit.Exit<void, never>>()
|
||||
let host: Parameters<PluginDefinition["effect"]>[0]
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let generation: readonly ID[] | undefined = []
|
||||
let host: Parameters<import("@opencode-ai/plugin/v2/effect").Plugin["effect"]>[0]
|
||||
|
||||
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginDefinition["effect"]) {
|
||||
if (loading.has(id)) return yield* Effect.die(new Error(`Plugin load cycle detected for ${id}`))
|
||||
const activate = Effect.fn("Plugin.activate")(function* (
|
||||
plugins: readonly import("@opencode-ai/plugin/v2/effect").Plugin[],
|
||||
) {
|
||||
const definitions = plugins.map((plugin) => ({ ...plugin, id: ID.make(plugin.id) }))
|
||||
const ids = new Set<ID>()
|
||||
for (const definition of definitions) {
|
||||
if (ids.has(definition.id)) return yield* Effect.die(new Error(`Duplicate plugin ID: ${definition.id}`))
|
||||
ids.add(definition.id)
|
||||
}
|
||||
|
||||
yield* locks.withLock(id)(
|
||||
Effect.sync(() => {
|
||||
loading.add(id)
|
||||
failures.delete(id)
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
State.batch(
|
||||
Effect.gen(function* () {
|
||||
const existing = active.get(id)
|
||||
active.delete(id)
|
||||
if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore)
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (
|
||||
generation !== undefined &&
|
||||
generation.length === definitions.length &&
|
||||
generation.every((id, index) => id === definitions[index]?.id)
|
||||
) {
|
||||
return
|
||||
}
|
||||
generation = undefined
|
||||
const exit = yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
const scopes = Array.from(active.values()).toReversed()
|
||||
active.clear()
|
||||
const inherit = yield* State.inherit()
|
||||
yield* Effect.forEach(scopes, (scope) => Scope.close(scope, Exit.void).pipe(Effect.ignore), {
|
||||
discard: true,
|
||||
})
|
||||
|
||||
for (const definition of definitions) {
|
||||
const child = yield* Scope.fork(scope)
|
||||
yield* effect(host).pipe(
|
||||
Scope.provide(child),
|
||||
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }),
|
||||
const loaded = yield* Effect.suspend(() => definition.effect(host)).pipe(
|
||||
inherit,
|
||||
Effect.updateContext((_context: Context.Context<never>) => Context.make(Scope.Scope, child)),
|
||||
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": definition.id } }),
|
||||
Effect.andThen(events.publish(Event.Added, { id: definition.id })),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
Effect.exit,
|
||||
)
|
||||
yield* events.publish(Event.Added, { id })
|
||||
active.set(id, child)
|
||||
yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), {
|
||||
discard: true,
|
||||
})
|
||||
waiters.delete(id)
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.onExit((exit) => {
|
||||
if (Exit.isSuccess(exit)) return Effect.void
|
||||
failures.set(id, exit)
|
||||
return Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.done(waiter, exit), {
|
||||
discard: true,
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(id))))
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => loading.delete(id))),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Plugin.remove")(function* (id: ID) {
|
||||
if (loading.has(id)) return yield* Effect.die(new Error(`Cannot remove plugin ${id} while it is loading`))
|
||||
|
||||
yield* locks.withLock(id)(
|
||||
State.batch(
|
||||
Effect.gen(function* () {
|
||||
const current = active.get(id)
|
||||
active.delete(id)
|
||||
failures.delete(id)
|
||||
if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const wait = Effect.fn("Plugin.wait")(function* (id: ID) {
|
||||
const waiter = yield* Deferred.make<void>()
|
||||
const pending = yield* locks.withLock(id)(
|
||||
Effect.sync(() => {
|
||||
if (active.has(id)) return false
|
||||
const failure = failures.get(id)
|
||||
if (failure) return failure
|
||||
const current = waiters.get(id) ?? new Set()
|
||||
current.add(waiter)
|
||||
waiters.set(id, current)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
if (!pending) return
|
||||
if (typeof pending !== "boolean") return yield* pending
|
||||
yield* Deferred.await(waiter).pipe(
|
||||
Effect.ensuring(
|
||||
locks.withLock(id)(
|
||||
Effect.sync(() => {
|
||||
const current = waiters.get(id)
|
||||
current?.delete(waiter)
|
||||
if (current?.size === 0) waiters.delete(id)
|
||||
if (Exit.isFailure(loaded)) return loaded
|
||||
active.set(definition.id, child)
|
||||
}
|
||||
return Exit.void
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (Exit.isFailure(exit)) return yield* exit
|
||||
generation = definitions.map((definition) => definition.id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* Effect.addFinalizer((exit) =>
|
||||
Effect.gen(function* () {
|
||||
active.clear()
|
||||
generation = []
|
||||
yield* State.batch(Scope.close(scope, exit))
|
||||
}),
|
||||
)
|
||||
|
||||
const service = Service.of({
|
||||
add,
|
||||
remove,
|
||||
wait,
|
||||
activate,
|
||||
list: Effect.fn("Plugin.list")(function* () {
|
||||
return Array.from(active.keys()).map((id) => ({ id }))
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * as AgentPlugin from "./agent"
|
||||
|
||||
import path from "path"
|
||||
import { define } from "./internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Global } from "../global"
|
||||
|
|
@ -100,7 +100,7 @@ Rules:
|
|||
- If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary`
|
||||
|
||||
export const Plugin = define({
|
||||
id: "agent",
|
||||
id: "opencode.agent",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const location = yield* Location.Service
|
||||
const worktree = location.directory
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
export * as CommandPlugin from "./command"
|
||||
|
||||
import { define } from "./internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
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",
|
||||
id: "opencode.command",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const location = yield* Location.Service
|
||||
yield* ctx.command.transform((draft) => {
|
||||
|
|
|
|||
|
|
@ -273,8 +273,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
},
|
||||
plugin: {
|
||||
list: () => response(plugin.list()),
|
||||
add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect),
|
||||
remove: (id) => plugin.remove(PluginV2.ID.make(id)),
|
||||
},
|
||||
reference: {
|
||||
list: () => response(reference.list()),
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
export * as PluginInternal from "./internal"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { httpClient } from "../effect/app-node-platform"
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Context, Effect, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { CommandV2 } from "../command"
|
||||
import { Config } from "../config"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command"
|
||||
import { ConfigExternalPlugin } from "../config/plugin/external"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
|
|
@ -25,8 +23,6 @@ import { Location } from "../location"
|
|||
import { LocationMutation } from "../location-mutation"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { Npm } from "../npm"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { PluginRuntime } from "../plugin/runtime"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { QuestionV2 } from "../question"
|
||||
import { Reference } from "../reference"
|
||||
|
|
@ -35,177 +31,137 @@ import { SessionInstructions } from "../session/instructions"
|
|||
import { SessionTodo } from "../session/todo"
|
||||
import { Shell } from "../shell"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { State } from "../state"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { Tools } from "../tool/tools"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { SdkPlugins } from "./sdk"
|
||||
import { SkillPlugin } from "./skill"
|
||||
import { VariantPlugin } from "./variant"
|
||||
import { ApplyPatchTool } from "../tool/apply-patch"
|
||||
import { EditTool } from "../tool/edit"
|
||||
import { GlobTool } from "../tool/glob"
|
||||
import { GrepTool } from "../tool/grep"
|
||||
import { QuestionTool } from "../tool/question"
|
||||
import { ReadTool } from "../tool/read"
|
||||
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
||||
import { ReadTool } from "../tool/read"
|
||||
import { ShellTool } from "../tool/shell"
|
||||
import { SkillTool } from "../tool/skill"
|
||||
import { SubagentTool } from "../tool/subagent"
|
||||
import { TodoWriteTool } from "../tool/todowrite"
|
||||
import { Tools } from "../tool/tools"
|
||||
import { WebFetchTool } from "../tool/webfetch"
|
||||
import { WebSearchTool } from "../tool/websearch"
|
||||
import { WriteTool } from "../tool/write"
|
||||
import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
import { SkillPlugin } from "./skill"
|
||||
import { VariantPlugin } from "./variant"
|
||||
|
||||
export type Requirements =
|
||||
| AgentV2.Service
|
||||
| Catalog.Service
|
||||
| CommandV2.Service
|
||||
| Config.Service
|
||||
| EventV2.Service
|
||||
| FileMutation.Service
|
||||
| FileSystem.Service
|
||||
| FSUtil.Service
|
||||
| Global.Service
|
||||
| HttpClient.HttpClient
|
||||
| Image.Service
|
||||
| Integration.Service
|
||||
| Location.Service
|
||||
| LocationMutation.Service
|
||||
| ModelsDev.Service
|
||||
| Npm.Service
|
||||
| PermissionV2.Service
|
||||
| PluginRuntime.Service
|
||||
| QuestionV2.Service
|
||||
| ReadToolFileSystem.Service
|
||||
| Reference.Service
|
||||
| Ripgrep.Service
|
||||
| SessionInstructions.Service
|
||||
| SessionTodo.Service
|
||||
| Shell.Service
|
||||
| SkillV2.Service
|
||||
| Tools.Service
|
||||
| WebSearchTool.ConfigService
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const sdkPlugins = yield* SdkPlugins.Service
|
||||
const services = Context.mergeAll(
|
||||
Context.make(Catalog.Service, yield* Catalog.Service),
|
||||
Context.make(CommandV2.Service, yield* CommandV2.Service),
|
||||
Context.make(Integration.Service, yield* Integration.Service),
|
||||
Context.make(AgentV2.Service, yield* AgentV2.Service),
|
||||
Context.make(Config.Service, yield* Config.Service),
|
||||
Context.make(Location.Service, yield* Location.Service),
|
||||
Context.make(ModelsDev.Service, yield* ModelsDev.Service),
|
||||
Context.make(Npm.Service, yield* Npm.Service),
|
||||
Context.make(EventV2.Service, yield* EventV2.Service),
|
||||
Context.make(FSUtil.Service, yield* FSUtil.Service),
|
||||
Context.make(FileSystem.Service, yield* FileSystem.Service),
|
||||
Context.make(Global.Service, yield* Global.Service),
|
||||
Context.make(HttpClient.HttpClient, yield* HttpClient.HttpClient),
|
||||
Context.make(LocationMutation.Service, yield* LocationMutation.Service),
|
||||
Context.make(FileMutation.Service, yield* FileMutation.Service),
|
||||
Context.make(Image.Service, yield* Image.Service),
|
||||
Context.make(PermissionV2.Service, yield* PermissionV2.Service),
|
||||
Context.make(QuestionV2.Service, yield* QuestionV2.Service),
|
||||
Context.make(ReadToolFileSystem.Service, yield* ReadToolFileSystem.Service),
|
||||
Context.make(SessionInstructions.Service, yield* SessionInstructions.Service),
|
||||
Context.make(SessionTodo.Service, yield* SessionTodo.Service),
|
||||
Context.make(SkillV2.Service, yield* SkillV2.Service),
|
||||
Context.make(Reference.Service, yield* Reference.Service),
|
||||
Context.make(Ripgrep.Service, yield* Ripgrep.Service),
|
||||
Context.make(Shell.Service, yield* Shell.Service),
|
||||
Context.make(Tools.Service, yield* Tools.Service),
|
||||
Context.make(PluginRuntime.Service, yield* PluginRuntime.Service),
|
||||
Context.make(WebSearchTool.ConfigService, yield* WebSearchTool.ConfigService),
|
||||
)
|
||||
const add = (input: Plugin<Requirements | Scope.Scope>) =>
|
||||
plugin.add(PluginV2.ID.make(input.id), (context: PluginContext) =>
|
||||
input.effect(context).pipe(Effect.provide(services)),
|
||||
)
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* add(ConfigReferencePlugin.Plugin)
|
||||
yield* add(AgentPlugin.Plugin)
|
||||
yield* add(CommandPlugin.Plugin)
|
||||
yield* add(SkillPlugin.Plugin)
|
||||
yield* add(ModelsDevPlugin)
|
||||
yield* add(ConfigExternalPlugin.Plugin)
|
||||
yield* add(ApplyPatchTool.Plugin)
|
||||
yield* add(EditTool.Plugin)
|
||||
yield* add(GlobTool.Plugin)
|
||||
yield* add(GrepTool.Plugin)
|
||||
yield* add(QuestionTool.Plugin)
|
||||
yield* add(ReadTool.Plugin)
|
||||
yield* add(ShellTool.Plugin)
|
||||
yield* add(SkillTool.Plugin)
|
||||
yield* add(SubagentTool.Plugin)
|
||||
yield* add(TodoWriteTool.Plugin)
|
||||
yield* add(WebFetchTool.Plugin)
|
||||
yield* add(WebSearchTool.Plugin)
|
||||
yield* add(WriteTool.Plugin)
|
||||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
yield* add(ConfigCommandPlugin.Plugin)
|
||||
yield* add(ConfigSkillPlugin.Plugin)
|
||||
for (const item of ProviderPlugins) yield* add(item)
|
||||
yield* add(ConfigProviderPlugin.Plugin)
|
||||
yield* add(VariantPlugin.Plugin)
|
||||
// Embedder-contributed plugins are added last so they layer over config.
|
||||
for (const plugin of sdkPlugins.all()) yield* add(plugin)
|
||||
}),
|
||||
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "plugin-internal",
|
||||
layer,
|
||||
deps: [
|
||||
Catalog.node,
|
||||
CommandV2.node,
|
||||
PluginV2.node,
|
||||
Integration.node,
|
||||
AgentV2.node,
|
||||
Config.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
Image.node,
|
||||
ModelsDev.node,
|
||||
Npm.node,
|
||||
EventV2.node,
|
||||
FSUtil.node,
|
||||
FileSystem.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
PermissionV2.node,
|
||||
QuestionV2.node,
|
||||
ReadToolFileSystem.node,
|
||||
SessionInstructions.node,
|
||||
SessionTodo.node,
|
||||
SkillV2.node,
|
||||
Reference.node,
|
||||
Ripgrep.node,
|
||||
Shell.node,
|
||||
ToolRegistry.toolsNode,
|
||||
PluginRuntime.node,
|
||||
SdkPlugins.node,
|
||||
WebSearchTool.configNode,
|
||||
],
|
||||
const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const command = yield* CommandV2.Service
|
||||
const config = yield* Config.Service
|
||||
const events = yield* EventV2.Service
|
||||
const mutation = yield* FileMutation.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const image = yield* Image.Service
|
||||
const integration = yield* Integration.Service
|
||||
const location = yield* Location.Service
|
||||
const locationMutation = yield* LocationMutation.Service
|
||||
const models = yield* ModelsDev.Service
|
||||
const npm = yield* Npm.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const question = yield* QuestionV2.Service
|
||||
const read = yield* ReadToolFileSystem.Service
|
||||
const reference = yield* Reference.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const instructions = yield* SessionInstructions.Service
|
||||
const todo = yield* SessionTodo.Service
|
||||
const shell = yield* Shell.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const websearch = yield* WebSearchTool.ConfigService
|
||||
return Context.mergeAll(
|
||||
Context.make(AgentV2.Service, agent),
|
||||
Context.make(Catalog.Service, catalog),
|
||||
Context.make(CommandV2.Service, command),
|
||||
Context.make(Config.Service, config),
|
||||
Context.make(EventV2.Service, events),
|
||||
Context.make(FileMutation.Service, mutation),
|
||||
Context.make(FileSystem.Service, filesystem),
|
||||
Context.make(FSUtil.Service, fs),
|
||||
Context.make(Global.Service, global),
|
||||
Context.make(HttpClient.HttpClient, http),
|
||||
Context.make(Image.Service, image),
|
||||
Context.make(Integration.Service, integration),
|
||||
Context.make(Location.Service, location),
|
||||
Context.make(LocationMutation.Service, locationMutation),
|
||||
Context.make(ModelsDev.Service, models),
|
||||
Context.make(Npm.Service, npm),
|
||||
Context.make(PermissionV2.Service, permission),
|
||||
Context.make(PluginRuntime.Service, runtime),
|
||||
Context.make(QuestionV2.Service, question),
|
||||
Context.make(ReadToolFileSystem.Service, read),
|
||||
Context.make(Reference.Service, reference),
|
||||
Context.make(Ripgrep.Service, ripgrep),
|
||||
Context.make(SessionInstructions.Service, instructions),
|
||||
Context.make(SessionTodo.Service, todo),
|
||||
Context.make(Shell.Service, shell),
|
||||
Context.make(SkillV2.Service, skill),
|
||||
Context.make(Tools.Service, tools),
|
||||
Context.make(WebSearchTool.ConfigService, websearch),
|
||||
)
|
||||
})
|
||||
|
||||
type ContextServices<A> = A extends Context.Context<infer R> ? R : never
|
||||
|
||||
export type Requirements = ContextServices<Effect.Success<ReturnType<typeof services>>>
|
||||
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
AgentPlugin.Plugin,
|
||||
CommandPlugin.Plugin,
|
||||
SkillPlugin.Plugin,
|
||||
ModelsDevPlugin,
|
||||
...ProviderPlugins,
|
||||
ApplyPatchTool.Plugin,
|
||||
EditTool.Plugin,
|
||||
GlobTool.Plugin,
|
||||
GrepTool.Plugin,
|
||||
QuestionTool.Plugin,
|
||||
ReadTool.Plugin,
|
||||
ShellTool.Plugin,
|
||||
SkillTool.Plugin,
|
||||
SubagentTool.Plugin,
|
||||
TodoWriteTool.Plugin,
|
||||
WebFetchTool.Plugin,
|
||||
WebSearchTool.Plugin,
|
||||
WriteTool.Plugin,
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
const post = [
|
||||
ConfigReferencePlugin.Plugin,
|
||||
ConfigAgentPlugin.Plugin,
|
||||
ConfigCommandPlugin.Plugin,
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigProviderPlugin.Plugin,
|
||||
VariantPlugin.Plugin,
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
export const list = Effect.fn("PluginInternal.list")(function* () {
|
||||
const context = yield* services()
|
||||
const resolve = (plugins: readonly InternalPlugin[]) =>
|
||||
plugins.map(
|
||||
(plugin): Plugin => ({
|
||||
id: plugin.id,
|
||||
effect: (host) => plugin.effect(host).pipe(Effect.provide(context)),
|
||||
}),
|
||||
)
|
||||
return {
|
||||
pre: resolve(pre),
|
||||
post: resolve(post),
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { define } from "./internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { EventV2 } from "../event"
|
||||
|
|
@ -197,7 +197,7 @@ function applyModel(
|
|||
}
|
||||
|
||||
export const ModelsDevPlugin = define({
|
||||
id: "models-dev",
|
||||
id: "opencode.models-dev",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const events = yield* EventV2.Service
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ type Registration = { readonly dispose: () => Promise<void> }
|
|||
|
||||
/**
|
||||
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only
|
||||
* loader (`PluginV2` / `PluginInternal`) can run it unchanged.
|
||||
* loader (`PluginV2` / `PluginSupervisor`) 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
|
||||
|
|
@ -93,11 +93,6 @@ export function fromPromise(plugin: Plugin) {
|
|||
},
|
||||
plugin: {
|
||||
list: (input) => run(host.plugin.list(input)),
|
||||
add: (input) => {
|
||||
const child = fromPromise(input)
|
||||
return run(host.plugin.add(child))
|
||||
},
|
||||
remove: (id) => run(host.plugin.remove(id)),
|
||||
},
|
||||
reference: {
|
||||
list: (input) => run(host.reference.list(input)),
|
||||
|
|
|
|||
|
|
@ -31,9 +31,8 @@ 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: PluginInternal.Plugin<PluginInternal.Requirements | Scope.Scope>[] = [
|
||||
export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
AlibabaPlugin,
|
||||
AmazonBedrockPlugin,
|
||||
AnthropicPlugin,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const AlibabaPlugin = define({
|
||||
id: "alibaba",
|
||||
id: "opencode.provider.alibaba",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Effect } from "effect"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
type MantleSDK = {
|
||||
|
|
@ -60,7 +60,7 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
|
|||
}
|
||||
|
||||
export const AmazonBedrockPlugin = define({
|
||||
id: "amazon-bedrock",
|
||||
id: "opencode.provider.amazon-bedrock",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const AnthropicPlugin = define({
|
||||
id: "anthropic",
|
||||
id: "opencode.provider.anthropic",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
|
||||
|
|
@ -11,7 +11,7 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
|
|||
}
|
||||
|
||||
export const AzurePlugin = define({
|
||||
id: "azure",
|
||||
id: "opencode.provider.azure",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
|
|
@ -54,7 +54,7 @@ export const AzurePlugin = define({
|
|||
})
|
||||
|
||||
export const AzureCognitiveServicesPlugin = define({
|
||||
id: "azure-cognitive-services",
|
||||
id: "opencode.provider.azure-cognitive-services",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const CerebrasPlugin = define({
|
||||
id: "cerebras",
|
||||
id: "opencode.provider.cerebras",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const CloudflareAIGatewayPlugin = define({
|
||||
id: "cloudflare-ai-gateway",
|
||||
id: "opencode.provider.cloudflare-ai-gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
|
||||
|
||||
export const CloudflareWorkersAIPlugin = define({
|
||||
id: "cloudflare-workers-ai",
|
||||
id: "opencode.provider.cloudflare-workers-ai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
const item = evt.provider.get(providerID)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const CoherePlugin = define({
|
||||
id: "cohere",
|
||||
id: "opencode.provider.cohere",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const DeepInfraPlugin = define({
|
||||
id: "deepinfra",
|
||||
id: "opencode.provider.deepinfra",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Npm } from "../../npm"
|
||||
|
||||
export const DynamicProviderPlugin = define({
|
||||
id: "dynamic-provider",
|
||||
id: "opencode.provider.dynamic",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const npm = yield* Npm.Service
|
||||
yield* ctx.aisdk.sdk(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const GatewayPlugin = define({
|
||||
id: "gateway",
|
||||
id: "opencode.provider.gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
function shouldUseResponses(modelID: string) {
|
||||
|
|
@ -12,7 +12,7 @@ function shouldUseResponses(modelID: string) {
|
|||
}
|
||||
|
||||
export const GithubCopilotPlugin = define({
|
||||
id: "github-copilot",
|
||||
id: "opencode.provider.github-copilot",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import os from "os"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const GitLabPlugin = define({
|
||||
id: "gitlab",
|
||||
id: "opencode.provider.gitlab",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
function resolveProject(options: Record<string, any>) {
|
||||
|
|
@ -55,7 +55,7 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
|
|||
}
|
||||
|
||||
export const GoogleVertexPlugin = define({
|
||||
id: "google-vertex",
|
||||
id: "opencode.provider.google-vertex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
|
|
@ -111,7 +111,7 @@ export const GoogleVertexPlugin = define({
|
|||
})
|
||||
|
||||
export const GoogleVertexAnthropicPlugin = define({
|
||||
id: "google-vertex-anthropic",
|
||||
id: "opencode.provider.google-vertex-anthropic",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const GooglePlugin = define({
|
||||
id: "google",
|
||||
id: "opencode.provider.google",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const GroqPlugin = define({
|
||||
id: "groq",
|
||||
id: "opencode.provider.groq",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const KiloPlugin = define({
|
||||
id: "kilo",
|
||||
id: "opencode.provider.kilo",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Integration } from "../../integration"
|
||||
|
||||
export const LLMGatewayPlugin = define({
|
||||
id: "llmgateway",
|
||||
id: "opencode.provider.llmgateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const integrations = yield* Integration.Service
|
||||
const configured = new Set((yield* integrations.list()).map((integration) => integration.id))
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const MistralPlugin = define({
|
||||
id: "mistral",
|
||||
id: "opencode.provider.mistral",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const NvidiaPlugin = define({
|
||||
id: "nvidia",
|
||||
id: "opencode.provider.nvidia",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const OpenAICompatiblePlugin = define({
|
||||
id: "openai-compatible",
|
||||
id: "opencode.provider.openai-compatible",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { createServer } from "node:http"
|
|||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import type { Scope } from "effect"
|
||||
import { Credential } from "../../credential"
|
||||
import { EventV2 } from "../../event"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
|
|
@ -159,7 +158,7 @@ const headless = {
|
|||
} satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
export const OpenAIPlugin = define({
|
||||
id: "openai",
|
||||
id: "opencode.provider.openai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const events = yield* EventV2.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
|
|
@ -225,7 +224,7 @@ export const OpenAIPlugin = define({
|
|||
}),
|
||||
)
|
||||
}),
|
||||
} satisfies PluginInternal.Plugin<PluginInternal.Requirements | Scope.Scope>)
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
|
||||
function headers(contentType: string) {
|
||||
return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function oauth(http: HttpClient.HttpClient) {
|
|||
}
|
||||
|
||||
export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Scope.Scope>({
|
||||
id: "opencode",
|
||||
id: "opencode.provider.opencode",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const events = yield* EventV2.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const OpenRouterPlugin = define({
|
||||
id: "openrouter",
|
||||
id: "opencode.provider.openrouter",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const PerplexityPlugin = define({
|
||||
id: "perplexity",
|
||||
id: "opencode.provider.perplexity",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Npm } from "../../npm"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const SapAICorePlugin = define({
|
||||
id: "sap-ai-core",
|
||||
id: "opencode.provider.sap-ai-core",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const npm = yield* Npm.Service
|
||||
yield* ctx.aisdk.sdk(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
|
||||
|
|
@ -65,7 +65,7 @@ export function cortexFetch(upstream: FetchLike = fetch) {
|
|||
}
|
||||
|
||||
export const SnowflakeCortexPlugin = define({
|
||||
id: "snowflake-cortex",
|
||||
id: "opencode.provider.snowflake-cortex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const TogetherAIPlugin = define({
|
||||
id: "togetherai",
|
||||
id: "opencode.provider.togetherai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const VenicePlugin = define({
|
||||
id: "venice",
|
||||
id: "opencode.provider.venice",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const VercelPlugin = define({
|
||||
id: "vercel",
|
||||
id: "opencode.provider.vercel",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const XAIPlugin = define({
|
||||
id: "xai",
|
||||
id: "opencode.provider.xai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Effect } from "effect"
|
||||
import { define } from "../internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const ZenmuxPlugin = define({
|
||||
id: "zenmux",
|
||||
id: "opencode.provider.zenmux",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ const defaultStore = makeStore()
|
|||
|
||||
/**
|
||||
* Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes,
|
||||
* so `PluginInternal` can add them on every Location boot through the ordinary
|
||||
* `ctx.plugin.add` seam — the same path `ConfigExternalPlugin` uses for plugins
|
||||
* discovered from config. A plugin registered after a Location has booted only
|
||||
* so `PluginSupervisor` can add them on every Location boot through the ordinary
|
||||
* generation path that `PluginSupervisor` uses for plugins discovered from
|
||||
* config. A plugin registered after a Location has booted only
|
||||
* applies to Locations booted afterward, matching config-plugin timing;
|
||||
* embedders register at startup before creating Sessions.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
export * as SkillPlugin from "./skill"
|
||||
|
||||
import { define } from "./internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { SkillV2 } from "../skill"
|
||||
|
|
@ -25,7 +25,7 @@ const REPORT_DESCRIPTION =
|
|||
"Use when the user wants to report an opencode issue or bug. Collect standard diagnostics, add user-specific reproduction context, and publish the issue with GitHub CLI."
|
||||
|
||||
export const Plugin = define({
|
||||
id: "skill",
|
||||
id: "opencode.skill",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const reportContent = yield* reportContentWithDiagnostics()
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
|
|
|
|||
270
packages/core/src/plugin/supervisor.ts
Normal file
270
packages/core/src/plugin/supervisor.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
export * as PluginSupervisor from "./supervisor"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Context, Effect, Fiber, Layer, Schema, Semaphore, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { Config } from "../config"
|
||||
import { ConfigPlugin } from "../config/plugin"
|
||||
import { EventV2 } from "../event"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Location } from "../location"
|
||||
import { Npm } from "../npm"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { PluginPromise } from "../plugin/promise"
|
||||
import { PluginInternal } from "./internal"
|
||||
import { SdkPlugins } from "./sdk"
|
||||
|
||||
const PluginModule = Schema.Struct({
|
||||
default: Schema.Union([
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
effect: Schema.declare<Plugin["effect"]>((input): input is Plugin["effect"] => typeof input === "function"),
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
|
||||
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
|
||||
),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
const PluginPackage = Schema.Struct({
|
||||
exports: Schema.optional(Schema.Unknown),
|
||||
main: Schema.optional(Schema.String),
|
||||
module: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
type Operation =
|
||||
| {
|
||||
readonly type: "add"
|
||||
readonly target: string
|
||||
readonly options: Record<string, unknown>
|
||||
}
|
||||
| {
|
||||
readonly type: "remove"
|
||||
readonly target: string
|
||||
}
|
||||
|
||||
type Candidate =
|
||||
| {
|
||||
readonly type: "definition"
|
||||
readonly definition: Plugin
|
||||
}
|
||||
| {
|
||||
readonly type: "package"
|
||||
readonly specifier: string
|
||||
readonly options: Record<string, unknown>
|
||||
}
|
||||
|
||||
type ConfiguredPackage = {
|
||||
readonly operation: Extract<Operation, { type: "add" }>
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
function parse(input: ConfigPlugin.Plugin): Operation {
|
||||
if (typeof input !== "string") {
|
||||
return { type: "add", target: input.package, options: input.options ?? {} }
|
||||
}
|
||||
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
|
||||
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
|
||||
return { type: "remove", target: input.slice(1) }
|
||||
}
|
||||
|
||||
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Config.Entry[]) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const discovered = yield* Effect.forEach(
|
||||
entries.filter((entry): entry is Config.Directory => entry.type === "directory"),
|
||||
(entry) => discoverDirectory(fs, entry.path),
|
||||
).pipe(Effect.map((items) => items.flat()))
|
||||
const configured = entries
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.flatMap((entry) =>
|
||||
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
||||
const target = operation.target.startsWith("file://")
|
||||
? fileURLToPath(operation.target)
|
||||
: operation.target.startsWith("./") || operation.target.startsWith("../")
|
||||
? path.resolve(directory, operation.target)
|
||||
: operation.target
|
||||
return operation.type === "add" ? { ...operation, target } : { type: "remove" as const, target }
|
||||
}),
|
||||
)
|
||||
// Explicit config is applied last so it can remove auto-discovered packages.
|
||||
return [...discovered, ...configured]
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
pre: readonly Plugin[],
|
||||
post: readonly Plugin[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
const plan = apply(pre, post, operations)
|
||||
return yield* load(plan)
|
||||
})
|
||||
|
||||
function apply(pre: readonly Plugin[], post: readonly Plugin[], operations: readonly Operation[]) {
|
||||
const matches = (selector: string, target: string) =>
|
||||
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
|
||||
const plugins = [...pre, ...post]
|
||||
const enabled = new Set(plugins.map((plugin) => plugin.id))
|
||||
const packages = new Map<string, ConfiguredPackage>()
|
||||
|
||||
for (const operation of operations) {
|
||||
if (operation.type === "remove") {
|
||||
plugins.filter((plugin) => matches(operation.target, plugin.id)).forEach((plugin) => enabled.delete(plugin.id))
|
||||
packages.forEach((item, target) => {
|
||||
if (matches(operation.target, target)) item.enabled = false
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const matched = plugins.filter((plugin) => matches(operation.target, plugin.id))
|
||||
const selectsDefinitions =
|
||||
matched.length > 0 ||
|
||||
operation.target === "*" ||
|
||||
operation.target.endsWith(".*") ||
|
||||
operation.target.startsWith("opencode.")
|
||||
if (selectsDefinitions) {
|
||||
matched.forEach((plugin) => enabled.add(plugin.id))
|
||||
packages.forEach((item, target) => {
|
||||
if (matches(operation.target, target)) item.enabled = true
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
packages.set(operation.target, { operation, enabled: true })
|
||||
}
|
||||
|
||||
const definitions: Candidate[] = pre.flatMap((definition) =>
|
||||
enabled.has(definition.id) ? [{ type: "definition", definition }] : [],
|
||||
)
|
||||
const configured: Candidate[] = Array.from(packages.values()).flatMap((item) =>
|
||||
item.enabled ? [{ type: "package", specifier: item.operation.target, options: item.operation.options }] : [],
|
||||
)
|
||||
const posts: Candidate[] = post.flatMap((definition) =>
|
||||
enabled.has(definition.id) ? [{ type: "definition", definition }] : [],
|
||||
)
|
||||
return [...definitions, ...configured, ...posts]
|
||||
}
|
||||
|
||||
const load = Effect.fn("PluginSupervisor.load")(function* (plan: readonly Candidate[]) {
|
||||
return yield* Effect.forEach(plan, (candidate) => {
|
||||
if (candidate.type === "definition") return Effect.succeed(candidate.definition)
|
||||
return Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
const entrypoint = path.isAbsolute(candidate.specifier)
|
||||
? pathToFileURL(candidate.specifier).href
|
||||
: (yield* npm.add(candidate.specifier)).entrypoint
|
||||
if (!entrypoint) return
|
||||
yield* Effect.log({ msg: "loading plugin", id: candidate.specifier, entrypoint })
|
||||
const mod = yield* Effect.promise(() => import(entrypoint))
|
||||
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
return {
|
||||
id: plugin.id,
|
||||
effect: (host) => plugin.effect({ ...host, options: candidate.options }),
|
||||
} satisfies Plugin
|
||||
}).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||
}).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
|
||||
})
|
||||
|
||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* fs
|
||||
.glob("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: directory,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
const directories = yield* fs
|
||||
.glob("{plugin,plugins}/*", {
|
||||
cwd: directory,
|
||||
absolute: true,
|
||||
include: "all",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(
|
||||
Effect.flatMap((items) => Effect.filter(items, (item) => fs.isDir(item), { concurrency: "unbounded" })),
|
||||
Effect.orElseSucceed(() => []),
|
||||
)
|
||||
const packages = yield* Effect.forEach(directories.sort(), (directory) => resolvePackageEntrypoint(fs, directory), {
|
||||
concurrency: "unbounded",
|
||||
}).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined)))
|
||||
return [...files.sort(), ...packages].map((target): Operation => ({ type: "add", target, options: {} }))
|
||||
})
|
||||
}
|
||||
|
||||
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
|
||||
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
|
||||
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined
|
||||
const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"]
|
||||
|
||||
return yield* Effect.forEach(entries, (entry) => {
|
||||
if (!entry) return Effect.succeed(undefined)
|
||||
const file = path.resolve(directory, entry)
|
||||
return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined)))
|
||||
}).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined)))
|
||||
})
|
||||
|
||||
export interface Interface {
|
||||
readonly ready: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* PluginV2.Service
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const config = yield* Config.Service
|
||||
const events = yield* EventV2.Service
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let applied: string | undefined
|
||||
const reload = Effect.fn("PluginSupervisor.reload")(() =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
||||
const pre = [...internal.pre, ...sdk.all()]
|
||||
// Read the current layered config before resolving plugin directives and packages.
|
||||
const entries = yield* config.entries()
|
||||
// Skip duplicate watcher notifications and config edits unrelated to plugins.
|
||||
const operations = yield* scan(entries)
|
||||
const version = JSON.stringify(operations)
|
||||
if (version === applied) return
|
||||
// Apply config operations and load enabled package plugins into one ordered generation.
|
||||
const plugins = yield* resolve(pre, internal.post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(plugins)
|
||||
applied = version
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* events.subscribe(Event.Updated).pipe(
|
||||
Stream.runForEach(() =>
|
||||
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const fiber = yield* reload().pipe(
|
||||
Effect.withSpan("PluginSupervisor.boot"),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({ ready: Fiber.join(fiber) })
|
||||
}),
|
||||
)
|
||||
|
||||
export { layer }
|
||||
|
|
@ -2,10 +2,10 @@ export * as VariantPlugin from "./variant"
|
|||
|
||||
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "./internal"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "variant",
|
||||
id: "opencode.variant",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
for (const record of catalog.provider.list()) {
|
||||
|
|
|
|||
|
|
@ -26,21 +26,32 @@ export interface Transformable<DraftApi> {
|
|||
readonly reload: Reload
|
||||
}
|
||||
|
||||
const CurrentBatch = Context.Reference<Set<Reload> | undefined>("@opencode/State/CurrentBatch", {
|
||||
type Batch = {
|
||||
active: boolean
|
||||
readonly reloads: Set<Reload>
|
||||
}
|
||||
|
||||
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
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 reloads = new Set<Reload>()
|
||||
const result = yield* effect.pipe(Effect.provideService(CurrentBatch, reloads))
|
||||
yield* Effect.forEach(reloads, (reload) => reload(), { discard: true })
|
||||
return result
|
||||
if (current?.active) return yield* effect
|
||||
const batch: Batch = { active: true, reloads: new Set() }
|
||||
const exit = yield* effect.pipe(Effect.provideService(CurrentBatch, batch), Effect.exit)
|
||||
batch.active = false
|
||||
yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true })
|
||||
return yield* exit
|
||||
})
|
||||
}
|
||||
|
||||
export const inherit = Effect.fnUntraced(function* () {
|
||||
const batch = yield* CurrentBatch
|
||||
return <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.provideService(effect, CurrentBatch, batch)
|
||||
})
|
||||
|
||||
export interface Options<State, DraftApi> {
|
||||
/** Creates the base value for initial state and every scoped-transform reload. */
|
||||
readonly initial: () => State
|
||||
|
|
@ -100,8 +111,8 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
|||
transforms = transforms.filter((item) => item !== transform)
|
||||
return Effect.gen(function* () {
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch) {
|
||||
batch.add(reload)
|
||||
if (batch?.active) {
|
||||
batch.reloads.add(reload)
|
||||
return
|
||||
}
|
||||
yield* materialize()
|
||||
|
|
@ -116,7 +127,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
|||
)
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch) batch.add(reload)
|
||||
if (batch?.active) batch.reloads.add(reload)
|
||||
else yield* reload()
|
||||
return { dispose }
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ type Prepared =
|
|||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "core-apply-patch-tool",
|
||||
id: "opencode.tool.apply-patch",
|
||||
effect: Effect.fn("ApplyPatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export const toModelOutput = (output: Output, oldString: string, newString: stri
|
|||
// TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
|
||||
|
||||
export const Plugin = {
|
||||
id: "core-edit-tool",
|
||||
id: "opencode.tool.edit",
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export const toModelOutput = (output: ModelOutput) => {
|
|||
|
||||
/** Glob leaf that defaults its filesystem root to the active Location. */
|
||||
export const Plugin = {
|
||||
id: "core-glob-tool",
|
||||
id: "opencode.tool.glob",
|
||||
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export const toModelOutput = (output: ModelOutput) => {
|
|||
|
||||
/** Grep leaf that defaults its filesystem root to the active Location. */
|
||||
export const Plugin = {
|
||||
id: "core-grep-tool",
|
||||
id: "opencode.tool.grep",
|
||||
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export const toModelOutput = (
|
|||
}
|
||||
|
||||
export const Plugin = {
|
||||
id: "core-question-tool",
|
||||
id: "opencode.tool.question",
|
||||
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const question = yield* QuestionV2.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const Input = LocationInput
|
|||
const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
|
||||
|
||||
export const Plugin = {
|
||||
id: "core-read-tool",
|
||||
id: "opencode.tool.read",
|
||||
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const reader = yield* ReadToolFileSystem.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ const externalCommandDirectories = Effect.fn("ShellTool.externalCommandDirectori
|
|||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "core-shell-tool",
|
||||
id: "opencode.tool.shell",
|
||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ const unableToLoad = (name: string, error?: unknown) =>
|
|||
new ToolFailure({ message: `Unable to load skill ${name}`, error })
|
||||
|
||||
export const Plugin = {
|
||||
id: "core-skill-tool",
|
||||
id: "opencode.tool.skill",
|
||||
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const skills = yield* SkillV2.Service
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export const description = [
|
|||
].join("\n")
|
||||
|
||||
export const Plugin = {
|
||||
id: "core-subagent-tool",
|
||||
id: "opencode.tool.subagent",
|
||||
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export type Output = typeof Output.Type
|
|||
export const toModelOutput = (output: Output) => JSON.stringify(output.todos, null, 2)
|
||||
|
||||
export const Plugin = {
|
||||
id: "core-todowrite-tool",
|
||||
id: "opencode.tool.todowrite",
|
||||
effect: Effect.fn("TodoWriteTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const todos = yield* SessionTodo.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ const convert = (content: string, contentType: string, format: Format) => {
|
|||
}
|
||||
|
||||
export const Plugin = {
|
||||
id: "core-webfetch-tool",
|
||||
id: "opencode.tool.webfetch",
|
||||
effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ const Output = Schema.Struct({
|
|||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "core-websearch-tool",
|
||||
id: "opencode.tool.websearch",
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const config = yield* ConfigService
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export const toModelOutput = (output: Output) =>
|
|||
// TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
|
||||
|
||||
export const Plugin = {
|
||||
id: "core-write-tool",
|
||||
id: "opencode.tool.write",
|
||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
|
|
|
|||
|
|
@ -1,252 +1,225 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Effect } from "effect"
|
||||
import { Database } from "../../src/database/database"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node])),
|
||||
)
|
||||
|
||||
describe("ConfigExternalPlugin", () => {
|
||||
it.live("resolves and loads a configured Promise plugin with options", () =>
|
||||
describe("PluginSupervisor config", () => {
|
||||
it.live("applies selectors in order", () =>
|
||||
withLocation(
|
||||
{ plugins: ["-opencode.provider.*", "opencode.provider.openai"] },
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
yield* ready()
|
||||
expect(
|
||||
(yield* plugins.list()).map((plugin) => plugin.id).filter((id) => id.startsWith("opencode.provider.")),
|
||||
).toEqual([PluginV2.ID.make("opencode.provider.openai")])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads configured Promise plugins with options", () =>
|
||||
withLocation(
|
||||
{
|
||||
plugins: [
|
||||
"-*",
|
||||
{
|
||||
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
|
||||
options: { description: "Loaded from config" },
|
||||
},
|
||||
],
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const agents = yield* AgentV2.Service
|
||||
expect(yield* agents.get(AgentV2.ID.make("configured"))).toMatchObject({
|
||||
description: "Loaded from config",
|
||||
mode: "subagent",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads configured Effect plugins with options", () =>
|
||||
withLocation(
|
||||
{
|
||||
plugins: [
|
||||
"-*",
|
||||
{
|
||||
package: path.join(import.meta.dir, "../plugin/fixtures/config-effect-plugin.ts"),
|
||||
options: { description: "Effect plugin from config" },
|
||||
},
|
||||
],
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const agents = yield* AgentV2.Service
|
||||
expect(yield* agents.get(AgentV2.ID.make("effect-configured"))).toMatchObject({
|
||||
description: "Effect plugin from config",
|
||||
mode: "subagent",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("ignores invalid packages and continues loading", () =>
|
||||
withLocation(
|
||||
{
|
||||
plugins: [
|
||||
"-*",
|
||||
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
|
||||
{
|
||||
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
|
||||
options: { description: "Loaded after invalid plugins" },
|
||||
},
|
||||
],
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const agents = yield* AgentV2.Service
|
||||
expect(yield* agents.get(AgentV2.ID.make("configured"))).toMatchObject({
|
||||
description: "Loaded after invalid plugins",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads auto-discovered plugin files and packages", () =>
|
||||
withLocation(
|
||||
undefined,
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const agents = yield* AgentV2.Service
|
||||
expect(yield* agents.get(AgentV2.ID.make("directory"))).toMatchObject({
|
||||
description: "Loaded from plugin directory",
|
||||
})
|
||||
expect(yield* agents.get(AgentV2.ID.make("folder"))).toMatchObject({
|
||||
description: "Loaded from plugin folder",
|
||||
})
|
||||
}),
|
||||
true,
|
||||
),
|
||||
)
|
||||
|
||||
it.live("applies explicit removals after auto-discovery", () =>
|
||||
withLocation(
|
||||
{ plugins: ["-*"] },
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const agents = yield* AgentV2.Service
|
||||
expect(yield* agents.get(AgentV2.ID.make("directory"))).toBeUndefined()
|
||||
expect(yield* agents.get(AgentV2.ID.make("folder"))).toBeUndefined()
|
||||
}),
|
||||
true,
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads user plugins before internal post plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
{
|
||||
plugins: [
|
||||
path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"),
|
||||
],
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const registry = yield* PluginV2.Service
|
||||
const ids = (yield* registry.list()).map((plugin) => String(plugin.id))
|
||||
expect(ids.indexOf("opencode.agent")).toBeLessThan(ids.indexOf("sdk-order"))
|
||||
expect(ids.indexOf("sdk-order")).toBeLessThan(ids.indexOf("config-promise-plugin"))
|
||||
expect(ids.indexOf("config-promise-plugin")).toBeLessThan(ids.indexOf("variant-source"))
|
||||
expect(ids.indexOf("variant-source")).toBeLessThan(ids.indexOf("opencode.config.provider"))
|
||||
expect(ids.indexOf("opencode.config.provider")).toBeLessThan(ids.indexOf("opencode.variant"))
|
||||
|
||||
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
|
||||
Effect.provideService(PluginV2.Service, plugins),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Location.Service, location),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
path: document,
|
||||
info: decode({
|
||||
plugins: [
|
||||
{
|
||||
package: "../plugin/fixtures/config-promise-plugin.ts",
|
||||
options: { description: "Loaded from config" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
const catalog = yield* Catalog.Service
|
||||
expect(
|
||||
(yield* catalog.model.get(ProviderV2.ID.make("configured"), ModelV2.ID.make("glm-5.2")))?.variants,
|
||||
).toEqual([
|
||||
expect.objectContaining({ id: "high", headers: { custom: "true" } }),
|
||||
expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
expect(yield* waitForAgent(agents, "configured")).toMatchObject({
|
||||
description: "Loaded from config",
|
||||
mode: "subagent",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads a configured Effect plugin with options", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
it.live("allows variant generation to be disabled", () =>
|
||||
withLocation(
|
||||
{
|
||||
plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"), "-opencode.variant"],
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const registry = yield* PluginV2.Service
|
||||
expect((yield* registry.list()).map((plugin) => String(plugin.id))).not.toContain("opencode.variant")
|
||||
|
||||
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
|
||||
Effect.provideService(PluginV2.Service, plugins),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Location.Service, location),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
path: path.join(import.meta.dir, "opencode.json"),
|
||||
info: decode({
|
||||
plugins: [
|
||||
{
|
||||
package: "../plugin/fixtures/config-effect-plugin.ts",
|
||||
options: { description: "Effect plugin from config" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* waitForAgent(agents, "effect-configured")).toMatchObject({
|
||||
description: "Effect plugin from config",
|
||||
mode: "subagent",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores invalid plugins and continues loading", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
|
||||
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
|
||||
Effect.provideService(PluginV2.Service, plugins),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Location.Service, location),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
path: path.join(import.meta.dir, "opencode.json"),
|
||||
info: decode({
|
||||
plugins: [
|
||||
"../plugin/fixtures/missing-plugin.ts",
|
||||
"../plugin/fixtures/invalid-plugin.ts",
|
||||
{
|
||||
package: "../plugin/fixtures/config-promise-plugin.ts",
|
||||
options: { description: "Loaded after invalid plugins" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* waitForAgent(agents, "configured")).toMatchObject({
|
||||
description: "Loaded after invalid plugins",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("installs and resolves npm plugin packages", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
let installed: string | undefined
|
||||
const npm = Npm.Service.of({
|
||||
add: (spec) =>
|
||||
Effect.sync(() => {
|
||||
installed = spec
|
||||
return {
|
||||
directory: import.meta.dir,
|
||||
entrypoint: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
|
||||
}
|
||||
}),
|
||||
install: () => Effect.void,
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
|
||||
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
|
||||
Effect.provideService(PluginV2.Service, plugins),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Location.Service, location),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
plugins: [
|
||||
{
|
||||
package: "example-plugin@1.0.0",
|
||||
options: { description: "Installed from npm" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* waitForAgent(agents, "configured")).toMatchObject({
|
||||
description: "Installed from npm",
|
||||
})
|
||||
expect(installed).toBe("example-plugin@1.0.0")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads plugin files from config directories", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
|
||||
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
|
||||
Effect.provideService(PluginV2.Service, plugins),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Location.Service, location),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Directory({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join(import.meta.dir, "fixtures")),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* waitForAgent(agents, "directory")).toMatchObject({
|
||||
description: "Loaded from plugin directory",
|
||||
mode: "subagent",
|
||||
})
|
||||
expect(yield* waitForAgent(agents, "folder")).toMatchObject({
|
||||
description: "Loaded from plugin folder",
|
||||
mode: "subagent",
|
||||
})
|
||||
}),
|
||||
const catalog = yield* Catalog.Service
|
||||
expect(
|
||||
(yield* catalog.model.get(ProviderV2.ID.make("configured"), ModelV2.ID.make("glm-5.2")))?.variants,
|
||||
).toEqual([expect.objectContaining({ id: "high", headers: { custom: "true" } })])
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const agent = yield* agents.get(AgentV2.ID.make(id))
|
||||
if (agent) return agent
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
return yield* Effect.die(`Timed out waiting for agent ${id}`)
|
||||
const ready = Effect.fnUntraced(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.ready
|
||||
})
|
||||
|
||||
function withLocation<A, E, R>(config: unknown, effect: Effect.Effect<A, E, R>, fixtures = false) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.tap((tmp) =>
|
||||
Effect.promise(async () => {
|
||||
if (fixtures) {
|
||||
const directory = path.join(tmp.path, ".opencode")
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await Promise.all(
|
||||
["plugin", "plugins"].map((name) =>
|
||||
fs.symlink(path.join(import.meta.dir, "fixtures", name), path.join(directory, name), "dir"),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (config !== undefined) {
|
||||
const directory = fixtures ? path.join(tmp.path, ".opencode") : tmp.path
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify(config))
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.flatMap((tmp) =>
|
||||
effect.pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { CommandV2 } from "@opencode-ai/core/command"
|
|||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
|
||||
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
|
||||
import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external"
|
||||
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
|
||||
import { ConfigReferencePlugin } from "@opencode-ai/core/config/plugin/reference"
|
||||
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
|
||||
|
|
@ -37,7 +36,7 @@ describe("config plugin reloads", () => {
|
|||
const references = yield* Reference.Service
|
||||
const skills = yield* SkillV2.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
let entries: Config.Entry[] = [config("first", "First plugin")]
|
||||
let entries: Config.Entry[] = [config("first")]
|
||||
const service = Config.Service.of({ entries: () => Effect.sync(() => entries) })
|
||||
const setup = <R>(effect: Effect.Effect<void, never, R>) =>
|
||||
effect.pipe(Effect.provideService(Config.Service, service))
|
||||
|
|
@ -47,7 +46,6 @@ describe("config plugin reloads", () => {
|
|||
yield* setup(ConfigSkillPlugin.Plugin.effect(host))
|
||||
yield* setup(ConfigReferencePlugin.Plugin.effect(host))
|
||||
yield* setup(ConfigProviderPlugin.Plugin.effect(host))
|
||||
yield* setup(ConfigExternalPlugin.Plugin.effect(host))
|
||||
|
||||
expect((yield* agents.get(AgentV2.ID.make("first")))?.description).toBe("First agent")
|
||||
expect((yield* commands.get("first"))?.description).toBe("First command")
|
||||
|
|
@ -56,9 +54,8 @@ describe("config plugin reloads", () => {
|
|||
).toBe(true)
|
||||
expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"])
|
||||
expect(yield* catalog.provider.get(ProviderV2.ID.make("first"))).toBeDefined()
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("First plugin")
|
||||
|
||||
entries = [config("second", "Second plugin")]
|
||||
entries = [config("second")]
|
||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||
yield* waitUntil(
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -80,12 +77,11 @@ describe("config plugin reloads", () => {
|
|||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
|
||||
).toBe(true)
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("First plugin")
|
||||
}).pipe(Effect.provideService(Global.Service, Global.Service.of(Global.make()))),
|
||||
)
|
||||
})
|
||||
|
||||
function config(name: string, pluginDescription?: string) {
|
||||
function config(name: string) {
|
||||
return new Config.Document({
|
||||
type: "document",
|
||||
path: document,
|
||||
|
|
@ -95,15 +91,6 @@ function config(name: string, pluginDescription?: string) {
|
|||
skills: [`/skills/${name}`],
|
||||
references: { [name]: `/references/${name}` },
|
||||
providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } },
|
||||
plugins:
|
||||
pluginDescription === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
package: "../plugin/fixtures/config-promise-plugin.ts",
|
||||
options: { description: pluginDescription },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Equal, Hash, Schema } from "effect"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Context, DateTime, Effect, Equal, Hash, Schema, Stream } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
|
|
@ -10,6 +11,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
|
|
@ -27,6 +29,124 @@ import { ToolRegistry } from "../src/tool/registry"
|
|||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])))
|
||||
|
||||
describe("LocationServiceMap", () => {
|
||||
it.live("applies ordered plugin config operations during boot", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(dir.path, "opencode.json"), JSON.stringify({ plugins: ["-*", "opencode.agent"] })),
|
||||
)
|
||||
const plugins = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
yield* (yield* PluginSupervisor.Service).ready
|
||||
return yield* plugins.list()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
|
||||
),
|
||||
)
|
||||
|
||||
expect(plugins.map((plugin) => plugin.id)).toEqual([PluginV2.ID.make("opencode.agent")])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads the plugin generation after config updates", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir.path, "opencode.json")
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
|
||||
yield* Effect.gen(function* () {
|
||||
const registry = yield* PluginV2.Service
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.ready
|
||||
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] })))
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.command")) break
|
||||
yield* Effect.sleep("20 millis")
|
||||
}
|
||||
|
||||
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.command"])
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
plugins: ["-*", path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts")],
|
||||
}),
|
||||
),
|
||||
)
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if ((yield* registry.list()).length === 0) break
|
||||
yield* Effect.sleep("20 millis")
|
||||
}
|
||||
expect(yield* registry.list()).toEqual([])
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.agent")) break
|
||||
yield* Effect.sleep("20 millis")
|
||||
}
|
||||
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("routes located events only to their location", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
|
||||
).pipe(
|
||||
Effect.flatMap(([first, second]) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const events = yield* EventV2.Service
|
||||
const firstRef = Location.Ref.make({ directory: AbsolutePath.make(first.path) })
|
||||
const secondRef = Location.Ref.make({ directory: AbsolutePath.make(second.path) })
|
||||
const firstContext = yield* locations.contextEffect(firstRef)
|
||||
const secondContext = yield* locations.contextEffect(secondRef)
|
||||
const received = { first: 0, second: 0 }
|
||||
yield* events.subscribe(Config.Event.Updated).pipe(
|
||||
Stream.runForEach(() => Effect.sync(() => received.first++)),
|
||||
Effect.provideContext(firstContext),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* events.subscribe(Config.Event.Updated).pipe(
|
||||
Stream.runForEach(() => Effect.sync(() => received.second++)),
|
||||
Effect.provideContext(secondContext),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
yield* events.publish(Config.Event.Updated, {}, { location: firstRef })
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
expect(received).toEqual({ first: 1, second: 0 })
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reuses cached services for constructed and decoded location refs", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
|
@ -64,7 +184,7 @@ describe("LocationServiceMap", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
|
||||
const registry = yield* ToolRegistry.Service
|
||||
// Tool plugins register during the forked PluginInternal boot; wait for
|
||||
// Tool plugins register during the forked PluginSupervisor boot; wait for
|
||||
// every expected tool rather than relying on batch ordering.
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
|
|
@ -257,7 +377,7 @@ describe("LocationServiceMap", () => {
|
|||
})
|
||||
.pipe(Effect.asVoid),
|
||||
})
|
||||
yield* plugins.add(PluginV2.ID.make(reviewer.id), reviewer.effect)
|
||||
yield* plugins.activate([{ id: PluginV2.ID.make(reviewer.id), effect: reviewer.effect }])
|
||||
|
||||
expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({
|
||||
description: "Reviews code",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
|
|
@ -16,6 +16,8 @@ import { PluginTestLayer } from "./plugin/fixture"
|
|||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSecret") {}
|
||||
|
||||
describe("PluginV2", () => {
|
||||
it.live("exposes public events through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -35,43 +37,15 @@ describe("PluginV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("waits for a plugin and returns immediately once active", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const id = PluginV2.ID.make("waited")
|
||||
const waiting = yield* plugins.wait(id).pipe(Effect.forkChild)
|
||||
|
||||
yield* plugins.add(id, () => Effect.void)
|
||||
yield* Fiber.join(waiting)
|
||||
yield* plugins.wait(id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("propagates plugin activation defects to waiters", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const id = PluginV2.ID.make("failed")
|
||||
const waiting = yield* plugins.wait(id).pipe(Effect.exit, Effect.forkChild)
|
||||
|
||||
const added = yield* plugins.add(id, () => Effect.die("boom")).pipe(Effect.exit)
|
||||
const pending = yield* Fiber.join(waiting)
|
||||
const later = yield* plugins.wait(id).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(added)).toBe(true)
|
||||
expect(Exit.isFailure(pending)).toBe(true)
|
||||
expect(Exit.isFailure(later)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds, replaces, and removes plugins", () =>
|
||||
it.effect("skips identical generations and replaces changed plugin IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
let description = "first"
|
||||
|
||||
const managed = () =>
|
||||
const managed = (id: string) =>
|
||||
define({
|
||||
id: "managed",
|
||||
id,
|
||||
effect: (ctx) =>
|
||||
ctx.agent
|
||||
.transform((agents) =>
|
||||
|
|
@ -82,19 +56,101 @@ describe("PluginV2", () => {
|
|||
.pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
yield* plugins.add(PluginV2.ID.make("managed"), managed().effect)
|
||||
yield* plugins.activate([managed("managed")])
|
||||
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
|
||||
|
||||
description = "second"
|
||||
yield* plugins.add(PluginV2.ID.make("managed"), managed().effect)
|
||||
yield* plugins.activate([managed("managed")])
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
|
||||
|
||||
yield* plugins.activate([managed("managed-next")])
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second")
|
||||
|
||||
yield* plugins.remove(PluginV2.ID.make("managed"))
|
||||
yield* plugins.activate([])
|
||||
expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects duplicate IDs before replacing the active generation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const active = PluginV2.ID.make("active")
|
||||
const duplicate = PluginV2.ID.make("duplicate")
|
||||
yield* plugins.activate([{ id: active, effect: () => Effect.void }])
|
||||
|
||||
const result = yield* plugins
|
||||
.activate([
|
||||
{ id: duplicate, effect: () => Effect.void },
|
||||
{ id: duplicate, effect: () => Effect.void },
|
||||
])
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
expect(yield* plugins.list()).toEqual([{ id: active }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries the same generation after materialization fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
let fail = true
|
||||
const plugin = define({
|
||||
id: "retry",
|
||||
effect: (ctx) =>
|
||||
ctx.agent
|
||||
.transform(() => {
|
||||
if (fail) throw new Error("materialization failed")
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
expect(Exit.isFailure(yield* plugins.activate([plugin]).pipe(Effect.exit))).toBe(true)
|
||||
fail = false
|
||||
yield* plugins.activate([plugin])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([{ id: PluginV2.ID.make("retry") }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("closes the previous generation in reverse order", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const closed: string[] = []
|
||||
yield* plugins.activate(
|
||||
["first", "second"].map((id) => ({
|
||||
id: PluginV2.ID.make(id),
|
||||
effect: () => Effect.addFinalizer(() => Effect.sync(() => closed.push(id))),
|
||||
})),
|
||||
)
|
||||
|
||||
yield* plugins.activate([])
|
||||
|
||||
expect(closed).toEqual(["second", "first"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("isolates plugins from ambient services", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
let visible = true
|
||||
const plugin = define({
|
||||
id: "isolated",
|
||||
effect: () =>
|
||||
Effect.serviceOption(Secret).pipe(
|
||||
Effect.tap((secret) => Effect.sync(() => (visible = secret._tag === "Some"))),
|
||||
Effect.asVoid,
|
||||
),
|
||||
})
|
||||
|
||||
yield* plugins
|
||||
.activate([{ id: PluginV2.ID.make(plugin.id), effect: plugin.effect }])
|
||||
.pipe(Effect.provideService(Secret, "secret"))
|
||||
|
||||
expect(visible).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers location tools through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
|
|
@ -114,12 +170,12 @@ describe("PluginV2", () => {
|
|||
.pipe(Effect.orDie),
|
||||
})
|
||||
|
||||
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
|
||||
yield* plugins.activate([{ id: PluginV2.ID.make(plugin.id), effect: plugin.effect }])
|
||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
|
||||
"plugin_tool",
|
||||
)
|
||||
|
||||
yield* plugins.remove(PluginV2.ID.make(plugin.id))
|
||||
yield* plugins.activate([])
|
||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).not.toContain(
|
||||
"plugin_tool",
|
||||
)
|
||||
|
|
@ -149,7 +205,7 @@ describe("PluginV2", () => {
|
|||
}),
|
||||
})
|
||||
|
||||
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
|
||||
yield* plugins.activate([{ id: PluginV2.ID.make(plugin.id), effect: plugin.effect }])
|
||||
|
||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toEqual([
|
||||
"plain",
|
||||
|
|
@ -201,7 +257,7 @@ describe("PluginV2", () => {
|
|||
}),
|
||||
})
|
||||
|
||||
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
|
||||
yield* plugins.activate([{ id: PluginV2.ID.make(plugin.id), effect: plugin.effect }])
|
||||
|
||||
const materialized = yield* registry.materialize({ model: testModel })
|
||||
const settlement = yield* materialized.settle({
|
||||
|
|
|
|||
7
packages/core/test/plugin/fixtures/failing-plugin.ts
Normal file
7
packages/core/test/plugin/fixtures/failing-plugin.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export default define({
|
||||
id: "failing-plugin",
|
||||
effect: () => Effect.die("plugin failed"),
|
||||
})
|
||||
29
packages/core/test/plugin/fixtures/variant-source-plugin.ts
Normal file
29
packages/core/test/plugin/fixtures/variant-source-plugin.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export default define({
|
||||
id: "variant-source",
|
||||
effect: (ctx) =>
|
||||
ctx.catalog
|
||||
.transform((catalog) => {
|
||||
catalog.provider.update("configured", (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
|
||||
})
|
||||
catalog.model.update("configured", "glm-5.2", (model) => {
|
||||
model.api = {
|
||||
id: "glm-5.2",
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
}
|
||||
model.variants = [
|
||||
{
|
||||
id: "high",
|
||||
settings: {},
|
||||
headers: { custom: "true" },
|
||||
body: {},
|
||||
},
|
||||
]
|
||||
})
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
})
|
||||
|
|
@ -59,8 +59,6 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
},
|
||||
plugin: overrides.plugin ?? {
|
||||
list: () => Effect.die("unused plugin.list"),
|
||||
add: () => Effect.die("unused plugin.add"),
|
||||
remove: () => Effect.die("unused plugin.remove"),
|
||||
},
|
||||
reference: overrides.reference ?? {
|
||||
list: () => Effect.die("unused reference.list"),
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ const addPlugin = Effect.fn(function* () {
|
|||
|
||||
describe("KiloPlugin", () => {
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("kilo"))),
|
||||
Effect.sync(() =>
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("opencode.provider.kilo")),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("applies legacy referer headers only to kilo", () =>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ const addPlugin = Effect.fn(function* () {
|
|||
|
||||
describe("LLMGatewayPlugin", () => {
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("llmgateway"))),
|
||||
Effect.sync(() =>
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("opencode.provider.llmgateway")),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("applies legacy referer headers only to enabled llmgateway", () =>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ const addPlugin = Effect.fn(function* () {
|
|||
|
||||
describe("NvidiaPlugin", () => {
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("nvidia"))),
|
||||
Effect.sync(() =>
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("opencode.provider.nvidia")),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("applies NVIDIA tracking headers only to nvidia", () =>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ const addPlugin = Effect.fn(function* () {
|
|||
|
||||
describe("OpenRouterPlugin", () => {
|
||||
it.effect("is registered so legacy OpenRouter behavior can be applied", () =>
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("openrouter"))),
|
||||
Effect.sync(() =>
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("opencode.provider.openrouter")),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("applies legacy referer headers only to openrouter", () =>
|
||||
|
|
|
|||
|
|
@ -43,9 +43,11 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
|||
describe("SnowflakeCortexPlugin", () => {
|
||||
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
|
||||
Effect.sync(() => {
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("snowflake-cortex"))
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("opencode.provider.snowflake-cortex"))
|
||||
const ids = ProviderPlugins.map((p) => p.id)
|
||||
expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible"))
|
||||
expect(ids.indexOf("opencode.provider.snowflake-cortex")).toBeLessThan(
|
||||
ids.indexOf("opencode.provider.openai-compatible"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ function required<T>(value: T | undefined): T {
|
|||
|
||||
describe("ZenmuxPlugin", () => {
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("zenmux"))),
|
||||
Effect.sync(() =>
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("opencode.provider.zenmux")),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("applies the exact legacy Zenmux headers", () =>
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
|||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
|
|
@ -101,7 +101,7 @@ const layer = Layer.effect(
|
|||
const skillDirs = yield* skill.dirs()
|
||||
const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length
|
||||
? yield* Effect.gen(function* () {
|
||||
yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference"))
|
||||
yield* (yield* PluginSupervisor.Service).ready
|
||||
return (yield* (yield* Reference.Service).list()).map((reference) => reference.path)
|
||||
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
|
||||
: []
|
||||
|
|
|
|||
|
|
@ -11,7 +11,4 @@ export function define<R = Scope.Scope>(plugin: Plugin<R>) {
|
|||
return plugin
|
||||
}
|
||||
|
||||
export interface PluginDomain extends PluginApi<unknown> {
|
||||
readonly add: (plugin: Plugin) => Effect.Effect<void>
|
||||
readonly remove: (id: string) => Effect.Effect<void>
|
||||
}
|
||||
export interface PluginDomain extends PluginApi<unknown> {}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,4 @@ export function define(plugin: Plugin) {
|
|||
return plugin
|
||||
}
|
||||
|
||||
export interface PluginDomain extends PluginApi {
|
||||
readonly add: (plugin: Plugin) => Promise<void>
|
||||
readonly remove: (id: string) => Promise<void>
|
||||
}
|
||||
export interface PluginDomain extends PluginApi {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue