From c6de195ccae97fc124410827bb1f770792fc52ed Mon Sep 17 00:00:00 2001 From: Yusuf Efe <120668197+yuefdev@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:32:21 +0300 Subject: [PATCH] fix(core): normalise absolute plugin paths before dedupe A configured plugin spelled `C:/x/plugins/foo` did not match the auto-discovered `C:\x\plugins\foo`, so it loaded twice, failed as a duplicate ID, and its options were dropped. Resolve absolute specs the same way relative ones already are. --- packages/core/src/config/plugin/source.ts | 3 +- .../test/plugin/supervisor-reload.test.ts | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/core/src/config/plugin/source.ts b/packages/core/src/config/plugin/source.ts index ef1057abe82d..c877d086fc40 100644 --- a/packages/core/src/config/plugin/source.ts +++ b/packages/core/src/config/plugin/source.ts @@ -137,9 +137,10 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* ( (entry.info.plugins ?? []).map(parse).map((operation) => { if (operation.type === "remove") return operation const directory = entry.path ? path.dirname(entry.path) : location.directory + // Absolute paths are normalised too, so `C:/x/` matches the discovered `C:\x` target. const target = operation.target.startsWith("file://") ? fileURLToPath(operation.target) - : operation.target.startsWith("./") || operation.target.startsWith("../") + : operation.target.startsWith("./") || operation.target.startsWith("../") || path.isAbsolute(operation.target) ? path.resolve(directory, operation.target) : operation.target return { ...operation, target } diff --git a/packages/core/test/plugin/supervisor-reload.test.ts b/packages/core/test/plugin/supervisor-reload.test.ts index c4005882e691..90372619e141 100644 --- a/packages/core/test/plugin/supervisor-reload.test.ts +++ b/packages/core/test/plugin/supervisor-reload.test.ts @@ -246,6 +246,44 @@ describe("PluginSupervisor reload", () => { ) }) + it.live("applies configured options to a discovered plugin spelled with different separators", () => + Effect.gen(function* () { + const directory = yield* tmpdirScoped() + const root = path.join(directory.path, ".opencode/plugins/greeter") + yield* Effect.promise(async () => { + await Bun.write( + path.join(root, "index.ts"), + `export default { + id: "greeter", + async setup(ctx) { + await ctx.command.transform((editor) => editor.add({ name: "greet-" + ctx.options.name, execute: async () => {} })) + }, + }`, + ) + // Forward slashes and a trailing separator spell the discovered directory differently on every platform. + await Bun.write( + path.join(directory.path, ".opencode/opencode.json"), + JSON.stringify({ + plugins: [{ package: root.replaceAll(path.sep, "/") + "/", options: { name: "configured" } }], + }), + ) + }) + 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("greet-configured")).toBeDefined() + expect(yield* commands.get("greet-undefined")).toBeUndefined() + expect((yield* plugins.list()).filter((plugin) => plugin.state.status === "failed")).toEqual([]) + }).pipe( + Effect.scoped, + Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))), + ) + }), + ) + it.live("keeps the running generation when an updated local plugin fails to import", () => Effect.gen(function* () { const directory = yield* tmpdirScoped()