diff --git a/bun.lock b/bun.lock index 3ca6fd949d..7d4a912af1 100644 --- a/bun.lock +++ b/bun.lock @@ -530,6 +530,7 @@ }, "packages/httpapi-codegen": { "name": "@opencode-ai/httpapi-codegen", + "version": "0.0.0", "dependencies": { "effect": "catalog:", "prettier": "3.6.2", diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 3f4c852be1..866747dafa 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -862,6 +862,13 @@ export interface VcsApi { readonly diff: VcsDiffOperation } +export type Endpoint25_0Output = EffectValue> +export type DebugLocationOperation = () => Effect.Effect + +export interface DebugApi { + readonly location: DebugLocationOperation +} + export interface AppApi { readonly health: HealthApi readonly location: LocationApi @@ -888,4 +895,5 @@ export interface AppApi { readonly reference: ReferenceApi readonly projectCopy: ProjectCopyApi readonly vcs: VcsApi + readonly debug: DebugApi } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index a9a91eb8c6..2a253cf016 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -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 }) => diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 79368764c7..f5fe29506e 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -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( + { + method: "GET", + path: `/api/debug/location`, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, } } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 151f0ff03f..f58135023c 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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 }> diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index d445a69c08..2dbd58a858 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -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", diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 48c380aef3..4bd33ca240 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -102,7 +102,7 @@ export class Info extends Schema.Class("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), }) {} diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 3aebf88fbe..fa8b61ce18 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -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 diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index 43ba4cd375..a7babbb5aa 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -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 diff --git a/packages/core/src/config/plugin/external.ts b/packages/core/src/config/plugin/external.ts deleted file mode 100644 index 70271dec23..0000000000 --- a/packages/core/src/config/plugin/external.ts +++ /dev/null @@ -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( - (input): input is EffectPlugin["effect"] => typeof input === "function", - ), - }), - Schema.Struct({ - id: Schema.String, - setup: Schema.declare( - (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 }[] = [] - - 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[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))) -}) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index d2d6a26029..320895d128 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -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() } diff --git a/packages/core/src/config/plugin/reference.ts b/packages/core/src/config/plugin/reference.ts index d33332f92d..75e0cc10e4 100644 --- a/packages/core/src/config/plugin/reference.ts +++ b/packages/core/src/config/plugin/reference.ts @@ -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 diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index ff83c53ff1..84546b901c 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -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 diff --git a/packages/core/src/event-logger.ts b/packages/core/src/event-logger.ts new file mode 100644 index 0000000000..f07c96320e --- /dev/null +++ b/packages/core/src/event-logger.ts @@ -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] }) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 3e352c6e6b..a5b052b42f 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -583,12 +583,32 @@ export const layerWith = (options?: LayerOptions) => .pipe(Effect.orDie) } + const local = (stream: Stream.Stream) => + 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 = (definition: D): Stream.Stream> => - 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), ) - const streamLive = (): Stream.Stream => Stream.fromPubSub(pubsub.live) + const streamLive = (): Stream.Stream => local(Stream.fromPubSub(pubsub.live)) const readAfter = ( aggregateID: string, diff --git a/packages/core/src/filesystem/location-watcher.ts b/packages/core/src/filesystem/location-watcher.ts index 1566d3d8ff..613b43b5f8 100644 --- a/packages/core/src/filesystem/location-watcher.ts +++ b/packages/core/src/filesystem/location-watcher.ts @@ -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({ diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 2a4e0d6ef6..bbb60482c1 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -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[] export const locationServices = LayerNode.group(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)), diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts index 22285974d8..e99d8919d2 100644 --- a/packages/core/src/observability.ts +++ b/packages/core/src/observability.ts @@ -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: [] }) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index a6ff337c62..907920c10e 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -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 - readonly remove: (id: ID) => Effect.Effect - readonly wait: (id: ID) => Effect.Effect + readonly activate: (plugins: readonly { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect readonly list: () => Effect.Effect } @@ -39,110 +30,83 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service - const locks = KeyedMutex.makeUnsafe() const scope = yield* Scope.make() - const active = new Map() - const loading = new Set() - const waiters = new Map>>() - const failures = new Map>() - let host: Parameters[0] + const active = new Map() + const lock = Semaphore.makeUnsafe(1) + let generation: readonly { readonly id: typeof ID.Type; readonly version?: string }[] | undefined = [] + let host: Parameters[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() + 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) => 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() - 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 })) }), diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index a0e3556ce8..7549089bf3 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -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 diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index 1989641238..7bb14dad31 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -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) => { diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 4c7bb8538c..2e58c6b144 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -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()), diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 939bb4e731..63637c96b0 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -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 { - readonly id: string - readonly effect: (context: PluginContext) => Effect.Effect -} - -export function define(plugin: Plugin) { - 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) => - 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 extends Context.Context ? R : never + +export type Requirements = ContextServices>> + +export type InternalPlugin = Plugin + +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), + } }) diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 27c176ed67..23a166e267 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -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 diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index b315d3214a..bcd3db1323 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -9,7 +9,7 @@ type Registration = { readonly dispose: () => Promise } /** * 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)), diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index 1749b474ed..7c17bcc4c7 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -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[] = [ +export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ AlibabaPlugin, AmazonBedrockPlugin, AnthropicPlugin, diff --git a/packages/core/src/plugin/provider/alibaba.ts b/packages/core/src/plugin/provider/alibaba.ts index c5c4be0d0b..607e565d4a 100644 --- a/packages/core/src/plugin/provider/alibaba.ts +++ b/packages/core/src/plugin/provider/alibaba.ts @@ -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) { diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index cbf9defc09..0bc3ced462 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -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()) { diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index efbd50b818..3a8a7c8659 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -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()) { diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 2e73e620bf..3269c7d2d9 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -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 diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index 57029cfba7..20e724d707 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -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()) { diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index d416f6f19d..afab0c36df 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -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) { diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 762ceb4d96..b2db49e4db 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -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) diff --git a/packages/core/src/plugin/provider/cohere.ts b/packages/core/src/plugin/provider/cohere.ts index 0ca0708577..8b0831604a 100644 --- a/packages/core/src/plugin/provider/cohere.ts +++ b/packages/core/src/plugin/provider/cohere.ts @@ -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) { diff --git a/packages/core/src/plugin/provider/deepinfra.ts b/packages/core/src/plugin/provider/deepinfra.ts index 1b23e08ba4..e8316012ad 100644 --- a/packages/core/src/plugin/provider/deepinfra.ts +++ b/packages/core/src/plugin/provider/deepinfra.ts @@ -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) { diff --git a/packages/core/src/plugin/provider/dynamic.ts b/packages/core/src/plugin/provider/dynamic.ts index c84a6ed51f..2e51674ba0 100644 --- a/packages/core/src/plugin/provider/dynamic.ts +++ b/packages/core/src/plugin/provider/dynamic.ts @@ -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( diff --git a/packages/core/src/plugin/provider/gateway.ts b/packages/core/src/plugin/provider/gateway.ts index f097dcaca3..07249391a0 100644 --- a/packages/core/src/plugin/provider/gateway.ts +++ b/packages/core/src/plugin/provider/gateway.ts @@ -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) { diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index d116058493..2a4881edc0 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -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) diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 8723cdaac2..5cd4b94858 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -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) { diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index b87fe61a32..630e353ef0 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -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) { @@ -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()) { diff --git a/packages/core/src/plugin/provider/google.ts b/packages/core/src/plugin/provider/google.ts index 476af5b912..3d2013a523 100644 --- a/packages/core/src/plugin/provider/google.ts +++ b/packages/core/src/plugin/provider/google.ts @@ -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) { diff --git a/packages/core/src/plugin/provider/groq.ts b/packages/core/src/plugin/provider/groq.ts index 0bddb44309..d84ece2151 100644 --- a/packages/core/src/plugin/provider/groq.ts +++ b/packages/core/src/plugin/provider/groq.ts @@ -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) { diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index 5d4c0d65d8..d901474ef2 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -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()) { diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index 005880c6f3..68c319b673 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -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)) diff --git a/packages/core/src/plugin/provider/mistral.ts b/packages/core/src/plugin/provider/mistral.ts index a731975659..92bc09abec 100644 --- a/packages/core/src/plugin/provider/mistral.ts +++ b/packages/core/src/plugin/provider/mistral.ts @@ -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) { diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index b1b9c9b117..4597f3070a 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -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()) { diff --git a/packages/core/src/plugin/provider/openai-compatible.ts b/packages/core/src/plugin/provider/openai-compatible.ts index d602ed0ff9..3854bdcde2 100644 --- a/packages/core/src/plugin/provider/openai-compatible.ts +++ b/packages/core/src/plugin/provider/openai-compatible.ts @@ -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) { diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 990a82554e..1b27511e49 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -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) +} satisfies PluginInternal.InternalPlugin) function headers(contentType: string) { return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` } diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index bfa84ecd78..1316c3e4d0 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -75,7 +75,7 @@ function oauth(http: HttpClient.HttpClient) { } export const OpencodePlugin = define({ - id: "opencode", + id: "opencode.provider.opencode", effect: Effect.fn(function* (ctx) { const events = yield* EventV2.Service const http = yield* HttpClient.HttpClient diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index 458ea1cd9b..a255844fdb 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -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()) { diff --git a/packages/core/src/plugin/provider/perplexity.ts b/packages/core/src/plugin/provider/perplexity.ts index 44c1ef2fc0..9eb5b1e246 100644 --- a/packages/core/src/plugin/provider/perplexity.ts +++ b/packages/core/src/plugin/provider/perplexity.ts @@ -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) { diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts index b6c3a540e6..ede4e31d40 100644 --- a/packages/core/src/plugin/provider/sap-ai-core.ts +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -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( diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts index 788ac63eb0..2e6cc9f9b4 100644 --- a/packages/core/src/plugin/provider/snowflake-cortex.ts +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -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 @@ -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) { diff --git a/packages/core/src/plugin/provider/togetherai.ts b/packages/core/src/plugin/provider/togetherai.ts index 8022e0de66..d9454cfd24 100644 --- a/packages/core/src/plugin/provider/togetherai.ts +++ b/packages/core/src/plugin/provider/togetherai.ts @@ -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) { diff --git a/packages/core/src/plugin/provider/venice.ts b/packages/core/src/plugin/provider/venice.ts index 1a602ffd50..c9d5b163ae 100644 --- a/packages/core/src/plugin/provider/venice.ts +++ b/packages/core/src/plugin/provider/venice.ts @@ -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) { diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index 8ff4d14098..0df949b1be 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -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()) { diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index 8145a3480a..76ec1df1a7 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -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) { diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index 4562d6bd2e..099a3affca 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -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()) { diff --git a/packages/core/src/plugin/sdk.ts b/packages/core/src/plugin/sdk.ts index 78173a50a9..221fb5fae9 100644 --- a/packages/core/src/plugin/sdk.ts +++ b/packages/core/src/plugin/sdk.ts @@ -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. * diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index c9eca99118..f6ca2ef4af 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -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) => { diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts new file mode 100644 index 0000000000..4d648a7d6f --- /dev/null +++ b/packages/core/src/plugin/supervisor.ts @@ -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((input): input is Plugin["effect"] => typeof input === "function"), + }), + Schema.Struct({ + id: Schema.String, + setup: Schema.declare[0]["setup"]>( + (input): input is Parameters[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 + 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 + readonly mtime?: number + } + +type ConfiguredPackage = { + readonly operation: Extract + 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() + + 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 +} + +export class Service extends Context.Service()("@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 } diff --git a/packages/core/src/plugin/variant.ts b/packages/core/src/plugin/variant.ts index 7c6e688399..499a2a32bf 100644 --- a/packages/core/src/plugin/variant.ts +++ b/packages/core/src/plugin/variant.ts @@ -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()) { diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index 79a2b6cda8..9843e69226 100644 --- a/packages/core/src/snapshot.ts +++ b/packages/core/src/snapshot.ts @@ -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({ diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index ab3457fc18..93dcdb4e5d 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -26,21 +26,32 @@ export interface Transformable { readonly reload: Reload } -const CurrentBatch = Context.Reference | undefined>("@opencode/State/CurrentBatch", { +type Batch = { + active: boolean + readonly reloads: Set +} + +const CurrentBatch = Context.Reference("@opencode/State/CurrentBatch", { defaultValue: () => undefined, }) export function batch(effect: Effect.Effect) { return Effect.gen(function* () { const current = yield* CurrentBatch - if (current) return yield* effect - const reloads = new Set() - 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 (effect: Effect.Effect) => Effect.provideService(effect, CurrentBatch, batch) +}) + export interface Options { /** Creates the base value for initial state and every scoped-transform reload. */ readonly initial: () => State @@ -100,8 +111,8 @@ export function create(options: Options): 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(options: Options): 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 } }), diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 2f481d8a0d..6700e6e1ba 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -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 diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 17ff28cfc1..bb9bb4dee9 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -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 diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index dcea52120f..753e36cab5 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -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 diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 35ac537c91..685925aa04 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -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 diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index 218edb57e9..9bf4249add 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -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 diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 16e229d97d..de03a97974 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -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 diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index c3644e4810..cd0c428a61 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -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 diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 1c5d23a047..669e36c4c8 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -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 diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 517d1ce65f..50db674ef2 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -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 diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts index 5f34ebd5aa..279792e38a 100644 --- a/packages/core/src/tool/todowrite.ts +++ b/packages/core/src/tool/todowrite.ts @@ -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 diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index efac4a2a75..b729fb43f7 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -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 diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 80a2422cee..d0c984b89a 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -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 diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index 73885b2187..ca89665be4 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -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 diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index b72a62df8a..34a69ffbc5 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -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( + config: unknown, + effect: Effect.Effect, + fixtures = false, + prepare?: (directory: string) => Promise, +) { + 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) { + 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") }) diff --git a/packages/core/test/config/reload.test.ts b/packages/core/test/config/reload.test.ts index 3550bddb95..e5f8bd10fc 100644 --- a/packages/core/test/config/reload.test.ts +++ b/packages/core/test/config/reload.test.ts @@ -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 = (effect: Effect.Effect) => 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 }, - }, - ], }), }) } diff --git a/packages/core/test/effect/observability.test.ts b/packages/core/test/effect/observability.test.ts index 4758563f28..c075986364 100644 --- a/packages/core/test/effect/observability.test.ts +++ b/packages/core/test/effect/observability.test.ts @@ -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 _ = { diff --git a/packages/core/test/event-logger.test.ts b/packages/core/test/event-logger.test.ts new file mode 100644 index 0000000000..ef48d7abbd --- /dev/null +++ b/packages/core/test/event-logger.test.ts @@ -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>() + 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" }) }], + ]) + }) +}) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 0f17b0dd2b..f0bef9fd32 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -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", diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 266d487862..e437feb066 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -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()("@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({ diff --git a/packages/core/test/plugin/fixtures/failing-plugin.ts b/packages/core/test/plugin/fixtures/failing-plugin.ts new file mode 100644 index 0000000000..4daac0acd0 --- /dev/null +++ b/packages/core/test/plugin/fixtures/failing-plugin.ts @@ -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"), +}) diff --git a/packages/core/test/plugin/fixtures/variant-source-plugin.ts b/packages/core/test/plugin/fixtures/variant-source-plugin.ts new file mode 100644 index 0000000000..087a714227 --- /dev/null +++ b/packages/core/test/plugin/fixtures/variant-source-plugin.ts @@ -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), +}) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 63ac18e31e..8ef98c6530 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -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"), diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index b34ceb2d5b..1ff356523c 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -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", () => diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index 5dce7cdcf7..8f4af57889 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -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", () => diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index 260ffff689..c176cef4ac 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -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", () => diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index d611f40363..60dfc67b51 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -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", () => diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index cd67feb40e..4de00105b7 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -43,9 +43,11 @@ function withEnv(vars: Record, 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"), + ) }), ) diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index b9d34a1f5b..9c44800ebc 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -24,7 +24,7 @@ function required(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", () => diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index ecdd555fa8..7d227aac19 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -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], diff --git a/packages/httpapi-codegen/package.json b/packages/httpapi-codegen/package.json index 84aa736531..f2a5e4da79 100644 --- a/packages/httpapi-codegen/package.json +++ b/packages/httpapi-codegen/package.json @@ -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": { diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 536a642fe4..cd0eb32f7c 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -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) })))) : [] diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 77629bdfca..66f57caf60 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -11,7 +11,4 @@ export function define(plugin: Plugin) { return plugin } -export interface PluginDomain extends PluginApi { - readonly add: (plugin: Plugin) => Effect.Effect - readonly remove: (id: string) => Effect.Effect -} +export interface PluginDomain extends PluginApi {} diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts index afb6c16f0e..eb91b9df53 100644 --- a/packages/plugin/src/v2/promise/plugin.ts +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -10,7 +10,4 @@ export function define(plugin: Plugin) { return plugin } -export interface PluginDomain extends PluginApi { - readonly add: (plugin: Plugin) => Promise - readonly remove: (id: string) => Promise -} +export interface PluginDomain extends PluginApi {} diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index 04d60888d7..2f91fae332 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -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 | FormGroups | SessionGroups @@ -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", diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 3976289ed9..3d2993a02b 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -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", diff --git a/packages/protocol/src/groups/debug.ts b/packages/protocol/src/groups/debug.ts new file mode 100644 index 0000000000..f41b02b864 --- /dev/null +++ b/packages/protocol/src/groups/debug.ts @@ -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.", + }), + ), +) diff --git a/packages/schema/src/plugin.ts b/packages/schema/src/plugin.ts index 40379e3051..4a003f1199 100644 --- a/packages/schema/src/plugin.ts +++ b/packages/schema/src/plugin.ts @@ -15,4 +15,8 @@ const Added = ephemeral({ type: "plugin.added", schema: { id: ID }, }) -export const Event = { Added, Definitions: inventory(Added) } +const Updated = ephemeral({ + type: "plugin.updated", + schema: {}, +}) +export const Event = { Added, Updated, Definitions: inventory(Added, Updated) } diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 0147c7469b..da344ba528 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -15,6 +15,7 @@ import { EventManifest } from "../src/event-manifest.js" import { FileSystemV1 } from "../src/filesystem-v1.js" import { IdeEvent } from "../src/ide-event.js" import { McpEvent } from "../src/mcp-event.js" +import { Plugin } from "../src/plugin.js" import { SessionEvent } from "../src/session-event.js" import { SessionTodo } from "../src/session-todo.js" import { SessionV1 } from "../src/session-v1.js" @@ -46,6 +47,7 @@ describe("public event manifest", () => { EventManifest.Definitions.map((definition) => definition.type), ) expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated) + expect(EventManifest.Latest.get("plugin.updated")).toBe(Plugin.Event.Updated) expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged) expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false) expect(Agent.Event.Updated.durable).toBeUndefined() @@ -70,6 +72,7 @@ describe("public event manifest", () => { expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied]) expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled]) expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated]) + expect(Plugin.Event.Definitions).toEqual([Plugin.Event.Added, Plugin.Event.Updated]) expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.StatusChanged]) expect(EventManifest.Latest.has("mcp.browser.open.failed")).toBe(false) expect(EventManifest.Latest.has("ide.installed")).toBe(false) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index ae8318d78e..59484dddfb 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -276,6 +276,8 @@ import type { V2CredentialRemoveResponses, V2CredentialUpdateErrors, V2CredentialUpdateResponses, + V2DebugLocationErrors, + V2DebugLocationResponses, V2EventChangesErrors, V2EventChangesResponses, V2EventSubscribeErrors, @@ -8036,6 +8038,20 @@ export class Vcs2 extends HeyApiClient { } } +export class Debug extends HeyApiClient { + /** + * List loaded locations + * + * List locations currently loaded by the server. + */ + public location(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/api/debug/location", + ...options, + }) + } +} + export class V2 extends HeyApiClient { private _health?: Health get health(): Health { @@ -8156,6 +8172,11 @@ export class V2 extends HeyApiClient { get vcs(): Vcs2 { return (this._vcs ??= new Vcs2({ client: this.client })) } + + private _debug?: Debug + get debug(): Debug { + return (this._debug ??= new Debug({ client: this.client })) + } } export class OpencodeClient extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2b6e1d031f..5b9d40cad5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -63,6 +63,7 @@ export type Event = | EventPermissionV2Asked | EventPermissionV2Replied | EventPluginAdded + | EventPluginUpdated | EventProjectDirectoriesUpdated | EventCommandUpdated | EventConfigUpdated @@ -1325,6 +1326,13 @@ export type GlobalEvent = { id: string } } + | { + id: string + type: "plugin.updated" + properties: { + [key: string]: unknown + } + } | { id: string type: "project.directories.updated" @@ -3071,6 +3079,7 @@ export type V2Event = | PermissionV2Asked | PermissionV2Replied | PluginAdded + | PluginUpdated | ProjectDirectoriesUpdated | CommandUpdated | ConfigUpdated @@ -5957,6 +5966,19 @@ export type PluginAdded = { } } +export type PluginUpdated = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "plugin.updated" + location?: LocationRef + data: { + [key: string]: unknown + } +} + export type ProjectDirectoriesUpdated = { id: string created: number @@ -7274,6 +7296,14 @@ export type EventPluginAdded = { } } +export type EventPluginUpdated = { + id: string + type: "plugin.updated" + properties: { + [key: string]: unknown + } +} + export type EventProjectDirectoriesUpdated = { id: string type: "project.directories.updated" @@ -10380,6 +10410,21 @@ export type PluginAdded2 = { } } +export type PluginUpdated2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "plugin.updated" + location?: LocationRef2 + data: + | { + [key: string]: unknown + } + | Array +} + export type ProjectDirectoriesUpdated2 = { id: string created: number @@ -11192,6 +11237,7 @@ export type V2EventV2 = | PermissionV2Asked2 | PermissionV2Replied2 | PluginAdded2 + | PluginUpdated2 | ProjectDirectoriesUpdated2 | CommandUpdated2 | ConfigUpdated2 @@ -19054,6 +19100,35 @@ export type V2VcsDiffResponses = { export type V2VcsDiffResponse = V2VcsDiffResponses[keyof V2VcsDiffResponses] +export type V2DebugLocationData = { + body?: never + path?: never + query?: never + url: "/api/debug/location" +} + +export type V2DebugLocationErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedErrorV2 +} + +export type V2DebugLocationError = V2DebugLocationErrors[keyof V2DebugLocationErrors] + +export type V2DebugLocationResponses = { + /** + * Success + */ + 200: Array +} + +export type V2DebugLocationResponse = V2DebugLocationResponses[keyof V2DebugLocationResponses] + export type PtyConnectData = { body?: never path: { diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index bfae813372..896cbbc592 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -13,6 +13,7 @@ import { EventHandler } from "./handlers/event" import { AgentHandler } from "./handlers/agent" import { PluginHandler } from "./handlers/plugin" import { HealthHandler } from "./handlers/health" +import { DebugHandler } from "./handlers/debug" import { PtyHandler } from "./handlers/pty" import { ShellHandler } from "./handlers/shell" import { QuestionHandler } from "./handlers/question" @@ -27,6 +28,7 @@ import { VcsHandler } from "./handlers/vcs" export const handlers = Layer.mergeAll( HealthHandler, + DebugHandler, LocationHandler, AgentHandler, PluginHandler, diff --git a/packages/server/src/handlers/debug.ts b/packages/server/src/handlers/debug.ts new file mode 100644 index 0000000000..f5ffd536e4 --- /dev/null +++ b/packages/server/src/handlers/debug.ts @@ -0,0 +1,14 @@ +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { Effect, Option, RcMap } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" + +export const DebugHandler = HttpApiBuilder.group(Api, "server.debug", (handlers) => + handlers.handle( + "debug.location", + Effect.fn(function* () { + const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service)) + return Array.from(yield* RcMap.keys(locations.rcMap)) + }), + ), +) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index c5e43989d0..606fde0b84 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -3,6 +3,8 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { EventV2 } from "@opencode-ai/core/event" +import { EventLogger } from "@opencode-ai/core/event-logger" +import { Observability } from "@opencode-ai/core/observability" import { Credential } from "@opencode-ai/core/credential" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { PtyTicket } from "@opencode-ai/core/pty/ticket" @@ -31,6 +33,7 @@ import { sessionLocationLayer } from "./middleware/session-location" const applicationServices = LayerNode.group([ Database.node, EventV2.node, + EventLogger.node, httpClient, ToolOutputStore.cleanupNode, Job.node, @@ -81,7 +84,7 @@ function makeRoutes( : AppNodeBuilder.build(applicationServices, replacements) return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( - Layer.provide(handlers), + Layer.provide(handlers.pipe(Layer.provide(serviceLayer))), Layer.provide(formLocationLayer), Layer.provide(sessionLocationLayer), Layer.provide(layer), @@ -89,6 +92,7 @@ function makeRoutes( Layer.provide(schemaErrorLayer), Layer.provide(auth), Layer.provide(serviceLayer), + Layer.provide(Observability.layer), ) } diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index a3b2c31973..b769a436a0 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1040,6 +1040,12 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }) }) + event.on("plugin.updated", (_evt, { directory, workspace }) => { + if (directory !== project.instance.directory()) return + if (workspace !== project.workspace.current()) return + toast.show({ variant: "success", message: "Plugins reloaded" }) + }) + event.on("tui.session.select", (evt, { workspace }) => { if (workspace !== project.workspace.current()) return route.navigate({ diff --git a/script/publish.ts b/script/publish.ts index 5ee296a6cf..4fcf0890ba 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -41,6 +41,9 @@ await $`bun ./packages/schema/script/publish.ts` console.log("\n=== protocol ===\n") await $`bun ./packages/protocol/script/publish.ts` +console.log("\n=== client ===\n") +await $`bun ./packages/client/script/publish.ts` + console.log("\n=== cli ===\n") await $`bun ./packages/cli/script/publish.ts`