Repository navigation
Description
Description
On a long-running opencode serve --service daemon, swapping the <name> symlink inside ~/.config/opencode/plugin/ (routine on NixOS: home-manager activation repoints every plugin symlink to a new /nix/store/... path) leaves the plugin running stale code — and the next rescan silently unloads it: hooks are disposed, /api/plugin stops listing the plugin, and no plugin-lifecycle log line is emitted (the only log noise is the known-unrelated #53210 watcher error). We only noticed because the tool hook our PR workflow depends on stopped firing after deployments; the logs gave us nothing to go on.
Per our source read at the pinned version below: the plugin entry is resolved once at boot via Bun.resolveSync and cached process-wide, anchored to the then-current realpath (stale after the swap). Any later rescan computes a fresh realpath(symlink) and runs a containment check against that cached resolution; the check can never match, the plugin operation is silently dropped, and the registry treats the plugin as deleted and unloads it.
Two isolation experiments narrow it down:
- Swapping the symlink alone does not kill the plugin — old code keeps running.
- The swap plus any later rescan silently unloads it. Re-pointing the symlink back at the original cached target revives the plugin on the next rescan, which strongly supports the mismatch reading; a full restart after re-pointing at the new target loads the new code correctly.
Note: the plugin config subsystem has been refactored since our pin (we could not find the same code path on current HEAD), so this may already be fixed or reshaped — reporting our fully reproduced evidence in case the failure mode survives the refactor. Related but different layer: #46551 (config parse drops file paths at startup; ours is a runtime rescan unloading a previously working plugin).
Plugins
Custom local plugin (a tool hook that injects a session deep-link into forgejo PR/issue bodies), installed as a directory symlink under ~/.config/opencode/plugin/.
OpenCode version
2.0.26 (commit 0d61729b3f), packaged via Nix on our side
Steps to reproduce
Isolated environment (fresh $XDG_* dirs — the client commands below run with the same $XDG_* so they hit the isolated daemon; an OpenAI-compatible mock provider so tool calls are deterministic). The probe plugin carries a MARKER constant and its hook appends a line with that MARKER to a hook.log file (a path of your choice, e.g. $PWD/hook.log), so you can tell which generation of code is live.
Layout (all under an env root you cd into):
env/
├── config/opencode/plugin/probe -> /abs/path/env/store-A/probe (symlink)
├── store-A/probe/ (index.js + package.json, MARKER=A)
└── store-B/probe/ (identical shape, MARKER=B)
- Create the two plugin generations and the symlink as shown (use an absolute symlink target).
- Start
opencode serve --serviceagainst the isolated XDG, open a session, invoke any tool —hook.loggains aMARKER=Aline;/api/pluginlistsprobe. - Swap the symlink:
ln -sfn "$PWD/store-B/probe" config/opencode/plugin/probe. Invoke a tool again — the hook still fires withMARKER=A(stale but alive). (On a platform where the directory watcher works, the unload may happen right here instead of waiting for step 4.) - Trigger a rescan by touching the old entry file that the daemon already loaded (
touch store-A/probe/index.js— per-file watches still work on NixOS, see NixOS: native recursive FS watcher never loads (libstdc++.so.6 not found) #53210), then invoke a tool: the hook no longer fires,/api/pluginno longer listsprobe, and no plugin-lifecycle log line was emitted at any point. (In production the same unload is reached via the supervisor's periodic full rescan, up to ~24h later.) - Swap the symlink back to
store-A/probeand touch the entry again — the plugin comes back (MARKER=A, setup re-runs), confirming the stale-realpath reading. - Point the symlink at
store-B/probeagain andopencode service restart— the plugin loads withMARKER=B(new code), as expected.
Expected Behavior
After the symlink swap plus a rescan, the plugin at the new target should be loaded (new code active). At minimum, dropping a previously-working plugin should produce a warning — a silent unload is the worst outcome for a long-running service.
Actual Behavior
The plugin is silently unloaded; hooks stop firing; nothing in the logs until a full restart.
Screenshot
n/a (log silence is the symptom)
Operating System
NixOS 26.11 (x86_64); plugin dirs managed by home-manager (symlink swap on every activation)
Terminal
Alacritty (daemon context: systemd user service)
Activity
opencodejr commentedon Oct 27, 2026
Thanks for the very thorough report and the isolation experiments. I think this is a duplicate of #53763, which reports the same problem and has already been reproduced. Retargeting a directory-symlinked local plugin (nix/home-manager style) makes the next plugin rescan drop it silently. The cause is that
Bun.resolveSynckeeps the old target cached while a freshrealpathsees the new one, so the containment check in the plugin source scan rejects the plugin, with no failed entry and no log line.Possible duplicates and related issues, ordered from most to least likely:
pluginsare silently never loaded; plugin import failures produce no log #58647: plugins given by absolute path are silently never loaded and import failures are not logged (different trigger, same missing log line).Your confirmation on 2.0.26, and the finding that pointing the symlink back revives the plugin, would be useful on #53763. The bot will close this issue as a duplicate in 1 day unless you reply explaining how your problem is different.
opencodejr commentedon Oct 28, 2026
We haven't heard back, so I'm closing this as a duplicate of #53763. That issue tracks the same problem: retargeting a directory-symlinked local plugin (as home-manager does) makes the next rescan drop the plugin silently, because the cached
Bun.resolveSyncresult and a freshrealpathdisagree. Please follow #53763 for updates. Your 2.0.26 confirmation and the finding that pointing the symlink back revives the plugin would be useful there.If your case turns out to be different, reply here and we can reopen it. Thanks again for the detailed investigation.