DSH plugin: one broken package fails the official route

TroubleshootingPublished 2026-10-03Author: DeepSeek Plugin Market
DeepSeek HarnessDSHREQUEST_EXTENSIONdeepseek-officialplugin-package-inventory-deepseekinitProfileprofile manifestllm-pi-ai
Every turn on DSH's official DeepSeek route reports request extension preparation failed and no request reaches the network: a bad plugin package or manifest.

The symptom is highly recognizable: only the official DeepSeek route (deepseek-official/*) fails every single turn with DeepSeek request extension preparation failed (REQUEST_EXTENSION), while switching to a pi-ai-hosted route (for example opencode-go) works perfectly — and no request ever reaches the network. The root cause has nothing to do with the network, the model, or auth: during the request-preparation stage, while collecting the purely diagnostic dsh_plugin_packages field, if any active plugin package cannot resolve a readable package.json, or some manifest found by walking up "has a name but lacks version", the resolver throws; that error is amplified by the registry's Promise.all into a whole-turn request failure, before fetch. The two real-world trigger paths are "a plugin directory hollowed out by an updater" and "the profile manifest generated by initProfile() inherently lacks version" — the latter means a new user hits this the first time they use the official route.

Triage first: this is not a network problem, it is local metadata blocking the preparation stage

Bottom line up front: narrow the scope first using the single signal "zero network traffic". REQUEST_EXTENSION is thrown inside the adapter, before fetch, so once you see this code you can rule out network, proxy, certificates, auth, and quota entirely — the request was never sent (#6497).

To confirm it belongs to this class, follow these four steps:

  1. Confirm the error code is REQUEST_EXTENSION and the message is DeepSeek request extension preparation failed. This is the adapter's wrapping of a rejection during the registry preparation stage.
  2. Confirm only the official route is affected. On the same profile, switching to a pi-ai-hosted route works — because llm-pi-ai does not consume this registry at all; only llm-deepseek registers and depends on it.
  3. Confirm it fails every turn, not just the first. There is no result-level cache anywhere in the chain, and failed resolutions are not cached either, so it re-throws on every turn (#7678).
  4. Go read the cause. The actionable package name / manifest path / which line to fix all live in LlmError.cause — it is simply not rendered by any interface, so this step usually means reading the host logs directly or temporarily adding a print.

The complete failure chain (each link has a corresponding source location):

one request turn
  └─ adapter awaits the registry's prepare() before fetch
       └─ prepare() runs all contributors concurrently with Promise.all
            └─ the dsh_plugin_packages contributor calls collectActivePluginPackages()
                 └─ per-package resolver.resolve(entry) —— no per-package try/catch
                      └─ one package fails to resolve → throw
   ↑ this throw rejects the whole Promise.all
  └─ adapter wraps it as LlmError(..., 'REQUEST_EXTENSION', { cause })
  └─ fetch never happens → every turn fails

One design mismatch worth calling out on its own: the registry's acceptance stage already uses tolerant "settle everything, no cross-interference" semantics (acceptAll), but the preparation stage uses Promise.all — one contributor rejecting drags down the whole batch. A confirmer argues that making the preparation stage consistent with the acceptance stage would be more natural (#6497).

Trigger path one: any active package that cannot resolve a readable package.json

As soon as one active plugin package cannot resolve a readable package.json on disk, PackageIdentityResolver.resolve throws; collectActivePluginPackages has no per-package try/catch in its loop, so the whole batch fails to collect, and therefore the whole turn's request fails. This is not a sporadic environment problem — it is a real incident that already happened.

The shape of the offending code (packages/llm/plugin-package-inventory-deepseek/src/index.ts):

ts
const packageName = barePackageName(entry.options.name)
let manifest: string | undefined
if (packageName !== undefined) {
  manifest = barePackageManifest(packageName, anchors)
  if (manifest === undefined) {
    throw new Error(`plugin-package-inventory-deepseek: cannot resolve active package ${JSON.stringify(packageName)}`)
  }
}

barePackageManifest() uses existsSync to look for package.json in Node's package search path; if it does not find one, it throws the line above. The same resolve also throws when "it was found but the manifest is invalid": JSON that cannot be parsed, or missing a non-empty name and version (#6497).

What the real incident looked like in full (this is more worth remembering than the theory): on some deployment, a third-party plugin updater ran "first rm -rf, then cp" against @memtensor/memos-local-plugin, and the cp was aborted with EPERM because better_sqlite3.node was in use by the running host. The result was a package left as an empty directory with no package.json. From that moment, every deepseek-official turn failed, and LlmError.cause named that package exactly. After restoring the package from the source directory, no host restart was needed — because the failed resolution result was never cached, and the next request recovered (#6497).

An easily overlooked detail: not every mounting form triggers that throw. Relative-path, absolute-path, and file-URL entries walk upward for the "nearest manifest" and silently omit when none is found, without throwing; only bare-name (or in-package subpath) entries reach the throw above — plus the case of "an existing manifest that is malformed or missing name/version" (#6497).

This also explains why the behavior went unclassified as a bug for so long: it is a documented contract, not a regression. docs/subsystems/llm-streaming.md states that "preparation, conflict, and acceptance failures use REQUEST_EXTENSION and fail the model request", and the plugin README says "malformed package metadata will make request preparation fail"; the repo's own test inventory.spec.ts even pins "malformed metadata → reject" as expected behavior. So fixing it requires changing the docs and tests at the same time — which is also why it is still here.

Trigger path two: the profile manifest generated by initProfile() lacks version

The second path is more serious, because it is not "you broke something" but "broken out of the box": the package.json that initProfile() writes when creating a profile has no version field; as long as the profile contains a relative-path module entry, the "nearest manifest" the collector finds by walking up is the profile's own package.json, immediately hitting the "a name requires a version" check.

The manifest written by initProfile() (packages/boot/app-boot/src/profile.ts):

js
const manifest = {
    name: `dsh-profile-${basename(dir)}`,
    private: true,
    dependencies: {},
    dsh: { profile: { bundles: [...bundles] } }
};

No version. Matching it in the collector's check:

js
if (typeof manifest.name !== "string" || manifest.name.length === 0
    || typeof manifest.version !== "string" || manifest.version.length === 0)
    throw new Error(`plugin-package-inventory-deepseek: ${path} must declare non-empty name and version`);

Here there is an asymmetry, and it is the key to the problem: the collector passes allowAnonymous: true for the loose-module branch, so a manifest with no name at all is tolerated (returns undefined, contributes no identity); but "has a name yet lacks version" is not tolerated and throws directly. And a relative-path entry (for example name: './x.mjs') takes exactly the loose-module branch and lands exactly on the profile's manifest — the single manifest in the profile, which falls exactly outside the tolerated range (#7678).

A minimal four-step reproduction (no third-party plugins needed):

  1. Initialize a brand-new profile so initProfile() generates the manifest:

    bash
    dsh headless --help
    cat ~/.dsh/profiles/headless/package.json
    # => { "name": "dsh-profile-headless", "private": true, ... }   no version
    
  2. Add a relative-path module entry in that profile's cordis.patch.yml:

    yaml
    - insert:
        - id: my-plugin
          name: './my-plugin.mjs'
    
  3. Send a message using that profile's DeepSeek official route.

  4. Observe the result: turn failed / DeepSeek request extension preparation failed.

A particularly convincing controlled experiment (two profiles on the same machine):

profilemanifestrelative-path entryresult
~/.dsh/profiles/webhas "version": "0.0.0"./eli-host2.mjsnot triggered
~/.dsh/profiles/desktopno versiona newly added local ./x.mjsevery turn becomes REQUEST_EXTENSION right after adding it

The only difference fed to identityFromManifest() is version: present returns an identity, absent falls through to the throw. After adding version, no restart is needed and the next request recovers — because the throw occurs before cache.set, so the failing key was never cached (#7678).

Three more details that are easy to misjudge:

  • Switching to an absolute path does not help. barePackageName() returns undefined for both ./x.mjs and /abs/x.mjs, so both go through nearestManifest(), walking upward from the entry's directory.
  • The desktop build does not self-heal. Its DesktopProjectManager.applyRelease() → createPluginProfile() → initProfile(), and initProfile() only writes the manifest when !existsSync(manifestPath); there is no migration anywhere on that path to add version. So patching initProfile() only rescues profiles created in the future; existing profiles must be edited by hand.
  • This is the eighth case in the family. The same shape (an active entry fails to resolve → the whole turn's request fails) already had six different causes: pnpm isolated layout (#5172 / #5173 / #5196), a stale fallback symlink (#5439), a clean build (#5683), an absolute file-path module (#5844), and a relative directory module (#6064) — and what makes the initProfile() case different is that it pushes the chain from "only historical residue gets hit" to "broken on first run" (#7678).

Three fixes and local workarounds

The priority order is clear: what actually closes this problem family is "per-package tolerance + warning in the collector"; adding version to initProfile() is the correct companion change, but it only rescues newly created profiles; until then, the immediate priority is giving existing users a recovery path that works right away.

Fix one (recommended, narrow): per-package warn-and-omit in the collector. In collectActivePluginPackages, wrap resolver.resolve(activeEntry) in try/catch, log a ctx.logger.warn(...), and continue, letting the remaining resolvable packages be collected as usual. There is already precedent in the repo (the controlled-listener pattern). Adopting this fix also means changing the "malformed metadata → reject" assertion in inventory.spec.ts to "warn + omit", and softening that README sentence. The reporter verified this patch locally: a broken bare-name entry now produces only one warning and is omitted, while the rest are still collected (for example it still collects ["dsh-better-sidebar@0.19.1"]) (#6497).

Fix two: registry-level isolation. Change prepare()'s Promise.all to Promise.allSettled, omitting the fields of rejected contributors (optionally with a warning). The benefit is making the preparation stage semantically consistent with the acceptance stage. The cost is that this is a documented strict contract, it changes the behavior of every contributor (including dsh_session_log), and it is pinned by multiple tests — if you do move it, make it explicit per-contributor opt-in rather than a blanket global relaxation (#6497).

Fix three (companion): make initProfile() write version: "0.0.0". This is the most upstream link in the chain and is a one-line change:

diff
 const manifest: ProfileManifest & { private: boolean } = {
   name: `dsh-profile-${basename(dir)}`,
+  version: '0.0.0',
   private: true,
   dependencies: {},
   dsh: { profile: { bundles: [...bundles] } },
 }

and add the assertion expect(manifest.version).toBe('0.0.0'). But note it only takes effect for profiles created afterward — which is exactly why "fix one must come before fix three" (#7678).

Three workarounds available right now:

  1. Add one version line to the profile's package.json (effective immediately, no restart):

    json
    {
      "name": "dsh-profile-web",
      "version": "0.0.0",
      "private": true
    }
    
  2. Put the local plugin in its own subdirectory with a package.json carrying name + version (for example profiles/desktop/my-plugin/{package.json,index.mjs}, with the entry written as ./my-plugin/index.mjs). Because nearestManifest() looks first at the entry's own directory, it will then never land on the profile manifest. Note this only bypasses the resolver, it is not a fix — adding version to the profile manifest remains the more correct move (#7678).

  3. Turn the collector off in cordis.patch.yml (accepting the loss of the whole field):

    yaml
    - id: plugin-package-inventory-deepseek
      name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek'
      config:
        enabled: false
    

    This line is enabled by default in the base bundle; turning it off is a workaround rather than a fix, and anyone who genuinely needs the diagnostic field should not choose it.

Troubleshooting and notes

  • The biggest current usability gap is that the error does not give you the package name. All three of the terminal, the session record, and the Web UI show only the outermost DeepSeek request extension preparation failed, while the package name and manifest path live in cause. Desktop is especially awkward: DesktopHostProcess accumulates host stderr with only slice(-65536) and reports it only when the host exits/fails — a request-level failure never triggers a report, so "render the cause" can only land on the session / Web UI side; host stderr is not a usable channel (#7678).
  • After fixing it, a restart is usually not needed. The failed resolution result is not cached, so repairing the directory or adding version makes the next request recover. The one exception is "swapping an already-cached package identity for another version" — that needs a restart.
  • Do not treat a "relative-path entry" as immune. Relative / absolute / file-URL entries silently omit only when no manifest is found; once the manifest found by walking up exists but is malformed (including "has name, no version"), it throws just the same. That is also why the profile's own manifest becomes the culprit.
  • Suggested troubleshooting order: first confirm the error code and "zero network traffic" → then check whether only the official route is affected → then check whether a plugin was recently updated / deleted / had its directory hollowed out → finally check whether the profile's package.json has version.
  • The community already has a stopgap plugin for this seam (@argszero/cordis-plugin-request-extension-guard): it wraps every registered provider's prepare, turning "one field throws = whole batch fails" into "that field is skipped for this request, and its own message is logged once verbatim"; the optional repairProfileManifest: true performs a purely additive write of a missing version and retries within the same request, so the field does not merely "survive" but "comes back". It explicitly states this is not a fix: the skipped field is genuinely absent from that request, and the durable fix still belongs in the harness (#7678).
  • Correcting a common misconception while we are here: in cordis.patch.yml, config for an existing entry is replaced wholesale, not deep-merged. The shared implementation applyEntryPatches overwrites key by key for non-insert patches, so writing only defaultPreset makes the entire presets table from the base bundle disappear. This is intentional (a deep merge would change the line that deliberately replaces a whole config), but the header comment's phrasing "id-targeted config overrides" makes it easy to assume it merges — remember to write the entire config when patching.

Troubleshooting this kind of trap — "the error says the request failed, but it is really invalid local metadata" — is hardest precisely because the error swallows the actionable clue. If you want the plugin environment itself to be inspectable, install DSH Plugin Hub — the official plugin marketplace built into the DeepSeek Harness desktop app. Beyond browsing, installing, uninstalling, and updating plugins, it offers "Custom install" so third-party sources are obvious at a glance, and after installing you can immediately verify the version in the installed list, avoiding the "directory hollowed out with nobody noticing" situation:

DSH Plugin Hub · Custom install

Managing plugin sources and versions in one place saves far more trouble than guessing which package is broken from an error message.

Source: Discussion #6497, Discussion #7678.

FAQ

Why does switching to another route work, while only the official DeepSeek route fails every turn?

Because the failing piece is a request-extension contributor unique to the official route. The deepseek-official route is registered by llm-deepseek, which collects the dsh_plugin_packages diagnostic field before sending a request; llm-pi-ai does not consume that registry at all. So the same broken plugin package only poisons the official route, and a pi-ai-hosted route (for example opencode-go) keeps working.

The error message does not say which package is broken — how do I locate it?

The actionable information is in the LlmError cause, but the terminal, the session record, and the Web UI all render only the outermost DeepSeek request extension preparation failed and drop the cause — a point repeatedly complained about in the reports. You can work backwards from the error shape: plugin-package-inventory-deepseek: cannot resolve active package "<name>" points to a bare-name entry whose package cannot be resolved; … must declare non-empty name and version points to some manifest found by walking up (very likely the profile's own package.json) missing version; a JSON parse error carries the manifest path.

I changed nothing — why did every turn suddenly start failing one day?

The most common case is "a plugin directory hollowed out by an updater": it did rm -rf then cp, and the cp aborted partway because a file was in use (for example EPERM on better_sqlite3.node), leaving the package as an empty directory with no package.json. In the actual incident reported, from that moment every single turn on the official route failed, and LlmError.cause named the offending package exactly; after restoring the package from the source directory, **the very next request recovered** with no host restart — because the failed resolution result was never cached.

Why is the official route broken from the start on a brand-new profile?

Because initProfile() does not write version when it writes the profile's package.json, while the collector requires that any manifest with a name must also declare a non-empty version. As long as the profile has a relative-path module entry (for example ./x.mjs), the collector walks up from the entry to the "nearest manifest", which lands on the profile's own package.json — hitting the check immediately. This is no longer "historical residue" but a broken-out-of-the-box first-run path; it is especially visible on desktop, because the desktop profile is created by exactly this code.

Has the official team fixed it? What can I do right now?

As of the reports cited here, this chain is still present on master, and it is pinned down by the repo's own tests and docs (inventory.spec.ts, adapter.spec.ts, registry.spec.ts, and llm-streaming.md all treat strict failure as the established contract). Right now, do three things in priority order: (1) add version: "0.0.0" to the profile's package.json (takes effect immediately, no restart); (2) repair the broken plugin package, or remove its plugin line from the composition; (3) if you must unblock immediately, set the plugin-package-inventory-deepseek line to enabled: false in cordis.patch.yml — but that loses the whole diagnostic field, so it is a workaround, not a fix.

Related Terms

REQUEST_EXTENSION
The error code for "request extension preparation failed" on the LLM adapter. The adapter awaits the registry's `prepare()` before making the HTTP call, and any rejection is wrapped as `LlmError('DeepSeek request extension preparation failed', 'REQUEST_EXTENSION', { cause })`. Because this happens before `fetch`, the signature of these failures is "zero network traffic" — which lets you rule out network, auth, and quota problems in one step.— https://github.com/deepseek-ai/deepseek-harness/discussions/6497
dsh_plugin_packages
A **diagnostic** extension field collected before every request, listing the identifiers of the currently active plugin packages. It is contributed by `plugin-package-inventory-deepseek` and is meant to give the model a bit of environment context. The irony is right here: a purely diagnostic field, yet because "a collection failure throws" it has the power to hard-fail an entire conversation turn.— https://github.com/deepseek-ai/deepseek-harness/discussions/7678
loose module and allowAnonymous
A module entry that cannot be attributed to a named package (relative path, absolute path, file URL) is called a loose module. When resolving this branch the collector passes `allowAnonymous: true`, so a manifest with no `name` at all is tolerated and returns `undefined` (contributing no identity); but "has a `name` yet lacks `version`" is outside that tolerance and throws directly. This asymmetry is exactly what causes #7678.— https://github.com/deepseek-ai/deepseek-harness/discussions/7678
nearestManifest
The rule by which the collector finds the "nearest package.json" for a module entry: starting from the directory containing the entry itself and walking upward. It determines which manifest a relative-path entry lands on — which is precisely why the profile's own `package.json` becomes "the nearest one". Conversely, putting a local plugin in its own subdirectory with a `package.json` carrying `name` + `version` stops the resolver from landing on the profile manifest.— https://github.com/deepseek-ai/deepseek-harness/discussions/7678

Sources