You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
plugin: a local plugin whose directory symlink is retargeted is silently dropped until restart #53763
A plugin in ~/.config/opencode/plugins/ that is a symlink to a directory disappears from a running server once the symlink is pointed at another directory (as nix/home-manager, stow or a dotfiles script do on update). The next plugin reload in each Location, and every Location created afterwards, leaves it out: no "failed" entry in /api/plugin, no log line. When the plugin registers a provider, every turn then fails with "Model unavailable". A restart loads the new target fine.
Cause: ConfigPluginSource.scan (packages/core/src/config/plugin/source.ts) drops a directory plugin unless FSUtil.contains(fs.resolve(target), fs.resolve(entry)). fs.resolve is a fresh realpathSync and sees the new target, but entry comes from Host.resolve → Bun.resolveSync, and Bun caches a symlinked directory's resolution for the life of the process, so it still points into the old target. With Bun 1.4.2:
// plugins/x -> ABun.resolveSync(`${dir}/index`,dir)// A/index.js// ln -sfn B plugins/xBun.resolveSync(`${dir}/index`,dir)// still A/index.js; realpathSync(dir) is B
Resolving the entry from the directory's real path (Host.resolve({ directory: fs.resolve(target) }), in scan and in PluginModule.load) would make both sides agree, and a plugin the containment check rejects could be reported as failed rather than dropped. Related: #52240 (the same Bun caching, for in-place edits under a symlinked config directory).
OpenCode version
2.0.24 (Bun 1.4.2)
Steps to reproduce
Two directories /tmp/p/A and /tmp/p/B, each with index.js: export default { id: "demo", setup() {} }.
ln -s /tmp/p/A ~/.config/opencode/plugins/demo, start opencode serve, open a session; GET /api/plugin lists demo.
ln -sfn /tmp/p/B ~/.config/opencode/plugins/demo.
GET /api/plugin no longer lists demo; a session in a new directory doesn't have it either.
Thanks for the detailed report. I reproduced this on v2.0.24:
Created /tmp/p/A and /tmp/p/B, each with index.js exporting { id: "demo", setup() {} }, and symlinked ~/.config/opencode/plugins/demo -> /tmp/p/A.
Started opencode2 serve. GET /api/plugin listed demo (local, /tmp/p/A/index.js, active).
Ran ln -sfn /tmp/p/B ~/.config/opencode/plugins/demo.
After the reload, demo was gone from /api/plugin for the existing directory, and a new directory didn't have it either. There was no failed entry and no log line.
After restarting the server, demo loaded from /tmp/p/B/index.js as expected.
This fits your analysis: the entrypoint still resolves into the old target while the realpath of the directory points at the new one, so the containment check in ConfigPluginSource.scan drops the plugin without reporting anything.
I read the scan path: ConfigPluginSource.scan builds the host entrypoints from operation.target (the symlink) and then checks them with FSUtil.contains(fs.resolve(operation.target), fs.resolve(entrypoint)). The entrypoint comes from Host.resolve -> Bun.resolveSync, which keeps the symlinked directory's resolution cached for the life of the process, while fs.resolve is a fresh realpathSync that sees the new target. After a retarget the two sides disagree, the containment check rejects the plugin, and it is dropped with no failed entry and no log line. The same shape is at packages/core/src/plugin/module.ts:115.
Plan: resolve the plugin directory to its real path before Host.resolve at both call sites, so the entrypoint resolution and the containment check are computed against the same directory, and make a plugin rejected by the containment check show up as failed rather than silently omitted. I'll add a regression test that retargets a directory symlink and asserts the plugin stays listed.
Targeting v2, since packages/core/src/config/plugin/source.ts does not exist on dev.
The fix resolves the plugin directory before asking the runtime for its entrypoints, and passes the resolved path to both the entrypoint resolution and the containment check, so the two can no longer disagree after a retarget. The same resolution is needed in PluginModule.load, which resolves the entrypoint again when loading — without it a retargeted plugin would keep loading the previous target's code. I also added a log line to the containment-rejection path, since it drops a plugin that exists and lives inside the config. It should now say why.
The regression test repoints a directory symlink and asserts the plugin stays listed across two scans; it fails on the unmodified source (the second scan returns nothing).
@nkoynov you mentioned you could test this against the home-manager setup where it bit you — a build of that branch would be the strongest confirmation, since my repro is a bare symlink retarget.
Tested both versions of #53945 in a NixOS VM, with a direct retarget and a home-manager style two-link chain: the base drops the plugin, the PR keeps it and runs the new target. Details on the PR.
Activity
github-actions commentedon Oct 7, 2026
This issue might be related to existing issues. Please check:
opencode-agent commentedon Oct 7, 2026
Thanks for the detailed report. I reproduced this on v2.0.24:
/tmp/p/Aand/tmp/p/B, each withindex.jsexporting{ id: "demo", setup() {} }, and symlinked~/.config/opencode/plugins/demo -> /tmp/p/A.opencode2 serve.GET /api/pluginlisteddemo(local,/tmp/p/A/index.js, active).ln -sfn /tmp/p/B ~/.config/opencode/plugins/demo.demowas gone from/api/pluginfor the existing directory, and a new directory didn't have it either. There was no failed entry and no log line.demoloaded from/tmp/p/B/index.jsas expected.This fits your analysis: the entrypoint still resolves into the old target while the realpath of the directory points at the new one, so the containment check in
ConfigPluginSource.scandrops the plugin without reporting anything.argszero commentedon Oct 8, 2026
I'd like to work on this.
I read the scan path:
ConfigPluginSource.scanbuilds the host entrypoints fromoperation.target(the symlink) and then checks them withFSUtil.contains(fs.resolve(operation.target), fs.resolve(entrypoint)). The entrypoint comes fromHost.resolve->Bun.resolveSync, which keeps the symlinked directory's resolution cached for the life of the process, whilefs.resolveis a freshrealpathSyncthat sees the new target. After a retarget the two sides disagree, the containment check rejects the plugin, and it is dropped with nofailedentry and no log line. The same shape is atpackages/core/src/plugin/module.ts:115.Plan: resolve the plugin directory to its real path before
Host.resolveat both call sites, so the entrypoint resolution and the containment check are computed against the same directory, and make a plugin rejected by the containment check show up as failed rather than silently omitted. I'll add a regression test that retargets a directory symlink and asserts the plugin stays listed.Targeting
v2, sincepackages/core/src/config/plugin/source.tsdoes not exist ondev.nkoynov commentedon Oct 8, 2026
That matches what I found. Once the PR is up I can test it on NixOS against the home-manager setup where this bit us.
argszero commentedon Oct 8, 2026
Opened #53945 for this (base
v2).The fix resolves the plugin directory before asking the runtime for its entrypoints, and passes the resolved path to both the entrypoint resolution and the containment check, so the two can no longer disagree after a retarget. The same resolution is needed in
PluginModule.load, which resolves the entrypoint again when loading — without it a retargeted plugin would keep loading the previous target's code. I also added a log line to the containment-rejection path, since it drops a plugin that exists and lives inside the config. It should now say why.The regression test repoints a directory symlink and asserts the plugin stays listed across two scans; it fails on the unmodified source (the second scan returns nothing).
@nkoynov you mentioned you could test this against the home-manager setup where it bit you — a build of that branch would be the strongest confirmation, since my repro is a bare symlink retarget.
nkoynov commentedon Oct 8, 2026
Tested both versions of #53945 in a NixOS VM, with a direct retarget and a home-manager style two-link chain: the base drops the plugin, the PR keeps it and runs the new target. Details on the PR.