Skip to content

cli: opencode plugin list prints "No plugins found" on the first call per cold location #54654

Open

Description

@ashleysommer

Description

Summary: opencode plugin list returns an empty list when invoked before the working directory's plugin scan finishes, then lists everything on the second call. Same cold-location race as #54192 (mcp.list), #51680 (skill.list), #51282 (agent.list), and #51703 (model.list) — plugin.list appears to be the remaining handler that reads the registry without awaiting plugin activation (contrast model.list, which does yield* awaitPlugins; fixed for skills in #51687).

Evidence: log timeline from a live repro — empty response at 09:29:13 with loading plugin for that directory logged 70 ms into the same call; three repeat calls at 09:33 across three directories all list the plugin. The plugin itself loads and works in sessions the whole time; only the listing races.

Expected: plugin.list awaits plugin activation for the requested location (the #54191/#51687 pattern) before reading the registry.

Plugins

No response

OpenCode version

v2.0.26

Steps to reproduce

Repro (2.0.26, Linux x64):

  1. Drop a local plugin file into ~/.config/opencode/plugins/ (e.g. codesearch.ts — server-side discovery accepts bare .ts files).
  2. opencode service restart.
  3. Immediately run opencode plugin list → "No plugins found" (exit 0).
  4. Run it again → the plugin is listed. Log shows msg="loading plugin" id=…/codesearch.ts timestamped inside the first (empty) call — the list request itself triggers the load, then reads the registry before it completes.

Screenshot and/or share link

No response

Operating System

Arch Linux

Terminal

No response

Activity

  1. argszero commented on Oct 12, 2026

    @argszero
    Contributor

    I'd like to work on this.

    Confirmed the premise by reading the current dev source: plugin.list in
    packages/server/src/handlers/plugin.ts calls Plugin.Service.use((plugin) => plugin.list()) without waiting
    for activation, and Plugin.list returns the inventory binding that starts as [] and is only replaced
    after a generation activates (packages/core/src/plugin.ts). So a cold Location's first call observes the
    empty registry — the same shape fix(server): await models before listing (#41783) addressed for
    model.list, where the handler now does yield* awaitPlugins from packages/server/src/location.ts.

    Plan: make plugin.list await activation before reading the registry (the model.list pattern, with the
    same five-second ServiceUnavailableError guard), plus a regression test that asserts a cold-location list
    call returns the plugin instead of an empty array. I'll verify against opencode serve from source.

  2. opencode-agent commented on Oct 12, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced this on 2.0.26, and it still happens on the latest dev (707de58) run from source.

    Steps (the script below does this in an isolated config):

    1. Put a local plugin file codesearch.ts (written with Plugin.define) in the global opencode/plugins/ config folder.
    2. Restart the service (opencode service restart).
    3. In each of three new directories, run opencode plugin list twice.

    In every directory, the first call printed No plugins found and the next call listed codesearch. That fits your analysis: plugin.list in packages/server/src/handlers/plugin.ts reads the plugin list without first waiting for plugins to finish loading (awaitPlugins), which model.list does. Related to the same race in other handlers: #54192 / #54191 (MCP), #51680 (skills) and #51282 (agents).

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #54654: `opencode plugin list` prints "No plugins found" on the first
    # call in each directory the service has not loaded yet; the second call lists the plugin.
    #   ./repro.sh                       -> uses `opencode` from PATH (e.g. @opencode/cli@2.0.26)
    #   OC_DEV=/path/to/checkout ./repro.sh -> runs the CLI and service from source
    set -u
    oc() {
      if [ -n "${OC_DEV:-}" ]; then
        bun --conditions=browser --preload="$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js" "$OC_DEV/packages/cli/src/index.ts" "$@"
      else
        opencode "$@"
      fi
    }
    mkdir -p /tmp/opencode
    export HOME=$(mktemp -d /tmp/opencode/home.XXXX)   # isolated config/state
    export XDG_CONFIG_HOME=$HOME/.config XDG_DATA_HOME=$HOME/.local/share XDG_STATE_HOME=$HOME/.local/state XDG_CACHE_HOME=$HOME/.cache
    mkdir -p "$XDG_CONFIG_HOME/opencode/plugins"
    cat > "$XDG_CONFIG_HOME/opencode/plugins/codesearch.ts" <<'P'
    import { Plugin } from "@opencode/plugin"
    export default Plugin.define({ id: "codesearch", async setup() {} })
    P
    echo "version: $(oc --version)"
    if [ -n "${OC_DEV:-}" ]; then
      # from source, the auto-spawned service lacks the preload, so start it ourselves
      (cd "$HOME" && oc serve --service > "$HOME/serve.log" 2>&1) & sleep 8
    else
      oc service restart >/dev/null 2>&1 || oc service start >/dev/null 2>&1
    fi
    for d in a b c; do
      mkdir -p "$HOME/proj-$d"; cd "$HOME/proj-$d"
      echo "== proj-$d: first call";  oc plugin list 2>&1
      echo "== proj-$d: second call"; oc plugin list 2>&1
    done
    if [ -n "${OC_DEV:-}" ]; then pkill -f "index.ts serve --service"; else oc service stop >/dev/null 2>&1; fi

    Run it: bash repro.sh

  3. youthtrouble commented on Oct 18, 2026

    @youthtrouble

    Confirming on v2.0.32 (macOS 26). With a globally configured local plugin (~/.config/opencode/plugins/orchestrator.ts, promise API), opencode plugin list prints "No plugins found" when run from the home directory or a fresh project, and lists the plugin when run from ~/.config/opencode itself; the plugin is in fact loaded and its tools work in both cases (the service log shows "loading plugin"). Note the config dir here is a real directory whose entries (plugins, agents, ...) are symlinks into a git checkout, which works fine for loading, so this looks like a listing-scope issue rather than the symlink problem in #55119.

  4. ZYMCao commented on Oct 26, 2026

    @ZYMCao

    Confirmed as well on my opencode 2.1.4 at macOS 15.6 (Darwin 24.6.0, arm64)

  5. abogartz commented on Oct 26, 2026

    @abogartz

    Confirming on 2.1.4, macOS arm64 — and the same race has a harder failure mode than the cosmetic No plugins found: opencode plugin update <name> errors with a misleading message and exit 1.

    $ opencode service stop && sleep 1
    $ opencode plugin update opencode-self-improvement
    Error: Plugin is not configured: opencode-self-improvement    # exit 1
        at cli.plugin.inspect ...
    $ sleep 3
    $ opencode plugin update opencode-self-improvement
    No plugin updates available
    

    Timeline from the service log: the CLI spawns the cold service, location services booted at T+0.05s, msg="loading plugin" id=opencode-self-improvement at T+0.07s — while the CLI's plugin.list request had already been served from the empty inventory. cli.plugin.inspect (packages/cli/src/commands/handlers/plugin/inventory.ts) then fails with Plugin is not configured: <name> because it finds neither a server item nor a cli.json TUI item — so the user is told their correctly-configured global opencode.json plugin doesn't exist.

    Typical trigger: opencode plugin add <pkg> (which never touches the server) immediately followed by opencode plugin update <pkg> — reported downstream as abogartz/opencode-self-improvement#115. The plugin itself loads and works throughout.

    #56998 (adding awaitPlugins to plugin.list/check/update) covers this too, since inspect reads plugin.list.

  6. szafranski commented on Oct 27, 2026

    @szafranski

    Confirming on OpenCode 2.2.0, macOS, with "plugins": ["@dietrichgebert/ponytail"] in the global opencode.json.

    opencode plugin check @dietrichgebert/ponytail --standalone exits with code 1: Plugin is not configured: @dietrichgebert/ponytail. In one reproduction, the same server logged loading plugin 1 ms after the CLI error.

    Our updater exhausted 10 retries, each starting a fresh standalone server.

    As a control, I started one private server with opencode serve --hostname 127.0.0.1 --port 0 and ran three checks against it using --server. The first two failed with the same error; the third succeeded and reported Ponytail 5.3.2 as current. No update was performed, and the private server was stopped afterward.

    This appears consistent with the activation race discussed here. I have not tested #56998.

  7. linghengqian commented on Nov 4, 2026

    @linghengqian

    Also reproduces on v2.2.5 (Linux x64, WSL2). The background service had been running for more than a day and each new working directory was cold again, so the empty answer is not limited to the first call after a service start.

    For plugin update this means a pending update is skipped without an error. plugin add writes the unversioned name to plugins, so this is not the exact-revision case that plugin update skips by design. Transcript from the script below (local registry, plugin add without a version, then 1.7.0 is published; $ROOT is the temp directory):

    $ opencode plugin add @repro/opencode-plugin       # registry latest is 1.6.1
    Plugin "@repro/opencode-plugin" installed and added to $ROOT/config/opencode/opencode.json
    $ cd proj1 && opencode plugin check                # first call in this directory
    No package plugins found
    $ sleep 8 && opencode plugin check
    Server
      repro.plugin 1.6.1 (current)
    # 1.7.0 is published
    $ opencode plugin check                            # proj1, warm
    Server
      repro.plugin 1.6.1 (update available)
    $ cd ../proj2 && opencode plugin update            # new directory
    No plugin updates available                         # exit 0
    $ cd ../proj3 && opencode plugin update @repro/opencode-plugin    # another new directory
    Error: Plugin is not configured: @repro/opencode-plugin           # exit 1
    # the cache still has only the 1.6.1 generation
    $ sleep 8 && opencode plugin update                # proj3 again
    Updated Server plugin "repro.plugin"
    

    The package stays in the global opencode.json throughout. The cold plugin update exits 0 with No plugin updates available, so a script cannot tell it from "everything is current" and has nothing to retry on.

    On a real setup with 4 server plugins (2 from npm, 2 local), two plugin check calls about a second apart in a new directory were both empty, and a call about 5 s after the first listed both npm plugins. #56998 was not tested.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # plugin check/update on a location whose plugins have not activated yet (#54654).
    # Needs node, npm, curl and opencode on PATH. Config, cache, state and the service are isolated under $ROOT.
    OC=${OC:-opencode}
    NAME=@repro/opencode-plugin
    FILE=${NAME#*/}
    PORT=${PORT:-47811}
    ROOT=$(mktemp -d)
    TO=$(command -v timeout || command -v gtimeout)
    mkdir -p "$ROOT"/{home,config/opencode,data,cache,state,tarballs,pkgsrc} "$ROOT"/proj{1,2,3}
    
    # Mock registry with one package. 1.6.1 is visible first; POST /publish makes 1.7.0 visible.
    cat > "$ROOT/registry.mjs" <<'EOF'
    import http from "node:http"
    import fs from "node:fs"
    import path from "node:path"
    import crypto from "node:crypto"
    
    const [root, port, name] = process.argv.slice(2)
    const file = name.split("/").pop()
    const releases = ["1.6.1", "1.7.0"]
    let published = 1
    const digest = (algo, buf, encoding) => crypto.createHash(algo).update(buf).digest(encoding)
    const tarball = (v) => fs.readFileSync(path.join(root, "tarballs", `${file}-${v}.tgz`))
    
    http
      .createServer((req, res) => {
        const url = decodeURIComponent(req.url.split("?")[0])
        if (req.method === "POST" && url === "/publish") {
          published = releases.length
          return res.end("published")
        }
        if (url === `/${name}`) {
          const versions = {}
          for (const v of releases.slice(0, published)) {
            const buf = tarball(v)
            versions[v] = {
              name,
              version: v,
              type: "module",
              main: "./index.js",
              exports: { ".": { import: "./index.js", default: "./index.js" } },
              dist: {
                tarball: `http://127.0.0.1:${port}/${name}/-/${file}-${v}.tgz`,
                shasum: digest("sha1", buf, "hex"),
                integrity: "sha512-" + digest("sha512", buf, "base64"),
              },
            }
          }
          res.setHeader("content-type", "application/json")
          return res.end(JSON.stringify({ name, "dist-tags": { latest: releases[published - 1] }, versions }))
        }
        const m = url.match(/\/-\/.+-(\d+\.\d+\.\d+)\.tgz$/)
        if (m && releases.includes(m[1])) return res.end(tarball(m[1]))
        res.statusCode = 404
        res.end()
      })
      .listen(Number(port), "127.0.0.1")
    EOF
    
    for v in 1.6.1 1.7.0; do
      d="$ROOT/pkgsrc/$v"; mkdir -p "$d"
      echo "{ \"name\": \"$NAME\", \"version\": \"$v\", \"type\": \"module\", \"main\": \"./index.js\", \"exports\": { \".\": { \"import\": \"./index.js\", \"default\": \"./index.js\" } } }" > "$d/package.json"
      echo 'export default { id: "repro.plugin", setup() {} }' > "$d/index.js"
      tgz=$(cd "$d" && HOME="$ROOT/home" npm pack --silent --pack-destination "$ROOT/tarballs")
      mv "$ROOT/tarballs/$tgz" "$ROOT/tarballs/$FILE-$v.tgz"
    done
    
    node "$ROOT/registry.mjs" "$ROOT" "$PORT" "$NAME" & REG=$!
    echo "registry=http://127.0.0.1:$PORT/" > "$ROOT/npmrc"
    echo '{ "update": "disable" }' > "$ROOT/config/opencode/opencode.json"
    
    oc() {
      env -i PATH="$PATH" HOME="$ROOT/home" XDG_CONFIG_HOME="$ROOT/config" XDG_DATA_HOME="$ROOT/data" \
        XDG_CACHE_HOME="$ROOT/cache" XDG_STATE_HOME="$ROOT/state" NPM_CONFIG_USERCONFIG="$ROOT/npmrc" \
        OPENCODE_DISABLE_AUTOUPDATE=1 NO_COLOR=1 no_proxy=127.0.0.1,localhost \
        ${TO:+$TO 120} $OC "$@" 2>&1 | grep -v -E '^[[:space:]]+at ' | head -n 3
      echo "[exit=${PIPESTATUS[0]}]"
    }
    installed() { grep -h -o '"version": *"[^"]*"' "$ROOT"/cache/opencode/npm/$NAME@latest/*/node_modules/$NAME/package.json | tr '\n' ' '; echo; }
    trap '(cd "$ROOT/proj1" && oc service stop >/dev/null); kill $REG' EXIT
    sleep 1
    
    # only needed when another service already listens on the default port
    (cd "$ROOT/proj1" && oc service set port 47812 >/dev/null)
    
    cd "$ROOT/proj1"
    echo "## plugin add, registry latest is 1.6.1"
    oc plugin add $NAME
    grep -A2 '"plugins"' "$ROOT/config/opencode/opencode.json"
    installed
    
    echo "## proj1: first check is cold, the next one after 8s is warm"
    oc plugin check; sleep 8; oc plugin check
    
    echo "## registry publishes 1.7.0"
    curl -s -X POST http://127.0.0.1:$PORT/publish; echo
    
    echo "## proj1 (warm): check"
    oc plugin check
    
    echo "## proj2 (new directory): update"
    cd "$ROOT/proj2"; oc plugin update
    
    echo "## proj3 (another new directory): update with a target"
    cd "$ROOT/proj3"; oc plugin update $NAME
    echo "## installed versions after the two updates above:"; installed
    
    echo "## proj3 again after 8s: update"
    sleep 8; oc plugin update
    echo "## installed versions:"; installed
    
    echo "## final check"
    oc plugin check
    echo "state kept in $ROOT"
  8. dluc commented on Nov 7, 2026

    @dluc

    We keep encountering this across servers: opencode plugin list sometimes reports no plugins on the first run, and opencode plugin update can consequently report no updates even when an update is available.

    Waiting for activation, as proposed in #56998, may address the immediate race, but could the longer-term design separate configured/installed plugins from currently running plugins? Listing configured plugins should not require them to start successfully; runtime status could be shown separately.

    Similarly, checking for or installing updates should identify packages from configuration and installed metadata, rather than depend on the active plugin inventory. Otherwise a slow or failed activation can make an installed plugin appear absent.

    When an update replaces a running plugin, graceful shutdown is a separate concern: active instances may need to finish or safely stop work and release resources before replacement. The plugin API already supports an unload cleanup function—does the update path wait for that cleanup across affected instances and report failures or uncertain outcomes?

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