dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
Per-session skill switch for DeepSeek Harness: see which skills a session loads, toggle them off, and keep a pure-chat session free of skill overhead. Also mounts whole skill families in and out of the host's skill hub.
A DeepSeek Harness plugin about spending fewer tokens on skills you are not using — by session, and by whole family.
Every session gets a collapsible strip above the composer showing which skills it loads, with a toggle per skill and three posture presets. Large interdependent skill families can be parked outside the host's skill hub entirely and mounted as one unit.
DSH skills are already lazily loaded — the model only reads a SKILL.md body when
it calls the skill tool. The catalog is not lazy. Before every step, the
official tool-skill injects every model-invocable skill as an
<available_skills> block, so a skill you never touch still bills you on every
single step. And there is no way to say "this session is just a conversation — do
not offer me any of these" before it starts.
The design targets three separate costs, in ascending order of size:
| Layer | Mechanism | What it saves |
|---|---|---|
| 1 | Skill removed from the catalog | One description line |
| 2 | Model cannot see it, so it never loads it | The whole SKILL.md body |
| 3 | Everything off → the skill tool itself unmounts | Even its JSON Schema |
Layer 2 is the point. A catalog line is tens to ~200 tokens; a real load injects a body that is routinely hundreds to thousands. Layer 1 exists mainly to achieve layer 2 — hiding the line is how the model stops knowing the skill is there. Layer 3 is the smallest but free once you are already all-off.
Numbers below come from an actual ~/.agents/skills hub and a parked family. They
are characters, not tokens — token count depends on the tokenizer and on how
the model splits mixed CJK/Latin text, so the honest unit here is the one that can
be reproduced from the files. Treat the ratios as the finding, not the absolute
figures:
| Skills | Catalog cost | |
|---|---|---|
| Hub skills, all enabled | 7 | 749 chars (avg 107/line) |
| One interdependent family, parked | 27 | 0 |
| The same family, mounted | 27 | 5,559 chars (avg 205/line) |
Two things that table says:
Two design choices follow from how skills are actually used:
lark-doc → ../lark-shared), so loading one alone
breaks its references, while listing all of them bills every line. All-or-nothing
is not a simplification — it is the only shape that matches the dependency.# 1. Build (inside the plugin directory)
npm install && npm run build
# 2. Link it into a profile: add to <profile>/package.json
# dependencies: { "dsh-skill-switch": "link:/path/to/dsh-skill-switch" }
# dsh.profile.bundles: [..., "dsh-skill-switch"]
# 3. Let the plugin resolve @deepseek-ai/* from the profile
ln -s ~/.dsh/profiles/node_modules <plugin>/node_modules
Or, once published:
dsh plugin --profile web add dsh-skill-switch
Restart DSH afterwards.
⚠️ This plugin replaces the official
skilltool. Itscordis.patch.ymldisables@deepseek-ai/dsh-tool-skilland registers its ownskilltool plus session catalog. The replacement is a port of the official implementation with per-session filtering added — not a wrapper around it. If you depend on the official plugin's exact behaviour, read Why it takes overtool-skillbefore installing.
Defaults are neutral and live in the bundled cordis.patch.yml. Everything
deployment-specific belongs in your profile patch (<profile>/cordis.patch.yml),
keyed by the row id:
- id: skill-switch
name: dsh-skill-switch
config:
defaultMode: all # posture a new session starts in: all | none | work
workSkills: [] # skills the `work` posture enables
syncScript: '' # absolute path of a sync script; empty hides the control
hubDir: '' # absolute path of the hub the host scans (needed for skillGroups)
skillGroups: [] # all-or-nothing bundles, see below
defaultMode: all keeps the stock behaviour — you turn things off when you want
to. Set none if you would rather opt in per session.
syncScript)The host reads skills from a hub directory, not from wherever you author them. If your skill repository lives elsewhere (a network mount, a synced folder), a script bridges the two:
| Stage | Who | Automatic? |
|---|---|---|
| Repository → hub (copy) | syncScript | ❌ a button in the strip |
| Hub → strip (display) | file watcher + SSE | ✅ automatic |
Point syncScript at your script and a Sync skills button appears. Clicking it
runs the script, invalidates the catalog cache, and pushes a refresh to every open
strip.
Manual on purpose. If the repository is on a network mount, every scan costs something; you decide when to pay it, and nothing runs while you are not looking. Both halves are required — without the sync the host does not know the new skill exists, so there is nothing for the strip to refresh.
Feedback follows the macOS idiom rather than a persistent log pane:
| Phase | Button shows | Then |
|---|---|---|
| Running | ◌ Syncing | — |
| Success | ✓ Synced | reverts after 2s — no layout cost |
| Failure | ⚠ Sync failed (red) | stays, and unfolds the script's output |
Success needs no acknowledgement, so it withdraws. Failure stays because it asks something of you, and its text is what tells you what went wrong.
skillGroups)Some skill families are both huge and interdependent: a member's
SKILL.md links its siblings by relative path (lark-doc → ../lark-shared). A
per-skill switch cannot express them — loading one alone breaks its references,
and listing all of them costs their catalog lines whether or not the session uses
them.
A skill group parks the family outside the hub and moves it in or out as one unit:
| State | Catalog cost | Members live in |
|---|---|---|
| Parked | 0 | parkDir |
| Mounted | one line each | hubDir |
hubDir: '/home/you/.agents/skills'
skillGroups:
- id: lark
label: 'Feishu CLI'
description: '27 Feishu skills, mounted and parked as one unit'
parkDir: '/home/you/.agents-parked/lark'
discover: true # read members from parkDir; no hand-maintained list
The strip shows a dashed chip per group: ⬡ Feishu CLI 27 when parked, solid when
mounted. Clicking moves the whole family.
Why whole groups, and why moving directories rather than hiding lines. Three design decisions, each buying tokens:
lark-shared dependency parked). A group switch
cannot express that state, which is the point.Member roster. Mounting writes the member names to <parkDir>/.members.
After a mount the park directory is empty, so that file is the only record of
membership — an in-memory list would not survive a host restart. Unmounting reads
it to decide what moves back.
Safety boundary. Group membership is read from parkDir only, never by
scanning the hub root — the hub holds unrelated skills, and scanning it would
adopt them into the group and sweep them into the park directory on the next
unmount. test/group.test.mjs locks this down.
A collapsible strip above the composer.
Collapsed (the default — one line):
▶ Skills 3/9 jxh, video-diag, insight-mining Sync skills
Expanded:
▼ Skills 3/9 Sync skills
[Chat] [Work] [All] ⬡ Feishu CLI 27 ● jxh ○ video-diag
▶ Skills to collapse or expand; the choice is remembered in localStorageChat = none, Work = only workSkills, All = everythingCollapsed by default because this seat sits directly above the composer: an expanded chip list pushes the conversation up.
In-memory, per session. A new session starts from defaultMode, and a restart
forgets every toggle. That is deliberate — each session picks again, with no
storage dependency.
Skill groups are the exception: they move directories on disk, so their state is a property of the machine, not the session, and it persists.
┌─ Browser ────────────────────────────────┐
│ conversation.input.dock: the strip │
│ │ fetch /skill-switch/* │
└─────────┼─────────────────────────────────┘
┌─ Host ───────────────────────────────────┐
│ webServer routes + Map<sessionId, state> │
│ agent/created → register tool + catalog │
│ renders the catalog itself (filtered) │
└───────────────────────────────────────────┘
tool-skillTwo constraints rule out a lighter interception:
ctx.skills.registerProvider() always files into the GLOBAL layer, and its
list() receives only { cwd, signal } — no agent identity — so a provider
cannot branch per session.So this plugin owns the catalog injection and the skill tool, registering them on
agent.ctx. tools.register reads the caller's scope, so the registration is
per-session and is collected when the agent is disposed.
Why disabling everything unmounts the tool instead of using tools.restrict():
a restriction filters only what a scope inherits and never what its own layer
registers. The skill tool is registered in the agent's own layer, so a
restriction cannot remove it and its schema would stay in the prompt — layer 3 of
the token model above would be unreachable. Unmounting the registration is what
actually takes the schema out of the prompt.
Why the takeover is worth its maintenance cost. The alternative — leaving the official plugin in place and filtering behind it — cannot work, for the digest reason above: every step would append a catalog-update message, so the "saving" would cost more context than it recovered. Owning the injection is the price of the per-session switch existing at all.
Cost: upstream changes to dsh-tool-skill are not picked up automatically. The
logic here is a port of the official implementation and behaves identically.
DSH validates a third-party bundle's peer ranges and silently skips the whole bundle when they do not match — the plugin does not load, but its patch still applies. Two things bite:
@deepseek-ai/* to the packages inside its own asar
(0.2.0-rc.2 today) via the loader's
internal.import(specifier, bareModuleBaseUrl), not to
<profile>/node_modules. Writing a peer range from the profile's copies can
therefore fail validation.0.1.x while the runtime ships 0.2.x still has
to be ranged against 0.2.x. Ranging it against what npm happens to offer
rejects the bundle. This is the mistake that shipped in 5b85d5d:
dsh-client-runtime publishes only up to 0.1.1-rc.2 on npm, but the desktop
runtime provides 0.2.0-rc.2, so a ^0.1.x range skipped the whole plugin."@deepseek-ai/dsh-skill": "^0.1.0-rc.2 || ^0.1.0-rc.6 || ^0.1.5-rc.1 || ^0.2.0-rc.1 || ^0.2.0-rc.2"
Diagnosing: plugin_manager list_bundles — look for
error.code: "incompatible-version" on the bundle. The failure is silent from
the plugin's side: it does not load, yet its cordis.patch.yml still applies, so
tool-skill stays disabled while nothing takes over the skill tool.
Verifying a range before shipping: check it against the runtime version with the same library DSH uses, rather than by reading version lists.
const semver = require('semver')
semver.satisfies('0.2.0-rc.2', range, { includePrerelease: true })
0.2.0-rc.2 changed Session from a whole-log array to seq + eventAt() (the
old reads are deprecated), and agent/pre-step decisions are best spread rather
than rebuilt. catalog.ts's readEventsBackwards() accepts both shapes, so one
build runs on either runtime.
bash test/run-all.sh
| File | Covers |
|---|---|
verify-scope-routing.mjs | agent.ctx.tools.register() lands in the agent scope, not global (the design's foundation) |
session-state.test.mjs | Posture application, work-list ∩ live catalog, row projection, input validation |
integration-routes.mjs | Real cordis context driving the HTTP routes: mount, toggle, mode, status codes, disposal cleanup |
integration-catalog.mjs | Driving agent/pre-step: first publication, no re-publish when unchanged, replacement on change, a disabled skill cannot load, no injection once unmounted |
group.test.mjs | Skill groups: mounting moves only its own members, unrelated skills are never swept, idempotence, unknown group 404, roster survives a restart |
skill tool. Upstream changes to dsh-tool-skill
need manual follow-up.skill-filesystem row ("presets own local discovery"); a deployment that
wants a local repository re-enables that provider against its own hub. This
plugin only filters what the registry already knows about.parkDir only. A family mounted and then
deleted from the hub by hand loses its roster on the next restart.MIT