Skip to content

plugin loader: local plugin hot-reload never re-evaluates when config dir is a symlink (stale Bun module cache) #52240

Open

Description

@jp41011

Description

On the Bun runtime (compiled binary), editing a local plugin file while the shared service is running never picks up the change. The reload path in packages/plugin/src/source.bun.ts invalidates Bun's module cache with delete require.cache[file] keyed by the entrypoint (symlink) path, but Bun keys the module record by the resolved real path. When the plugin lives under a symlinked config directory (e.g. ~/.config/opencode → a dotfiles checkout), the deletion is a no-op and import(entrypoint) keeps returning the stale module for the life of the process.

Environment

  • opencode version: 2.0.20 (embeds Bun 1.4.2)
  • OS: Linux 6.8.0-139-generic (linux x64)
  • Terminal: TERM_PROGRAM=ghostty, TERM=xterm-256color, COLORTERM=truecolor
  • Shell: /bin/bash
  • Install/channel: latest (homebrew opencode-v2)
  • Active plugins: @plannotator/opencode@latest (npm), plus an auto-discovered local file plugin at ~/.config/opencode/plugins/herdr-agent-state.js (the herdr terminal-integration plugin)

Reproduction

  1. Make ~/.config/opencode a symlink to a real directory that contains a package.json (no "type" field) and plugins/herdr-agent-state.js whose only export is a named function (no default export) — this mirrors herdr integration v10.
  2. Start the shared service. The plugin load fails as expected for the old shape:
    msg="loading plugin" id=~/.config/opencode/plugins/herdr-agent-state.js entrypoint=file:///…/herdr-agent-state.js
    message="failed to load plugin" target=~/.config/opencode/plugins/herdr-agent-state.js
    cause="Cause([Fail(PluginModule.LoadError: Plugin must export a default definition with an id and an effect or setup function.
    (cause: SchemaError(Missing key at [\"default\"])))])"
    
  3. Fix the file in place: add export default { id: "herdr.opencode", server, setup() {} } (mirrors herdr integration v13, the dual V1/V2 shape from the plugin migration guide).
  4. The watcher fires and the plugin reloads — but every reload fails with the exact same SchemaError(Missing key at ["default"]), even though the file on disk now has a valid default export. Only opencode service restart clears it (a fresh process loads the fixed file fine — verified with a sandboxed opencode serve).

Minimal isolated repro against Bun 1.4.2, matching what prepareSource's visit() does:

const mod1 = await import(pathToFileURL(symlinkPath).href)   // keys: ["HerdrAgentStatePlugin"] (old file, no default)
// … update the real file on disk to add the default export …
delete require.cache[symlinkPath]                            // no-op: Bun keys the cache by the real path
const mod2 = await import(pathToFileURL(symlinkPath).href)   // still ["HerdrAgentStatePlugin"] — stale module
delete require.cache[realPath]                               // this is what actually invalidates it
const mod3 = await import(pathToFileURL(symlinkPath).href)   // fresh: ["HerdrAgentStatePlugin", "default"]

Expected Behavior

Changing a local plugin file while the service runs should re-evaluate it on reload. The cache invalidation should work under a symlinked config directory, like it does under a real path — e.g. resolve the real path before deleting from require.cache, or freshen the import URL on reload the way source.node.ts does with ?__opencode_reload=<version>.

Actual Behavior

Every reload after the edit returns the stale cached module, so the plugin keeps failing with:

PluginModule.LoadError: Plugin must export a default definition with an id and an effect or setup function.
(cause: SchemaError(Missing key at ["default"]))

until the service is restarted. Related symptom of the same symlinked config dir: recurring watcher errors in the server log,

message="failed to subscribe" directory=~/.config/opencode
cause="Error: inotify_add_watch on '~/.config/opencode' failed: Not a directory"

(the watcher falls back to the node backend, so it is noisy rather than fatal).

Additional Context

  • packages/plugin/src/source.bun.ts — visit() deletes require.cache[file] using the path derived from the entrypoint URL (the symlink path). Bun 1.4.2 keys the module cache by the resolved real path, so the deletion misses and Host.load(entrypoint) returns the cached old module.
  • packages/plugin/src/source.node.ts avoids the class of problem by freshening the import specifier with ?__opencode_reload=<version> on each reload.
  • Fresh process → correct load (verified); same process after in-place edit → stale module (verified). 100% reproducible.
  • Workaround: opencode service restart after any edit to a local plugin file that lives under a symlinked config directory.

Activity

  1. added
    needs:complianceThis means the issue will auto-close after 2 hours.
    and removed
    needs:complianceThis means the issue will auto-close after 2 hours.
    on Sep 30, 2026
  2. github-actions commented on Sep 30, 2026

    @github-actions
    Contributor

    Thanks for updating the issue with the Bug Report description and reproduction details.

  3. jp41011 commented on Sep 30, 2026

    @jp41011
    Author

    This does seem related to a previous issue: #51602
    But it looks like that one was automatically closed.

    Main issue content was by the report skill built into opencode.
    I'll leave that in case its useful.

    Additional information from the human:
    I noticed this issue because i had setup the herdr plugin for opencode while i was using opencode v1.
    I then upgraded to opencode v2. I ran the opencode v1 to v2 migration
    But I think at the time herdr did not have a version that was compatible with opeoncode v2.
    I just ignored the popup message with the herdr plugin error message.
    Until today when i finally had enough energy to update some skills and plugins.
    I updated herdr. Their site says it now supports opencode v2.

    I closed and reopened opencode. Thinking that restart would be enough.
    but I still got the herdr plugin error message on startup.

    I forgot about the switch to the opencode service, in opencode v2, running in the background and i technically did not restart that.

    So maybe this can be attributed to the dumb human behind the keyboard.
    Or maybe a reminder can be shown to the user reminding them they need to restart the opencode service and not just the client instance. I don't know. Documenting it here in case its useful to someone else in the future.

    p.s. I do have a dotfiles folder that I symlink to my ~/.config/opencode/ directory using gnu stow. I don't think that was the main issue. But maybe it didn't help with the caching.

    Thank you.

  4. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed report and the minimal repro. I could reproduce this.

    On 2.0.20: I made ~/.config/opencode a symlink to a real directory containing package.json and plugins/herdr-agent-state.js, with only a named export in the plugin. The service failed to load the plugin, as expected for that file. I then added export default { id: "herdr.opencode", setup() {} } in place. The watcher triggered a reload, and it failed again with the same SchemaError(Missing key at ["default"]).

    On the latest dev (86ce19d), run from source: the script below edits the plugin and reads it again. It shows the same thing:

    • With a real config directory, the reload picks up the new default export.
    • With a symlinked one, the reload still returns the old module.

    This matches your analysis: source.bun.ts clears the module cache using the symlink path, not the resolved real path. Until it's addressed, restarting the service after editing the plugin (or pointing the config at the real directory) works around it.

    Reproduction script (reload-symlink.ts)
    // Reproduces #52240: local plugin reload returns a stale module when the plugin
    // path goes through a symlinked directory (Bun runtime).
    // Run from a checkout: bun /workspace/repro/reload-symlink.ts [/path/to/opencode/repo]
    import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, realpathSync } from "node:fs"
    import os from "node:os"
    import path from "node:path"
    import { pathToFileURL } from "node:url"
    
    const repo = process.argv[2] ?? "/workspace/repo"
    const { createPluginSources } = await import(path.join(repo, "packages/plugin/src/source.ts"))
    
    for (const linked of [false, true]) {
      const base = mkdtempSync(path.join(os.tmpdir(), "oc-52240-"))
      const real = path.join(base, "dotfiles-opencode")
      mkdirSync(path.join(real, "plugins"), { recursive: true })
      writeFileSync(path.join(real, "package.json"), "{}\n")
      const config = linked ? path.join(base, "config-opencode") : real
      if (linked) symlinkSync(real, config, "dir")
      const file = path.join(config, "plugins", "herdr-agent-state.js")
      writeFileSync(file, "export const HerdrAgentStatePlugin = async () => ({})\n")
    
      const sources = createPluginSources(async () => {})
      const entry = pathToFileURL(file).href
      const first = await sources.read(entry)
      // edit the file in place (as the watcher-triggered reload would see)
      writeFileSync(
        file,
        'export const HerdrAgentStatePlugin = async () => ({})\nexport default { id: "herdr.opencode", setup() {} }\n',
      )
      const second = await sources.read(entry)
      console.log(
        `${linked ? "symlinked" : "real     "} config dir: first=${JSON.stringify(Object.keys(first.module))} ` +
          `reload=${JSON.stringify(Object.keys(second.module))} versionChanged=${first.version !== second.version}` +
          (linked ? ` (real path ${realpathSync(file)})` : ""),
      )
      sources.dispose()
    }

    Run it: bun reload-symlink.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions