Skip to content

Global plugins not loaded when ~/.config/opencode is a symlink (inotify_add_watch "Not a directory" aborts config load) #55119

Open

Description

@Armadillidiid

Description

On Linux, when the global config directory ~/.config/opencode is a symlink (e.g. into a dotfiles repo), plugins listed in the global opencode.json plugins array are never loaded. The config file itself is read (the TUI sees model, providers, etc.), but plugin reconciliation from the global config silently fails, so a provider registered by a global plugin never appears.

Root cause: the config watcher calls inotify_add_watch on the symlink path, and the kernel rejects a symlink with ENOTDIR. The failure aborts loading of the global config's plugins. The service log shows:

level=ERROR message="failed to subscribe" directory=/home/<user>/.config/opencode
cause="Error: inotify_add_watch on '/home/<user>/.config/opencode' failed: Not a directory
    at Config.reconcileWatches (...)
    at ServerProcess.start (...)"

The standalone / local .opencode/plugin/ discovery path works, so the plugin code itself is fine — only the global plugins config path is affected.

Workaround: set XDG_CONFIG_HOME to the resolved real directory (the parent of realpath ~/.config/opencode) so the watch target is a real directory, not the symlink.

Expected: a symlinked config directory should be watched/followed (resolve to the real path before inotify_add_watch), and global plugins should load.

Plugins

Local re-export plugin registering a custom provider via ctx.provider.transform + ctx.aisdk.hook, loaded through the global plugins array.

OpenCode version

2.0.24

Steps to reproduce

  1. Make ~/.config/opencode a symlink to a real directory elsewhere (e.g. ~/.dotfiles/.../opencode).
  2. Add a plugins entry to the global opencode.json that registers a provider.
  3. Start opencode and query opencode api get /api/provider/<id> → ProviderNotFoundError; the log shows the inotify_add_watch ... Not a directory error.

Operating System

Arch Linux (Linux)

Activity

  1. opencode-agent commented on Oct 13, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I tried this on 2.0.24 on Linux and couldn't reproduce plugins failing to load.

    What I tried: temporary XDG directories with $XDG_CONFIG_HOME/opencode symlinked to a real directory. The global opencode.json there had "plugins": ["./myplugin"], pointing at a local plugin directory that uses @opencode/plugin. Then I ran opencode serve --print-logs.

    So the ProviderNotFoundError may have a different cause. Some things to check:

    • Search your log for failed to load plugin or configured plugin path must be a directory. In 2.0.24, a plugins entry must point to the plugin directory, not a file. The plugin also needs @opencode/plugin to resolve from that directory, or it fails with Cannot find package '@opencode/plugin' (see plugins: local plugins cannot resolve "@opencode/plugin" import (2.0.12) #50434).
    • Please share the exact plugins entry from your global opencode.json, your plugin's setup (in particular the ctx.provider.transform part), and any log lines that mention your plugin or provider.
    • Does the same plugin register the provider when the config directory is a real directory? You mentioned the XDG_CONFIG_HOME workaround fixes it. Did that workaround change anything else, such as how the plugin path resolves?

    Related issues:

  2. Armadillidiid commented on Oct 13, 2026

    @Armadillidiid
    Author

    Thanks — you were right to push back. My original "symlinked config dir / inotify" root cause was wrong. I applied the XDG_CONFIG_HOME workaround so the config dir resolves to a real directory (no more inotify_add_watch … Not a directory), restarted the service, and the provider was still ProviderNotFoundError. So the watch error is cosmetic here, exactly as you said. I kept digging and found the real cause.

    Real root cause: absolute-directory plugins entry mis-resolves under Bun

    A global plugins entry that is an absolute path to a plugin directory is dropped silently. resolveEntryPoint (core npm.ts) resolves the directory's entrypoint with:

    entrypoint =
      typeof Bun !== "undefined"
        ? import.meta.resolve(name, dir)      // Bun path (what opencode runs)
        : pathToFileURL(createRequire(path.join(dir, "package.json")).resolve(name)).href

    For an absolute directory, the two branches disagree:

    # Bun (the branch opencode actually uses)
    import.meta.resolve("/abs/opencode-kiro")        -> file:///abs/opencode-kiro        # the directory itself
    
    # Node branch
    createRequire(".../package.json").resolve(dir)   -> file:///abs/opencode-kiro/src/plugin.ts   # correct manifest entry
    

    So under Bun the entrypoint comes back as the directory, not the package.json main/exports target. In the config-plugin scan the entry is then discarded (the resolved server equals the directory, so the containment check / subsequent handling drops it), and the whole branch is wrapped so the failure is swallowed — there is no loading plugin line and no error for the entry. That matches what I see: the config is read with plugins: ["/abs/opencode-kiro"], but nothing is ever attempted for it.

    What I verified

    • Plugin setup is correct: driven with a mock ctx it runs ctx.provider.transform and editor.adds kiro-acp + model kiro, no throw.
    • The directory is a valid package: package.json present, @opencode/plugin resolvable from it, main/exports → ./src/plugin.ts.
    • The service reads the entry (/api/config shows plugins: ["/abs/opencode-kiro"]) but logs no load attempt for it; /api/plugin reports 0 non-builtin plugins; /api/provider/kiro-acp → ProviderNotFoundError.
    • import.meta.resolve(absDir) under Bun returns the dir; require.resolve(absDir) returns src/plugin.ts. Only the Bun branch is broken.

    This looks distinct from the known cases (#50434 Cannot find package, the "must be a directory" rule for file entries, the symlink hot-reload #54933). Here the entry is a valid directory and there is no error at all — it's the Bun import.meta.resolve directory-resolution that silently yields the wrong entrypoint.

    Repro

    1. A plugin package dir at an absolute path, with package.json "main"/"exports" → a child entry (e.g. ./src/plugin.ts), @opencode/plugin installed in it.
    2. Global opencode.json: "plugins": ["/abs/path/to/plugin-dir"].
    3. Start opencode (Bun). The plugin never loads: no loading plugin log, no error, provider absent.

    Workaround that works

    Dropping the absolute-dir entry and using config-dir file discovery instead — <configdir>/plugin/<name>.ts re-exporting the plugin's entry — loads correctly, because that path resolves the file target directly and never goes through the directory import.meta.resolve.

    Env: opencode 2.0.24, Linux, Bun runtime.

  3. opencode-agent commented on Oct 13, 2026

    @opencode-agent
    Contributor

    Thanks for digging further. I can confirm this on 2.0.24, and it still happens on the latest dev (c1615b1).

    What I ran: a global opencode.json with "plugins": ["<path to plugin dir>"], where the plugin directory has @opencode/plugin installed and a package.json whose main/exports point to ./src/plugin.ts. The plugin's setup never runs, and the log has no loading plugin line, warning or error. Moving the same file to <plugin dir>/index.ts makes it load. The script below reproduces it.

    One correction to the cause: it doesn't depend on the path being absolute. A relative ./myplugin entry behaves the same. When a configured plugin is a directory, OpenCode only looks for <dir>/server or <dir>/index. It doesn't read package.json main/exports, so it finds no server entrypoint and skips the plugin silently.

    Until this is fixed, there are two workarounds:

    • the re-export file in <configdir>/plugin/ that you already use
    • an index.ts at the root of the plugin directory that re-exports ./src/plugin.ts

    The symlink and inotify_add_watch error is a separate issue, tracked in #54933.

    Reproduction script (repro2.sh)
    #!/usr/bin/env bash
    # #55119: global `plugins` entry = directory whose package.json main/exports point into src/
    # Usage: repro2.sh [abs|rel] [src|root]   (OPENCODE_CMD overrides the opencode command)
    set -u
    ENTRY=${1:-abs}; LAYOUT=${2:-src}
    OC=${OPENCODE_CMD:-opencode}
    PORT=${PORT:-47125}
    mkdir -p /tmp/opencode
    R=$(mktemp -d /tmp/opencode/plugdir.XXXX)
    mkdir -p $R/xdg/opencode $R/data $R/state $R/cache $R/proj
    P=$R/xdg/opencode/myplugin   # plugin package dir (real directory, no symlinks)
    if [ "$LAYOUT" = src ]; then F=src/plugin.ts; else F=index.ts; fi
    mkdir -p $P/$(dirname $F)
    cat > $P/$F <<PEOF
    import { Plugin } from "@opencode/plugin"
    import { writeFileSync } from "fs"
    export default Plugin.define({
      id: "symprobe",
      setup: async (ctx) => {
        writeFileSync("$R/plugin-loaded", "yes")
        await ctx.agent.transform((agent) => {
          agent.update("build", (item) => { item.description = "PLUGIN-LOADED" })
        })
      },
    })
    PEOF
    echo "{\"name\":\"symprobe\",\"type\":\"module\",\"main\":\"./$F\",\"exports\":{\".\":\"./$F\"}}" > $P/package.json
    (cd $P && bun add @opencode/plugin@${PLUGIN_VERSION:-2.0.24} >/dev/null 2>&1)
    if [ "$ENTRY" = abs ]; then SPEC=$P; else SPEC=./myplugin; fi
    echo "{ \"plugins\": [\"$SPEC\"] }" > $R/xdg/opencode/opencode.json
    cd $R/proj
    XDG_CONFIG_HOME=$R/xdg XDG_DATA_HOME=$R/data XDG_STATE_HOME=$R/state XDG_CACHE_HOME=$R/cache \
      $OC serve --hostname 127.0.0.1 --port $PORT --print-logs > $R/log 2>&1 &
    PID=$!
    for i in $(seq 1 120); do
      PW=$(grep -oE 'password[^A-Za-z0-9]*[A-Za-z0-9_-]{8,}' $R/log | head -1 | grep -oE '[A-Za-z0-9_-]{8,}$')
      curl -s -o /dev/null -u "opencode:$PW" "http://127.0.0.1:$PORT/api/agent?directory=$R/proj" && break
      sleep 1
    done
    sleep 10
    echo "entry=$ENTRY ($SPEC) layout=$LAYOUT main=./$F"
    echo -n "plugin setup ran: "; [ -f $R/plugin-loaded ] && echo yes || echo NO
    echo -n "build agent description: "; curl -s -u "opencode:$PW" "http://127.0.0.1:$PORT/api/agent?directory=$R/proj" | grep -oE '"description":"PLUGIN-LOADED"' | head -1; echo
    echo "--- plugin log lines ---"
    grep -iE 'plugin' $R/log | grep -viE 'figma|builtin' | cut -c1-300 | head -10
    kill $PID 2>/dev/null; wait $PID 2>/dev/null

    Run it: bash repro2.sh

  4. Dante-dan commented on Oct 14, 2026

    @Dante-dan
    Contributor

    I’m working on the directory entrypoint issue clarified above. The current resolver still only checks server and index for local directories. I’ll make local packages honor their main/exports metadata while preserving the existing server entrypoint precedence and import conditions.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions