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