Skip to content

server: failed plugin reload on config change silently drops custom agents and commands until restart #49982

Open

Description

@Nuokfish

Summary

On v2.0.9 running as the background service (opencode serve --service, desktop app), config edits repeatedly fail with ERROR failed to reload plugins — TypeError: pe is not a function. After these failed reloads, the live service silently loses all file-discovered custom agents and commands: /api/agent and /api/command (and the TUI pickers) degrade to builtins only until the service is restarted. A freshly started server on the identical config loads everything, so the definitions and config are valid.

Environment

  • opencode version: 2.0.9
  • OS: Darwin 27.0.0 (darwin arm64), macOS
  • Terminal: OpenCode desktop app (session host); TERM_PROGRAM=Apple_Terminal, TERM=xterm-256color
  • Shell: /bin/zsh
  • Install/channel: latest; binary at ~/.opencode/bin/opencode, background service mode
  • Active plugins: none in final state (transient local plugins were registered and removed during the session that triggered the bug; config has no plugin key afterwards)

Reproduction

  1. Set up custom agents (e.g. ~/.config/opencode/agent/review.md) and commands (project .opencode/commands/*.md ×23, global ~/.config/opencode/commands/*.md ×1). Start the background service and confirm via /api/agent / /api/command that they are loaded.
  2. With the service running (active sessions, MCP servers connected, two locations booted: home + project), edit ~/.config/opencode/opencode.json repeatedly — in my case registering then removing local plugin entries, including a moment where a registered plugin's files were deleted before its config key was removed.
  3. Each reload logs: ERROR failed to reload plugins cause="Cause([Die(TypeError: pe is not a function. (In 'pe()', 'pe' is an instance of Object))])"
  4. Afterwards /api/agent returns only the 7 builtins (custom review agent gone) and /api/command returns only the 2 builtins (all 24 custom commands gone). No error is logged at the moment the catalogs degrade — the loss is silent.
  5. opencode service restart → everything comes back.

Honest trigger caveat: on an isolated opencode serve (no --service, no connected clients) I could not trigger the crash with an mtime touch, a byte-identical rewrite, or adding a valid no-op plugin entry. It reproduced only on the shared --service instance with active sessions and multiple booted locations, which points at the concurrent/multi-location reload path also seen in #48121.

Expected Behavior

A failed plugin reload should not take down the agent/command catalogs, and file-discovered agents/commands should remain registered. Note the agent/command directories are not subscribed by any watcher (only skills and config files are), so once the catalogs degrade there is no self-healing path short of a restart.

Actual Behavior

Log excerpts (paths trimmed; timestamps UTC):

13:07:26 ERROR failed to reload plugins cause="Cause([Die(TypeError: pe is not a function. (In 'pe()', 'pe' is an instance of Object))])"
13:25:02 ERROR failed to reload plugins cause=… (same defect, recurs ≥10× over ~30 min)
13:35:19 WARN  failed to load plugin target="<project>/.opencode/plugin/aios-canary" cause="ENOENT …"   (plugin files removed before config key)
13:35:19 ERROR failed to reload plugins cause=… (same TypeError, ×3 in <200 ms)

After this window, on the still-running service:

/api/agent    → build, compaction, explore, general, plan, summary, title   (custom "review" agent gone)
/api/command  → init, review                                                 (23 project + 1 global commands gone)

Same binary + same config on a fresh opencode serve: 8 agents (incl. review) and 26 commands load correctly — verified twice on isolated instances.

Additional Context

Activity

  1. Nuokfish commented on Sep 19, 2026

    @Nuokfish
    Author

    Workaround applied on the reporting machine: opencode service restart at 2026-09-19 21:55:57 CST; will attach post-restart catalog verification shortly.

  2. Nuokfish commented on Sep 19, 2026

    @Nuokfish
    Author

    Post-restart verification on the reporting machine: agents = build, compaction, explore, general, plan, review, summary, title; commands = 26 commands: archive-project, check-work, deliver-closeout, force, generate-prd, generate-report, generate-spc, grill-me, init, log-time, log-work, migrate-log, next-step, quick-log, refresh-daily-insights, refresh-dashboard-insights, review, review-all, review-artifact, review-evolution, review-project, start-project, startup, suspend-project, switch-project, sync-company-dictionary; verdict = PASS (review agent restored, project commands restored).

  3. holny commented on Sep 20, 2026

    @holny
    Contributor

    Dug into the plugin supervisor after seeing this. The TypeError: pe is not a function side looks like the legacy-export problem already tracked in #50021, but the durable defect is that a failed activation takes down the whole generation - file-discovered agents and commands included - and supervisor.ts:229 only logs the die, so with nothing watching the agent/command dirs there's no way back until restart. Happy to take this one. Plan: keep the last-good generation on a failed activation (treat the bad entry as a per-plugin failure like the duplicate-ID path already does) and add a regression test for the reload-failure path. Should have a PR in a day or two.

  4. holny commented on Sep 21, 2026

    @holny
    Contributor

    Quick update before opening the PR: keeping the last-good generation on a failed activation turns out to collide with semantics the registry tests pin down on purpose. disables plugins after replay failures discovered during setup without restoring the old generation (packages/core/test/plugin.test.ts:349) asserts that a failing revision tears the previous activation down, and the lifecycle tests require stops to fire before starts, which a keep-previous fallback breaks. So a naive fallback contradicts existing tested behavior rather than filling a gap. Would you rather see failed reloads retain the previous generation (revisiting those tests), or should the fix stay narrower — supervisor retrying from the last good packages while the registry semantics stay as-is?

  5. holny commented on Sep 24, 2026

    @holny
    Contributor

    Following up on my question above — either answer works for me: keep-last-good if that's the intended semantics (I'll revisit the registry test that pins removal), or I'll go with the narrow fix (keep 61dce72334's applySafe + a regression test that the catalog survives a failed reload). Just don't want to guess wrong on intent before opening the PR.

  6. holny commented on Sep 26, 2026

    @holny
    Contributor

    Still happy to take this in whichever direction you prefer — the narrow fix (applySafe + catalog-survival regression test, commit already on my branch) is a day of latency away from a PR, and the keep-last-good route is fine too if that's the intended semantics. Otherwise I'll go with the narrow fix when I next pick this up.

  7. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed report. I could reproduce this on 2.0.9 with a plain opencode serve; --service, the desktop app and active sessions aren't needed. It still happens on the latest dev (62ae125).

    What triggers it here: a local plugin whose setup returns an object instead of a cleanup function, e.g. setup: async () => ({}).

    1. Add the plugin to opencode.json. Custom agents and commands are still listed.
    2. Remove it from the config. Unloading it tries to call that object as the cleanup. The log shows failed to reload plugins … TypeError: … is not a function. (In '…()', '…' is an instance of Object), which matches your error. On dev the unminified name is cleanup.
    3. Afterwards, /api/agent no longer has the custom review agent and /api/command returns only init and review, in every booted location. Nothing else is logged.

    The script below reproduces it. One difference from what you saw: here, a later unrelated config edit restored the agents and commands without a restart.

    The transient plugins you registered may have returned an object from setup, for example a hooks object. If so, that would explain the error.

    Closely related: #48121, where a different reload error also leaves agents unavailable until restart.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #49982: failed plugin reloads under a live server, then check agent/command catalogs.
    # Usage: OC="opencode" bash repro.sh   (or OC="bun ... packages/cli/src/index.ts")
    set -u
    OC=${OC:-opencode}
    R=${R:-/tmp/opencode/r49982}; rm -rf $R; mkdir -p $R/h/.config/opencode/{agent,commands} $R/proj/.opencode/commands $R/plug
    export HOME=$R/h XDG_CONFIG_HOME=$R/h/.config XDG_DATA_HOME=$R/h/.local/share XDG_STATE_HOME=$R/h/.local/state XDG_CACHE_HOME=$R/h/.cache
    printf -- '---\ndescription: review agent\nmode: subagent\n---\nReview code.\n' > $R/h/.config/opencode/agent/review.md
    printf -- '---\ndescription: g\n---\nglobal cmd\n' > $R/h/.config/opencode/commands/gcmd.md
    for i in 1 2 3; do printf -- "---\ndescription: p$i\n---\nproj $i\n" > $R/proj/.opencode/commands/p$i.md; done
    echo '{}' > $R/h/.config/opencode/opencode.json
    (cd $R/proj && git init -q)
    mkplug(){ mkdir -p $R/plug/$1; cat > $R/plug/$1/index.ts <<P
    // v1-style habit: setup returns an object (hooks) instead of a cleanup function
    export default { id: "$1", setup: async () => ({}) }
    P
    }
    PORT=${PORT:-4211}
    q(){ curl -s -u opencode:pw -H "x-opencode-directory: $2" localhost:$PORT/api/$1 | jq -c '[.data[].name]'; }
    (cd $R/proj && OPENCODE_SERVER_PASSWORD=pw $OC serve --port $PORT --print-logs --log-level info > $R/serve.log 2>&1) &
    SRV=$!; for i in $(seq 40); do sleep 1; [ "$(q agent $R/proj 2>/dev/null)" != "[]" ] && [ -n "$(q agent $R/proj 2>/dev/null)" ] && break; done
    check(){ echo "[$1] proj agents=$(q agent $R/proj) cmds=$(q command $R/proj)"; echo "[$1] home agents=$(q agent $R/h) cmds=$(q command $R/h)"; }
    check before
    CFG=$R/h/.config/opencode/opencode.json
    mkplug canary
    echo "{\"plugin\":[\"$R/plug/canary\"]}" > $CFG; sleep 3
    check "plugin added"
    echo '{}' > $CFG   # remove plugin: unloading it calls the returned object as a cleanup
    sleep 4
    check after
    echo '{"username":"x"}' > $CFG; sleep 4   # another, unrelated config edit
    check "after another edit"
    grep -E 'ERROR|failed to (re)?load' $R/serve.log | cut -c1-300 | sort | uniq -c | head -20
    kill $SRV

    Run it: bash repro.sh

  8. holny commented on Nov 10, 2026

    @holny
    Contributor

    PR #57775 fixes this trigger: the plugin teardown was calling whatever setup() returned as a cleanup function, and the repro's setup: async () => ({}) plugin is exactly the case it guards — the value is now ignored unless it's an actual function, so the reload no longer aborts and the agent/command catalogs stay intact. Once it merges this repro should pass without the restart.

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