メインコンテンツへスキップ
J

dsh-model-manager

jyshangguan/dsh-model-manager

Role-based model manager for DeepSeek Harness: assign models to planning, execution, vision and main roles, route subagent work by role, escalate sessions that invoke a planning skill (grilling / grill-me) to the planning role, report which model every su

インストール

dsh plugin --profile web add github:jyshangguan/dsh-model-manager

README

@darkbear9494/dsh-model-manager

A host-half plugin for DeepSeek Harness that assigns models to four roles — main, planning, execution, vision — applies them on the agent/request waterfall, and reports which model every subagent actually used, with its role, the reason, and its live status. Since 0.3.0 it also prompts the top-level agent to delegate (strategy.autoDelegate) and checks npm for a newer release of itself (update), surfacing an upgrade notice in the log, through the model_manager tool, on the settings card, and as a chip in the session header. Since 0.4.0 the delegation label is a contract, not a hint: a leading plan:/vision: prefix decides the tier outright, and each role's failover chain wears down on its own.

What it is

DSH already ships the primitives: the subagent tool accepts an explicit provider/model per delegation, and the subagent-model-selection-settings row constrains which routes an agent may name. What it does not ship is a layer above that: nothing assigns models to kinds of work (planning, implementation, image reading), and nothing tells you afterwards which model a given subagent ended up on.

This plugin is that layer. It keeps one ordered route list per role, classifies each delegated child from the label the delegating agent gives it, rewrites the child's resolved route to the role's route when inheritance is provable, and exposes a read-only model_manager tool that reports what happened. 0.3.0 adds two layers on top of routing proper: an auto-delegation policy — a system-prompt section that teaches the top-level agent to plan and dispatch subagents instead of doing everything itself, which is the behaviour the harness's own instructions only nudge at — and an update check that compares the installed version with the registry's dist-tags.latest and tells you how to upgrade. 0.4.0 makes the label a contract instead of a hint: a leading plan:/vision: prefix decides the tier before any keyword is consulted, the policy never advertises a tier with no models behind it, it teaches the delegating agent to leave provider/model unset on tiered units (an explicitly named route overrides the tier under hybrid), and the failover cursor is tracked per (session, role) so one tier's wear never pushes another tier off its first model. It is still deliberately small: one ESM host file, one config schema, and one hand-written client file that contributes a configuration card to the Plugins page. There is no build step for either half.

Install

One command — from the registry, straight from GitHub, or from a working copy:

# from npm
dsh plugin --profile desktop add @darkbear9494/dsh-model-manager@latest

# from GitHub, no registry involved; needs git access to the repo
dsh plugin --profile desktop add github:jyshangguan/dsh-model-manager

# from a working copy (path must be absolute)
git clone https://github.com/jyshangguan/dsh-model-manager.git
dsh plugin --profile desktop add /absolute/path/to/dsh-model-manager

Use the profile your app actually boots. dsh web and the Web GUI run the web profile; the desktop app (DeepSeek Harness.app) runs desktop, so a plugin installed into web is invisible there. When in doubt, check the running process's working directory: it is $DSH_HOME/profiles/<name>.

dsh plugin forwards everything after --profile <name> to pnpm, run inside the profile directory under a file lock (lib/bin.js → runPlugin → runPluginCommand in @deepseek-ai/dsh-plugin-manager). It is the same code path the GUI's install_bundle action uses, so both leave an identical result.

What makes it one command instead of three: after the install, reconcile() walks every new profile dependency, reads its manifest, and when it declares dsh.bundle.patch it validates that patch and appends the package name to dsh.profile.bundles, writing package.json atomically. A dependency that declares no dsh.bundle prints installed as a plain dependency, not a profile layer and is never mounted. The profile-layer registration you would otherwise hand-edit is the installer's job.

Then restart the harness once and confirm the row mounted: plugin_manager list_plugins should show row id model-manager, enabled: true, fiberPhase: active.

Add plugin, from the Plugins page

The Plugins page's Add plugin field reads "Enter the plugin's package name, GitHub repository address, or local directory path." It is the GUI's install_bundle, and it parses the string through the same parseInstallSpec (@deepseek-ai/dsh-plugin-manager/lib/types/install-spec.js) before handing it to pnpm. All four forms work:

