Compare commits

...
Author SHA1 Message Date
Kit Langton 4e546127f1 fix(core): reload directory plugins as one unit 2026-09-02 17:12:52 -04:00
8 changed files with 248 additions and 15 deletions
+1 -3
View File
@@ -301,9 +301,7 @@ export const layer = (options?: Options) =>
const initial = yield* discover()
let configs = initial
const updates = yield* PubSub.unbounded<Watcher.Update>()
// Vendored trees inside config roots (a plugin's node_modules, a nested
// .git) produce event blizzards that can never change discovery output.
const ignore = ["node_modules", ".git", "**/{node_modules,.git}/**"]
const ignore = Watcher.vendored
// Watch-once: roots leave discovery only by deletion, so a stale watch is
// inert, bounded, and dies with this layer — and keeping a deleted root's
// watch alive is exactly what makes its recreation observable.
+10 -10
View File
@@ -41,8 +41,10 @@ export const layer = Layer.effect(
const configuredChanges = yield* PubSub.unbounded<void>()
const watched = new Set<string>()
// Configured local plugin entrypoints can live outside config roots, where the
// config change feed cannot see them; watch those entrypoints directly.
// Configured local plugin directories can live outside config roots, where the
// config change feed cannot see them; watch those directories directly, with
// the same vendored-tree ignores as config roots, so a sibling module edit
// reloads the plugin exactly as it does under a config root.
// 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.
@@ -56,7 +58,11 @@ export const layer = Layer.effect(
// The config change feed already covers {plugin,plugins} directories.
if (isPluginSource(entries, operation.target)) continue
watched.add(operation.target)
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
const updates = yield* watcher.subscribe({
path: path.dirname(operation.target),
type: "directory",
ignore: Watcher.vendored,
})
yield* updates.pipe(
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
Effect.catchCause((cause) =>
@@ -158,13 +164,7 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
// Explicit config is applied last so it can remove auto-discovered packages.
return yield* Effect.forEach([...discovered, ...resolved], (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.orElseSucceed(() => operation),
)
return PluginSourceDirectory.mtime(fs, operation.target).pipe(Effect.map((mtime) => ({ ...operation, mtime })))
})
})
+4
View File
@@ -36,6 +36,10 @@ export type WatchInput =
| { readonly path: string; readonly type: "file" }
| { readonly path: string; readonly type: "directory"; readonly ignore?: readonly string[] }
// Vendored trees inside a watched root (a plugin's node_modules, a nested .git)
// produce event blizzards that can never change what the root discovers.
export const vendored: readonly string[] = ["node_modules", ".git", "**/{node_modules,.git}/**"]
export type Subscription = {
readonly unsubscribe: () => Promise<void>
/** Backend name for logging, e.g. "node" or "fs-events". */
+4 -2
View File
@@ -10,6 +10,7 @@ import { pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Generation } from "../plugin.js"
import { PluginPromise } from "./promise.js"
import { PluginSourceDirectory } from "./source-directory.js"
const Module = Schema.Struct({
default: Schema.Union([
@@ -87,8 +88,9 @@ export const load = Effect.fn("PluginModule.load")(function* (
})
function localFeatures(entrypoint: string) {
if (!path.basename(entrypoint).startsWith("index.")) return Effect.succeed({})
return Effect.promise(() => readdir(path.dirname(entrypoint), { withFileTypes: true })).pipe(
const directory = PluginSourceDirectory.root(entrypoint)
if (!directory) return Effect.succeed({})
return Effect.promise(() => readdir(directory, { withFileTypes: true })).pipe(
Effect.map((entries) => {
const names = new Set(
entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name),
@@ -32,6 +32,49 @@ export const discover = Effect.fn("PluginSourceDirectory.discover")(function* (
return targets.flatMap(Option.toArray)
})
/** The directory a plugin entrypoint belongs to, or undefined for a single-file plugin. */
export function root(entrypoint: string) {
return path.basename(entrypoint).startsWith("index.") ? path.dirname(entrypoint) : undefined
}
/**
* Revision timestamp for a local plugin: the newest mtime across the directory
* for a directory plugin, so an edit to any sibling module reloads the whole
* unit, or the entrypoint's own mtime for a single-file plugin. Vendored trees
* cannot change the plugin and are skipped.
*/
export const mtime = Effect.fn("PluginSourceDirectory.mtime")(function* (fs: FSUtil.Interface, entrypoint: string) {
const directory = root(entrypoint)
const files = directory ? yield* walk(fs, directory) : [entrypoint]
const times = yield* Effect.forEach(files, (file) =>
fs.stat(file).pipe(
Effect.map((info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime()),
Effect.orElseSucceed(() => 0),
),
)
return Math.max(0, ...times)
})
const vendored = new Set(["node_modules", ".git"])
// Symlinks are stamped but never followed, so a linked directory cannot loop the walk.
// An unreadable directory contributes nothing rather than failing the revision.
function walk(fs: FSUtil.Interface, directory: string): Effect.Effect<string[]> {
return fs.readDirectoryEntries(directory).pipe(
Effect.orElseSucceed(() => []),
Effect.flatMap((entries) =>
Effect.forEach(
entries.filter((entry) => !vendored.has(entry.name)),
(entry) =>
entry.type === "directory"
? walk(fs, path.join(directory, entry.name))
: Effect.succeed([path.join(directory, entry.name)]),
),
),
Effect.map((nested) => nested.flat()),
)
}
export function entrypoint(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const root = yield* fs.resolve(directory)
@@ -0,0 +1,146 @@
import { describe, expect, setDefaultTimeout } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Duration, Effect, Layer, LayerMap, Schedule } from "effect"
import { Event } from "@opencode-ai/schema/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Bus } from "@opencode-ai/core/bus"
import { Command } from "@opencode-ai/core/command"
import { Database } from "@opencode-ai/core/database/database"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Instance } from "@opencode-ai/core/instance"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tempGlobalLayer } from "../fixture/global"
import { tmpdirScoped } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
// Real Location boot with plugin-directory discovery, so directory plugins are loaded and reloaded from disk.
setDefaultTimeout(15_000)
const watcher = Watcher.testLayer
const instances = Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const map = yield* LayerMap.make((ref: Location.Ref) => Instance.layer(ref, { replacements: bindings }), {
idleTimeToLive: Duration.infinity,
})
const bindings: LayerNode.Replacements = [
Global.node.replace(tempGlobalLayer),
Watcher.node.replace(watcher),
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)),
Instance.node.replace(
Layer.succeed(Instance.Service, {
provide: (session) => Effect.provide(map.get(session.location)),
}),
),
]
return map
}),
)
const it = testEffect(
Layer.merge(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
LocationServiceMap.node.replace(instances),
]),
watcher,
),
)
// The entrypoint never changes; only the sibling module it imports does.
const index = `import { description } from "./greeting.ts"
export default {
id: "acme",
async setup(ctx) {
await ctx.command.transform((editor) => editor.add({ name: "acme", description, execute: async () => {} }))
},
}`
const greeting = (description: string) => `export const description = ${JSON.stringify(description)}\n`
// Local plugin revisions key on mtime, so give each write a distinct timestamp.
const write = (file: string, content: string, mtime: Date) =>
Effect.promise(async () => {
await Bun.write(file, content)
await fs.utimes(file, mtime, mtime)
})
// Reloads do filesystem work after the publish returns, so poll for the outcome.
const described = (commands: Command.Interface, description: string) =>
commands.get("acme").pipe(
Effect.flatMap((command) =>
command?.description === description ? Effect.succeed(command) : Effect.fail("not reloaded"),
),
Effect.retry({ times: 200, schedule: Schedule.spaced("25 millis") }),
)
describe("directory plugin reload", () => {
it.live("reloads a discovered directory plugin when only a sibling module changes", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const plugin = path.join(directory.path, ".opencode/plugins/acme")
const past = new Date(Date.now() - 60_000)
yield* write(path.join(plugin, "index.ts"), index, past)
yield* write(path.join(plugin, "greeting.ts"), greeting("Greets v1"), past)
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
yield* plugins.awaitActivation
expect(yield* commands.get("acme")).toMatchObject({ description: "Greets v1" })
yield* write(path.join(plugin, "greeting.ts"), greeting("Greets v2"), new Date())
yield* bus.publish(Event.Updated, {})
expect(yield* described(commands, "Greets v2")).toMatchObject({ description: "Greets v2" })
}).pipe(
Effect.scoped,
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
)
}),
)
it.live("watches a configured plugin directory outside config roots as one unit", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const plugin = path.join(directory.path, "tools/acme")
const past = new Date(Date.now() - 60_000)
yield* write(path.join(plugin, "index.ts"), index, past)
yield* write(path.join(plugin, "greeting.ts"), greeting("Greets v1"), past)
// Relative plugin paths resolve against the config file's directory.
yield* Effect.promise(() =>
Bun.write(path.join(directory.path, "opencode.json"), JSON.stringify({ plugins: ["./tools/acme"] })),
)
const locations = yield* LocationServiceMap.Service
const watches = yield* Watcher.Test
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
yield* plugins.awaitActivation
expect(yield* commands.get("acme")).toMatchObject({ description: "Greets v1" })
expect(yield* watches.subscriptions()).toContainEqual({
path: plugin,
type: "directory",
ignore: [...Watcher.vendored].toSorted(),
})
yield* write(path.join(plugin, "greeting.ts"), greeting("Greets v2"), new Date())
yield* watches.emit({ type: "update", path: path.join(plugin, "greeting.ts") })
expect(yield* described(commands, "Greets v2")).toMatchObject({ description: "Greets v2" })
}).pipe(
Effect.scoped,
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
)
}),
)
})
+27
View File
@@ -1,3 +1,30 @@
import path from "node:path"
// A query on a plain-path import makes Bun evaluate a fresh copy of that file,
// but the file's relative imports still resolve to their cached originals. Carry
// the importer's query down so a re-imported entrypoint re-evaluates its whole
// local module graph, not just its own file. Bare specifiers are untouched.
Bun.plugin({
name: "opencode-import-query",
setup(build) {
build.onResolve({ filter: /^\.\.?\// }, (args) => {
const index = args.importer.indexOf("?")
if (index === -1) return undefined
const resolved = resolveRelative(args.path, path.dirname(args.importer.slice(0, index)))
return resolved ? { path: `${resolved}${args.importer.slice(index)}` } : undefined
})
},
})
// Fall through to Bun's own resolution (and its error message) when the import is unresolvable.
function resolveRelative(specifier: string, directory: string) {
try {
return Bun.resolveSync(specifier, directory)
} catch {
return undefined
}
}
export function importModule(specifier: string) {
return import(specifier) as Promise<unknown>
}
+13
View File
@@ -16,6 +16,19 @@ await new Script('import("node:module")', {
}).runInThisContext()
conditionHooks.deregister()
// A query on a file: import makes Node evaluate a fresh copy of that file, but
// the file's relative imports still resolve to their cached originals. Carry the
// importer's query down so a re-imported entrypoint re-evaluates its whole local
// module graph, not just its own file. Bare specifiers are untouched.
registerHooks({
resolve(specifier, context, nextResolve) {
const result = nextResolve(specifier, context)
if (!context.parentURL || !/^\.\.?\//.test(specifier) || !result.url.startsWith("file:")) return result
const search = new URL(context.parentURL).search
return search ? { ...result, url: `${result.url}${search}` } : result
},
})
export async function importModule(specifier: string) {
const imported = (await new Script(`import(${JSON.stringify(specifier)})`, {
importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER,