Skip to content

dsh-model-manager

Verified

@darkbear9494/dsh-model-manager ยท v0.2.0 ยท MIT ยท Web UI

Role-based model manager for DeepSeek Harness: assign models to planning, execution, vision and main roles, route subagent work by role, and report which model every subagent used.

Install

dsh plugin add @darkbear9494/dsh-model-manager

Confirm the layer applied with dsh --profile default --dump-config โ€” see the install guide.

Source

Tags

Creators

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.

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. It is 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 input Spec kind
@darkbear9494/dsh-model-manager registry โ€” the shortest one to type
github:jyshangguan/dsh-model-manager git โ€” the https://github.com/โ€ฆ URL works too
/Users/you/src/dsh-model-manager absolute local directory (relative paths are refused)
/Users/you/darkbear9494-dsh-model-manager-0.2.0.tgz local tarball โ€” npm pack makes one

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 registry copy

@darkbear9494/[email protected] is the current release, public access, 8 files, 63.1 kB packed (0.1.0, the first publish, was 62.3 kB with the same 8 files). Its declared gate is >=0.1.7-rc.2 <0.3.0, so it installs on the 0.2.0 line 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/[email protected] \
  --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.

File Purpose
lib/index.js Host half: Cordis identity model-manager, exported Config, apply, the agent/request routing, the agent/request-error failover, and the model_manager tool.
client.js Client half: the configuration card, contributed as a page in the Settings panel.
locale/en.json, locale/zh.json Display metadata only โ€” the bundle's title and description in the plugin list.
cordis.patch.yml Bundle layer: inserts exactly one row, id model-manager.
package.json Package @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.

Role What it is for When it applies Bundled default
main The 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)
planning Reasoning, 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
execution Default 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
vision Image, 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. 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 and strategy.mode โ€” 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 two surfaces and deliberately nothing on the Plugins page:

Slot Registration id What it renders
settings.section model-manager The configuration card: the four roles and their model lists, the shared selection strategy, the distribution mode.
conversation.session.header.utilities model-manager-usage The per-session model-usage summary the host half folds from the durable log.

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.

Both 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

Control Writes Notes
Model rows, one per role (Main model, Planning and reasoning, Execution, Image recognition) set roles.<role>.models Each 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 model set roles.<role>.models One dashed control under the rows, listing every catalog model the role has not already used, so a model cannot be added twice.
up / remove set roles.<role>.models Reorder 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 picker set 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 pick One 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.
Distribution mode set strategy.mode hybrid / 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.

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:

State Cause Rendered copy
No form on a page render the host rendered this slot without values โ€” a defensive path, not the row page This 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 render the row-list render always omits it renders nothing: the card returns null and the row's description stands
Catalog loading modelCatalog() in flight every 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 client form.state.status === 'unavailable' โ€” the namespace is not exposed here These settings are not available to this client right now. (warning colour)
Read-only deployment status === 'ready' but writable === false role rows render disabled, plus This deployment stores settings read-only.
Nothing advertised catalog ready with zero provider groups No models are advertised yet. Configure a provider route, then reopen this page.
Partial failure catalog failures non-empty Some providers could not be listed: <name, name> (warning colour)
Fetch failed modelCatalog() returned an error or threw the 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 46 keys in English and Chinese (the test compares the sorted key sets, not just their counts), that exactly two surfaces are injected โ€” settings.section under model-manager and conversation.session.header.utilities under model-manager-usage โ€” 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.

Mode Behaviour
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.
managed The role route is always applied, overriding an explicit per-delegation provider/model. Use this if you want routing regardless of lineage.
advisory Never 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 and anchored at a leading word boundary, in this order:

  1. Empty label โ†’ execution.
  2. Any visionKeywords match โ†’ vision.
  3. Else any planningKeywords match โ†’ planning.
  4. Else execution.

Vision is tested before planning, so a label that matches both lands on vision.

Delegation description Matched by Role Applied route (bundled defaults)
"plan: design the migration" plan, design planning pku-corpus/qwen3.8-max-0902
"vision: read this screenshot" vision, screenshot vision falls back to execution โ†’ pku-corpus/qwen3.8-flash
"fix the typo" nothing execution pku-corpus/qwen3.8-flash
"analyze the parser design" analyze, design planning pku-corpus/qwen3.8-max-0902
"summarize the changelog" nothing execution pku-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.

Verdict Condition Detail text
ok inputModalities includes image accepts images
NO IMAGES inputModalities present without image declares <modalities> โ€” image work routed here will fail
unknown no inputModalities on the resolved info (absent means unknown) image support not declared
unresolved resolveModelInfo threw could 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.

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

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.

Each entry of models is:

Key Required Default Meaning
provider yes โ€” Provider id, e.g. pku-corpus.
model yes โ€” Model id, e.g. qwen3.8-flash.
reasoningEffort no none Applied 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

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 four actions:

Action Returns
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.
routes Role table (role, effective models, role note), the shared selection strategy, active mode, allow-list audit with a YAML snippet for unlisted routes, the vision image-capability check, and the first 8 keywords of each list.
usage Request and subagent counts per role/model across the whole process, including top-level turns.
client Whether 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.

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:

Reason Meaning
applied The 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, figure, โ€ฆ
  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.
  • Vision is tested before planning, so a label matching both goes to vision.
  • 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   (20): vision, visual, image, images, screenshot, screen shot, ocr, figure,
               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, figure, โ€ฆ
  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 start a delegation description with plan: for reasoning work, vision: for image work, or leave it plain for execution, and 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.

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.
  • 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,