Merge branch 'v2' into fix-stale-tools

This commit is contained in:
Kit Langton 2026-07-05 19:48:02 -04:00 committed by GitHub
commit 12c7dc1bfd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
109 changed files with 1574 additions and 872 deletions

View file

@ -530,6 +530,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode-ai/httpapi-codegen",
"version": "0.0.0",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",

View file

@ -862,6 +862,13 @@ export interface VcsApi<E = never> {
readonly diff: VcsDiffOperation<E>
}
export type Endpoint25_0Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location"]>>
export type DebugLocationOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
export interface DebugApi<E = never> {
readonly location: DebugLocationOperation<E>
}
export interface AppApi<E = never> {
readonly health: HealthApi<E>
readonly location: LocationApi<E>
@ -888,4 +895,5 @@ export interface AppApi<E = never> {
readonly reference: ReferenceApi<E>
readonly projectCopy: ProjectCopyApi<E>
readonly vcs: VcsApi<E>
readonly debug: DebugApi<E>
}

View file

@ -1043,6 +1043,11 @@ const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_1Input
const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint24_0(raw), diff: Endpoint24_1(raw) })
const Endpoint25_0 = (raw: RawClient["server.debug"]) => () =>
raw["debug.location"]({}).pipe(Effect.mapError(mapClientError))
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ location: Endpoint25_0(raw) })
const adaptClient = (raw: RawClient) => ({
health: adaptGroup0(raw["server.health"]),
location: adaptGroup1(raw["server.location"]),
@ -1069,6 +1074,7 @@ const adaptClient = (raw: RawClient) => ({
reference: adaptGroup22(raw["server.reference"]),
projectCopy: adaptGroup23(raw["server.projectCopy"]),
vcs: adaptGroup24(raw["server.vcs"]),
debug: adaptGroup25(raw["server.debug"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>

View file

@ -173,6 +173,7 @@ import type {
VcsStatusOutput,
VcsDiffInput,
VcsDiffOutput,
DebugLocationOutput,
} from "./types"
import { ClientError } from "./client-error"
@ -1448,6 +1449,19 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
debug: {
location: (requestOptions?: RequestOptions) =>
request<DebugLocationOutput>(
{
method: "GET",
path: `/api/debug/location`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
}
}

View file

@ -4974,6 +4974,14 @@ export type EventSubscribeOutput =
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly id: string }
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "plugin.updated"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {}
}
| {
readonly id: string
readonly created: number
@ -6003,3 +6011,5 @@ export type VcsDiffOutput = {
readonly status?: "added" | "deleted" | "modified"
}>
}
export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }>

View file

@ -30,7 +30,9 @@ test("exposes every standard HTTP API group", () => {
"reference",
"projectCopy",
"vcs",
"debug",
])
expect(Object.keys(client.debug)).toEqual(["location"])
expect(Object.keys(client.message)).toEqual(["list"])
expect(Object.keys(client.integration)).toEqual([
"list",

View file

@ -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),
}) {}

View file

@ -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

View file

@ -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

View file

@ -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)))
})

View file

@ -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() }

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,24 @@
export * as EventLogger from "./event-logger"
import { Effect, Layer } from "effect"
import { makeGlobalNode } from "./effect/app-node"
import { EventV2 } from "./event"
const Types = new Set([
"agent.updated",
"catalog.updated",
"command.updated",
"config.updated",
])
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const events = yield* EventV2.Service
const unsubscribe = yield* events.listen((event) =>
Types.has(event.type) ? Effect.logInfo("event", { event }) : Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
}),
)
export const node = makeGlobalNode({ name: "event-logger", layer, deps: [EventV2.node] })

View file

@ -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,

View file

@ -34,46 +34,53 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const configService = yield* Config.Service
const config = (yield* configService.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
events.publish(FileSystem.Event.Changed, {
file: update.path,
event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink",
})
if (path.resolve(location.directory) !== path.resolve(os.homedir())) {
yield* watcher
.subscribe({
path: location.directory,
type: "directory",
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
})
.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
} else {
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
}
yield* Effect.gen(function* () {
const config = (yield* configService.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
const home = path.resolve(location.directory) === path.resolve(os.homedir())
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
if (!home) {
yield* watcher
.subscribe({ path: vcs, type: "directory", ignore })
.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
.subscribe({
path: location.directory,
type: "directory",
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
})
.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
if (home) {
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
}
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
: undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
yield* watcher
.subscribe({ path: vcs, type: "directory", ignore })
.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
}).pipe(
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
Effect.forkScoped,
)
return Service.of({})
}).pipe(
Effect.catchCause((cause) =>
Effect.logError("failed to init location watcher service", { cause }).pipe(Effect.as(Service.of({}))),
),
),
}),
)
export const node = makeLocationNode({

View file

@ -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)),

