From aea3e7c1d22cda943a68547e643df7ba2e57e817 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 28 Aug 2026 15:43:12 -0400 Subject: [PATCH] fix(core): refresh plugins in background (#45993) --- packages/core/src/plugin/module.ts | 2 +- packages/core/src/plugin/supervisor.ts | 16 +++++ packages/core/test/config/plugin.test.ts | 87 ++++++++++++++++++++++- packages/core/test/npm.test.ts | 2 +- packages/core/test/plugin/module.test.ts | 29 ++++++++ packages/util/src/npm.ts | 1 + packages/www/src/docs/content/plugins.mdx | 7 +- 7 files changed, 137 insertions(+), 7 deletions(-) create mode 100644 packages/core/test/plugin/module.test.ts diff --git a/packages/core/src/plugin/module.ts b/packages/core/src/plugin/module.ts index 2d7ef0a2fbb..56cb837aaad 100644 --- a/packages/core/src/plugin/module.ts +++ b/packages/core/src/plugin/module.ts @@ -40,7 +40,7 @@ export const load = Effect.fn("PluginModule.load")(function* ( const npm = yield* Npm.Service const entrypoint = path.isAbsolute(operation.target) ? pathToFileURL(operation.target).href - : (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint + : (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`)) // Bun currently ignores query parameters when caching file:// imports. const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 4bbc05008fa..a656b4d4231 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -78,6 +78,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( ...post.filter((plugin) => enabled.has(plugin.id)), ], failures: [...failures.values()], + refreshes: [...packages.entries()].flatMap(([target, plugin]) => + !path.isAbsolute(target) && enabled.has(plugin.id) ? [target] : [], + ), } }) @@ -89,6 +92,7 @@ export const layer = Layer.effect( const instance = yield* InstancePlugins.Service const sources = yield* ConfigPluginSource.Service const bus = yield* Bus.Service + const npm = yield* Npm.Service const ready = yield* Latch.make() let observed = 0 @@ -113,6 +117,18 @@ export const layer = Layer.effect( const resolved = yield* resolve(pre, post, operations) // Replace the active generation in one scoped, batched activation. yield* registry.activate(resolved.plugins, resolved.failures) + if (resolved.refreshes.length) { + yield* Effect.forEach( + resolved.refreshes, + (target) => + npm + .add(target, { subpaths: ["server", ""], refresh: true }) + .pipe( + Effect.catchCause((cause) => Effect.logWarning("failed to refresh package plugin", { target, cause })), + ), + { concurrency: "unbounded", discard: true }, + ).pipe(Effect.forkDetach) + } }) const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe( // Make accepted work visible to flush before coalescing the burst. diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index 97695ca37fa..b42e1c008a6 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -7,8 +7,10 @@ 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 { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Global } from "@opencode-ai/util/global" +import { Npm } from "@opencode-ai/util/npm" import { Bus } from "@opencode-ai/core/bus" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-services" @@ -18,7 +20,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { Model } from "@opencode-ai/core/model" import { Provider } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" -import { Effect, Fiber, Logger, Stream } from "effect" +import { Effect, Fiber, Layer, Logger, Schedule, Stream } from "effect" import { Database } from "../../src/database/database" import { tmpdir } from "../fixture/tmpdir" import { tempGlobalLayer } from "../fixture/global" @@ -35,6 +37,40 @@ const staticIt = testEffect( [Global.node, tempGlobalLayer], ]), ) +const refreshNpm = makeGlobalNode({ + service: Npm.Service, + layer: Layer.effect( + Npm.Service, + Effect.gen(function* () { + const global = yield* Global.Service + const directory = path.join(global.tmp, "background-refresh-plugin") + const installed = { directory, entrypoint: pathToFileURL(path.join(directory, "index.js")).href } + return Npm.Service.of({ + add: (_pkg, options) => + options?.refresh + ? Effect.gen(function* () { + yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-requested"), "")) + yield* waitForFile(path.join(directory, "refresh-release")).pipe(Effect.orDie) + yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-finished"), "")) + return installed + }) + : Effect.succeed(installed), + resolve: () => Effect.succeed(installed), + which: () => Effect.succeed(undefined), + }) + }), + ), + deps: [Global.node], +}) +const refreshIt = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]), + [ + [Global.node, tempGlobalLayer], + [Npm.node, refreshNpm], + ], + ), +) describe("PluginSupervisor config", () => { it.live("applies selectors in order", () => @@ -51,7 +87,6 @@ describe("PluginSupervisor config", () => { }), ), ) - it.live("allows the built-in Plan agent to be disabled", () => withLocation( { agents: { plan: { disabled: true } } }, @@ -441,6 +476,47 @@ describe("PluginSupervisor config", () => { ) }), ) + + refreshIt.live("refreshes active package plugins after setup without blocking flush", () => + Effect.gen(function* () { + const global = yield* Global.Service + const directory = path.join(global.tmp, "background-refresh-plugin") + const activated = path.join(directory, "activated") + const release = path.join(directory, "release") + const refreshed = path.join(directory, "refresh-requested") + const refreshRelease = path.join(directory, "refresh-release") + const refreshFinished = path.join(directory, "refresh-finished") + yield* Effect.promise(async () => { + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile( + path.join(directory, "index.js"), + `export default { + id: "background-refresh-plugin", + async setup() { + await Bun.write(${JSON.stringify(activated)}, "") + while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(10) + }, + }`, + ) + }) + + yield* withLocation( + { plugins: ["background-refresh-plugin"] }, + Effect.gen(function* () { + yield* waitForFile(activated) + yield* Effect.sleep("100 millis") + expect(yield* Effect.promise(() => Bun.file(refreshed).exists())).toBeFalse() + yield* Effect.promise(() => Bun.write(release, "")) + yield* waitForFile(refreshed) + yield* ready().pipe(Effect.timeout("2 seconds")) + yield* Effect.promise(() => Bun.write(refreshRelease, "")) + yield* waitForFile(refreshFinished) + const plugins = yield* Plugin.Service + expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("background-refresh-plugin") + }), + ) + }), + ) }) const ready = Effect.fnUntraced(function* () { @@ -448,6 +524,13 @@ const ready = Effect.fnUntraced(function* () { yield* supervisor.flush }) +const waitForFile = (file: string) => + Effect.promise(() => Bun.file(file).exists()).pipe( + Effect.filterOrFail((exists) => exists), + Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }), + Effect.timeout("2 seconds"), + ) + function withLocation( config: unknown, effect: Effect.Effect, diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 4c542dabd86..f31fcec764c 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -234,7 +234,7 @@ describe("Npm.add", () => { const first = await Effect.gen(function* () { const npm = yield* Npm.Service - const mutableEntry = yield* npm.add(mutable, { refresh: true }) + const mutableEntry = yield* npm.add(mutable) const pinnedEntry = yield* npm.add(pinned, { refresh: true }) yield* Effect.promise(async () => { await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n') diff --git a/packages/core/test/plugin/module.test.ts b/packages/core/test/plugin/module.test.ts new file mode 100644 index 00000000000..5b3fc2f20f2 --- /dev/null +++ b/packages/core/test/plugin/module.test.ts @@ -0,0 +1,29 @@ +import path from "path" +import { pathToFileURL } from "url" +import { expect, test } from "bun:test" +import { PluginModule } from "@opencode-ai/core/plugin/module" +import { Npm } from "@opencode-ai/util/npm" +import { Effect } from "effect" + +test("loads cached plugin packages without requesting a refresh", async () => { + const calls: unknown[] = [] + const entrypoint = path.join(import.meta.dir, "fixtures", "config-effect-plugin.ts") + const plugin = await PluginModule.load({ type: "add", target: "fixture-plugin", options: {} }).pipe( + Effect.provideService( + Npm.Service, + Npm.Service.of({ + add: (_pkg, options) => + Effect.sync(() => { + calls.push(options) + return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href } + }), + resolve: () => Effect.die(new Error("Unexpected resolve")), + which: () => Effect.die(new Error("Unexpected which")), + }), + ), + Effect.runPromise, + ) + + expect(plugin.id).toBe("config-effect-plugin") + expect(calls).toEqual([{ subpaths: ["server", ""] }]) +}) diff --git a/packages/util/src/npm.ts b/packages/util/src/npm.ts index 7948a943087..8c1058e5e47 100644 --- a/packages/util/src/npm.ts +++ b/packages/util/src/npm.ts @@ -193,6 +193,7 @@ const layer = Layer.effect( } const tree = yield* reify({ dir, add: [pkg] }) + if (isMutable(parsed)) refreshed.add(pkg) const first = tree.edgesOut.values().next().value?.to if (!first) { const installed = yield* installedName(pkg, dir, parsedName) diff --git a/packages/www/src/docs/content/plugins.mdx b/packages/www/src/docs/content/plugins.mdx index 783ee094b7b..7119963b4df 100644 --- a/packages/www/src/docs/content/plugins.mdx +++ b/packages/www/src/docs/content/plugins.mdx @@ -93,9 +93,10 @@ opencode2 plugin add 'github:acme/plugins#main::path:packages/opencode-plugin' Branches, tags, complete commit hashes, and npm's `::path:` repository-subdirectory selectors are supported. Configure local paths directly; tarball and npm alias targets are not accepted by `plugin add`. -Changes under watched config directories reload automatically. On server startup, OpenCode refreshes unpinned package and -Git plugins once, then uses that result for the lifetime of the server. Exact npm versions and full Git commit hashes stay -pinned. Changes to unwatched local dependencies may still require restarting OpenCode. +Changes under watched config directories reload automatically. Server startup loads cached package plugins immediately, +then refreshes unpinned npm and Git plugins in the background. A refreshed package becomes active the next time the server +starts. Exact npm versions and full Git commit hashes stay pinned. Changes to unwatched local dependencies may still require +restarting OpenCode. ```sh touch .opencode/plugins/concise.ts