Field inputSpec kind
@darkbear9494/dsh-model-managerregistry — the shortest one to type
github:jyshangguan/dsh-model-managergit — the https://github.com/… URL works too
/Users/you/src/dsh-model-managerabsolute local directory (relative paths are refused)
/Users/you/darkbear9494-dsh-model-manager-0.2.0.tgzlocal tarball — npm pack makes one (0.3.0's would be named the same way)

Registering the plugin by name was verified end-to-end on this runtime: dsh plugin --profile <name> add @darkbear9494/dsh-model-manager resolves the current release (^0.1.0 at first publish, ^0.2.0 since) from the registry, reconcile() appends the bundle, the composed tree mounts row model-manager, and a boot reaches the LLM stage with no peer refusal and no loader error. The git and local-path forms were checked the same way. The same add command is the upgrade path: re-running it with @latest resolves the newer version and updates the dependency in place.

The registry copy

@darkbear9494/dsh-model-manager@0.4.0 is the current release, public access, the same 8 files as 0.2.0 (the tarball grew, not the file list: the host half, the client half and this README carry the new sections). 0.2.0 packed 63.1 kB with the same 8 files, and 0.1.0, the first publish, 62.3 kB. The declared gate is >=0.1.7-rc.2 <0.3.0, so every release on the 0.1.7–0.2.x runtime lines installs without an exemption.

Two things learned while publishing it, in case a future version has to be released:

  • The scope has to equal the npm account, or name an organization the account belongs to. See Why the package is scoped.
  • The registry's read path lags the write. Immediately after a successful npm publish (PUT 200, exit 0) the packument can still answer 404 Not found — and so can npm view, npm install and a GUI install — while the tarball URL already returns 200. It cleared on its own in about five minutes here. Wait it out rather than republishing; the second npm publish would fail on the version already existing.

Why the package is scoped, and why the scope is @darkbear9494

The unscoped name dsh-model-manager is already taken on npm by a different plugin: another DeepSeek Harness package whose Chinese display name is also 模型管理器, and which also declares dsh.bundle.patch. Installing by that bare name fetches theirs and auto-registers it into your boot graph; were both ever mounted together their loader rows could collide on one id, which aborts startup with duplicate loader entry id. test/packaging.test.mjs pins the scoped name so this cannot silently regress.

The scope is not decoration. npm accepts a user-scoped package only when the scope equals the authenticated npm username; anything else is refused at publish time with 404 Not Found - PUT https://registry.npmjs.org/@<scope>%2f<name> - Scope not found, unless the scope is an organization you belong to. This package was briefly @jyshangguan/dsh-model-manager, which failed to publish for exactly that reason — @jyshangguan is a GitHub handle, not the npm account. @darkbear9494 is the npm account, so scope and account agree and no organization is needed. If the publishing account ever changes, the package name has to change with it.

The runtime version gate

peerDependencies["@deepseek-ai/dsh"] is >=0.1.7-rc.2 <0.3.0. The harness checks exactly that peer — only names equal to @deepseek-ai/dsh or beginning @deepseek-ai/dsh- — with semver, prereleases included (semver.satisfies(runtime, range, { includePrerelease: true }) in @deepseek-ai/dsh-app-boot), and refuses an incompatible install while printing the exemption command:

dsh plugin --profile desktop allow-version @darkbear9494/dsh-model-manager@0.2.0 \
  --dsh-version <exact runtime version> --accept-risk

Without that peer this plugin would install silently onto a harness too old to have sessionProjections.stateOf or the model/selection event, and then degrade with no explanation. Profiles ship autoInstallPeers: false, so declaring the peer does not pull a second copy of dsh into the profile.

The range was >=0.1.7-rc.2 <0.2.0 through 0.1.0, and that upper bound did not mean what it looked like. In semver a prerelease sorts below its release, so 0.2.0-rc.2 < 0.2.0 is true: the old range admitted every 0.2.0 prerelease while excluding 0.2.0 itself. It therefore permitted exactly the builds nobody had tested and blocked the release it appeared to target. <0.3.0 is what it was meant to say, and it now covers the whole 0.2.0 line deliberately.

The 0.2.0 line is admitted because the host half is verified against it. Against dsh 0.2.0-rc.2 — the core bundled in DeepSeek Harness.app 0.2.0-rc.2 and the latest tag on npm — every API this plugin touches is unchanged from 0.1.7-rc.2: dsh-session-projection (stateOf), dsh-plan-mode, dsh-agent, dsh-subagent, dsh-tool-subagent + model-selection-settings, dsh-tools, dsh-llm, dsh-session and dsh-client-modules are byte-identical, all five subscribed events and every probed service still exist, and all 22 failover failure.code values are still emitted. A real boot on that runtime applied the host half, bound every event, resolved every injection and registered the model_manager tool. The client half is unchanged too: window.__ModuleLoader__.load({ id, factory }), the dsh.client manifest keys and every injected namespace still match what 0.2.0 provides.

What did change between the two runtimes touches nothing here: dsh-agent-loop adds ToolCallRecovery on step failure, dsh-session refactors tail repair, dsh-config-editor changes how inherited config composes, and dsh-api-remotes gains transport code plus two namespaces (productAnalytics, userQuestions) with no removals.

Renaming or moving a linked install

Both of these were hit for real while scoping this package, on DSH 0.1.7-rc.2:

remove_bundle drops the dependency from the profile's package.json and removes it from dsh.profile.bundles, but leaves the symlink behind in the profile's node_modules. Delete that orphan yourself: a directory whose name no longer matches the name in its own manifest is precisely what a resolution scanner should not have to reason about. (Clearing it did not fix the symptom below, so it is hygiene rather than a remedy.)

And a running host cannot import a package name that did not exist when it booted. The runtime resolution is computed from the installation and the bundles selected at startup, then handed to Node's ESM and CommonJS resolvers, so after a rename the entry reports failed to import — inactiveEntries finding entry.fiber === undefined — however many times you toggle the bundle. The package itself is fine: node -e "import('<new name>')" from the profile directory succeeds, and dsh --profile <name> --dump-config composes the row correctly, override config included. Only a restart picks it up. Remove the old name, install the new one, then restart once.

FilePurpose
lib/index.jsHost half: Cordis identity model-manager, exported Config, apply, the agent/request routing, the agent/request-error failover, and the model_manager tool.
client.jsClient half: the configuration card, contributed as a page in the Settings panel.
locale/en.json, locale/zh.jsonDisplay metadata only — the bundle's title and description in the plugin list.
cordis.patch.ymlBundle layer: inserts exactly one row, id model-manager.
package.jsonPackage @darkbear9494/dsh-model-manager, declaring dsh.bundle.patch, dsh.client, and the @deepseek-ai/dsh version gate.
test/The test suite; npm test runs all of it. See Testing.

Making the schema dependency resolvable

@deepseek-ai/schemastery is declared as an optional peer: the harness ships it, and this package deliberately does not install its own copy, so the Config schema is built by the same schemastery instance the loader validates with.

But a path-based install is linked, not copied, so Node resolves this package's imports from its own directory upward — which never reaches the profile's node_modules. Point it at the harness's copy once per machine:

mkdir -p node_modules/@deepseek-ai
ln -s "$(npm root -g)/@deepseek-ai/dsh/node_modules/@deepseek-ai/schemastery" \
      node_modules/@deepseek-ai/schemastery

npm root -g is the global install root; adjust if your dsh lives somewhere else. node_modules/ is git-ignored, so this step is not carried by the repository.

Skipping it does not break the plugin: Config degrades to undefined, cordis's resolveConfig passes the raw config straight through, and routing keeps working. What you lose is the Settings card — the harness only serves a settings namespace for a row that has a Config schema, so the card would report the namespace as unavailable.

The test suite needs it too, and fails less gracefully than the plugin does: without a resolvable schemastery, edge dies on TypeError: plugin.Config is not a constructor and client-diagnostic loses one assertion. This is a known rough edge — the suites should skip those cases with a warning instead of crashing. Create the symlink before running npm test on a fresh clone.

Restart the harness once after installing. The client half reaches the browser through a boot manifest: the node half of client-modules scans the loader's entries for packages declaring dsh.client, and package metadata — including the negative "not a client package" verdict — is cached per loader specifier until restart. It is the same restart the host half needs, so do both at once. Before it, the Plugins page simply shows no configure control for this bundle.

{
  "exports": {
    ".": "./lib/index.js",
    "./client": "./client.js",
    "./locale/*.json": "./locale/*.json",
    "./package.json": "./package.json"
  },
  "dsh": {
    "bundle": {
      "patch": "./cordis.patch.yml"
    },
    "client": {
      "platform": "web",
      "immediately": true,
      "inject": [
        "@deepseek-ai/dsh-client-locale",
        "@deepseek-ai/dsh-client-ui-settings",
        "@deepseek-ai/dsh-api-remotes"
      ]
    }
  }
}

platform: "web" is what makes this a browser module for the web shell, and immediately: true marks it for stage-one prefetch, so its factory is registered during module-face boot instead of on demand. inject is a dependency edge, not a mount whitelist: each named package has to arrive first because it declares something this half uses — @deepseek-ai/dsh-client-locale for ctx.locale.register, @deepseek-ai/dsh-client-ui-settings for the settings.section slot, and @deepseek-ai/dsh-api-remotes for the remote.llm and remote.session faces the card reads the model catalog and the usage projection through. If one is missing from the composition that registration has nothing to attach to; the host half still routes, and the boot log names the surface that never appeared.

To smoke-test it before installing anything, mount the entry by path in a throwaway patch layer and boot the whole plugin tree headless — a plugin that breaks startup fails here instead of in your running server:

# smoke.patch.yml
- insert:
    - id: model-manager-smoke
      name: '/absolute/path/to/dsh-model-manager/lib/index.js'
      config:
        roles:
          planning:
            models:
              - provider: pku-corpus
                model: qwen3.8-max-0902
          execution:
            models:
              - provider: pku-corpus
                model: qwen3.8-flash
        strategy:
          mode: hybrid
dsh --profile web --patch ./smoke.patch.yml headless "say ok"

The four roles

Each role holds an ordered list of routes. Since 0.2.0 the selection strategy is one top-level pick for every role — the per-role pick field of 0.1.x is gone, and a config that still carries it gets a warning naming the move. A role with no models resolves to no route, and requests classified into it pass through unchanged — except vision, which falls back to execution. A subagent never uses main.

RoleWhat it is forWhen it appliesBundled default
mainThe top-level agent outside plan mode.Requests from a non-subagent session while plan mode is not active.models: [] (no route — the composer choice is left alone)
planningReasoning, design, analysis, research.Top-level requests while plan mode is active; subagents whose label matches a planning keyword (and no vision keyword).pku-corpus/qwen3.8-max-0902
executionDefault tier for delegated implementation.Every subagent that matches neither keyword list, including a child with an empty label; also the fallback for vision when vision has no models.pku-corpus/qwen3.8-flash
visionImage, screenshot, chart and OCR work.Subagents whose label matches a vision keyword.models: [] (falls back to execution)

A route is { provider, model, reasoningEffort? }. The top-level pick is first (default) or round-robin and applies to every role alike; with one model in a role's list it has no effect for that role. Under first the list is a failover chain, and since 0.4.0 the wear position is tracked per (session, role), not per session: a session that failed over while it was serving planning (plan mode moves one session between main and planning on every request) comes back to main still on main's first model. A role may also carry a note — free text for humans, printed under the role in the routes table and never used for routing.

Configure in the UI

The client half contributes a configuration card to the Plugins page. It writes exactly the values documented under Configuration — roles.<role>.models, the top-level pick, strategy.mode and strategy.autoDelegate — so the two paths are interchangeable: use the card to try a routing choice, the YAML to pin it. This package's own cordis.patch.yml is the declarative source that ships with the bundle and is re-read on every boot, and a profile patch overrides it by row id; the card never edits patch files, so if a patch layer pins a value, edit the patch.

Where the card appears

client.js registers three surfaces and deliberately nothing on the Plugins page:

SlotRegistration idWhat it renders
settings.sectionmodel-managerThe configuration card: the four roles and their model lists, the shared selection strategy, the auto-delegation level, the distribution mode, and the update banner.
conversation.session.header.utilitiesmodel-manager-usageThe per-session model-usage summary the host half folds from the durable log.
conversation.session.header.utilitiesmodel-manager-updateThe update-available chip (0.3.0): renders nothing unless a newer release was found, then shows 更新:<latest> and discloses the upgrade command on click.

So the path is 设置 → 模型管理器 — not Plugins → the row → configure. plugins.item and plugins.row.config are not registered: one card in one place was the requirement, and a second copy on the Plugins page would only be a second place for the same values to disagree.

All entries are registered unconditionally rather than behind the shipped configForms.whileServed([ns], …) gate. The Host serves a settings namespace only for a row whose Config schema has volatile fields, so that gate would hide the card entirely whenever the optional schemastery dependency failed to resolve — making a packaging problem look exactly like a missing feature. The entry is therefore always reachable, and the card itself reports why it cannot show values.

The usage surface only subscribes to a finished value: the host half folds modelManagerUsage out of the durable session log and ships it as a wired projection, so there is no folding and no polling on the client, and the numbers survive a restart.

What the card offers

ControlWritesNotes
Model rows, one per role (Main model, Planning and reasoning, Execution, Image recognition)set roles.<role>.modelsEach configured route is its own row with a <select> grouped by provider in <optgroup>s, fed by ctx.remote.session.modelCatalog() and labelled name — id. A route the catalog stopped advertising stays selectable and is marked (not in catalog) rather than being silently swapped. An empty list is the YAML default: no route for that role, and vision falls back to execution.
Add a modelset roles.<role>.modelsOne dashed control under the rows, listing every catalog model the role has not already used, so a model cannot be added twice.
up / removeset roles.<role>.modelsReorder and delete. The whole list is rewritten in one atomic operation, so a reorder cannot interleave into a partial state. The up control is disabled on the first row rather than absent, so the column never changes width.
Reasoning-effort pickerset roles.<role>.models (single route with reasoningEffort)Appears only when the selected model advertises reasoning.efforts in the catalog. (model default) writes a route with no reasoningEffort, letting the adapter choose; naming a tier explicitly is how you satisfy a model that rejects a request without one.
Pick (first / round-robin)set pickOne control for every role (since 0.2.0), rendered next to the distribution mode with a hint line. first always uses index 0 of each role's list and only advances when a route fails; round-robin spreads consecutive requests across each role's list.
Auto delegation (off / balanced / eager)set strategy.autoDelegateNew in 0.3.0. The delegation policy level, labelled with one-line explanations. A stored level the card does not know displays as balanced, mirroring the host half's fallback.
Distribution modeset strategy.modehybrid / managed / advisory, labelled with the same one-line explanations this README uses. A stored mode the card does not know displays as hybrid, mirroring the host half's fallback.
Update banner (read-only)—New in 0.3.0. When the client-side update store says a newer release exists, a warn-toned banner opens the card: versions, the upgrade command, and a Re-check button. See Automatic update checks.

Layout. One CSS grid per role, with fixed column tracks auto minmax(0, 1fr) auto auto — index, model, effort, actions. That choice is the whole reason the rows line up: in a flex row a control with flex: 1 1 16rem takes width from its siblings, so any row that omits one control (a model with no reasoning tiers, a first row with no reorder button) pulls every other row's right edge out of alignment. Under the grid a row with no effort control still emits an empty cell, so the action column sits at the same x everywhere. The two actions are glyphs rather than words, so the column is also the same width in either locale; each keeps an aria-label and a native title. The add affordance is deliberately unlike a model row — dashed border, no fill, muted text, spanning the value columns.

Each role also carries a hint line (Top-level agent outside plan mode. Leave unset to keep following the composer selection., Used for image, screenshot and OCR work. Pick a model that accepts images., …) so the card explains itself without this README open.

The card cannot tell you whether a model accepts images: the session model catalog carries ids, names and reasoning efforts, and no modality field. Use model_manager(action: "routes") for the vision capability audit, which asks the host half's llm service instead.

How a change reaches the router

Immediately, with no Save button. Every control writes on change, and the host half re-reads its config on loader/volatile-update, so the next request uses the new value without a restart. A short status line confirms the outcome (Saved, or the host's refusal or error message) in the theme's success/error colour.

The UI never writes settings storage itself. The page owner hands the card a form built from the row's settings namespace (model-manager): form.state with the accepted value, the current revision, writable and status, and form.mutate(ops, revision) for ordered path operations. The card always submits with the revision it just read, and a mutate that returns falsy or throws is reported rather than assumed to have worked. That is why this card cannot race the host's own settings writes: a stale write is fenced off by the revision rather than silently overwriting a newer one.

When the card cannot show anything

A component that throws blanks its whole slot entry, so each unavailable state renders a notice instead — and every optional collaborator is probed, with any failure logged as [model-manager] <step>: <message> through a wrapper that keeps applying:

StateCauseRendered copy
No form on a page renderthe host rendered this slot without values — a defensive path, not the row pageThis page did not supply configuration values, so the model roles cannot be edited here. Open this plugin's own row on the Plugins page to configure it. (warning colour)
No form on the summary renderthe row-list render always omits itrenders nothing: the card returns null and the row's description stands
Catalog loadingmodelCatalog() in flightevery control renders disabled, the add control offers only its own Add a model… placeholder, a configured route the catalog has not returned yet still shows as provider/model (not advertised) so its value is never silently swapped, and Loading models… appears below
Not available to this clientform.state.status === 'unavailable' — the namespace is not exposed hereThese settings are not available to this client right now. (warning colour)
Read-only deploymentstatus === 'ready' but writable === falserole rows render disabled, plus This deployment stores settings read-only.
Nothing advertisedcatalog ready with zero provider groupsNo models are advertised yet. Configure a provider route, then reopen this page.
Partial failurecatalog failures non-emptySome providers could not be listed: <name, name> (warning colour)
Fetch failedmodelCatalog() returned an error or threwthe error message, in the error colour

The notices are ordered so a read-only deployment says so before anything about the catalog, and the write-result line (Saved / refusal) is last, because it is the most transient.

The catalog is fetched once per page load and refreshed on the llm/adapters-updated and settings/document-updated remote events, so adding a provider route makes its models appear without a reload. A generation counter discards a response that is older than the most recent request, and a throwing subscriber cannot break the other subscribers.

A configured route the catalog no longer advertises stays selectable, appended as an extra option labelled pku-corpus/some-retired-model (not advertised) and kept as the selected value. Without that, a <select> whose value matches no <option> renders as its first option instead — so the card would appear to name a model the config does not select, and saving anything else on that row would overwrite the real route.

Note also that picking a different model for a role preserves a reasoningEffort already stored on that route. If the new model advertises no efforts, the effort picker disappears while the stored effort remains in the config; clear it by writing the role's models from the patch layer.

How the card is built

  • It requires exactly one module: react. The harness's plugin practices forbid require-ing Harness Client packages from a hand-written client half — including @deepseek-ai/dsh-client-ui-primitives — because a second copy of a UI package in the browser graph is a breakage waiting to happen. So the <select> controls here are written locally in client.js rather than imported.
  • Styling uses only --dsw-alias-* theme tokens: bg-layer-1, border-l1, label-primary, label-secondary, state-success-primary, state-error-primary, state-warn-primary. Light and dark follow the host, and a future token rename degrades to an inherited colour instead of breaking the entry.
  • Its copy is localized. apply registers a dsh-model-manager locale namespace with inline English and Chinese dictionaries through ctx.locale, and binds it for rendering; if the binding fails, labels degrade to their key names. The locale/en.json and locale/zh.json files in the package are separate: they are the bundle's title and description as shown in the plugin list.

Verification status, stated honestly: the client half was verified by JavaScript syntax, manifest validation, a structural load of the window.__ModuleLoader__.load factory — which confirms that only react is required, that apply registers the dsh-model-manager locale namespace with the same 61 keys in English and Chinese (the test compares the sorted key sets, not just their counts), that exactly three registrations land across the two surfaces — settings.section under model-manager and conversation.session.header.utilities under model-manager-usage (the per-session usage) plus model-manager-update (the update chip, which renders null while the update store is idle) — and that apply still returns normally when every injected collaborator throws. It was also checked against the live slot contract in @deepseek-ai/dsh-client-ui-settings. Visual verification of the rendered card is not available in this environment: the harness's own verification guidance forbids emulating React/DOM or building mock previews as a substitute for a browser, so the rendered appearance is unverified. Remember the restart after installing, since the live slot only populates once the client-module boot manifest has been rebuilt.

Distribution strategy

Modes

strategy.mode decides whether the selected role route is written into the request.

ModeBehaviour
hybrid (default)Top-level requests are rewritten whenever the role has a route. A subagent is rewritten only when inheritance is provable — its resolved route equals the parent's effective route, or equals a route this manager already applied to that child (see below). Anything else is respected as the child's own choice.
managedThe role route is always applied, overriding an explicit per-delegation provider/model. Use this if you want routing regardless of lineage.
advisoryNever rewrites anything. It classifies the request and records the model actually used.

hybrid is what makes per-delegation choices work: pass nothing and the role decides; pass an explicit provider/model to the delegation tool and your choice survives.

main and the composer's model picker

These are two different mechanisms, and they used to collide: the composer writes the session's own route (session.selectModel), while the manager rewrote it at request time whenever main was configured — so the picker kept displaying a model that was never used.

In hybrid the manager now backs off when you have actually chosen. The test is durable, not a guess: selecting a model in the composer appends a model/selection event to the session log — that event has exactly one append site in the whole install (selectForNextRequest) — and this plugin folds it into a modelManagerSelection session projection. A session with such an event keeps your pick and records explicit session selection (respected); a session that never had one is running on the deployment default, which is the route main exists to replace.

Three things follow from that. managed is unchanged — it means "the manager owns every route", including a session you picked by hand. A respected selection does not consume the round-robin rotation, because rotation commits only when a route is applied; the first request the manager does take over still gets the first model. And the choice survives a restart, a resume and a fork, because it is read from the log rather than remembered in process state.

One caveat worth knowing: the composer's picker also overwrites the deployment default (implementation note: selectModel calls selectForNextRequest and agentDefaultModel.saveSelection in the same action). Picking a model for one session therefore changes what brand-new sessions start on too, which is the harness's behaviour, not this plugin's.

What "inherited" means

Three rules decide whether a subagent request is treated as inherited.

The parent's effective route. Read the same way DSH itself computes the route it passes to a child: the parent's latest request header (agent.session.requestHeader()?.config) owns provider/model, and the parent's creation options (agent.options) are only the pre-first-request fallback. That matters because after request-time selection the header reflects what the parent actually ran on — including a route this plugin applied. Comparing against agent.options instead would misread a manager-rewritten parent as an explicit child choice and silently stop routing.

Manager-owned children. Once the manager routes a child, that child's own request header changes, so on its next request it no longer equals the parent's route. Without a second rule, a long-running child would be reclassified as an explicit choice and quietly stop being managed. So a child is also treated as inherited when its route equals the route the manager itself last applied to it. This is tracked by an internal applied flag recorded per request, and it is true only when a route was really applied — advisory, respected-explicit and passthrough all record false, so an explicit choice is never re-adopted. Manager-ownership is checked before lineage, so a child the manager already routed stays managed even if its parent goes away.

Unresolvable lineage fails closed. If no delegating parent can be found and the child is not manager-owned, the plugin cannot prove inheritance: it respects the child's own route and records lineage unresolved (child route respected). If you want the role route applied no matter what, use managed.

All comparisons are by value. A round-robin advance is committed only when a route is actually applied: advisory passes, a respected explicit route, and a respected unresolved-lineage route all leave the rotation where it was.

Label-driven classification

Only a session whose session.header.origin is 'subagent' is classified by label. The label is the description the delegating agent passed to the subagent tool, which the harness stores as the child's label. Matching is case-insensitive, anchored at a leading word boundary, and prefix-first, in this order:

  1. Empty label → execution.
  2. A label starting with plan: (case-insensitive, leading whitespace ignored) → planning.
  3. A label starting with vision: → vision.
  4. Else any visionKeywords match → vision.
  5. Else any planningKeywords match → planning.
  6. Else execution.

The leading prefix is authoritative — it is the one part of the label the delegating model writes on purpose, so "plan: analyse the screenshots" is planning work. Among keyword matches, vision is tested before planning, so a label matching both lands on vision.

Delegation descriptionMatched byRoleApplied route (bundled defaults)
"plan: design the migration"plan, designplanningpku-corpus/qwen3.8-max-0902
"vision: read this screenshot"vision, screenshotvisionfalls back to execution → pku-corpus/qwen3.8-flash
"fix the typo"nothingexecutionpku-corpus/qwen3.8-flash
"analyze the parser design"analyze, designplanningpku-corpus/qwen3.8-max-0902
"summarize the changelog"nothingexecutionpku-corpus/qwen3.8-flash

A non-subagent session is never classified by label: it gets planning while plan mode is active and main otherwise.

The vision → execution fallback

If vision.models is empty when a vision request is routed, the plugin serves it from the execution role's models instead. The role recorded for that request is still vision; the model is the execution role's, and the shared pick strategy with the execution role's round-robin counter are the ones used. The routes table marks such a role (via execution). If execution is empty as well, there is no route and the request passes through. This is why the bundled default (vision: []) is safe: vision work is served by the execution tier until you configure a dedicated vision model.

Vision image capability check

A text-only model cannot serve image work: the harness rejects image blocks on requests to a model that does not declare image input. Because the vision role has a fallback and can name any provider, routes verifies the models it will actually use, through ctx.get('llm') → resolveModelInfo(provider, model) → inputModalities. The check follows the effective vision route, so with the bundled default it audits the execution model the fallback would use.

VerdictConditionDetail text
okinputModalities includes imageaccepts images
NO IMAGESinputModalities present without imagedeclares <modalities> — image work routed here will fail
unknownno inputModalities on the resolved info (absent means unknown)image support not declared
unresolvedresolveModelInfo threwcould not resolve (<error message>)
Vision role (image capability):
  ok         pku-corpus/kimi-k3 — accepts images
  NO IMAGES  pku-corpus/qwen3.8-flash — declares text — image work routed here will fail
  unknown    pku-corpus/mystery — image support not declared
  unresolved pku-corpus/broken — could not resolve (no adapter registered for provider "pku-corpus")

The check reports three distinct states on purpose, and they are never conflated.

Checked — the block above. With the bundled default (no dedicated vision model) it audits the execution model the fallback would use:

Vision role (image capability):
  NO IMAGES  pku-corpus/qwen3.8-flash — declares text — image work routed here will fail

Nothing to check — no effective vision route, i.e. vision and execution are both empty. Image-labelled children then keep their parent's route:

Vision role: no route configured, so image-labelled delegations have no dedicated model
  and will simply inherit the delegating parent's route.

Not checked — no llm service in this composition:

Vision role: not checked (no llm service in this composition).

At boot the plugin runs the same check once and warns only for the NO IMAGES case, since that is the one that fails at run time:

warn  [model-manager] vision role route(s) declare no image input and image work routed to them will fail: pku-corpus/qwen3.8-flash. Point roles.vision.models at an image-capable model.

unknown and unresolved are reported by routes but not warned about: a model with undeclared modalities may well accept images, and an unresolvable route is already reported by the allow-list audit.

Auto-delegation policy (strategy.autoDelegate)

The problem it solves. The harness gives the top-level agent a subagent tool and a one-line nudge ("Start independent delegations together in one message"), but nothing tells it when to delegate. In practice the model does the work itself and only reaches for a delegation when it cannot proceed — image reading being the canonical case. This plugin is already in the business of routing delegations by role; 0.3.0 makes it also ask for them.

strategy.autoDelegate controls a second system-prompt section, model-manager:delegation (order 550 — next to the harness's own PLAN_POLICY/TEAM_POLICY rows, not next to the routing cheat sheet, because this is a behavioural policy rather than a routing fact):

LevelRendered forWhat it tells the top-level agent
offnobodyNothing — no section text at all.
balanced (default)the orchestrating top-level agentDefault to solving substantive work through subagents: delegate the decomposition itself as a plan: unit, dispatch the units it returned together in one message, keep coordination / trivial edits / user-facing decisions / final synthesis for yourself, verify what comes back.
eagerthe orchestrating top-level agentYou are the orchestrator: nearly all substantive work — research, analysis, implementation, review, verification — belongs in delegations, the decomposition itself going out as a plan: unit first; do work yourself only when no delegation can carry it.

Both levels end with the same labelling block, so the policy and the role tiers are used together — which is the point of having both in one plugin. The block labels each delegation by the kind of work it carries (plan: = must be thought through, vision: = reads images, plain = mechanical execution), states that a leading prefix decides the tier with keyword matching as the fallback, and — under any mode that actually routes — tells the agent to omit provider and model on plan:/vision: units, because a route named explicitly on the delegation overrides the tier. That last line is the one that makes the label matter under hybrid, where a respected explicit choice is exactly what defeats role routing.

Rendering is per assembly, and deliberately narrow. The section's text function receives the assembly context ({ agent, scope }, the same object dsh-agent's assembleContextFor builds) and returns '' — no policy at all — unless every gate passes:

  • the manager is enabled and the level is not off;
  • the assembly is not a delegated child: session.header.origin === 'subagent' or header.delegationDepth > 0 both disqualify. Children already carry the harness's fixed subagent:delegation runtime context, and encouraging a child to re-delegate would push every unit one depth level down for no benefit;
  • the assembly's scope actually exposes a delegation tool, checked with tools.get('subagent', scope) / tools.get('subagent_fork', scope) — the same probe dsh-tool-subagent's own prompt section uses. An agent whose composition restricted the delegation tools never sees a policy naming a tool it cannot call, and a harness that renames the tool loses the policy rather than rendering a broken instruction.

Beyond the gates, the labelling block adapts to what is actually behind it: a tier with no configured models is never advertised (no plan: line when the planning role is empty — and the balanced "plan first" bullet loses its label too), and under advisory, where nothing is routed at all, the omit-provider/model line is dropped because it would warn about nothing.

Only the tools that resolve are named: with both present the policy mentions subagent and the subagent_fork preference for work that needs this conversation's context; with only subagent the fork line is dropped. model_manager(action: "routes") reports the level (Auto-delegation: balanced — …), and the settings card offers the three-way select, so switching levels needs no restart — the section reads the live config, so a saved edit applies to the next request.

Automatic update checks

The host half checks whether a newer @darkbear9494/dsh-model-manager is published, and tells you — passively in the log, on demand through the tool, and persistently in the Web UI.

How it works. The plugin reads its own version from its package.json (so a git checkout, an npm install and a tarball install all report what actually shipped), fetches the abbreviated packument <registry>/@darkbear9494%2fdsh-model-manager with Accept: application/vnd.npm.install-v1+json, reads dist-tags.latest, and compares the two with a semver comparator that handles prereleases (0.3.1-rc.1 < 0.3.1, numeric prerelease identifiers compare numerically). The first check runs ~10 s after boot — never on the boot path — and then once per intervalHours; every failure path (no network, CORS, a malformed packument) lands in a logged diagnostic and a CHECK FAILED verdict, never a throw, and never touches routing.

Where a newer release is surfaced:

SurfaceWhat you see
Host logwarn [model-manager] update available: @darkbear9494/dsh-model-manager 0.2.0 -> 0.3.0 (checked https://registry.npmjs.org). Run the model_manager tool with action "update" for the upgrade command, then restart.
model_manager action updateThe full report below — including the paste-ready upgrade command with the live profile name.
report / routesOne status line each (Update check: 0.3.0 AVAILABLE (installed 0.2.0, …)); routes always carries the line, report only when an update exists.
Settings cardA warn-toned banner at the top of the card with the versions, the upgrade command, and a Re-check button.
Session headerA small 更新:0.3.0 chip next to the usage button; clicking it discloses the upgrade command. It disappears when the store says up to date.

Asking for the check directly:

model_manager(action: "update")
Model manager update check

  installed : 0.2.0
  latest    : 0.3.0
  registry  : https://registry.npmjs.org
  checked   : 2026-10-07T03:12:44.612Z
  verdict   : UPDATE AVAILABLE (0.2.0 -> 0.3.0)

To upgrade, run in a terminal:

  dsh plugin --profile desktop add @darkbear9494/dsh-model-manager@latest

then restart the harness so the new version mounts. The Plugins page can reinstall
the same package instead; both paths run the same installer. Tell the user this
upgrade is available and what command to run.

The <profile> in the command is read from the host's working directory ($DSH_HOME/profiles/<name>); when the layout does not match, the command prints <profile> as a placeholder. Upgrading is re-running the add command — the installer (dsh plugin add / the GUI's install_bundle) resolves the new version, updates the dependency and keeps the bundle registration; restart the harness afterwards so the new host half mounts.

The browser half runs the same comparison itself. The Web client cannot read the host's in-memory state, so client.js pins the version it shipped with (a packaging test pins that constant equal to package.json) and fetches the same packument once per page load — plus on every settings/document-updated (the registry URL is row config, so a change re-checks) and on the banner's Re-check button. That is why the chip and banner can appear the moment the GUI opens. An offline or registry-blocked deployment simply sees no banner; the host log and the tool action still report the update. This also means the browser makes one cross-origin fetch to the configured registry — registry.npmjs.org (and npmmirror) answer it with Access-Control-Allow-Origin: *; a registry that does not is silently unusable for the client half and only the host surfaces remain.

Configuration

Config is the config of the row id model-manager. This bundle ships a layer that inserts it; a profile can override it by id, and the harness also presents it on the Settings page — roles and strategy are declared volatile, so the form is auto-generated from the schema and a saved edit is committed in place without a remount. The card on the Plugins page writes the same paths into the same namespace, so the YAML below and the card are two views of one config; the patch remains the declarative copy that ships with the package.

Saved Settings edits apply live. The loader commits a volatile-only save and returns before re-running apply, so the plugin listens on loader/volatile-update and re-reads the config itself — including rebuilding the keyword matchers. A keyword or role change saved on the Settings page — or from the card on the Plugins page, which writes the same volatile namespace — takes effect on the next request, with no restart. The event logs settings reloaded — mode …, pick … on success; a value that cannot be re-read logs could not reload settings; routing keeps the previous config: … and the previously active config stays in force.

Complete key set, with the schema defaults shown in comments:

- id: model-manager
  config:
    pick: first               # default: first; "first" | "round-robin" — ONE strategy for every role
    roles:
      main:
        models: []            # default: [] — empty means "leave the composer/model selection alone"
        note: ''              # default: none — optional free text, printed by `routes` under the role
      planning:
        models: []            # schema default: [] (this package's own layer sets pku-corpus/qwen3.8-max-0902)
      execution:
        models: []            # schema default: [] (this package's own layer sets pku-corpus/qwen3.8-flash)
      vision:
        models: []            # schema default: [] — empty falls back to the execution role
    strategy:
      mode: hybrid            # default: hybrid; "hybrid" | "managed" | "advisory"
      # visionKeywords: omitted = the 20 built-ins; any list you write replaces them
      # planningKeywords: omitted = the 24 built-ins; [] DISABLES that classifier
      historyLimit: 300       # default: 300; any positive value floors to at least 1
      autoDelegate: balanced  # default: balanced; "off" | "balanced" | "eager" — the delegation policy
    update:                   # 0.3.0 — automatic update check
      enabled: true           # default: true; only an explicit false stops the checks
      registry: https://registry.npmjs.org   # any npm-compatible registry (npmmirror works)
      intervalHours: 24       # default: 24; explicit 0 = check at boot only

The two keyword lists are shown commented out because their default is the built-in list rather than a literal value: omitting the key keeps the built-ins, while writing any list — including [] — replaces them, and [] switches that classifier off entirely.

Behaviours of the update block worth knowing:

  • registry must be an http(s) origin; anything else (or a non-string) falls back to https://registry.npmjs.org. Trailing slashes are trimmed.
  • intervalHours accepts a fraction (0.5 = 30 min, useful when watching a release land) and is floored at one minute so a mis-typed hour cannot hammer a registry. An absent, malformed or negative value means the 24 h default; an explicit 0 means "check at boot only". enabled: false is the actual off switch.
  • A settings save that changes the update block re-arms the schedule and runs one immediate check, so switching to a mirror registry takes effect without a restart.

Each entry of models is:

KeyRequiredDefaultMeaning
provideryes—Provider id, e.g. pku-corpus.
modelyes—Model id, e.g. qwen3.8-flash.
reasoningEffortnononeApplied as the route's reasoning effort when the manager writes the route.

Behaviours of readConfig worth knowing:

  • A route missing provider or model is dropped rather than failing the plugin. A role whose every route is invalid behaves exactly like an empty role.
  • Unknown keys are reported rather than ignored, at three levels: the row, a role, and an individual route. A route written with the snake_case alias is called out by name — unknown key "roles.planning.models[].reasoning_effort" ignored — the key is "reasoningEffort".
  • historyLimit accepts any positive number, floored to an integer and raised to a minimum of 1, so a fractional value cannot floor to 0 and silently disable usage tracking. 0, a negative number, a non-number or an absent key all mean 300.
  • When the manager switches a request to a different route it drops an inherited reasoning effort unless the role names one, because the destination model may not accept the previous effort and prepareCall rejects unsupported explicit efforts instead of clamping them.

A profile patch entry with an id and no insert replaces the target row's fields, and config is replaced wholesale, never deep-merged. An override that sets only strategy.mode therefore also drops every role route. Restate every key you want to keep.

The bundled layer this package ships is:

- insert:
    - id: model-manager
      name: '@darkbear9494/dsh-model-manager'
      config:
        roles:
          main:
            models: []
          planning:
            models:
              - provider: pku-corpus
                model: qwen3.8-max-0902
          execution:
            models:
              - provider: pku-corpus
                model: qwen3.8-flash
          vision:
            models: []
        strategy:
          mode: hybrid
          autoDelegate: balanced
        update:
          enabled: true
          registry: https://registry.npmjs.org
          intervalHours: 24

Seeing which model each subagent used

First: the harness already shows this per Turn

Before reaching for the tool, note that the Web UI already attributes each completed Turn to the exact provider/model that served it. On every completed Turn's footer there is a button labelled 用量 {total} ("Usage {total}", with a database icon); clicking it opens the 本轮用量 ("Turn usage") dialog, whose rows are Uncached input, Output (with its reasoning subset), Cached input, Cache write, Cache hit, and Provider / model.

Two conditions decide whether it appears:

  • Settings → General → Performance & usage must be detailed, not compact. detailed is the default. compact hides per-Turn usage entirely.
  • Accounting is deliberately all-or-nothing. A Turn discloses usage only when the loaded window includes turn/start and every started model attempt reported safe, exact usage. The harness hides a partial total rather than showing a misleading one.

That second rule interacts with this plugin's failover, and it is worth understanding before filing it as a bug: a failed attempt settles as an assistant/attempt event, which carries no token usage unless its stream reported some. deriveTurnTokenUsage then marks the whole Turn invalid and discloses nothing. So on a Turn where a model failed and the manager switched to another, the usage row will be absent — the accounting genuinely cannot prove an exact total for that Turn. This is the harness's own rule and it applies equally to the harness's built-in retries; failover simply produces such Turns more often.

There is no per-model aggregate for one whole session in the harness itself: its session-level projections (tokenUsage, contextPressure, contextBreakdown) accumulate four token buckets with no route split. This plugin supplies exactly that, as a Model usage button in the session header.

It is a session projection folded from the durable log (modelManagerUsage), not a live counter, so the numbers survive a restart and are identical after fork, resume, and replay. Each row shows provider/model, uncached input, output, cached input and cache write when reported, request count, and a total. Only attempts carrying an exact usage sample and a provider/model on the committed message are counted — the same refusal the per-Turn dialog makes, so the two never disagree about which numbers are provable.

Because the host half declares the projection and wires a view, the client half only subscribes to a finished value: no folding in the browser, no polling, no fetch route. That is the sanctioned path — "a domain ships projection support with zero client code."

The tool

The plugin registers one read-only tool, model_manager, with five actions:

ActionReturns
report (default)A Top-level agents table — session id, role, model, reason, live status, and whether the session's model was picked by hand or left at the deployment default — then the per-subagent table: child id, role, model, reason, live status, delegation label, and finally the active mode with subagent-only totals by role/model. Carries one update-available line when a newer release was found.
routesRole table (role, effective models, role note), the shared selection strategy, active mode, the auto-delegation level, the update-check status line, allow-list audit with a YAML snippet for unlisted routes, the vision image-capability check, and the first 8 keywords of each list.
usageRequest and subagent counts per role/model across the whole process, including top-level turns.
clientWhether the Host composed this package's Web client half into the browser boot graph, the route serving its bundle, and the current graph revision. Use it when the settings card does not appear.
updateForces a fresh update check and returns installed vs latest, the registry, the timestamp, and — when a newer release exists — the paste-ready upgrade command with the live profile name. Use it whenever report/routes says an update is available, and relay the command to the user.

Both report and usage read process-local in-memory state: they answer for the current dsh process only, and a restart clears them. A subagent that finished before a restart therefore reappears as (not yet routed) with role ?, because it was re-registered on resume but issued no request in this process. For history that survives a restart, use the per-Turn usage dialog described above — it is derived from the durable session log.

Asking for the report:

model_manager(action: "report")

Sample output, generated by running the real renderer against a mock harness instead of typed by hand — a hand-typed example is exactly how this file shipped a misaligned table. The session and child columns hold 10-character id prefixes:

Top-level agents — 1

session      role       model                            reason                                       status    the session model was
-----------  ---------  -------------------------------  -------------------------------------------  --------  --------------------------------
a1b2c3d4-e  main       user-picked/glm-5.2              explicit session selection (respected)       running   picked by hand, so main yields

A reason ending in (respected) is the verdict itself: a human choice won and the
manager stepped back. Under managed it never does, and a session that never had
a manual pick is rewritten by the main role.

Subagent model report — 3 subagent(s)

child       role       model                            reason                                       status    label
----------  ---------  -------------------------------  -------------------------------------------  --------  --------------------
7c1f0a92-a  planning   pku-corpus/qwen3.8-max-0902      applied                                      running   "plan: design the migration"
925d4c6e-a  execution  pku-corpus/deepseek-v4-flash-0731  applied                                      running   "fix the typo"
e0c7b551-a  vision     pku-corpus/kimi-k3               applied                                      running   "vision: read this screenshot"

mode: hybrid

Subagent requests by role/model:
     1 req /  1 subagent(s)  execution  pku-corpus/deepseek-v4-flash-0731
     1 req /  1 subagent(s)  planning  pku-corpus/qwen3.8-max-0902
     1 req /  1 subagent(s)  vision  pku-corpus/kimi-k3

Reading it:

  • e0c7b551-e is a vision row served by the first vision model; with vision: [] it would show the execution model instead. Role and model are reported independently.
  • 925d4c6e-d was dispatched with an explicit route, so hybrid left it alone.
  • b84e2d10-b issued two requests. The first was routed by the manager; on the second the child's own header already carried the applied route, so it reads applied (manager-owned) rather than being mistaken for an explicit choice. Hence the planning total of 3 req / 2 subagent(s).
  • 0a1b2c3d-f has no resolvable delegating parent and no manager-applied route, so hybrid failed closed: the child kept the route it was given, and with it no label the catalog could resolve.
  • The totals are subagent-only, so the top-level turn this parent made is not counted here. usage is the action that covers the whole process.

The status column is the child's live status as the harness reports it (running, idle, …), live when the harness exposes the agent without a status, and inactive when agents.get cannot resolve the child — for example after it has gone away.

The reason column is the outcome for that request:

ReasonMeaning
appliedThe role route was written into the request.
applied (manager-owned)The child's route matched what the manager last applied to it, so it stayed under management and the role route was written again.
explicit session selection (respected)hybrid, top-level: the session log carries a model/selection event, so the model running is one a person chose in the composer. main is skipped and the rotation is not advanced.
explicit child route (respected)hybrid found the child's resolved route differed from the parent's effective route, so it was treated as the delegating agent's own choice and left alone.
lineage unresolved (child route respected)No delegating parent could be resolved and the child was not manager-owned, so inheritance was not provable and the child kept its route.
advisory (not applied)advisory mode classified the request but changed nothing; the model shown is the one actually used.
passthrough (role unconfigured)The role resolved to no route at all, so the request was returned unchanged.

Verbatim example rows for the two reasons not shown above:

99aa11bb-0  execution  pku-corpus/qwen3.8-max           advisory (not applied)            running   "fix the typo"
4d5e6f70-0  execution  pku-corpus/qwen3.8-max           passthrough (role unconfigured)   running   "fix the typo in README"

A subagent the catalog knows about but which has not issued a request yet shows (not yet routed) in the model column and - as its reason. Labels are collapsed to a single line, truncated at 42 characters, and any " in them is displayed as ' so the column can never be misread.

usage counts every request the process recorded, including top-level turns — its header says so, and a top-level bucket shows 0 subagent(s):

Requests by role/model (whole process, including top-level turns)

     1 req    1 subagent(s)  execution  pku-corpus/deepseek-v4-flash-0731
     1 req    1 subagent(s)  execution  pku-corpus/qwen3.8-flash
     1 req    1 subagent(s)  execution  pku-corpus/qwen3.8-max
     1 req    0 subagent(s)  main  pku-corpus/qwen3.8-max
     3 req    2 subagent(s)  planning  pku-corpus/qwen3.8-max-0902
     1 req    1 subagent(s)  vision  pku-corpus/kimi-k3

The allow-list gotcha

The profile's subagent-model-selection-settings row (.allowedModels) constrains what an agent may name. assertAllowedModelSelection in the subagent tool returns early when the delegation call names no provider, model or reasoning_effort, so:

  • Role routing is not blocked by the allow-list. The manager rewrites inherited delegations at request time, and an inherited delegation names nothing, so a route outside the list still serves it.
  • What the list does affect: an agent that explicitly names a route outside it is refused at delegation time with an error of the form child LLM route "pku-corpus/qwen3.8-max-0902" is not allowed for this Session, and that route is absent from what list_subagent_models advertises — so the model is invisible to agents even while the manager uses it.

Two further properties matter in practice:

  • The list is sampled per Session when the session receives its delegation tools, so a session started before you edited it keeps the old list until the session is recreated.
  • With enabled: false, no subagent can select a model at all. routes says so explicitly when it detects that state.

The plugin cannot fix the list for you. Inserting a second subagent-model-selection-settings row would abort startup with duplicate loader entry id, so the plugin only reads it. If any role's effective route is unlisted — including a vision → execution fallback — it logs a warning at boot naming the routes, and routes renders the audit plus the exact YAML to add. Unlisted routes are de-duplicated, one snippet entry each:

model_manager(action: "routes")
Model manager roles

Enabled: yes

Selection: first — every role starts at its top model and fails over down its list (one strategy for all roles)

Role          effective model(s)
----------  ------------------------------------------
main         (unconfigured — passes through)
planning     pku-corpus/qwen3.8-max-0902
              note: use for architecture and migration design
execution    pku-corpus/qwen3.8-flash
vision       pku-corpus/kimi-k3, pku-corpus/qwen3.8-flash, pku-corpus/mystery, pku-corpus/broken

Strategy mode: hybrid (apply when the child inherited the parent route)

Allow-list: enabled, 4 route(s) currently dispatchable
  allowed   planning   pku-corpus/qwen3.8-max-0902
  allowed   execution  pku-corpus/qwen3.8-flash
  allowed   vision     pku-corpus/kimi-k3
  allowed   vision     pku-corpus/qwen3.8-flash
  BLOCKED   vision     pku-corpus/mystery
  BLOCKED   vision     pku-corpus/broken

2 role route(s) are not in the subagent allow-list.
This does NOT block role routing: the harness gates only *explicit* model-facing
choices, and the manager routes pure-inheritance delegations at request time. What
it does affect: an agent that names one of these routes explicitly will be refused,
and the route will not appear in `list_subagent_models`. Add them to
`subagent-model-selection-settings` (`.allowedModels`) — in the profile patch, or on
the Subagent settings page — to make them selectable:

      - provider: pku-corpus
        model: mystery
      - provider: pku-corpus
        model: broken

Vision role (image capability):
  ok         pku-corpus/kimi-k3 — accepts images
  NO IMAGES  pku-corpus/qwen3.8-flash — declares text — image work routed here will fail
  unknown    pku-corpus/mystery — image support not declared
  unresolved pku-corpus/broken — could not resolve (no adapter registered for provider "pku-corpus")

Delegation labels select the tier. The `description` you pass to a delegation
becomes the child label, which the manager matches against (word-anchored):
  vision   : vision, visual, image, images, screenshot, screen shot, ocr, diagram, …
  planning : plan, planning, design, architect, architecture, analyse, analyze, analysis, …
  else     : execution

When nothing is missing the audit says so, and the role table marks a fallback (via execution) while the audit row is labelled with the role that uses it:

Allow-list: enabled, 2 route(s) currently dispatchable
  allowed   planning   pku-corpus/qwen3.8-max-0902
  allowed   execution  pku-corpus/qwen3.8-flash
  allowed   vision     pku-corpus/qwen3.8-flash
  All role routes are allow-listed.

To add a route, paste the snippet under allowedModels: in the profile patch, restating the whole config because an id override replaces config wholesale:

- id: subagent-model-selection-settings
  config:
    enabled: true
    allowedModels:
      - provider: pku-corpus
        model: qwen3.8-flash
      - provider: pku-corpus
        model: qwen3.8-max-0902   # a route the audit reported
      - provider: pku-corpus
        model: deepseek-v4-flash-0731

When the audit cannot read the list

routes distinguishes three outcomes, because they call for different fixes.

No such service in this composition — a missing feature, not a mistake:

Allow-list: unavailable (no subagent model-selection settings service in this composition).

Model selection is disabled — subagents cannot choose a model at all:

Allow-list: model selection is DISABLED — subagents cannot select a model at all.

The settings owner refused to report — a configuration error, not a missing feature. Its current() throws when selection is enabled with an empty allowedModels, which is a broken deployment that every delegation would hit:

Allow-list: the subagent model-selection settings refused to report its routes:
  enabled subagent model selection requires at least one allowed model
Fix `subagent-model-selection-settings` in the profile patch (an enabled policy needs a
non-empty `allowedModels`), then reload.

The same refusal is logged at boot as subagent model-selection settings refused to report its routes: <error>, so it is not mistaken for a normal "no routes listed" state.

Tuning the classification

strategy.visionKeywords and strategy.planningKeywords are the whole classification policy.

  • An absent key uses the built-ins. An explicit [] disables that classifier. That is the only way to switch one off: visionKeywords: [] sends every vision word to the planning and execution tests instead. Treat a disabled classifier as intentional — with planning: [] the planning role becomes unreachable for subagents, and with vision: [] no subagent is ever classified vision.
  • Matching is case-insensitive and anchored at a leading word boundary. A keyword must begin at the start of a word, so spec no longer matches "inspect" and design no longer matches "redesign", while screenshot still matches "screenshots" and the multi-word ui mock still matches. It is not a full-word match, so plan still matches "planet" and render still matches "rendering". Prefer distinctive words.
  • A leading plan: / vision: prefix is authoritative: it decides the tier before any keyword is consulted. Among keyword matches, vision is tested before planning, so a label matching both goes to vision.
  • The built-in vision list has no figure — "figure out the bug" is ordinary reasoning work, and a genuine figure-reading unit can say vision: read figure 3. Re-add it with strategy.visionKeywords if your labels read that way.
  • Regex metacharacters in a keyword are escaped, so pkg.io or a+b match literally.
  • The matchers are rebuilt on a saved Settings edit, so a keyword change applies live.

The built-in lists, used when a keyword list is absent:

vision   (19): vision, visual, image, images, screenshot, screen shot, ocr, diagram,
               chart, plot, photo, picture, png, jpg, jpeg, webp, gif,
               ui mock, render
planning (24): plan, planning, design, architect, architecture, analyse, analyze,
               analysis, research, investigate, review, critique, strategy, reasoning,
               reason, compare, evaluate, assess, spec, specification, decompose,
               breakdown, proposal, root cause

routes prints the first 8 entries of each list, appending … only when there are more, and prints (disabled) for an explicitly emptied list:

Delegation labels select the tier. The `description` you pass to a delegation
becomes the child label, which the manager matches against (word-anchored):
  vision   : vision, visual, image, images, screenshot, screen shot, ocr, diagram, …
  planning : (disabled)
  else     : execution

Replace a list with the keywords you want, remembering that a patch override replaces config wholesale:

- id: model-manager
  config:
    roles:
      planning:
        models:
          - provider: pku-corpus
            model: qwen3.8-max-0902
      execution:
        models:
          - provider: pku-corpus
            model: qwen3.8-flash
    strategy:
      mode: hybrid
      planningKeywords:       # replaces the 24 built-ins entirely
        - plan
        - design
        - audit
        - benchmark
      visionKeywords: []      # disables the vision classifier

The plugin also contributes a system-prompt section (name model-manager, order 60) that tells the delegating agent to label a delegation description with plan: for thinking work, vision: for image work, or leave it plain for execution — a leading prefix winning over keyword matches — to leave provider and model unset so the role route applies (dropped under advisory), and it lists each role's effective models (including a fallback). It is empty text when no role has any models, so the prompt is not padded with a description of routing that does nothing; when only the planning tier is missing it says so, so a plan: label is never a silent no-op.

How it stays safe

Both halves obey the same rule: neither may be able to break the harness it is loaded into. A host apply that throws fails the loader entry and therefore startup, and a client component that throws blanks its whole slot entry, so every risky path is probed and degrades to a notice or a log line.

  • Its own schema dependency cannot fail startup. @deepseek-ai/schemastery is loaded through a guarded dynamic import and the schema is built inside a guard. If the dependency is missing or incompatible (a version whose .default() / .volatile() chain differs), the plugin still loads and still routes: Config is left undefined, so cordis's resolveConfig passes the raw config straight through and readConfig normalizes it defensively. Only schema validation and the auto-generated Settings page are skipped.
  • No settings API. settings.installSection / installSettingsSection / settingsNamespace were removed in DSH 0.1.7, and calling them fails apply, which fails the loader entry and therefore startup. The host half never touches that API: it exports a volatile Config and lets the harness auto-generate the Settings form (autoGenerate defaults to true). It claims no settings namespace either — and neither does the client half, which writes the row's existing namespace through the form the plugins page hands it, so the card cannot register a second store or race the host.
  • The host half imports nothing but its own schema dependency. It does not import @deepseek-ai/dsh-tools, dsh-llm or cordis, which are not resolvable from a profile-installed plugin; the tool is registered in the normalized shape the registry expects, using plain JSON Schema, and the llm service is reached through ctx.get('llm') rather than an import.
  • The client half requires one module: react. Harness Client packages — including @deepseek-ai/dsh-client-ui-primitives — are not required from a hand-written client half, so the controls are local and styled with --dsw-alias-* tokens only. Its collaborators (slots, locale, remote) arrive as injected services, and each use of them sits inside a safely(label, fn) wrapper that logs [model-manager] <step>: <message> and continues: a missing locale seat, a failed catalog fetch, a rejected subscription or an absent slot registry leaves the card unreadable, never fatal. Every value read from the host is re-narrowed before use (isObject guards on form.state.value, roles, each catalog group and route), so a shape change degrades to "unset" rather than a render error.
  • Every optional integration is probed and wrapped. Plan mode, the agent list and lineage, the parent's request header, subagent listing, the allow-list service, the model-capability lookup, tools and systemPrompt are each reached through a guarded accessor and a try/catch; a missing service means that feature is inert, not that the plugin fails. The agent/request handler catches its own errors, logs one warning (routing skipped for one request: …) and returns the request it was given, unchanged.
  • The delegation policy cannot corrupt a prompt. The section's text function is wrapped end-to-end and returns '' on any failure — including a throwing tools.get — so the worst case is the pre-0.3.0 prompt with no policy, never a broken assembly. It renders only for depth-0 top-level agents that actually have the delegation tools, and reads the live config, so a volatile save changes it on the next request.
  • The update check cannot disturb anything. It starts ~10 s after boot (never on the boot path), runs behind a timeout and a coalescing guard, and every failure path lands in updateState.status === 'error' with a reason at info level — offline deployments log one line per interval, not a warning storm. It writes nothing, changes no routing, and a missing fetch is just another failed check. The client half's check is gated on window.location, browser-only, wrapped in the same safely discipline, and its timers (host side) are unref'd or cordis-effect-owned so a test process still exits.
  • apply never throws. The whole body sits behind a backstop that logs failed to initialise; model routing is disabled: … and returns, so a misconfiguration degrades routing instead of preventing the harness from booting. Malformed configs (roles: "not-an-object", strategy: null, an unknown mode, a negative historyLimit, routes with empty ids) all apply cleanly; bad routes are filtered out and the mode falls back to hybrid.
  • A bad live edit cannot break a running router. The loader/volatile-update handler is wrapped: a config it cannot re-read logs could not reload settings; routing keeps the previous config: … and the previous config stays in force. The boot capability check is an async probe with its own catch, so a slow or failing llm service cannot disturb startup.
  • Typos are reported, not silently ignored. Schemastery does not reject unknown keys, so readConfig returns a warnings array and apply logs each entry at warn level: unknown config key "bogus" ignored, unknown role "nope" ignored, unknown key "roles.planning.mispelled" ignored, unknown key "strategy.nope" ignored, unknown key "update.nope" ignored, and the route-level form with its reasoningEffort hint.
  • The pure helpers are exported for testing: name, Config, apply plus readConfig, applyRoute, effectiveRouteOf, parseVersion, compareVersions, installedVersion, profileHint, delegationPolicyText and the two default keyword lists.
  • It does not touch the subagent allow-list row, precisely to avoid the duplicate loader entry id that would abort startup.
  • The model_manager tool is read-only. Its description says so and the implementation only reads state — the update action forces a network check but writes nothing.

Testing

npm test

Runs ten suites in child processes and aggregates the result — 848 assertions at the time of writing. Each suite also runs on its own: node test/<name>.test.mjs.

SuiteAssertionsWhat it pins
packaging.test.mjs35The package identity that nothing else cross-checks: the scoped name is the one string the bundle patch's loader row, the client half's module id and the host half's PACKAGE_NAME must all agree on, while the cordis row id, the client row namespace and the locale namespace must all stay unscoped. Plus publishability (not private, public access, a real @deepseek-ai/dsh version gate, schemastery still optional) and tarball completeness — every exports target and every files pattern resolved against disk, so a published package cannot mount-less. The gate is not string-matched: a self-validating semver comparator (prerelease-aware, and checked against the ordering trap first) proves the declared range admits 0.1.7-rc.2, 0.2.0-rc.2 and 0.2.0 while refusing 0.1.6 and 0.3.0 — because <0.2.0 reads as "every 0.2.0 prerelease" while excluding 0.2.0 itself. 0.3.0 also pins the release plumbing both halves share: the client's VERSION constant equals package.json (a drift would ship a lying update banner), both halves fetch the same percent-encoded packument path, and the bundle patch carries the new autoDelegate/update defaults.
routing.test.mjs10The four roles route as documented; label classification (including a leading plan: beating vision keywords under hybrid); an explicit child route is respected; the tool and both system-prompt sections register; apply survives malformed configs.
delegation.test.mjs39The auto-delegation policy: config normalization (absent → balanced, unrecognised → balanced, volatile refs unwrapped), the schema union, section registration and its 550 order, and every rendering gate — children (by origin and by delegationDepth) get '', tool-less scopes get '', fork-only scopes still get a policy naming subagent_fork, the master switch suppresses it, a volatile save switches levels live, and a throwing tools.get degrades to '' instead of throwing out of a prompt section. Plus tier awareness: the policy delegates the decomposition, states the prefix rule, teaches the omit-provider rule (dropped under advisory), and never advertises a plan: tier with no models behind it.
update.test.mjs58Version parsing and semver comparison (prerelease ordering, numeric identifiers, build-metadata equality, unparseable → undefined), installedVersion against the real manifest, profileHint from a real chdir, update-config normalization (registry sanitization, interval semantics including explicit 0 and the one-minute floor), and the check itself end-to-end with fetch mocked: available / up-to-date / older-latest / HTTP failure / malformed packument / rejected fetch / disabled-never-fetches / custom registry, plus the report/routes status lines, the log levels (available → warn, failure → info), scheduling through the cordis timer API (10 s boot delay, 24 h interval, boot-only, floor, disarm), and a volatile re-arm that immediately re-checks a changed registry.
edge.test.mjs557Hostile configs, keyword anchoring and trimming, the leading-prefix rule (plan:/vision: beat keyword matches, case-insensitive, whitespace-tolerant; "figure out" is no longer an image label), explicit-[]-disables versus absent-uses-defaults, the 0.2.0 pick migration (legacy per-role values warn and are ignored; the top-level value normalizes), round-robin committing only on apply, LRU eviction, the manager-owned rule, volatile settings reload, every reason string, the report/usage/routes output, and the generated allow-list YAML round-tripped through the real harness validator.
failover.test.mjs28The failover chain and every guard: it walks to the end of the list and then stops rather than wrapping; ABORTED, INVALID_REQUEST and IMAGE_OFFLOAD_REQUIRED never fail over; a downstream recovery decision is passed through untouched; the per-step cap holds and resets on a new step; round-robin does wrap; single-model and disabled roles do nothing; a route outside the role's list is left alone; hostile payloads never throw; subagent chains fail over too. And the cursor isolation of 0.4.0: a planning failure advances only the planning cursor, so the same session back on main starts at main's first model, and re-entering plan mode resumes the worn planning chain.
client-structure.test.mjs37The client half loads through window.__ModuleLoader__, requires only react, exports { inject, apply }, registers its locale dictionaries and exactly three surfaces (settings card, usage surface, update chip — the last of which renders null while the update store is idle), and — because the check mirrors the real SlotCore.register validation — fails if a registration ever loses its name. It also declares each remote.<namespace> used must appear in inject, drives the settings-form bindings, renders the usage surface against a stubbed projection face in both its collapsed and expanded states (an empty-projection case proving those render assertions are not vacuous), and pins the 0.2.0 card shape: exactly one shared selection control writing the top-level pick, with no per-role pick control left anywhere in the tree. The update check itself is gated on window.location, so the suite provably never fetches.
client-diagnostic.test.mjs17Every branch of model_manager with action: "client", including the two that matter most: a bundle the Host will not serve, and a bundle that serves but registers the wrong id.
usage-projection.test.mjs42The per-session model-usage fold: bucket sums against the harness's own field names, refusal of unsafe or fractional counts without aborting the fold, usage recovered from an embedded stream, separate attribution per provider/model, immutable state transitions, reference stability that suppresses republishing, and both schemas rejecting junk through the only method cordis calls (.parse).
explicit-selection.test.mjs25The rule that main yields to a hand-picked session model, and nothing else does: respected under hybrid, still applied under managed, untouched by a respected call in the round-robin rotation, and falling back to the old rewriting behaviour when the projection service is absent, when stateOf throws, or when the key was never registered. A disabled manager is checked too. Plus the Top-level agents report table: the hand-picked versus default-driven verdict, no orphaned subagent header when no subagent exists, a row that outlives its agent degrading to unknown, agent no longer live, and — read back out of the renderer's own source — proof that the reason column is still padded past the longest reason string it can print.

The suites are plain Node scripts — no test framework, and no dependency beyond Node itself.

Two of them optionally pin their expectations against the real harness internals rather than a transcription of them: dsh-subagent's resolveChildAgentOptions (what a delegated child actually inherits) and SubagentModelSelectionConfig.current (the allow-list gate that rejects a repeated route). test/paths.mjs discovers the DSH installation from the dsh executable on PATH, or from DSH_INSTALL_DIR if you set it. When neither resolves, those assertions fall back to a local re-implementation and print a WARN — the suite still passes, but it is then testing my model of the harness rather than the harness, so prefer running it where dsh is installed.

Nothing in the suite touches the network, a profile, or a running harness, and nothing writes outside the repository.

Limitations

  • Classification is label-driven, not content-driven. At agent/request time the plugin has no supported way to read a child's message blocks, so it matches the child's delegation label and nothing else. Image detection is therefore label-based: a delegation about an image whose description contains no vision keyword is classified execution.
  • Leading-boundary matching still matches word prefixes (plan in "planet", render in "rendering"), and the first match wins with vision tested before planning. Two classifier lists cannot express precedence beyond that.
  • hybrid compares routes by value. A child that deliberately names the same route as the parent's effective route is indistinguishable from one that inherited it, and will be rewritten to the role route.
  • Under hybrid, an unresolved lineage means the child keeps its own route. The manager will not touch a delegation whose inheritance it cannot prove; if you want routing regardless of lineage, use managed. The report shows lineage unresolved (child route respected) for those requests, so a broken lineage integration is visible rather than silent — but it does also mean a child that should have been managed was not.
  • Manager-ownership is sticky. Once a route has been applied to a child, that child keeps being managed as long as its route matches the applied one, even after its parent is gone. If a child legitimately changes its own model to one the parent also uses, the manager takes it back.
  • round-robin is per-process; the strategy itself is global. The pick strategy is one top-level value shared by every role, but the rotation counters are per-role, live in an in-memory Map and reset to index 0 on restart. A role with a single model never advances, and an advance is committed only when a route is applied, so advisory passes and both kinds of respected route do not rotate. Routing happens per LLM request, not per turn, so a round-robin role can still rotate models between the steps of one turn.
  • Usage history is in-process. A Map capped at strategy.historyLimit (300), evicted least-recently-used: an active subagent stays, an idle one goes first. Nothing is persisted, so after a restart report and usage start empty.
  • report lists top-level rows but its totals stay subagent-only. The Top-level agents table covers every session this process saw a request for, top-level or not; the Subagent requests by role/model: block beneath it counts subagents only. usage counts the whole process, including top-level main/planning turns (shown as 0 subagent(s)). So the two tables inside report answer for different populations, and neither one matches usage.
  • The status column needs the live agent list. It is the status the harness reports, live when a live agent exposes no status, and inactive when agents.get cannot resolve the child. If the agents service is unavailable or has no get, every row reads inactive whether or not the subagent is alive.
  • The image-capability check covers only the vision role's effective models. A planning or execution child handed an image is not checked, unknown and unresolved verdicts are reported but never warned about at boot, and with the bundled default (vision: []) the check audits the execution fallback rather than a dedicated vision model.
  • The allow-list audit is informational for routing. Since the harness gates only explicit choices, a BLOCKED row does not mean role routing failed — it means agents cannot name that route and list_subagent_models will not show it.
  • The child column is a 10-character id prefix, so two session ids sharing their first 10 characters are indistinguishable in the table.
  • Labels are flattened for display. Newlines collapse to spaces, the text is truncated at 42 characters with …, and " becomes '.
  • roles.<role>.note affects only the routes table. It is human annotation; no routing decision, report or count reads it, and the boot log does not print it.
  • main empty is the simplest policy, and now not the only one. With main: [] a top-level turn outside plan mode is never rewritten. With main configured under hybrid, a session whose model was picked by hand keeps it (see "main and the composer's model picker") and only untouched sessions are rewritten; under managed every top-level request is rewritten, chosen by hand or not.
  • The card can only pick what the catalog advertises. A route the catalog stopped returning keeps its row and stays editable, labelled (not advertised), but you cannot add such a route — or any model absent from the catalog — from the UI. Provider and model are never free text here, so a typo cannot be written into the config; use the patch layer for a route the host does not advertise.
  • The card has no image-capability information. session/modelCatalog exposes ids, names and reasoning efforts, not modalities, so nothing in the UI stops you picking a text-only model for vision; the host half's routes audit is the check.
  • A stored effort can be invisible. Picking a different model keeps the route's previous reasoningEffort, and the effort picker only renders when the newly selected model advertises efforts — so a stale effort can remain in the config with no control to clear it. Remove it in the patch layer.
  • The card lives in exactly one place: 设置. It registers settings.section under model-manager, and nothing on the Plugins page — plugins.item and plugins.row.config are deliberately not registered, so there is one copy of these values and one place to edit them. The second surface, conversation.session.header.utilities under model-manager-usage, is read-only: it shows the per-session model usage the host half folds from the durable log, and writes nothing.
  • The card needs one restart after installing, because the client-module boot manifest caches package metadata per loader specifier until restart. Before that the Settings entry is simply absent.
  • The delegation policy is a prompt, not an enforcement. strategy.autoDelegate changes what the top-level agent is told; it cannot make a model delegate, and a strong-willed model can still do the work itself. eager is the stronger instruction, not a guarantee. A label is likewise overridable: under hybrid, a delegation that names provider/model keeps that route — which is why the policy now tells the agent to leave those unset on plan:/vision: units, but it cannot stop a model that insists on naming one. The policy also disappears silently if the harness's subagent tool is renamed (toolName in dsh-tool-subagent config), because the text function only names tools it can resolve in scope.
  • The update check sees dist-tags.latest, nothing else. It compares against the latest tag on the configured registry, so a newer prerelease (or a next tag) is invisible, and an intranet mirror that lags npm will keep reporting "up to date" after a release. The browser banner additionally needs the registry to answer CORS — npmjs and npmmirror do; one that does not simply leaves the client-side surfaces empty while the host log and the tool action still report.
  • The client banner's "installed" version is the pinned constant. The browser cannot read package.json, so client.js pins VERSION and a packaging test keeps it equal; a hand-edited checkout that bumps only one side will show a self-inconsistent banner (which the same test refuses to let the repo ship).

関連プラグイン