omdsh-plughub
English | 中文
A plugin hub inside the DeepSeek Harness Settings: one more tab beside the shipped Plugins pages, listing what you can install from a configurable upstream, and configuring — or parking — everything already installed. The hub and the mode system stay on.
Installing a plugin used to be dsh plugin --profile web add <path> in a
terminal, and configuring one used to be editing a profile's cordis.patch.yml
by hand. This makes both a page.
What it adds
| Surface | Where it comes from |
|---|---|
| A third tab in Settings → Plugins, Plugin hub | An entry in settings.plugins.tab, the seat ui-settings declares for inventory and configuration plugins |
| The merged catalog, and what each source reported | GET /api/plughub/catalog, resolved from the local, registry and github sources |
| Install or Update on Available; Update and Remove on Installed | POST /api/plughub/install, /update and /uninstall, each shelling out to dsh plugin --profile <name> |
| Enable/Disable on Installed | POST /api/plughub/enabled, rewriting dsh.profile.bundles and a parked list; the package stays in node_modules. The hub and the mode system can be updated, and cannot be disabled or uninstalled. |
| A configuration form for every installed plugin | GET/POST /api/plughub/settings, carrying ctx.settings.describe({ redactSecrets: true }) and ctx.settings.mutate |
| Operation progress and the restart banner | GET /api/plughub/events, an event stream |
The omdsh.plugin.card slot |
ctx.slots, where a plugin whose control the generic form cannot draw registers its own face |
Its own settings namespace, omdsh-plughub |
ctx.settings.register, rendered by the same generic form every other plugin gets |
omdsh-plughub on a terminal |
A bin, resolving the same catalog and running the same Installer the routes do |
Two strings name this package on that page, one word apart, and the difference
is a convention rather than an accident. The tab reads Plugin hub: it is
settings chrome, so it takes the sentence case of the tab the harness ships
beside it, Plugin list. Plugin Hub is this package's own
dsh.plughub.displayName, which titles exactly one card in the installed list —
the card for this plugin itself, drawn by the same code that titles every other
card from the same field, and Title Case is what rule 5
asks of every displayName here. Nothing in the harness is modified: the tab is
a published seat, and removing the row hands it straight back.
The idea
The hard part of a plugin hub is not the install button. It is the second half: once a plugin is installed, where does its configuration UI come from?
Two answers were possible. Every plugin could ship a card for this panel — but then a plugin published after this one shows up with nothing to click, and this package grows an entry per plugin forever. Or the panel could read what the plugin ALREADY declares.
The harness makes the second answer available, because it already has a
user-settings seam. A plugin registers a namespace with a schemastery schema;
ctx.settings.describe({ redactSecrets: true }) hands back that schema together
with the current value, the composition base, the raw user layer, the redacted
secret slots, and a revision. That is the complete input a configuration form
needs.
So this package renders forms and installs packages, and knows nothing about any particular plugin. A plugin written next year gets a configuration page the day it is installed, having done nothing but follow the conventions.
plugin (host half) plughub (host half) plughub (browser half)
────────────────── ─────────────────── ──────────────────────
ctx.settings.register( describe(redact) ────────→ rehydrateSchema
'omdsh-shortcuts', ─→ plan ─→ controls
Config, settings.mutate ←──────── one path-addressed edit
{ base: entryConfig })
Nothing in the left column mentions this package; nothing in the right column mentions chords.
The middle column, and why it is there
That middle step used to exist because the harness's own settings wire was
gated by a hard-coded allowlist of namespace names, so no out-of-tree plugin
could cross it. 0.1.0-rc.7 lifted that gate: the Host now serves every
registered namespace, and a plugin that wants a card on the official
Configurable tab registers one into settings.plugin.item.
This route is still here for a different reason. The official tab only renders
namespaces that claimed that slot; this hub draws a generic form from the
schema every omdsh plugin already registers, including ones that never wrote a
card. And the boundary it draws is narrower than the Host's — a namespace is
reachable only when an INSTALLED bundle declares it under
dsh.plughub.settings, so shell and agent-loop are registered in the same
process and unreachable here.
It carries transport and nothing else: validation, layering, redaction,
revisions, and commits all stay in ctx.settings.
What the tab shows
Catalog sources — this plugin's own configuration, at the top, because these fields are the answer to "where does the list below come from". It is an ordinary settings namespace rendered by the same generic form every other plugin gets; it simply sits here instead of in the installed list, so nobody looking at an empty catalog has to hunt for the control that fixes it. It opens itself once when every source failed or nothing came back.
Available — the merged catalog, one button per card. Not installed is
Install; installed with nothing to fetch is a grey Update; a newer version
lights the same button. A card's title, summary, and documentation link are
the plugin's own, read from its dsh.plughub manifest section and resolved
for the active locale.
Installed — one row per plugin this profile has, composed or parked.
Enable and Disable share one button: Disable takes a dependency off the layer
stack without touching node_modules, so using it again is Enable rather than
another install. Template bundles, the hub, and the mode system stay on. Expanding a row
shows a form built from that plugin's settings schema; a plugin that registered
no namespace says so, which is a real answer rather than an empty box.
A change to the profile puts a restart banner at the top. Plugin layers are composed at boot and only the user patch layers are watched, so a newly installed bundle genuinely cannot be hot-mounted — saying "restart" is the honest report, not a limitation being papered over.
Updates
The Update button is the only action on an installed Available card, and is
grey until there is something to fetch. Which it is, is decided on the Host
from two numbers it already holds —
the version the winning catalog source advertises, and the version of the
package on disk — compared by semver, not by string order (0.10.0 is newer
than 0.9.0, and 1.0.0 is newer than 1.0.0-rc.2).
| State | The card shows | The button |
|---|---|---|
available |
0.1.0 → 0.2.0 |
Highlighted |
current |
the one version | Grey: up to date |
linked |
the one version | Grey: installed from a directory on this machine, so its files already ARE the source |
unknown |
whatever version is known | Grey: this source publishes no version to compare against |
An update runs the same dsh plugin add an install does, against a specifier
that names where it is going. For a git specifier that is the specifier
unchanged, since re-resolving the ref is the whole of what it does. For a
registry one the advertised version is appended — pnpm add @scope/name@0.2.0
— because a bare pnpm add <name> on a dependency the manifest already
satisfies prints Already up to date, changes nothing, and exits zero: the
operation would report success while the card went on offering the same update.
Naming the version buys two things past correctness. The button installs the
version printed above it rather than whatever latest means at the moment it
is pressed; and an explicit version is exempt from pnpm's minimumReleaseAge,
which hides a release for its first day and would otherwise make the first
press after a publish that same silent no-op. The profile then records the
exact version rather than a range, which is the honest thing for a manifest
whose updates are button presses.
It is its own route because the preconditions are opposite: an install refuses a package the profile has, an update requires it.
Afterwards the restart banner appears. An update changes no bundle LIST — the same package name, different code behind it — so the usual comparison would miss the one operation that swaps running code out from under the process; the runtime latches it instead. Conservative on purpose: an update that fetched the same version costs a needless restart, and the other way costs somebody running code they believe they replaced.
linked is what a checkout install looks like, and dsh plugin add <path>
records link:. So a profile assembled from local directories will show every
Update button grey, correctly — editing the checkout is already editing the
plugin.
Where the catalog comes from
Three sources, merged on the package name, highest precedence first:
| Source | What it is | Why it exists |
|---|---|---|
local |
Directories of plugin checkouts | The copy you are editing beats the copy somebody published |
registry |
One curated JSON manifest | One request, full metadata, and the upstream's chance to say what it recommends |
github |
Repository enumeration | Zero maintenance: push a plugin repo to the account and it appears |
A losing source still contributes its repo when the winner has none — a local
checkout rarely knows where it is published, and the card's link is nicer for
it.
Out of the box the catalog is the curated manifest this collection publishes, fetched from a CDN that caches GitHub:
https://cdn.jsdmirror.com/gh/omdsh-plugins/registry/registry.json
GitHub enumeration is off — upstream is empty — because that one file already
lists every plugin, and asking GitHub for the account (then for each
repository's package.json) is what made a fresh tab slow. Point upstream
at an account to enumerate it as well, or empty registryUrl so the hub
derives https://raw.githubusercontent.com/<account>/registry/HEAD/registry.json
from the account instead. Empty both to run on localSources alone.
A local source is scanned exactly one directory deep, so a monorepo whose
installable half sits in packages/ is not offered. That is usually right —
what a monorepo here holds is a bundle for a DIFFERENT surface, and a profile
composes one surface. Point localSources at the inner directory when you do
want one listed.
A source that fails is REPORTED rather than hidden. "No plugins here" and "GitHub rate-limited this account" look identical on an empty list, and only one of them resolves itself.
With one exception, in the other direction: a 404 on a derived manifest URL
is absence, not failure. Publishing no curated manifest is the ordinary state of
an upstream account, enumeration is what covers it, and a red row under every
default install — naming a file nobody ever promised — would only teach people
to ignore the place failures are reported. A URL somebody typed into
registryUrl is the opposite case: they meant a manifest to be there, so a 404
on it is reported like any other.
The registry manifest is { "plugins": [...] } (or a bare array):
{
"plugins": [
{
"name": "@omdsh-plugins/omdsh-shortcuts",
"repo": "omdsh-plugins/omdsh-shortcuts",
"version": "0.1.0",
"plughub": { "displayName": { "": "Shortcuts", "zh": "快捷键" }, "order": 10 }
}
]
}
spec may be given explicitly; omitted, it is github:<repo>.
The manifest this account publishes lives in
omdsh-plugins/registry, generated
from the plugins' own package.json files rather than kept by hand.
The routes it holds
| Route | Method | What it does |
|---|---|---|
/api/plughub/catalog |
GET | The merged catalog. ?refresh=1 consults every source again |
/api/plughub/installed |
GET | This profile's plugins, which of them can be removed, and which are composed |
/api/plughub/install |
POST | { id } — install one catalog entry |
/api/plughub/update |
POST | { name } — reinstall one installed plugin from what the catalog offers now |
/api/plughub/uninstall |
POST | { name } — remove one dependency-managed bundle |
/api/plughub/enabled |
POST | { name, enabled } — compose or park a dependency-managed plugin |
/api/plughub/events |
GET | Operation progress, the restart flag, and settings invalidations, as an event stream |
/api/plughub/settings |
GET | Every namespace an installed plugin owns, redacted |
/api/plughub/settings |
POST | { ns, ops, expectedRevision } — one path-addressed edit |
Reach
The read routes carry the same fence /api carries: a Host header naming us —
loopback, or an authority this deployment was told to serve — plus same-origin
browser markers. They are exactly as reachable as the settings panel that
renders them.
The write routes are loopback only, whatever --trusted-host says. Each of
them changes this machine: an install runs that package's prepare script, and
a settings write persists to the Host document. "The deployment published
/api to the LAN" is not consent to either. Someone who genuinely wants to
install over a published dsh web still can, from a terminal, where the
decision is visibly theirs.
And a write names something the Host already resolved. An install names a catalog ENTRY, never a package specifier — the Host looks the specifier up in the catalog it resolved itself, so no request can reach a package the configured upstreams did not offer, and there is no request shape that can carry a specifier at all. A settings write names a namespace an INSTALLED plugin declares it owns. Both allowlists are structural rather than checks somebody has to remember to write.
How an install actually runs
It shells out to dsh plugin --profile <name> add <spec>.
pnpm add is only half of an install; the other half is reconciling
dsh.profile.bundles against what is now on disk, and that reconciliation
belongs to the launcher this runtime was started by. Reimplementing it here
would mean carrying a copy that has to track a program the user upgrades
independently, and getting it wrong means a profile that boots without the
plugin it just "installed".
One thing this package does have to know: pnpm ≥10 refuses to run a
dependency's install scripts until they are allowlisted, and a git-hosted dsh
plugin BUILDS ITSELF in prepare — its published tree has no lib/. So a git
install that is not allowlisted succeeds, writes the dependency, reconciles the
bundle list, and then the next boot dies on Cannot find module .../lib/index.js.
The allowBuilds entry is written into the profile's pnpm-workspace.yaml
BEFORE the install, because the failure arrives one restart later than the
mistake.
The package NAME is the right entry for a registry dependency and not enough
for a git one. pnpm keys a git-hosted package by the tarball it resolved —
@scope/name@https://codeload.github.com/owner/repo/tar.gz/<sha> — and refuses
an allowlist naming anything else, so the entry written ahead of the install is
correct in form and inert in fact. That commit is not knowable beforehand
without re-implementing pnpm's resolution, and it changes on every push.
So the name goes in first, and if pnpm refuses anyway it is asked. Its refusal prints the exact key it wants; that key is read back, written, and the install runs again.
It runs again as many times as it keeps learning something, because pnpm
reports the refusals it REACHED rather than the ones it would reach next. A
plugin with a native dependency is blocked on that dependency first and on its
own prepare only once the dependency is allowed — omdsh-remdev takes three
passes for exactly that reason. Progress is the loop's condition, not a count:
an entry already set to true is nothing new to write, so the loop ends the
moment an attempt teaches it nothing, and a bound of four is a backstop rather
than the thing that stops a healthy install.
One thing pnpm does that has to be answered rather than read: it writes the
blocked package into that file ITSELF, valued set this to true or false. That
is a question, and the person who pressed Install already answered it, so the
value is replaced rather than treated as an entry that already exists.
Operations run one at a time: two pnpm runs in one directory race over the
same lockfile, and the loser's diagnostic describes the race rather than
anything the person did.
The same installs, on a terminal
This package ships a bin. It is the tab's install path with argv where the
route was:
omdsh-plughub list # what the catalog offers, and what is installed
omdsh-plughub add omdsh-status # install one
omdsh-plughub update omdsh-status # move it to the version the catalog offers
omdsh-plughub remove omdsh-status # and remove it
It exists because of the paragraph above. Two packages in the collection
install from npm — this one, and omdsh-basemode. Every other plugin
installs from GitHub, and a git install has no working dsh plugin add line
— the allowlist key pnpm demands carries the commit it resolved, so it can
be copied out of a failure and never written down in advance. This package
has always known how to answer that, and until now it answered only to a
button.
Nothing here is a second implementation. The command resolves the same catalog,
takes the same specifier out of it, and hands it to the same Installer — so a
plugin installed from a terminal and one installed from the tab are the same
dependency, the same bundle row, and the same restart.
A name, or a specifier
Which of the two an argument is decides whether the catalog is consulted at all, and it is what lets one account be named without moving the catalog:
| What you type | What it installs |
|---|---|
omdsh-status |
the catalog entry whose name ends in that segment; two matches is reported rather than guessed |
@omdsh-plugins/omdsh-status |
that entry, named exactly |
github:someone/omdsh-status |
that repository, as written, without consulting the catalog at all |
@omdsh-plugins/omdsh-status@0.1.2 |
the same, pinned to a version |
/checkouts/omdsh-status |
the same, from a checkout — admissible here and refused in a route, which is what isInstallableSpec's allowPath has always been for: a path typed at a keyboard is not a path arriving inside somebody's manifest |
--upstream <account> moves the whole catalog to another account for one run.
That is the other half of the same question — an argument says WHAT to take, the
upstream says WHERE the catalog looks — and it is why a bare name does not try
to carry an account of its own.
It does not read this plugin's stored settings. A namespace is resolved by
the harness's settings service inside a running tree, and this program is not
one, so --upstream, --github-token, --registry-url and the timeouts are
flags with the same defaults the schema declares. --help lists them.
Which controls the form draws
| Schema node | Control |
|---|---|
string |
Text field |
string with role('secret') |
Write-only field, masked once a value is stored; the Host reports only whether one is |
number |
Number field, honoring min / max / step |
boolean |
Checkbox |
union of constants |
Select |
array(string) |
Editable list |
dict(string) |
Editable key/value rows |
object |
Heading plus indented children, to three levels |
| anything else | Read-only JSON, with a pointer at the settings document |
The last row is deliberate. A generic form that guesses at an arbitrary schema
produces controls that silently write the wrong shape, and a settings write
that passes validation while meaning something else is worse than no control
at all. A plugin that needs a control this form will not draw registers a card
in omdsh.plugin.card instead — see rule 6 of the conventions.
Every write is one path-addressed op carrying the revision this panel read.
Path-addressed rather than wholesale because what the panel received was
redacted: a replace rebuilt from what is on screen would delete every secret
the wire never sent. Revision-carrying because two surfaces can hold this panel
open at once, and without it the second writer silently overwrites the first —
with it, the second is refused, re-reads, and shows the current value.
A field's TITLE is derived from its property name (maxRepos → Max repos)
and the schema's description goes underneath it. Schemastery descriptions are
sentences, and a sentence makes a poor label; this way a schema author writes
one thing and it lands where it reads well.
A property name is also an English identifier, which is how a form rendered in
Chinese comes out half translated. So a schema may write its own title, in
meta.extra — schemastery's own slot for form-renderer metadata — as the same
locale map a localized description uses:
Schema.string().extra('extra', { label: { '': 'Model route', zh: '模型路由' } })
Schema.string().role('secret', { label: { '': 'API key', zh: '密钥' } })
The second spelling is not decoration: role(text, extra) writes that same
slot, and writes undefined into it when called with one argument, so a field
carrying a role declares its title THROUGH the role. A declared title also
brings the property name back as a code chip beside it, which is what somebody
editing the settings document needs. Every plugin in this collection that owns
a namespace declares its titles this way, including the fields below — a form
renderer that left its own panel half translated would be arguing against
itself.
Configuring it
This plugin follows its own conventions, so it configures itself in its own
panel — under Catalog sources, at the top of the tab. Every field but one is
reachable from there without touching a file; the same fields can still be set
as the composition entry in cordis.patch.yml, which becomes the base layer
the panel writes over. Namespace omdsh-plughub:
| Field | Default | What it does |
|---|---|---|
upstream |
(empty) | GitHub account enumerated as the fallback source; empty disables it |
registryUrl |
https://cdn.jsdmirror.com/gh/omdsh-plugins/registry/registry.json |
The curated manifest; empty derives it from upstream |
localSources |
[] |
Directories of plugin checkouts to offer |
githubToken |
— | Lifts the 60-per-hour anonymous rate limit (secret) |
maxRepos |
100 |
Most repositories examined when enumerating |
timeoutMs |
10000 |
Per-request timeout for remote sources |
cacheTtlMs |
300000 |
How long a resolved catalog is reused |
profileDir |
derived | The profile to manage; empty uses the one this runtime booted from. Composition only — see below |
launcher |
derived | Path to dsh; empty uses the running runtime, then PATH |
pnpmPath |
derived | Path to pnpm; empty searches the runtime, the profile, and the usual install locations |
profileDir is the one field the panel does not offer. Which profile this
runtime manages is settled when the plugin mounts — the installer, the routes,
and the bundle list a restart is judged against are all bound to it, and the
settings layer is resolved after that. So it is .hidden() from the form and
set where the plugin is composed, which is the same line omdsh-shortcuts
draws between its items and its bindings.
With no settings provider composed at all — a headless surface, a test bench —
the hub runs on that composition entry and nothing else: Catalog sources
says so instead of drawing a form, every installed plugin reads as declaring
nothing to configure, and the catalog, the installs, and the removals work
exactly as they otherwise would. The registration rides
ctx.inject(['settings'], …), so being configurable is additive here rather
than a precondition.
Install
dsh plugin --profile web add @omdsh-plugins/omdsh-plughub
dsh web
Then Settings → Plugins → Plugin hub, where the rest of the collection is already listed — the catalog manifest is the default, so this is the only plugin that has to be installed from a terminal. A release can equally be named the way the hub names one on a card, straight from the account:
dsh plugin --profile web add github:omdsh-plugins/omdsh-plughub
That one fails on the first run, and the failure is pnpm's rather than
this package's: a git dependency builds through prepare, and pnpm ≥10 runs
no such script until the package is in allowBuilds. Both pnpm and dsh
print the entry to add to $DSH_HOME/profiles/web/pnpm-workspace.yaml, and it
is the whole specifier — '@omdsh-plugins/omdsh-plughub@https://codeload.github.com/…/<sha>': true
— rather than the package name, which is not enough once an attempt has been
refused. The npm form above needs none of this, and neither does anything
installed through the hub: this package writes that entry itself before it
runs an install, which is the difference between a button and a paste.
Or from a checkout, when you are working on the hub itself:
pnpm install && pnpm run build
dsh plugin --profile web add /path/to/omdsh-plughub
To offer your local checkouts alongside the upstream, set localSources to the
directory holding them; a checkout wins over anything published under the same
package name.
Where the omdsh-plughub command comes from
The bin ships with this package, so installing it into a profile puts it in
that profile's node_modules/.bin — where nothing on your PATH will find it,
which is what the first form below is for:
npx @omdsh-plugins/omdsh-plughub add omdsh-status # nothing installed
npm install -g @omdsh-plugins/omdsh-plughub # then: omdsh-plughub add …
"$DSH_HOME"/profiles/web/node_modules/.bin/omdsh-plughub add omdsh-status
All three run the same program against the same profile: it reads $DSH_HOME
and --profile rather than anything about how it was started, so where the
binary came from never changes which profile it writes to.
Remove it the same way:
dsh plugin --profile web remove @omdsh-plugins/omdsh-plughub
which takes the tab, the routes, and the settings gateway with it. The Plugins section goes back to Plugin configuration and Plugin list, and every plugin installed THROUGH the hub stays installed — those are the profile's own bundle rows, written by the launcher, not held by this package.
Nothing else has to be composed beside it. The host half injects webServer and
nothing more, and the settings registration rides ctx.inject(['settings'], …),
so a profile with no settings provider at all still gets the tab, the catalog,
the installs and the removals — every installed plugin simply reads as declaring
nothing to configure.
Commands
pnpm install
pnpm run build # tsc → lib/types, then tsdown → lib/{index,contract,client,cli}.js
pnpm test
pnpm run typecheck
pnpm run harness:local ../../deepseek-harness # build against a checkout
pnpm run harness:npm # back to the committed pin
pnpm run check:harness-pin # fails while anything is linked
Where it came from
The harness declares settings.plugins.tab precisely so that "inventory and
configuration plugins collaborate without depending on one another"
(packages/client/ui-settings/src/client/contract/slots.ts). This package is a
third occupant of that seat, beside the two tabs the harness ships there:
Plugin configuration (the configurable entry, which owns the shipped Bash,
Agent loop and Web search cards) and Plugin list (the all entry, the
inventory of every composed bundle). It adds no slot to the harness, patches
nothing, and removing it leaves the Plugins section with those two.
Known limitations
- Every install, update, removal, enable and disable needs a restart. Plugin layers are composed at boot and only the user patch layers are watched, so a newly installed or newly parked bundle cannot be hot-mounted. The banner says so; there is nothing behind it that a future version quietly fixes.
- A local source is scanned exactly one directory deep. A configured root
holds plugin checkouts, and anything that does not declare
dsh.bundle.patchin its ownpackage.jsonis passed over — so a monorepo whose installable half sits inpackages/is not offered. PointlocalSourcesat the inner directory when you want one listed. - Anonymous GitHub enumeration is rate-limited. 60 requests an hour without
a token, and
maxReposstops at 100 repositories in any case. The failure is reported on the source row rather than hidden;githubTokenlifts it. profileDircannot be set from the panel. Which profile this runtime manages is settled when the plugin mounts, before the settings layer resolves, so the field is.hidden()and belongs on the composition entry.- The write routes are loopback only. A
dsh webpublished to the LAN can browse the catalog and read the panel, but installs, updates, removals, enables, disables and settings writes are refused — publishing/apiis not consent to run a package'spreparescript on this machine. - A namespace is reachable only when an installed bundle declares it. The
gateway resolves ownership from
dsh.plughub.settings, so a namespace registered by something the profile does not carry as a bundle — the harness's ownshelloragent-loop— is invisible here by construction. - The generic form refuses schemas it cannot draw. Anything outside strings,
numbers, booleans, closed unions, string lists, string dictionaries and nested
objects renders as read-only JSON with a pointer at the settings document. A
plugin that needs more registers a card in
omdsh.plugin.card.
还没有评论,来写第一条。