Skip to content

[Bug]: Plugin and runtime provider can diverge — same package cached twice under two specifiers #149

Closed

Description

@depler

Summary

After the package is updated, opencode can keep loading an old version of opencode-cmd-provider even though npm reports the latest version as installed. The reason is that the same package exists in two cache directories at the same time and only one of them is refreshed.

Environment

  • OS: Ubuntu 24.04.4 LTS
  • opencode: 1.18.30
  • opencode-cmd-provider: @latest dir at 1.7.3, bare dir at 1.6.2
  • npm: reports 1.7.3 as the latest installed version

What happens

The package lives in two cache directories simultaneously:

~/.cache/opencode/packages/opencode-cmd-provider@latest   # loaded as the plugin
~/.cache/opencode/packages/opencode-cmd-provider          # loaded as the runtime provider (model.api.npm)

This is a consequence of how opencode resolves the two roles:

  • the plugin loader appends @latest (<name> -> <name>@latest), so the plugin lives in opencode-cmd-provider@latest;

  • the provider/SDK loader uses model.api.npm verbatim, and this plugin registers that as the bare name:

    // src/plugin/index.ts
    autoRegister(config, MODEL_SNAPSHOT, {
      npm: "opencode-cmd-provider",   // no version
      ...
    })

Because opencode keys its cache by the exact specifier string, opencode-cmd-provider@latest and opencode-cmd-provider are two separate directories. When the package is updated, only the @latest copy is refreshed. The bare copy stays at the previous version, and opencode keeps loading that stale copy at runtime (its Npm.add returns an existing directory as-is).

So the effective state is: npm says the latest version is installed, but opencode still runs the old one.

Reproduction

  1. Install the plugin so both dirs exist at some version X.
  2. Publish/release a newer version Y.
  3. Update the package so that only the @latest dir moves to Y.
  4. Check the runtime: opencode still uses the bare dir at X, while npm reports Y as installed.

Why this is a plugin-side issue

The plugin registers its runtime provider by an unpinned bare name. That guarantees a second cache entry which cannot follow the plugin version and can silently go stale. Pinning the provider specifier to the plugin's own version would make the cache entry version-addressed, so an update would necessarily fetch the matching provider code and the two would never diverge.

Proposed fix

Register the runtime provider with a version-pinned specifier instead of the bare name, e.g.:

npm: `opencode-cmd-provider@${PLUGIN_VERSION}`

so the plugin and the provider it loads always come from the same release.

Related

Upstream opencode-side root cause (cache keyed by exact specifier + no refresh of existing installs): anomalyco/opencode#48514

Activity

  1. rashidrazak commented on Sep 12, 2026

    @rashidrazak
    Owner

    Investigation: plugin defect or upstream defect?

    Short answer: the root cause is upstream. The cache design that makes divergence
    possible is opencode's, and it is not new. This plugin owns the trigger (it registers an
    unpinned provider specifier) and it is the only side that can neutralize it today, but
    pinning is a mitigation, not a fix — and it does not fix the half that actually hurts
    users: installs that never move forward. The chosen fix and the trade-offs we weighed are
    at the end of this comment.

    Verified mechanism (opencode 1.18.30: source at tag v1.18.30 + the shipped binary)

    1. Cache is keyed by the raw specifier string.
      packages/core/src/npm.ts:79 → const directory = (pkg) => path.join(global.cache, "packages", sanitize(pkg)).
    2. An existing install is never refreshed or version-checked.
      packages/core/src/npm.ts:125 → if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) return resolveEntryPoint(...). Still unchanged on dev, so no upstream fix has landed.
    3. The two roles get different strings for the same package.
      packages/opencode/src/plugin/shared.ts:210 appends @latest for a bare plugin spec;
      packages/opencode/src/provider/provider.ts:1846 calls Npm.add(model.api.npm) with the provider's value verbatim.
      Confirmed in the installed 1.18.30 binary:
      if (A.api.npm.startsWith("file://")) return A.api.npm;
      let u = await fN.add(A.api.npm);
      So a bare npm for a package that is the plugin guarantees a second cache key that is never version-tied to the plugin. Note the plugin has no API that tells it which specifier loaded it, so it cannot choose "the same key" — it can only choose a specifier that cannot go stale independently.

    Registering a bare provider npm is upstream's documented convention (docs use
    "npm": "@ai-sdk/openai-compatible"; built-ins use gitlab-ai-provider), so
    src/plugin/index.ts:14 is not violating a contract. The contract simply has no case for
    "provider is this same package".

    Live reproduction (isolated HOME, opencode 1.18.30, this machine)

    Seeded packages/opencode-cmd-provider@latest with 1.7.1 (byte-for-byte this machine's
    real cache), project config "plugin": ["opencode-cmd-provider"], no declared provider:

    Step Result
    opencode models 68 commandcode/* models auto-registered → the 1.7.1 plugin ran
    opencode run --model commandcode/claude-sonnet-5 Npm.add("opencode-cmd-provider") created the bare dir at 1.7.4 (npm latest)
    cache after …@latest = 1.7.1, bare = 1.7.4

    That is the divergence reproduced end-to-end, in the reverse direction of the report
    (runtime newer than the plugin registering its models), because a bare spec resolves to
    npm latest whenever that dir is missing. The run leg then hit the known upstream hang
    (zero requests reached the mock), which does not affect this finding.

    This machine's own cache also shows the other half: opencode-cmd-provider@^1.6.4 and
    …@latest both sit at 1.7.1 while npm latest is 1.7.4 — both copies are stale purely
    from the no-refresh behavior, at 91 MB each.

    Attribution

    • Upstream owns the class. Specifier-keyed cache plus never-refreshing installs is upstream's, and it predates this package: #6774 (Jan 2026), #25293 (auto-closed for inactivity, same root behavior), and the cross-filed #48514 — now assigned to rekram1-node. Upstream's own text on #48514: "A plugin author can work around this by pinning a version, but opencode should not depend on that discipline to keep a plugin internally consistent."
    • This plugin owns the trigger. An unpinned provider specifier makes the second key reachable; that is the whole actionable surface on our side.
    • Pinning fixes divergence, never staleness. After a pin, the runtime half matches the plugin half — but neither moves until the package cache is deleted, so the README's rm -rf ~/.cache/opencode/packages/opencode-cmd-provider* step stays mandatory.

    Decision: pin the provider to the plugin's version

    We're going with the fix proposed in the report: pin the provider specifier to the
    plugin's own version
    , exact — opencode-cmd-provider@<version>, read from the
    package.json that ships next to dist. Not a range: a range would just be another
    cache key that can drift on its own.

    We also looked at a file:// self-reference (npm: new URL("../../index.js", import.meta.url).href), where opencode imports the plugin's own entry file and skips
    Npm.add completely. It removes the second install, but it leans on an internal branch
    of the loader, bypasses the normal npm path (mirrors, proxies, integrity), and would fail
    hard if that branch ever changes. For a package people install the normal way, that's the
    wrong risk to take. Pin it is.

    Downside vs upside of the pin

    Topic Downside Upside
    Version mismatch — (this is what gets fixed) Plugin and provider can never disagree; the version in the cache folder name is the version that runs
    Updating Both halves stay old until the cache folder is deleted They stay old together: one answer to "which version am I on?" instead of two
    Freshness The provider half can no longer jump to newest on its own when its folder is missing Model info and runtime code always come from the same release, so no old-metadata + new-code surprises
    Disk The second ~91 MB copy stays, and old pinned copies pile up until the cache is wiped Folder names now show exact versions, and the README's wildcard delete already matches them
    First message after an update ~91 MB download with nothing on screen; can look frozen Uses the normal npm path (mirrors, proxies, integrity) — no new mechanism to trust
    Dev / test runs If the local version isn't on npm, the provider won't load at all It fails loudly, instead of quietly running published code that doesn't match your changes
    Releases The version in package.json must match the code in dist The release pipeline already guarantees that, and npm versions never change, so a version's code is fixed forever
    Code to maintain ~10 lines to locate its own package.json; if that breaks, the plugin dies or the bug returns Small, testable, easy to revert, and harmless if upstream fixes the root cause later
    Users' own config Anyone who declares provider.commandcode.npm themselves doesn't get the fix Nobody is forced into anything; the plugin still only fills in blanks
    Support load Some may read it as "updates work now" Removes the odd two-version state that produces confusing, hard-to-diagnose reports

    Net: the upsides are about being correct and predictable; the downsides are about space
    and patience, plus one dev-only trap. Silent and confusing is worse than slow and obvious.

    What changes for users, exactly

    Before After
    Plugin half (models, config, login) Updates only after a cache delete Same — unchanged
    Provider half (the code that talks to Command Code) Installs the newest version when its folder is missing; frozen once it exists Always the same version as the plugin
    How a new version arrives Cache delete for the plugin, plus a lucky jump for the provider Cache delete for both
    End state The halves can differ (newer or older) The halves always match

    Worth being precise, because it's easy to read this fix backwards: users never got
    automatic plugin updates, and they still won't. The only "free" update today is the
    provider half installing newest when its folder happens not to exist — which is exactly
    how this issue's mismatch is born, in either direction. The fix removes that path and
    leaves the documented one: delete the cache folder, restart.

    What this does not fix

    The stale-install half is upstream's: a cache keyed by the exact specifier string, and
    Npm.add never refreshing an install that already exists. That is
    anomalyco/opencode#48514 (now assigned), and it stays the real fix — every opencode
    version in the wild still needs the manual cache wipe, so the README and CHANGELOG will
    keep saying so explicitly.

    Implementation

    • Read the plugin's own version by walking up from the module to the nearest
      package.json named opencode-cmd-provider; if that can't be read, fall back to
      today's bare name so the plugin never fails to load.
    • Tests for both paths, plus one asserting the registered specifier equals
      package.json's version.
    • Note the decision in docs/adr/ (the auto-registration mechanism lives in ADR-0001).
    • CHANGELOG entry under Unreleased.
  2. rashidrazak commented on Sep 12, 2026

    @rashidrazak
    Owner

    Spec published: #151 — Spec: version-pinned provider specifier — plugin and runtime provider never diverge (labelled ready-for-agent).

    It carries the chosen fix (exact version pin read from the shipped package.json, with a bare-name fallback so the plugin never fails to load), the two seams the behaviour will be tested at, the accepted costs, and the explicit statement that updating still requires clearing the package cache.

  3. rashidrazak commented on Sep 12, 2026

    @rashidrazak
    Owner

    Shipped on main via PR #153 (merge 1c183c0) — not yet released; npm still serves 1.7.4, so no user can install it yet.

    What changed: the runtime provider is now registered as opencode-cmd-provider@<exact version of the running plugin>, so opencode's specifier-keyed cache can no longer hold a runtime provider from a different release than the plugin that registered it — in either direction. Verified with the isolated-HOME probe from this report against real opencode 1.18.30: before, the cache held …@latest = 1.7.1 next to a bare directory at 1.7.4; now there is exactly one entry, and it matches the plugin.

    What did not change: the stale-install half. Npm.add still returns an existing directory without checking its version, so an already-cached plugin stays at the version it was installed at until the package cache is cleared — rm -rf ~/.cache/opencode/packages/opencode-cmd-provider* remains the update path, and ADR-0009 says so explicitly rather than letting this fix read as "updates now happen automatically". That half is upstream's: anomalyco/opencode#48514.

    Keeping this open until a release carrying the fix is published, so it is not closed against a version users cannot install.

  4. rashidrazak commented on Sep 12, 2026

    @rashidrazak
    Owner

    Released as 1.7.5 — GitHub Release · npm [email protected] (latest), published 2026-09-12 with provenance attestations.

    From this release on, the runtime provider is registered under the plugin's exact version (opencode-cmd-provider@<version>), so opencode's specifier-keyed package cache can no longer hold a runtime provider from a different release than the plugin that registered it. Verified against real opencode 1.18.30 with the isolated-HOME probe from this report: before, the cache held …@latest = 1.7.1 alongside a bare directory at 1.7.4; now there is exactly one entry, and it matches the plugin.

    To pick it up: rm -rf ~/.cache/opencode/packages/opencode-cmd-provider*, then restart OpenCode. The cache wipe is still required — Npm.add never refreshes an existing install, so neither half moves on its own. That half is unchanged by design and remains upstream: anomalyco/opencode#48514.

    Closing as fixed for the divergence described here; the staleness behaviour is tracked upstream.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions