From 7deb2eed12e83b64e2f4a8c58970839d76a711b5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 26 Oct 2026 08:56:35 +1000 Subject: [PATCH] fix(core): reload compatibility skills only for their own sources Backport of #56260 from beta. Any config change reloaded every .claude and .agents skill and published a skill update. The compatibility plugin now reloads only when the changed path is a compatibility skill directory or a .claude or .agents folder. --- .../core/src/config/plugin/compatibility.ts | 21 +++++++- packages/core/test/config/skill.test.ts | 50 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/core/src/config/plugin/compatibility.ts b/packages/core/src/config/plugin/compatibility.ts index 2ca898ace730..d1d97513a304 100644 --- a/packages/core/src/config/plugin/compatibility.ts +++ b/packages/core/src/config/plugin/compatibility.ts @@ -20,6 +20,8 @@ export const Plugin = define({ const changes = yield* PubSub.sliding(1) const lock = Semaphore.makeUnsafe(1) const skills: Skill.Info[] = [] + const compatibility = () => + config.compatibility ? config.compatibility() : Effect.succeed({ claude: [], agents: [] }) const watch = Effect.fn("ConfigCompatibilityPlugin.watch")(function* ( target: string, @@ -39,7 +41,7 @@ export const Plugin = define({ const refresh = Effect.fn("ConfigCompatibilityPlugin.refresh")( function* () { yield* FiberMap.clear(watches) - const roots = config.compatibility ? yield* config.compatibility() : { claude: [], agents: [] } + const roots = yield* compatibility() const directories = [...roots.claude, ...roots.agents].map((root) => path.join(root, "skills")) const loaded = new Map() for (const directory of directories) { @@ -66,7 +68,10 @@ export const Plugin = define({ Effect.forkScoped({ startImmediately: true }), ) yield* config.changes().pipe( - Stream.runForEach(() => reload), + Stream.filterEffect((update) => + Effect.map(compatibility(), (roots) => isCompatibilitySource(roots, update.path)), + ), + Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid)), Effect.forkScoped({ startImmediately: true }), ) yield* refresh() @@ -75,3 +80,15 @@ export const Plugin = define({ }) }), }) + +const sourceDirectories = [".claude", ".agents"] as const + +function isCompatibilitySource( + roots: { readonly claude: readonly string[]; readonly agents: readonly string[] }, + file: string, +) { + return ( + sourceDirectories.some((name) => path.basename(file) === name) || + [...roots.claude, ...roots.agents].some((root) => FSUtil.contains(root, file)) + ) +} diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 5568eda0f41b..6d0882f0caf0 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -21,6 +21,7 @@ import { Skill } from "@opencode/core/skill" import { SkillDiscovery } from "@opencode/core/skill/discovery" import { WellKnown } from "@opencode/core/wellknown" import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes" +import { advance, drain } from "../lib/clock" import { tmpdir } from "../fixture/tmpdir" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" @@ -426,4 +427,53 @@ describe("ConfigSkillPlugin.Plugin", () => { ), ), ) + + it.effect("ignores unrelated config updates and coalesces compatibility bursts into one reload", () => + Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const claude = path.join(tmp.path, ".claude") + const skills = path.join(claude, "skills") + const service = yield* Skill.Service + const configTest = yield* Config.Test + let reloads = 0 + yield* ConfigCompatibilityPlugin.Plugin.effect( + host({ + skill: { + list: () => Effect.die("unused skill.list"), + transform: service.transform, + reload: () => service.reload().pipe(Effect.tap(() => Effect.sync(() => reloads++))), + }, + }), + ) + yield* Effect.yieldNow + + yield* configTest.emitChange({ type: "update", path: path.join(tmp.path, "opencode.json") }) + yield* configTest.emitChange({ type: "create", path: path.join(tmp.path, "commands", "review.md") }) + yield* configTest.emitChange({ type: "create", path: path.join(tmp.path, "agents", "reviewer.md") }) + yield* drain + expect(reloads).toBe(0) + + yield* Effect.promise(async () => { + await fs.mkdir(path.join(skills, "review"), { recursive: true }) + await write(skills, "review", "Review once") + }) + yield* configTest.emitChange({ type: "create", path: claude }) + yield* configTest.emitChange({ type: "create", path: path.join(skills, "review", "SKILL.md") }) + yield* configTest.emitChange({ type: "update", path: path.join(skills, "review", "SKILL.md") }) + yield* advance(() => reloads >= 1) + yield* drain + expect(reloads).toBe(1) + expect((yield* service.list()).find((item) => item.id === "review")?.description).toBe("Review once") + }).pipe( + Effect.provide( + Config.testLayer( + [new Directory({ type: "directory", path: AbsolutePath.make(tmp.path) })], + { claude: [AbsolutePath.make(path.join(tmp.path, ".claude"))], agents: [] }, + ), + ), + ), + ), + ), + ) })