fix: render plugin-supplied labels and package references faithfully - #9
Merged
Conversation
Three places where the templates dropped meaning a plugin had already
expressed.
The interface badge printed its label raw. Every other consumer-supplied
string goes through `| loc`, so a plugin handing it a per-locale map --
which the block API accepts everywhere else -- rendered the map itself:
`{'en': 'command', 'zh': '命令'}` in the badge.
`dependencies` was inert text while `required_by` was a link, so an edge
the build had already resolved could only be walked uphill. The resolver
now records which slug a dep string resolves to, and the template links
the ones that name a package this index carries. External deps stay
text, because there is nothing to point at.
A table cell could only be flat text, so a plugin's own table could not
reference the pages its index already has. A cell may now be
`{"text": ..., "slug": ...}`; the plugin supplies the slug and the core
builds the URL, exactly as it does for `required_by`. A plain string
behaves as before.
Found while building dsh-index, whose Agent pages list their member
packages with a version and a commit -- a table that names five packages
and links to none of them.
Sunrisepeak
added a commit
to Sunrisepeak/dsh-index
that referenced
this pull request
Aug 14, 2026
An Agent named one fixed set of plugins but not which versions of them, so its deps resolved to whatever `latest` was that day -- `agent-tui-coding@0.1.0` would have meant different bytes after any member released. Members now carry their version and commit, deps are emitted as `dsh:<name>@<version>`, and a test asserts the recorded pin still matches the member's own descriptor so the two cannot drift. The members table shows both, because the pin is the point: a reader comparing two Agents is comparing the versions inside them. Each member name links to its package page, which needs openxlings/xpkgindex#9 -- a table cell could not reference a package until now.
Sunrisepeak
added a commit
to Sunrisepeak/dsh-index
that referenced
this pull request
Aug 14, 2026
* fix: let the user name profiles, as upstream does
Upstream's model is explicit that a profile name belongs to the user: `dsh
plugin --profile <name> add <pkg>` creates whatever name is passed, and its own
docs pick `demo`, while dsh-cc-tui's README picks `cc-tui`. Nothing about a
plugin says what its profile should be called.
This index had layered two of its own rules on top of that. A plugin
categorised `tui` or `desktop` got a profile named after itself, on the
argument that a surface overrides base rows and two of them would conflict; and
everything else followed the current subos, on the argument that a plugin set
should travel with the environment. Each was defensible on its own.
Together they made every profile name in every upstream document wrong here.
Following dsh-cc-tui's own README and running `dsh --profile cc-tui` produced
"profile does not exist" -- twice, for two different reasons, because the index
had quietly named it something else both times.
Both rules are gone. `DSH_PROFILE` or `web`, where `web` is the profile
upstream's `dsh web` boots and `DSH_PROFILE` maps one-to-one onto upstream's
`--profile`. An index exists to deliver upstream's packages; what a user learns
from upstream's documentation has to keep working under it.
The stated cost: two plugins that both override base rows now land in the same
profile and can conflict. That is upstream's behaviour too, and it is the
user's composition to manage.
config() prints `dsh web` for the web profile and `dsh --profile <name>`
otherwise -- upstream's own spellings, copyable as printed.
* docs(design): distribute Agents, not loose plugins
v1 positioned the index as a package index for plugins. Two things measured
since say that is the wrong unit.
xlings adds nothing at the package-management layer: dsh plugin is a pnpm
forwarder, and pnpm's content-addressed store already makes version switching
free (downloaded 0, reused 2 when switching back). And a single plugin is
rarely the goal -- 65 of 68 READMEs tell readers to install into web, because
they are additive capabilities, not products.
So the unit becomes harness + a set of plugins = a runnable Agent, which is
what dsh already calls a profile. Three tiers, composed with xpkg's own deps
rather than a new field: plugin (atom, owns bytes and mirrors), group
(reusable non-conflicting set), profile (a complete Agent).
The split also dissolves the question v1 spent three rounds on. Plugins no
longer decide which profile they land in -- only a profile package names a
profile, because that name is part of the Agent rather than something the index
derives. Row-level conflict detection moves from an install-time warning to a
pre-publish CI gate, which is where it belongs: a composition whose members
patch the same base row is broken by construction.
* docs(design): Agent packages are manifests; boot never installs
Two corrections from review:
1. The distinction between --patch and a profile is dependency
declaration, not code. A profile carries no code either: copying
the tui profile's 222-byte package.json into an empty directory
and running pnpm install reproduces the full 36M node_modules
(resolved 92, reused 92, downloaded 0). An Agent package is
therefore a few hundred bytes of manifest with no payload, and
its member specs may mix npm names, pinned git commits, and
local mirror tarballs in one dependencies map.
2. Pointing dsh at a .yml does not auto-install. pnpm appears only
in the `dsh plugin` subcommand, never in the boot path. A patch
referencing dsh-tui@0.2.0 -- present on npm, absent from the
profile -- dies with "plugin tree failed to load" and leaves the
profile package.json untouched.
Together these are the grounds for the Agent package: dsh lacks the
step that turns a manifest into an installed tree, and dependency
closure installation is exactly what xpkg deps already does.
* docs(design): the patch layer is where an Agent states its opinion
Adds section 2.5. The previous revision established what a yml cannot
do (carry code); this establishes what it is, because half an Agent
package's value lives there.
A dsh app is itself a config tree of cordis loader rows. A yml is a
declarative overlay on that tree -- id-targeted config overrides,
disables, insert lists, with !!js expressions allowed -- so a bundle
changes harness behaviour by declaring overrides rather than by
editing code.
Three consequences that bear on this design:
- A patch replaces the targeted row's whole config rather than merging
into it. This is stated in dsh-cc-tui's own patch header, so the row
conflicts in section 3 are the rule working as intended, not a
defect; conflict checking must stay row-scoped and pre-publish.
- !!js means a patch is a small config program, and parameters enter
through it. That is why dsh reads only three environment variables
of its own, and why the index should not invent more.
- Reconfiguring never requires forking a bundle.
The four layers are four authorship scopes: bundle author, this Agent,
this machine or subos, this single boot. Only the profile's own
cordis.patch.yml is both persistent and per-profile, so that is where
an Agent's opinion belongs:
Agent package = deps (member code) + profile cordis.patch.yml
This settles the position of the dsh.patch field and completes the
three-tier semantics: plugin yields bytes, group yields a combination,
Agent yields an opinion.
Evidence: --dump-default-config exists precisely to print the tree
without the user layer or --patch overlays.
* docs(design): show the native dsh install first on plugin pages
A plugin is something dsh installs on its own -- dsh plugin forwards to
pnpm -- so the package page leads with the native command and puts the
xlings one under it. Hiding a path the user already has is the wrong
kind of advocacy; the index earns its place by stating what it adds
(mirror, sha256, dependency closure), not by omitting the alternative.
Only the plugin tier gets a native command. Section 2.4 established
that dsh has no entry point for installing a profile, so groups and
Agents keep the xlings command alone. That is a capability fact, not a
layout preference.
Two generation rules, both fail-closed, because printing a command that
404s is worse than printing none:
- Choose the spec by measurement, not by the shape of bundle_name.
bundle_name is the package's own package.json name, and a bare name
does not imply publication: of 68 packages, 19 carry the synthetic
@dsh-external/ prefix and only 18 of the remaining 49 resolve on npm.
The rest get github:<owner>/<repo>#<commit>, which the descriptor
already pins. Resolve name and pinned version together at generation
time and record the result -- verifying the name alone would miss an
unpublished version, and our dsh-cc-tui pin at 0.1.6 against npm's
0.2.0 is a live example of that gap.
- Take --profile from the upstream README, which mine_profiles.py has
already extracted (65 web, 2 tui, 1 cc-tui today). Section 4 forbids
the index inventing profile names; quoting upstream is not inventing,
while deriving one from the tui and web-ui categories is exactly the
approach v1 had to retract.
The 13 packages with needs_build carry the allowBuilds caveat when
shown with a git spec, quoting dsh's own error text, since pnpm blocks
prepare scripts until the key is added.
Finally, the page says what each command actually gives: the native one
goes direct with no CN acceleration and no checksum, the index one
carries dual mirrors and three-way verification where a member is
mirrorable.
* feat: distribute Agents, not only loose plugins
Implements .agents/docs/2026-08-14-agent-distribution-design.md.
An Agent is a harness plus a composed set of plugins -- in dsh's own
model, a profile. Until now this index could only hand out the atoms,
and a single plugin is rarely what anyone wants: 65 of 68 READMEs tell
their reader to install into `web`, because these are capabilities that
stack. What was missing was a way to distribute the stack.
Three tiers now share template.lua, branching on dsh.kind:
plugin one upstream bundle. Unchanged behaviour: fetch the bytes,
mirrored or direct, and add it to the profile its own README
documents.
group a reusable set. No bytes of its own; deps bring the members.
profile a complete Agent. Creates its profile, composes every member
into it, and writes its own cordis.patch.yml -- the only one
of dsh's four patch layers that is both persistent and
per-profile, so the only place an Agent can state an opinion
about its own set.
kind lives under dsh.* rather than in xpkg's type because type is a
closed enum: libxpkg's parse_type() maps any unknown string to
PackageType::Package, so type = "dsh-agent" would not fail, it would
evaporate.
Composition reuses xpkg's own deps rather than inventing a field. A
group and an Agent are manifests a few hundred bytes long -- copying the
tui profile's 222-byte package.json into an empty directory and running
pnpm install reproduces its full 36M node_modules, so the payload was
never theirs to carry.
Two composition rules are refusals rather than warnings, because here
the index chooses the combination:
members must be mirrored a curated set whose contents fetch from
upstream at boot inherits every failure
mode the mirror exists to remove.
no two members may replace a patch replaces a row's whole config
the same dsh-base row rather than merging, so the later member
silently wins. Row data comes from
tools/mine_overrides.py, measured against
dsh-base's own ids: 5 of 68 bundles touch
a base row, and exactly two pairs collide.
Also in this change:
- template.lua's conflict check used xmake's `try {}`, which is nil in
the libxpkg plain-Lua sandbox. A nil call there kills the build with
the error swallowed and the index silently loses every xpm section.
It is pcall now.
- The profile override is XIM_DSH_PROFILE, not DSH_PROFILE. dsh reads no
such variable -- its tree reads DSH_HOME, DSH_WEB_URL and
DSH_TELEMETRY_DISABLED and nothing else -- so the old name claimed an
upstream contract that does not exist.
- TestProfileResolution was dead: a second class of the same name, with
only a docstring, shadowed the whole suite. Restored and extended.
- Plugin pages lead with dsh's own install command and put this index's
below it. The spec is resolved at generation time, never guessed from
the shape of bundle_name: of 68 packages 19 carry the synthetic
@dsh-external/ prefix, and of the remaining 49 only 18 resolve on npm.
Version is resolved with the name -- dsh-tui is on npm but our pinned
0.2.1 is not, so it correctly falls back to its git spec.
- tools/discover.py and tools/sync.py, plus a scheduled workflow, track
the dsh-plugin topic. New plugins, releases and missing pins are three
separate PRs. A pin that vanished upstream fails the run and never
auto-merges.
- READMEs, site config and docs restate what this index is: browse the
ecosystem, install with dsh or with xlings for mirror acceleration,
and get Agents as a distribution channel.
* fix(site): keep the tier out of the category facet
`dsh-agent` and `dsh-group` are the categories the generator writes onto
a composite, and they name the tier -- which already has its own axis.
Left in, the same distinction appeared on two facets, where filtering by
one silently narrows the other. `dsh-plugin` was already excluded for
the same reason; the two new values just were not.
* fix(template): resolve members without os.files, and remove by bundle name
Both found by an isolated end-to-end install of dsh:agent-web-coding,
neither reachable from the static checks.
`os.files` does not exist in the libxpkg hook runtime. The Agent had
already composed all five members into its profile when the glob it used
to locate the next tarball died with `attempt to call a nil value (field
'files')`, so the traceback pointed at the Agent rather than at the glob.
Nothing needed globbing: xpkg installs a dependency into
`<store>/dsh-x-<name>/<version>` and tools/mirror.py names every tarball
`<name>-<version>.tgz`, so the version is the last path component and the
filename follows from it. That also cannot drift from the member's own
descriptor the way a copied filename would.
Uninstall passed the descriptor name to `dsh plugin remove`, but the
profile manifest records the BUNDLE name, and the two are frequently
different -- `dsh-annotation` is `@omdsh-dev/dsh-annotation` there.
Removal got ERR_PNPM_CANNOT_REMOVE_MISSING_DEPS on the second member and
stopped, leaving the profile half dismantled. Members now carry both
names, read out of each member's descriptor by tools/gen_agents.py, and
a test asserts the recorded bundle still matches its source.
A further test forbids the whole class: os.files / os.dirs / os.filedirs
/ cprintf / raise / os.getwinsize / try{} are all absent from one or both
of the two runtimes these hooks meet, and every one of them fails
silently or misleadingly.
* fix(discover): pin before reading, and notice renames and name clashes
Three gaps a smoke run over the top 60 repos of the topic exposed.
--new emitted no commit at all, so every row it produced was refused by
sync.py. Fail-closed, but the pipeline could never have written a
descriptor. The head sha is now resolved FIRST and package.json is read
AT that sha, which is also the order that makes a version key describing
different bytes than its own commit unrepresentable -- 19 of 169
descriptors once disagreed that way because the two came from separate
snapshots.
Matching only on repo name missed renames. ccch1mneyyy/dsh-cc-tui is now
ccch1mneyyy/DSH-TUI and hust-open-atom-club/oh-dsh moved likewise;
GitHub redirects so the descriptors keep working, but the scan reported
both as new and would have done so every day forever. The bundle name is
what identifies a package, and it is now checked too.
Two repos can normalise to the same descriptor name -- DSH-TUI would
have become `dsh-tui`, which orriduck/dsh-tui already holds. sync.py
refuses to overwrite, which is the safe half; the scan now says so,
because a silent skip reads as "nothing found" rather than "a real
package needs a distinct name".
Verified end to end afterwards: 2 genuinely new plugins found, both
written by sync.py with a 40-hex pin, and the descriptors pass the
schema suite. They are not committed here -- new packages are their own
PR.
* fix: name an Agent's profile after its package, and render members as packages
Three review points.
The mirrored callout claimed more than the badge needs to say. "survives
upstream deletion, and needs no build authorisation" are consequences a
reader can work out, and stating them made a one-line fact into three
clauses. It now says what it is: mirrored, sha256, CN mirror, installs
offline. en / zh / zh-Hant together.
The phrasing is the equation everywhere: `Agent = Harness +
Plugins/Packages`, `Agent = dsh + 一组插件`. It was spelled out as prose
in five places and read differently in each.
An Agent's profile is now its package name. `xlings install
dsh:agent-web-coding` followed by `dsh --profile coding` gave a reader
two names for one thing and no way to know they were related -- it reads
as a typo, or as a second package. Only upstream documentation can
license a different name, and these Agents are this index's own, so
there is nothing to defer to. gen_agents.py derives it from `name`
rather than accepting a field, and a test asserts it.
Members were also being rendered as raw dicts -- the same Python-repr
leak the facets had -- and as a private list, which was the wrong
vocabulary: every member is an xpkg package in this very index. They are
`pkg.deps` now, so they sit on the core's own axis and each member page
gains a linked `required by` pointing back at every group and Agent that
contains it. The relationship is visible from both sides instead of one.
Alongside that, a table gives the descriptor name and the bundle name
the profile manifest actually records, since those differ often enough
to have broken uninstall.
Needs openxlings/xpkgindex#8: `.blk > .table-wrap` and its siblings
select a direct child of `.blk`, but content is wrapped in `.blk-body`,
so a titled block had only the h3's 8px and its heading sat flush
against its content.
* feat: a plugin fetches, an Agent composes and becomes a command
Two changes that belong together.
Installing a plugin no longer registers it into any profile. Fetching and
composing are separate acts with separate owners, and while the atom
registered itself, installing one Agent also put its five members into
`web` -- each member had already decided where it belonged before the
Agent ever ran. Verified in an isolated home: installing a plugin now
creates no profile at all, and installing agent-web-coding creates
exactly one, its own.
The alternative was to let a member know it was being installed as a
dependency. It cannot: xlings installs deps by recursively constructing
a CmdProcessor, and libxpkg's runtime carries the package's own name,
version, dirs and deps -- no parent, no root, and no environment
variable marking the difference.
An un-mirrored plugin still fetches something real. `pnpm store add
<pinned git spec>` warms pnpm's content-addressed store at the commit
the descriptor pins, so composing later resolves locally instead of
downloading again. It fetches without building -- the tarball comes from
codeload -- which is why the index no longer carries DSH_ALLOW_BUILDS:
nothing it runs executes upstream code any more, and pnpm's own
allowBuilds already guards the step where a prepare script would
actually run. A second gate here would have been a weaker copy.
An Agent now registers a command named after the package.
`xvm.add(<name>, { alias = "dsh --profile <name>" })`, the same shape
mcpp-short-cmd uses, so it needs no knowledge of where dsh's launcher
lives. Installing agent-web-coding leaves you `agent-web-coding`. The
name is xvm's, so it is versioned and per-subos for free: two versions
coexist and `xlings use` switches which one the command resolves to, in
that subos only.
Docs, READMEs and the site say all of this, and the two-step flow for a
single plugin -- xlings fetches, then one printed line composes -- is
written down rather than implied.
* feat(site): show an Agent's command the way a program index shows its binary
An Agent produces something you type, and the page did not say what.
xim-pkgindex answers the same question with `$ <binary>` in the page's
interface slot and on the card; an Agent's answer is the xvm shim named
after the package, so it uses the same two places.
The interface slot also settles the spacing. `.iface` owns `16px 0 4px`,
the install command below it owns 14px, a block owns 34px and its title
12px, so every gap on the page belongs to the element that draws it
rather than to whatever happened to precede it.
The callout now names the command first and gives `dsh --profile <name>`
as the long-hand, which is the order a reader needs them in.
* fix(site): say why there is no CN mirror, in one sentence
The direct-delivery note ran to three clauses: no CN mirror, gone if
upstream disappears, and a paragraph on redistribution rights. The badge
already says `direct`, and the consequence is not what a reader is
asking -- what they want to know is whether the mirror is coming.
So it now states the reason and nothing else: "Upstream's licence is
unknown, so there is no CN mirror", or, when the licence does permit it,
that the tarball simply is not published yet -- which is the case where
the answer is "not yet" rather than "no".
* feat: pin an Agent's members, and show what it pinned
An Agent named one fixed set of plugins but not which versions of them,
so its deps resolved to whatever `latest` was that day --
`agent-tui-coding@0.1.0` would have meant different bytes after any
member released. Members now carry their version and commit, deps are
emitted as `dsh:<name>@<version>`, and a test asserts the recorded pin
still matches the member's own descriptor so the two cannot drift.
The members table shows both, because the pin is the point: a reader
comparing two Agents is comparing the versions inside them. Each member
name links to its package page, which needs openxlings/xpkgindex#9 --
a table cell could not reference a package until now.
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.
Three places where a template dropped meaning the plugin had already expressed.
1 · The interface badge did not localize
Every other consumer-supplied string goes through
| loc. The block API accepts a per-locale map everywhere else, so a plugin that passed one here got the map printed:2 ·
dependencieswas text,required_bywas a linkThe same edge, and it could only be walked uphill.
_reverse_depsalready resolves each dep string to a package; it now records the slug, and the template links the deps that name a package this index carries. External deps (xim:pnpm) stay text — there is nothing to point at.3 · A table cell could not reference a package
A plugin's own table had no way to link into the index it belongs to. A cell may now be
{"text": ..., "slug": ...}:The plugin supplies the slug, the core builds the URL — the same split
required_byalready uses. A plain string cell is unchanged.Test plan
python3 -m pytest tests/ -q→ 46 passed; no existing test changed, and all three paths keep their previous behaviour for plain strings / unresolvable deps.命令under/zh/andcommandunder/<a class="dep" href="../../packages/dsh-cc-tui/">on an Agent page,xim:pnpmstill plain