diff --git a/packages/core/src/config/plugin/source.ts b/packages/core/src/config/plugin/source.ts new file mode 100644 index 000000000000..8123a0949a45 --- /dev/null +++ b/packages/core/src/config/plugin/source.ts @@ -0,0 +1,179 @@ +export * as ConfigPluginSource from "./source" + +import { Directory, Document, type Entry } from "@opencode-ai/schema/config" +import { ConfigPlugin } from "@opencode-ai/schema/config/plugin" +import { FSUtil } from "@opencode-ai/util/fs-util" +import { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect" +import path from "path" +import { fileURLToPath } from "url" +import { Config } from "../../config" +import { Watcher } from "../../filesystem/watcher" +import { Location } from "../../location" + +export type Operation = + | { + readonly type: "add" + readonly target: string + readonly options: Record + readonly mtime?: number + } + | { + readonly type: "remove" + readonly target: string + } + +export interface Interface { + readonly operations: () => Effect.Effect + readonly changes: () => Stream.Stream +} + +export class Service extends Context.Service()("@opencode/ConfigPluginSource") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const watcher = yield* Watcher.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const configuredChanges = yield* PubSub.unbounded() + const watched = new Set() + + // Configured local plugin files can live outside config roots, where the + // config change feed cannot see them; watch those entrypoints directly. + // Watches start on first sighting and are never torn down individually: + // a stale watch after a config edit costs one deduped fs handle and a + // no-op activation, and every watch dies with this layer's scope. + const watchConfiguredSources = Effect.fn("ConfigPluginSource.watchConfiguredSources")(function* ( + entries: readonly Entry[], + operations: readonly Operation[], + ) { + for (const operation of operations) { + if (operation.type !== "add" || !path.isAbsolute(operation.target)) continue + if (watched.has(operation.target)) continue + // The config change feed already covers {plugin,plugins} directories. + if (isPluginSource(entries, operation.target)) continue + // Directory targets can't hot-reload (their stat mtime ignores edits + // inside), so don't watch what can't trigger anything. + if (yield* fs.isDir(operation.target)) continue + watched.add(operation.target) + const updates = yield* watcher.subscribe({ path: operation.target, type: "file" }) + yield* updates.pipe( + Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)), + Effect.catchCause((cause) => + Effect.logError("configured plugin watch failed", { target: operation.target, cause }), + ), + Effect.forkScoped({ startImmediately: true }), + ) + } + }) + + return Service.of({ + operations: Effect.fn("ConfigPluginSource.operations")(function* () { + const entries = yield* config.entries() + const operations = yield* scan(fs, location, entries) + yield* watchConfiguredSources(entries, operations) + return operations + }), + changes: () => + Stream.merge( + config.changes().pipe( + Stream.filterEffect((update) => + Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)), + ), + Stream.map(() => undefined), + ), + Stream.fromPubSub(configuredChanges), + ), + }) + }), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Config.node, FSUtil.node, Watcher.node, Location.node], +}) + +export const empty = makeLocationNode({ + service: Service, + layer: Layer.succeed( + Service, + Service.of({ + operations: () => Effect.succeed([]), + changes: () => Stream.never, + }), + ), + deps: [], +}) + +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("ConfigPluginSource.scan")(function* ( + fs: FSUtil.Interface, + location: Location.Interface, + entries: readonly Entry[], +) { + const discovered = yield* Effect.forEach( + entries.filter((entry): entry is Directory => entry.type === "directory"), + (entry) => discoverDirectory(fs, entry.path), + ).pipe(Effect.map((items) => items.flat())) + const configured = entries + .filter((entry): entry is Document => entry.type === "document") + .flatMap((entry) => + (entry.info.plugins ?? []).map(parse).map((operation) => { + if (operation.type === "remove") return 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, 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)), + ) + }) +}) + +function discoverDirectory(fs: FSUtil.Interface, directory: string) { + return Effect.gen(function* () { + const files = yield* fs + .scan("{plugin,plugins}/*.{ts,js}", { + cwd: directory, + absolute: true, + include: "file", + dot: true, + symlink: true, + }) + .pipe(Effect.orElseSucceed(() => [])) + return files.sort().map((target): Operation => ({ type: "add", target, options: {} })) + }) +} + +const sourceDirectories = ["plugin", "plugins"] as const + +function isPluginSource(entries: readonly Entry[], file: string) { + return entries.some( + (entry) => + entry.type === "directory" && + sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)), + ) +} diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index a76d6994abef..2d2c4b3f27b4 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -1,6 +1,8 @@ export * as PluginInternal from "./internal" import type { Plugin } from "@opencode-ai/plugin/effect/plugin" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { httpClient } from "@opencode-ai/util/effect/app-node-platform" import { Context, Effect, Scope } from "effect" import { HttpClient } from "effect/unstable/http" import { Agent } from "../agent" @@ -137,6 +139,41 @@ type ContextServices = A extends Context.Context ? R : never export type Requirements = ContextServices>> +export const requirements = LayerNode.group([ + Agent.node, + Catalog.node, + Command.node, + Config.node, + Credential.node, + Bus.node, + Environment.node, + FileMutation.node, + Formatter.node, + FileSystem.node, + FSUtil.node, + Global.node, + httpClient, + Image.node, + Integration.node, + KV.node, + Location.node, + LocationMutation.node, + ModelsDev.node, + Npm.node, + Permission.node, + PluginRuntime.node, + Form.node, + ReadToolFileSystem.node, + Reference.node, + WebSearch.node, + Ripgrep.node, + SessionInstructions.node, + Shell.node, + Skill.node, + Tool.node, + WellKnown.node, +]) + export type InternalPlugin = Plugin const pre = [ diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 9343424ac25b..4e60e4a434b8 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -1,48 +1,17 @@ export * as PluginSupervisor from "./supervisor" import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin" -import { Directory, Document, Event, type Entry } from "@opencode-ai/schema/config" -import { ConfigPlugin } from "@opencode-ai/schema/config/plugin" -import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" +import { Event } from "@opencode-ai/schema/config" +import { Context, Deferred, Effect, Layer, Schema, Stream } from "effect" import path from "path" -import { fileURLToPath, pathToFileURL } from "url" -import { Agent } from "../agent" -import { Catalog } from "../catalog" -import { Command } from "../command" -import { Config } from "../config" -import { Credential } from "../credential" +import { pathToFileURL } from "url" +import { ConfigPluginSource } from "../config/plugin/source" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" -import { httpClient } from "@opencode-ai/util/effect/app-node-platform" import { Bus } from "../bus" -import { Environment } from "../environment" -import { FileMutation } from "../file-mutation" -import { Formatter } from "../formatter" -import { FileSystem } from "../filesystem" -import { Watcher } from "../filesystem/watcher" -import { Form } from "../form" -import { FSUtil } from "@opencode-ai/util/fs-util" -import { Global } from "@opencode-ai/util/global" -import { Image } from "../image" -import { Integration } from "../integration" -import { KV } from "../kv" -import { Location } from "../location" -import { LocationMutation } from "../location-mutation" -import { ModelsDev } from "../models-dev" import { Npm } from "@opencode-ai/util/npm" -import { Permission } from "../permission" import { Plugin } from "../plugin" import { PluginPromise } from "../plugin/promise" -import { Reference } from "../reference" -import { Ripgrep } from "../ripgrep" -import { SessionInstructions } from "../session/instructions" -import { Shell } from "../shell" -import { Skill } from "../skill" -import { ReadToolFileSystem } from "../tool/read-filesystem" -import { Tool } from "../tool" -import { WebSearch } from "../websearch" -import { WellKnown } from "../wellknown" import { PluginInternal } from "./internal" -import { PluginRuntime } from "./runtime" import { SdkPlugins } from "./sdk" import { importModule } from "@opencode-ai/util/runtime-import" @@ -63,65 +32,10 @@ const PluginModule = Schema.Struct({ ]), }) -type Operation = - | { - readonly type: "add" - readonly target: string - readonly options: Record - readonly mtime?: number - } - | { - readonly type: "remove" - readonly target: string - } - -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 Entry[]) { - const fs = yield* FSUtil.Service - const location = yield* Location.Service - const discovered = yield* Effect.forEach( - entries.filter((entry): entry is Directory => entry.type === "directory"), - (entry) => discoverDirectory(fs, entry.path), - ).pipe(Effect.map((items) => items.flat())) - const configured = entries - .filter((entry): entry is Document => entry.type === "document") - .flatMap((entry) => - (entry.info.plugins ?? []).map(parse).map((operation) => { - if (operation.type === "remove") return 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, 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.Versioned[], post: readonly Plugin.Versioned[], - operations: readonly Operation[], + operations: readonly ConfigPluginSource.Operation[], ) { const matches = (selector: string, target: string) => selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target) @@ -168,7 +82,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( ] }) -const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract) { +const load = Effect.fn("PluginSupervisor.load")(function* ( + operation: Extract, +) { const npm = yield* Npm.Service const entrypoint = path.isAbsolute(operation.target) ? pathToFileURL(operation.target).href @@ -192,31 +108,6 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract [])) - return files.sort().map((target): Operation => ({ type: "add", target, options: {} })) - }) -} - -const sourceDirectories = ["plugin", "plugins"] as const - -function isPluginSource(entries: readonly Entry[], file: string) { - return entries.some( - (entry) => - entry.type === "directory" && - sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)), - ) -} - export interface Interface { /** Wait for the initial plugin generation and startup updates to settle. */ readonly flush: Effect.Effect @@ -224,72 +115,29 @@ export interface Interface { export class Service extends Context.Service()("@opencode/PluginSupervisor") {} -const layer = Layer.effect( +export const layer = Layer.effect( Service, Effect.gen(function* () { const registry = yield* Plugin.Service const sdk = yield* SdkPlugins.Service - const config = yield* Config.Service + const sources = yield* ConfigPluginSource.Service const bus = yield* Bus.Service - const watcher = yield* Watcher.Service - const fs = yield* FSUtil.Service const ready = { current: yield* Deferred.make() } let observed = 0 - // Configured local plugin files can live outside config roots, where the - // config change feed cannot see them; watch those entrypoints directly. - // Watches start on first sighting and are never torn down individually: - // a stale watch after a config edit costs one deduped fs handle and a - // no-op activation, and every watch dies with this layer's scope. - const configuredChanges = yield* PubSub.unbounded() - const watched = new Set() - const watchConfiguredSources = Effect.fn("PluginSupervisor.watchConfiguredSources")(function* ( - entries: readonly Entry[], - operations: readonly Operation[], - ) { - for (const operation of operations) { - if (operation.type !== "add" || !path.isAbsolute(operation.target)) continue - if (watched.has(operation.target)) continue - // The config change feed already covers {plugin,plugins} directories. - if (isPluginSource(entries, operation.target)) continue - // Directory targets can't hot-reload (their stat mtime ignores edits - // inside), so don't watch what can't trigger anything. - if (yield* fs.isDir(operation.target)) continue - watched.add(operation.target) - const updates = yield* watcher.subscribe({ path: operation.target, type: "file" }) - yield* updates.pipe( - Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)), - Effect.catchCause((cause) => - Effect.logError("configured plugin watch failed", { target: operation.target, cause }), - ), - Effect.forkScoped({ startImmediately: true }), - ) - } - }) - const activate = Effect.fn("PluginSupervisor.activate")(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.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()] const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" })) - const entries = yield* config.entries() - const operations = yield* scan(entries) - yield* watchConfiguredSources(entries, operations) + const operations = yield* sources.operations() // Apply config operations and load enabled package plugins into one ordered generation. const plugins = yield* resolve(pre, post, operations) // Replace the active generation in one scoped, batched activation. yield* registry.activate(plugins) }) - const updates = Stream.merge( - config.changes().pipe( - Stream.filterEffect((update) => - Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)), - ), - Stream.merge(Stream.fromPubSub(configuredChanges)), - ), - bus.subscribe([Event.Updated, SdkPlugins.Updated]), - ).pipe( + const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe( // Make accepted work visible to flush before coalescing the burst. Stream.mapEffect(() => Effect.gen(function* () { @@ -315,48 +163,13 @@ const layer = Layer.effect( }), ) -const nodeLayer = layer as Layer.Layer - -export const node = makeLocationNode({ - service: Service, - layer: nodeLayer, - deps: [ - Plugin.node, - SdkPlugins.node, - Agent.node, - Catalog.node, - Command.node, - Config.node, - Credential.node, - Bus.node, - Environment.node, - FileMutation.node, - Formatter.node, - FileSystem.node, - FSUtil.node, - Global.node, - httpClient, - Image.node, - Integration.node, - KV.node, - Location.node, - LocationMutation.node, - ModelsDev.node, - Npm.node, - Permission.node, - PluginRuntime.node, - Form.node, - ReadToolFileSystem.node, - Reference.node, - Ripgrep.node, - SessionInstructions.node, - Shell.node, - Skill.node, - Tool.node, - Watcher.node, - WebSearch.node, - WellKnown.node, - ], -}) +const nodeDeps = [ + Plugin.node, + SdkPlugins.node, + ConfigPluginSource.node, + Bus.node, + Npm.node, + PluginInternal.requirements, +] as const -export { layer } +export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps }) diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index 65a91c6f5053..bf6b0cf52f6d 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -5,6 +5,7 @@ import { describe, expect } from "bun:test" import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect" import { Agent } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" +import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Bus } from "@opencode-ai/core/bus" @@ -24,6 +25,11 @@ import { testEffect } from "../lib/effect" const it = testEffect( AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])), ) +const staticIt = testEffect( + AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [ + [ConfigPluginSource.node, ConfigPluginSource.empty], + ]), +) describe("PluginSupervisor config", () => { it.live("applies selectors in order", () => @@ -157,6 +163,29 @@ describe("PluginSupervisor config", () => { ), ) + staticIt.live("uses only internal and SDK plugins when the static source is wired", () => + Effect.gen(function* () { + const sdk = yield* SdkPlugins.Service + yield* sdk.register(EffectPlugin.define({ id: "static-sdk", effect: () => Effect.void })) + yield* withLocation( + { plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] }, + Effect.gen(function* () { + yield* ready() + const plugins = yield* Plugin.Service + const ids = (yield* plugins.list()).map((plugin) => String(plugin.id)) + expect(ids).toContain("opencode.agent") + expect(ids).toContain("static-sdk") + expect(ids).not.toContain("config-promise-plugin") + + const agents = yield* Agent.Service + expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined() + expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined() + }), + true, + ) + }), + ) + it.live("reloads an auto-discovered plugin when its file changes", () => withLocation( undefined,