View file

@ -8,6 +8,12 @@ import { OtlpSerialization } from "effect/unstable/observability"
import { Logging } from "./observability/logging"
import { Otlp } from "./observability/otlp"
const local = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
Layer.provide(NodeFileSystem.layer),
Layer.orDie,
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
)
export const layer = Layer.unwrap(
Effect.gen(function* () {
const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers()], { mergeWithExisting: false }).pipe(
@ -19,6 +25,6 @@ export const layer = Layer.unwrap(
)
return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer))
}),
)
).pipe(Layer.catchCause(() => local))
export const node = LayerNode.make({ name: "observability", layer, deps: [] })

View file

@ -1,16 +1,15 @@
export * as PluginV2 from "./plugin"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Event, ID, type Info } from "@opencode-ai/schema/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 { Plugin } from "@opencode-ai/schema/plugin"
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
import { AgentV2 } from "./agent"
import { AISDK } from "./aisdk"
import { Catalog } from "./catalog"
import { CommandV2 } from "./command"
import { EventV2 } from "./event"
import { Integration } from "./integration"
import { KeyedMutex } from "./effect/keyed-mutex"
import { Location } from "./location"
import { PluginHost } from "./plugin/host"
import { PluginRuntime } from "./plugin/runtime"
@ -20,16 +19,8 @@ import { State } from "./state"
import { ToolRegistry } from "./tool/registry"
import { ToolHooks } from "./tool/hooks"
export const ID = Plugin.ID
export type ID = typeof ID.Type
export const Info = Plugin.Info
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 { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect<void>
readonly list: () => Effect.Effect<Info[]>
}
@ -39,110 +30,83 @@ 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 active = new Map<typeof ID.Type, Scope.Closeable>()
const lock = Semaphore.makeUnsafe(1)
let generation: readonly { readonly id: typeof ID.Type; readonly version?: string }[] | undefined = []
let host: Parameters<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 { readonly plugin: Plugin; readonly version?: string }[],
) {
const definitions = plugins.map((entry) => ({
...entry.plugin,
id: ID.make(entry.plugin.id),
...(entry.version === undefined ? {} : { version: entry.version }),
}))
const ids = new Set<typeof ID.Type>()
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(
(plugin, index) => plugin.id === definitions[index]?.id && plugin.version === definitions[index]?.version,
)
) {
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) => ({
id: definition.id,
...(definition.version === undefined ? {} : { version: definition.version }),
}))
yield* events.publish(Event.Updated, {})
}),
)
})
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 }))
}),

View file

@ -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

View file

@ -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) => {

View file

@ -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()),

View file

@ -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),
}
})

View file

@ -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

View file

@ -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)),

View file

@ -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,

View file

@ -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) {

View file

@ -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()) {

View file

@ -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()) {

View file

@ -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

View file

@ -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()) {

View file

@ -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) {

View file

@ -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)

View file

@ -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) {

View file

@ -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) {

View file

@ -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(

View file

@ -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) {

View file

@ -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)

View file

@ -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) {

View file

@ -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()) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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()) {

View file

@ -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))

View file

