|
|
@@ -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<string, unknown>
|
|
|
- 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<Operation, { type: "add" }>) {
|
|
|
+const load = Effect.fn("PluginSupervisor.load")(function* (
|
|
|
+ operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
|
|
+) {
|
|
|
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<Op
|
|
|
} satisfies Plugin.Versioned
|
|
|
})
|
|
|
|
|
|
-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)),
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
export interface Interface {
|
|
|
/** Wait for the initial plugin generation and startup updates to settle. */
|
|
|
readonly flush: Effect.Effect<void>
|
|
|
@@ -224,72 +115,29 @@ export interface Interface {
|
|
|
|
|
|
export class Service extends Context.Service<Service, Interface>()("@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<void>() }
|
|
|
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<void>()
|
|
|
- const watched = new Set<string>()
|
|
|
- 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<Service, never, PluginInternal.Requirements>
|
|
|
-
|
|
|
-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 })
|