Browse Source

refactor(core): move plugin discovery and watching to the config side (#41618)

Kit Langton 6 days ago
parent
commit
71f5e4189d

+ 179 - 0
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<string, unknown>
+      readonly mtime?: number
+    }
+  | {
+      readonly type: "remove"
+      readonly target: string
+    }
+
+export interface Interface {
+  readonly operations: () => Effect.Effect<readonly Operation[], never, Scope.Scope>
+  readonly changes: () => Stream.Stream<void>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@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<void>()
+    const watched = new Set<string>()
+
+    // 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)),
+  )
+}

+ 37 - 0
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> = A extends Context.Context<infer R> ? R : never
 
 export type Requirements = ContextServices<Effect.Success<ReturnType<typeof services>>>
 
+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<Requirements | Scope.Scope>
 
 const pre = [

+ 21 - 208
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<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 })

+ 29 - 0
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,