feat: dsh plugin scanning, enable/disable, and a real MCP writer - #118
Merged
Conversation
dsh's own vocabulary is the composed cordis patch ROW: Settings β Plugins lists one entry per row (`timer`, `hmr`, `llm`, β¦) and never lists a bundle, because a bundle is a patch LAYER that inserts rows. `read_plugins` mirrors that with two sources per profile: rows defined by each mounted bundle's own patch file (resolved via `dsh.bundle.patch`, from the symlink farm for in-box bundles) and rows defined by the user's own home/profile patch files. Composition follows dsh's: bundle patches in `bundles` order, then the profile patch, then the home patch. The earliest layer defining a row id owns the entry (upstream, a later restatement can only override), while `enabled` folds `disabled` across the WHOLE chain β which is why `hmr`, defined enabled by dsh-base and disabled by dsh-web-app, reads as disabled exactly like dsh shows it. Identity carries profile + bundle + row id, since two profiles compose different chains and can disagree about one row. Packages that are profile dependencies but which no layer mounts are NOT listed: dsh warns once at install time and then never loads or displays them. Same principle applies to skills β dsh drops a skill whose frontmatter uses a camelCase invocation key, so `scan_skill_dir` emits no extension for it under dsh. In a shared skills root that removes only dsh from the skill's agent list; a dsh-only skill disappears from HK entirely. The detector is a small `pub(crate)` predicate beside `CAMELCASE_INVOCATION_KEYS`, so the rejected-key vocabulary keeps one home while the audit rule's own scan is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Toggling a dsh plugin appends an `{id, disabled}` override inside HarnessKit's
comment-marker block at the end of the HOME `cordis.patch.yml` β the precedent
is dsh's own web-app bundle, which disables base rows exactly that way. The
home layer is applied after every profile layer, so the toggle is
machine-global, like the P0 MCP toggle, and hot-reload picks it up live.
What is new over the P0 helpers is the base state. P0 folded row ids in the
home file's text alone; a plugin row can live in a bundle patch or a profile
patch, and its base state is the fold across the WHOLE layer chain, not the
layer that defines it. `hmr` is the proof: dsh-base defines it enabled and
dsh-web-app disables it two layers later, so folding only the defining layer
would read it as enabled, make "enable" look like a no-op against base, drop
the override and leave the row disabled. `PluginEntry` therefore carries
`base_layers` β that profile's ordered layers below the home patch, produced
by the scan β and `deployer::set_dsh_plugin_enabled` folds exactly those plus
the home patch while writing only the home file. Bundle patch files stay
read-only inputs. The `layer == home_patch` identity guard is load-bearing: a
home-defined row must fold the block-stripped user text, not HK's own block.
Bundle-provided rows are toggleable like any other row. Anonymous rows (no
`id:`) are not, and get a backend `Validation` error advising an `id:` β UI
graying alone is not a guard. `toggle_plugin` gains an explicit dsh branch
BEFORE the generic manifest-rename fallback, which would otherwise hunt for a
`plugin.json` in node_modules and rename it, corrupting a pnpm-managed tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Finishes wiring dsh MCP install now that the insert writer works. dsh's
remote rows are `{transport, url, headers}` YAML, which no existing
`RemoteMcpSchema` variant describes β reusing `Toml` would break the
documented "Codex is the only Toml agent" invariant β so add a `DshTransport`
variant and update BOTH sites that switch on the schema: the SSE capability
derivation in `AgentCapabilities::from_adapter` (http true, sse false) and
`validate_remote_mcp_target`, which now refuses SSE for dsh with a clear
message before anything is written. Missing either would let SSE servers look
installable and reach the writer. The two now-stale `Toml`-is-the-only-HTTP
comments in adapter/mod.rs are corrected alongside.
`remote_mcp_schema()` is flipped last on purpose: advertising it before the
writer existed would have offered an install path that always errored.
Kit installs route through the same `deploy_mcp_server`, so dsh Kit MCP
install starts working implicitly β which means the `kits/install_plan.rs`
conflict-detection arm can no longer answer a hardcoded `false`. It now looks
the serverName up in the home patch text, normalizing the query the same way
the writer sanitizes what it stores, so a conflict is reported for the row
that actually exists on disk.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dsh evaluates `!!js` config expressions at plugin MOUNT time. An expression that reads `process.env.X` with no `??`/`||` fallback yields `undefined` when the variable is unset, which fails mcp-client config validation and kills the WHOLE dsh boot β real-machine verified on rc.6. The rule reports it as a warning with real file:line. The patch text rides a dedicated `AuditInput::raw_config` field rather than `content`: the cordis patch file is shared by every dsh MCP row, so putting it in `content` would let the content-scanning rules report a neighbouring server's token on this row. `service::run_audit` attaches the raw text to the first dsh MCP row only; the reported file:line may therefore point into another server's block, which is accepted. A rule ships with the surface that displays it, so this also registers `dsh-js-env-no-fallback` in the frontend `AUDIT_RULES` registry and adds its label/description to all three locales (en, zh, zh-TW), with an i18n parity test so a rule can never again reach the Audit page untranslated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four block-corruption tests were byte-identical apart from the malformed input, so they become one table-driven test whose case labels carry the reason each input is corruption. `toggle_and_remove_work_through_the_ original_name` duplicated the path `remove_and_toggle_by_original_name_ hit_the_sanitized_row` already walks; its one extra assertion (toggle back on) moves into the survivor, which keeps asserting on raw bytes so a reader that also normalized could not hide a writer that did not. The reader round-trip it covered is `install_records_the_original_name_and_ the_reader_round_trips_it`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A dsh plugin is a ROW in a composed patch layer naming an npm package, reached through two manifest records β the profile's `dependencies` and its `dsh.profile.bundles` layer list. `delete_extension`'s fallback deleted `PluginEntry::path`, which for dsh is that package inside a SHARED `profiles/node_modules` farm: both records kept naming it, dsh failed to boot (a configured-but-absent package is a hard mount error), sibling rows instantiating the same package broke with it, and the next scan re-reported the row from the patch text anyway. Destructive and ineffective. Reachable only since this branch made dsh report plugins. Adapters now answer `plugin_removal()` β `Files` (the default, unchanged for every directory-based agent), `Command` (delegate to the agent's own uninstaller: `dsh plugin remove` runs pnpm AND reconciles `bundles` in one step), or `Shipped`. In-box bundles are not dependencies, so dsh's own reconcile never touches them either; refusing them mirrors the vendor's constraint rather than inventing one. A missing binary reports the exact command instead of falling back to deleting files. `PluginEntry::pack` carries the bundle that provided a row, since the git-URL derivation only fires for git checkouts and would leave the whole vendor baseline unattributed. `AgentCapabilities::vendor_baseline_packs` publishes the `Shipped` set so the detail panel greys out delete on exactly the rows the backend refuses. Deleting also reported success when it failed: the optimistic removal drops rows and toasts five seconds BEFORE the request, and the rejection was dropped as an unhandled rejection from a timer callback. Failures now restore the rows and say why β pre-existing, but this refusal is the first routine way to hit it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dsh contributes ~130 plugin rows against ~20 from every other agent combined, because its whole product is plugins and its baseline ships as data rather than compiled in. Flat, that baseline IS the plugin list. Two additions, both driven by `capabilities.vendor_baseline_packs` so no UI code names an agent. An agent's in-box bundles collapse into one source option (`DeepSeek built-in (129)`) β three bundles is how dsh ships, not three sources the user chose between. And a "Hide built-in" toggle sits by the result count, rendered only where something is actually hideable, so the other eleven agents never see it. Shown by default: VS Code hides its built-ins and Obsidian gives core plugins their own tab, but dsh's own list shows everything, and hiding a row the user could re-enable would be worse than the noise. The label names the mode and never changes β flipping it to the next action would contradict the tint, since a lit control reads as "on" while the verb claims the opposite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The agent key stays `dsh` (config paths, env vars, adapter name); only the display name changes, which flows to every surface that renders `agentDisplayName` β filter dropdowns, install targets, onboarding. The i18n prose already said "DeepSeek Harness". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The capability matrix still showed "β" for its Plugins column. Hooks stay "β": dsh has no hook format of its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 18, 2026
Merged
RealZST
added a commit
that referenced
this pull request
Aug 18, 2026
The hero GIF was recorded in June, before Kiro, Oh My Pi and DeepSeek Harness existed and before Windsurf became Devin Desktop. It showed nine agents against a README that claims twelve. Replace it with a recording of all twelve under current names, at 1376x480 (down from 3840x732), which also drops the file from 13.26 MB to 9.45 MB. Both READMEs reference the same path, so neither needs a markup change. Also mark Plugins as supported for DeepSeek Harness in README.zh-CN.md. #118 shipped dsh plugin scanning and enable/disable and updated the English table, but the Chinese one still said the type was unsupported β which contradicted the very feature that release led with. Every one of the twelve rows now matches between the two files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #116 (dsh P0). Makes MCP install actually work for dsh, adds plugin scanning and enable/disable, and protects the parts dsh owns.
Plugins
dsh models a plugin as a row in a composed patch layer naming an npm package β not a directory. Rows come from each mounted bundle's patch, then the profile's, then the home layer, and only the booted profile's chain participates. Scanning reads that composition, so HarnessKit reports exactly what dsh's own plugin list reports: verified against
dsh --dump-configβ 132 config rows = 129 plugins + 3 MCP, sets identical, zero name mismatches.Toggling writes an id-targeted row into a managed block appended to
$DSH_HOME/cordis.patch.yml, the layer dsh documents as the user's own. Bundle files are never touched (an upgrade would overwrite them anyway). Toggling back to the base state removes the entry; emptying the block removes the markers. Everything outside the markers is preserved byte-for-byte β a serde round-trip would destroy the!!jstags, comments, anchors and flow style real files carry.MCP
Install writes a real
dsh-mcp-clientrow instead of leaving a placeholder.serverNamemust match/^[A-Za-z0-9_-]{1,32}$/, so a name likemicrosoft/markitdownis sanitized and the original stored in_hk_nameβ the same round-trip Codex already uses, which keeps the server grouped with the other agents' copies instead of forming a second row. Remove and toggle normalize the same way, soremove("My Server")can't silently no-op against theMy-Serverrow it wrote.Deletion
delete_extension's fallback deletedPluginEntry::path. For dsh that is the npm package inside a sharedprofiles/node_modulesfarm, while both the profile'sdependenciesand itsdsh.profile.bundleskeep naming it β dsh then fails to boot, sibling rows instantiating the same package break with it, and the next scan re-reports the row anyway. Destructive and ineffective.Adapters now answer
plugin_removal():FilesCommanddsh plugin removeruns pnpm and reconcilesbundlesin one step)ShippedDeleting also reported success when it failed: removal is optimistic (rows leave the list and the toast fires five seconds before the request) and the rejection was dropped as an unhandled rejection from a timer callback. Pre-existing, but this refusal is the first routine way to hit it.
Extensions list
dsh contributes ~130 plugin rows against ~20 from every other agent combined, because its whole product is plugins and its baseline ships as data rather than compiled in. Its in-box bundles now collapse into one source option, and a "Hide built-in" toggle appears only where something is actually hideable. Both read
capabilities.vendor_baseline_packs, so no UI code names an agent β the eleven others never see either control.Shown by default: dsh's own list shows everything, and a bundle-disabled row the user may want to re-enable shouldn't vanish.
Audit
One rule,
dsh-js-env-no-fallback: a!!jsconfig expression readingprocess.env.Xwith no??/||fallback fails the whole boot at mount time when the variable is unset.!!jsis evaluated throughnew Functionwith norequirein scope, so it can only read the environment β which is what makes a bare read a boot risk rather than a style nit.Verified on a real dsh install
All four write paths, against dsh's own
--dump-configand its livepluginInventory/list:agent-loopleft dsh with no agent factory and visibly broke its UI (it is the only package registering one)hmrcorrectly overrode thedsh-web-appbundle's disable_hk_name, read back under the original nameBundle patches stayed byte-identical throughout; the tree was restored to a sha256-identical state.
π€ Generated with Claude Code