@ -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) {

View file

@ -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()) {

View file

@ -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) {

View file

@ -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}` }

View file

@ -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

View file

@ -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()) {

View file

@ -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) {

View file

@ -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(

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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()) {

View file

@ -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) {

View file

@ -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()) {

View file

@ -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.
*

View file

@ -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) => {

View file

@ -0,0 +1,293 @@
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, Option, 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 mtime?: number
}
| {
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>
readonly mtime?: number
}
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 yield* Effect.forEach([...discovered, ...configured], (operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.catch(() => Effect.succeed(operation)),
)
})
})
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,
...(item.operation.mtime === undefined ? {} : { mtime: item.operation.mtime }),
},
]
: [],
)
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({ plugin: 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
// Bun currently ignores query parameters when caching file:// imports.
const source =
candidate.mtime === undefined
? entrypoint
: `${candidate.specifier.replaceAll("\\", "/")}?mtime=${candidate.mtime}`
yield* Effect.log({ msg: "loading plugin", id: candidate.specifier, entrypoint: source })
const mod = yield* Effect.promise(() => import(source))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
plugin: {
id: plugin.id,
effect: (host) => plugin.effect({ ...host, options: candidate.options }),
} satisfies Plugin,
...(candidate.mtime === undefined ? {} : { version: String(candidate.mtime) }),
}
}).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)
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()
const operations = yield* scan(entries)
// 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)
}),
),
)
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 }

View file

@ -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()) {

View file

@ -224,7 +224,7 @@ const layer = Layer.effect(
})
return Service.of({ capture, files, diff, preview, restore, checkout })
}),
}).pipe(Effect.withSpan("Snapshot.boot")),
)
export const node = makeLocationNode({

View file

@ -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 }
}),

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,252 +1,298 @@
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin"
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", () =>
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")
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([Plugin.ID.make("opencode.provider.openai")])
}),
),
)
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" },
},
],
}),
}),
]),
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("reloads an auto-discovered plugin when its file changes", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const agents = yield* AgentV2.Service
const events = yield* EventV2.Service
const location = yield* Location.Service
const plugins = yield* PluginV2.Service
const file = path.join(location.directory, ".opencode", "plugin", "mutable.ts")
const first = (yield* plugins.list()).find((plugin) => plugin.id === "mutable-plugin")?.id
expect(first).toBeDefined()
expect((yield* agents.get(AgentV2.ID.make("mutable")))?.description).toBe("first")
yield* Effect.promise(async () => {
await fs.writeFile(file, mutablePlugin("second"))
const modified = new Date(Date.now() + 5_000)
await fs.utimes(file, modified, modified)
})
yield* events.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil(
Effect.gen(function* () {
const current = (yield* plugins.list()).find((plugin) => plugin.id === "mutable-plugin")?.id
return current === first && (yield* agents.get(AgentV2.ID.make("mutable")))?.description === "second"
}),
),
)
)
}),
false,
async (directory) => {
const plugin = path.join(directory, ".opencode", "plugin")
await fs.mkdir(plugin, { recursive: true })
await fs.writeFile(path.join(plugin, "mutable.ts"), mutablePlugin("first"))
},
),
)
expect(yield* waitForAgent(agents, "configured")).toMatchObject({
description: "Loaded from config",
mode: "subagent",
})
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 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"))
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" } }),
])
}),
)
}),
)
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
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,
prepare?: (directory: string) => Promise<void>,
) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.tap((tmp) =>
Effect.promise(async () => {
await prepare?.(tmp.path)
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) }))),
),
),
)
}
function mutablePlugin(description: string) {
const plugin = pathToFileURL(path.join(import.meta.dir, "../../../plugin/src/v2/promise/index.ts")).href
return `
import { define } from ${JSON.stringify(plugin)}
export default define({
id: "mutable-plugin",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("mutable", (agent) => {
agent.description = ${JSON.stringify(description)}
agent.mode = "subagent"
})
})
},
})
`
}
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
for (let attempt = 0; attempt < 200; attempt++) {
if (yield* condition) return
yield* Effect.sleep("10 millis")
}
return yield* Effect.die(`Timed out waiting for agent ${id}`)
return yield* Effect.die("Timed out waiting for plugin reload")
})

View file

@ -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 },
},
],
}),
})
}

View file

@ -52,6 +52,42 @@ describe("resource", () => {
})
})
test("falls back to local logging when OTLP initialization fails", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-observability-test-"))
await using _ = {
async [Symbol.asyncDispose]() {
await fs.rm(dir, { recursive: true, force: true })
},
}
const child = Bun.spawn(
[
process.execPath,
"--eval",
`
import { Effect } from "effect"
import { Observability } from "./src/observability.ts"
await Effect.void.pipe(Effect.provide(Observability.layer), Effect.scoped, Effect.runPromise)
`,
],
{
cwd: path.join(import.meta.dir, "../.."),
env: {
...process.env,
OTEL_EXPORTER_OTLP_ENDPOINT: "://invalid",
XDG_CACHE_HOME: path.join(dir, "cache"),
XDG_CONFIG_HOME: path.join(dir, "config"),
XDG_DATA_HOME: path.join(dir, "data"),
XDG_STATE_HOME: path.join(dir, "state"),
},
stdout: "ignore",
stderr: "pipe",
},
)
const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" })
})
test("file logger appends concurrent runs with a run on every line", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-log-test-"))
await using _ = {

View file

@ -0,0 +1,45 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Logger } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventLogger } from "@opencode-ai/core/event-logger"
import { Agent } from "@opencode-ai/schema/agent"
import { Catalog } from "@opencode-ai/schema/catalog"
import { Command } from "@opencode-ai/schema/command"
import { Config } from "@opencode-ai/schema/config"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
const UnlistedUpdated = EventV2.ephemeral({ type: "test.updated", schema: {} })
describe("EventLogger", () => {
test("logs explicitly listed updated events", async () => {
const output = new Array<ReturnType<typeof Logger.formatStructured.log>>()
const logger = Logger.map(Logger.formatStructured, (entry) => {
output.push(entry)
})
await Effect.gen(function* () {
const events = yield* EventV2.Service
yield* events.publish(Agent.Event.Updated, {})
yield* events.publish(Catalog.Event.Updated, {})
yield* events.publish(Command.Event.Updated, {})
yield* events.publish(Config.Event.Updated, {})
yield* events.publish(McpEvent.StatusChanged, { server: "example" })
yield* events.publish(UnlistedUpdated, {})
}).pipe(
Effect.provide(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, EventLogger.node]))),
Effect.provide(Logger.layer([logger])),
Effect.scoped,
Effect.runPromise,
)
expect(output.map((entry) => entry.message)).toEqual([
["event", { event: expect.objectContaining({ type: "agent.updated" }) }],
["event", { event: expect.objectContaining({ type: "catalog.updated" }) }],
["event", { event: expect.objectContaining({ type: "command.updated" }) }],
["event", { event: expect.objectContaining({ type: "config.updated" }) }],
])
})
})

View file

@ -1,7 +1,9 @@
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 { Plugin } from "@opencode-ai/schema/plugin"
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 +12,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 +30,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([Plugin.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 +185,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 +378,7 @@ describe("LocationServiceMap", () => {
})
.pipe(Effect.asVoid),
})
yield* plugins.add(PluginV2.ID.make(reviewer.id), reviewer.effect)
yield* plugins.activate([{ plugin: reviewer }])
expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({
description: "Reviews code",

View file

@ -1,7 +1,8 @@
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 { Plugin } from "@opencode-ai/schema/plugin"
import { AgentV2 } from "@opencode-ai/core/agent"
import { EventV2 } from "@opencode-ai/core/event"
import { PluginV2 } from "@opencode-ai/core/plugin"
@ -16,6 +17,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,39 +38,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 versions", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
const events = yield* EventV2.Service
let description = "first"
const updated = yield* events
.subscribe(Plugin.Event.Updated)
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
const managed = () =>
define({
@ -82,19 +61,102 @@ describe("PluginV2", () => {
.pipe(Effect.asVoid),
})
yield* plugins.add(PluginV2.ID.make("managed"), managed().effect)
yield* plugins.activate([{ plugin: managed() }])
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
description = "second"
yield* plugins.add(PluginV2.ID.make("managed"), managed().effect)
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second")
yield* plugins.activate([{ plugin: managed() }])
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
yield* plugins.remove(PluginV2.ID.make("managed"))
yield* plugins.activate([{ plugin: managed(), version: "next" }])
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second")
expect(yield* Fiber.join(updated)).toHaveLength(2)
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 = Plugin.ID.make("active")
const duplicate = "duplicate"
yield* plugins.activate([{ plugin: { id: active, effect: () => Effect.void } }])
const result = yield* plugins
.activate([
{ plugin: { id: duplicate, effect: () => Effect.void } },
{ plugin: { 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: Plugin.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) => ({
plugin: {
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([{ plugin }]).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 +176,12 @@ describe("PluginV2", () => {
.pipe(Effect.orDie),
})
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
yield* plugins.activate([{ plugin }])
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 +211,7 @@ describe("PluginV2", () => {
}),
})
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
yield* plugins.activate([{ plugin }])
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toEqual([
"plain",
@ -201,7 +263,7 @@ describe("PluginV2", () => {
}),
})
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
yield* plugins.activate([{ plugin }])
const materialized = yield* registry.materialize({ model: testModel })
const settlement = yield* materialized.settle({

View 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"),
})

View 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),
})

View file

@ -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"),

View file

@ -19,7 +19,7 @@ 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("opencode.provider.kilo")),
)
it.effect("applies legacy referer headers only to kilo", () =>

View file

@ -21,7 +21,7 @@ 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("opencode.provider.llmgateway")),
)
it.effect("applies legacy referer headers only to enabled llmgateway", () =>

View file

@ -19,7 +19,7 @@ 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("opencode.provider.nvidia")),
)
it.effect("applies NVIDIA tracking headers only to nvidia", () =>

View file

@ -22,7 +22,7 @@ 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("opencode.provider.openrouter")),
)
it.effect("applies legacy referer headers only to openrouter", () =>

View file

@ -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("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"),
)
}),
)

View file

@ -24,7 +24,7 @@ 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("opencode.provider.zenmux")),
)
it.effect("applies the exact legacy Zenmux headers", () =>

View file

@ -22,14 +22,12 @@ import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Integration } from "@opencode-ai/schema/integration"
import { LLM } from "@opencode-ai/schema/llm"
import { Permission } from "@opencode-ai/schema/permission"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Pty } from "@opencode-ai/schema/pty"
import { Reference } from "@opencode-ai/schema/reference"
import { SessionTodo } from "@opencode-ai/schema/session-todo"
import { Skill } from "@opencode-ai/schema/skill"
import { AbsolutePath, DateTimeUtcFromMillis, optional, statics } from "@opencode-ai/schema/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { PluginV2 } from "@opencode-ai/core/plugin"
test("Core reuses the canonical shared schemas", async () => {
const [
@ -129,8 +127,6 @@ test("Core reuses the canonical shared schemas", async () => {
[corePermission.Ruleset, Permission.Ruleset],
[corePermissionV1.Event, PermissionV1.Event],
[coreProjectCopy.Event, ProjectDirectories.Event],
[PluginV2.ID, Plugin.ID],
[PluginV2.Event, Plugin.Event],
[corePty.Info, Pty.Info],
[corePty.Event, Pty.Event],
[coreProject.ID, Project.ID],

View file

@ -1,6 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/httpapi-codegen",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {

View file

@ -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) }))))
: []

View file

@ -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> {}

View file

@ -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 {}

View file

@ -16,6 +16,7 @@ import type { Definition } from "@opencode-ai/schema/event"
import { AgentGroup } from "./groups/agent.js"
import { PluginGroup } from "./groups/plugin.js"
import { HealthGroup } from "./groups/health.js"
import { DebugGroup } from "./groups/debug.js"
import { PtyGroup } from "./groups/pty.js"
import { ShellGroup } from "./groups/shell.js"
import { makeQuestionGroup } from "./groups/question.js"
@ -81,6 +82,7 @@ type ApiGroups<
Event extends HttpApiGroup.Any,
> =
| typeof HealthGroup
| typeof DebugGroup
| LocationGroups<LocationId>
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
| SessionGroups<SessionLocationId, SessionLocationService>
@ -165,6 +167,7 @@ const makeApiFromGroup = <
.add(ReferenceGroup.middleware(locationMiddleware))
.add(ProjectCopyGroup.middleware(locationMiddleware))
.add(VcsGroup.middleware(locationMiddleware))
.add(DebugGroup)
.annotateMerge(
OpenApi.annotations({
title: "opencode HttpApi",

View file

@ -34,6 +34,7 @@ export const ClientApi: ClientApiShape = makeDefaultApi({
export const groupNames = {
"server.health": "health",
"server.debug": "debug",
"server.location": "location",
"server.agent": "agent",
"server.plugin": "plugin",

View file

@ -0,0 +1,15 @@
import { Location } from "@opencode-ai/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
export const DebugGroup = HttpApiGroup.make("server.debug").add(
HttpApiEndpoint.get("debug.location", "/api/debug/location", {
success: Schema.Array(Location.Ref),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.debug.location",
summary: "List loaded locations",
description: "List locations currently loaded by the server.",
}),
),
)

Some files were not shown because too many files have changed in this diff Show more