A single request for all 317 catalog repos now exceeds GitHub's per-query
resource limit: the reply carries partial data plus an error, the script
prints 'Probed 317', and every repo past the limit keeps its stale count
(hindsight showed 26.5k stars while GitHub had 34.5k). Batch at 100; probed
live: 317/317 repos in 4 requests, no errors.
Change-detectors, tautologies, source-reading tests, redundant duplicates,
mock-echo tests and dead/unrunnable tests. Per-test rationale in the lane
ledger (category + reason for every removal).
The `Probe plugin catalog stars` step reused the GitHub App installation token
minted at job start. That token lives 1 h; `build_skills_index.py` (the step
before it) has walked ClawHub for 60-85 min since Sep 17, so every scheduled
probe since Sep 18 ran on an expired token and logged
`GraphQL probe failed (HTTP Error 401: Unauthorized); keeping previous counts`.
114 of the 213 catalog repos ended up without a star count and the "Most
starred" default sort fell to alphabetical for half the catalog.
Run-log evidence (job step timestamps; `gh run view --json jobs` + job logs):
run 35569229751 (Sep 21): mint 06:36:52Z -> probe 08:01:50Z (85 min) -> 401,
"Probed 213 repos ... wrote 99 star counts"
run 35426522555 (Sep 19): mint 06:24:52Z -> probe 07:39:59Z (75 min) -> 401
run 35133575877 (Sep 16, last success): mint 18:18:39Z -> probe 18:40:12Z
(21 min) -> "Probed 100 repos ... wrote 100 star counts"
Fix: a second `./.github/actions/get-app-token` step (`probe-token`)
immediately before the probe; the probe reads that token. Ordering stays (the
probe does not depend on the walk, but the artifact upload wants both files
and the step comment already documents the walk timing).
Freshness: `main()` stamped `fetched_at = now` whenever the written map
differed from the previous one, so a FAILED probe still moved the timestamp
forward and the catalog footer read "popularity ranking as of <today>" over
Sep-16 counts (live `plugin-stars.json`: fetched_at 2026-09-21 with Sep-16
data). `probe_stars` now returns `(stars, probed)`; `fetched_at` advances only
when GitHub actually answered, otherwise the previous timestamp is kept, a
`:⚠️:` names how many catalog repos have no count, and the summary line
says "Probe failed; wrote N cached star counts (as of <ts>)" instead of
"Probed N repos".
Local repro (3 catalog repos, cache from Sep 16 with 2 of them, GraphQL 401):
before: fetched_at restamped to now; "Probed 3 repos ... wrote 2 star counts"
after: fetched_at stays 2026-09-16T18:40:16+00:00; ":⚠️:plugin star
probe failed; reused 2 cached counts from 2026-09-16..., 1 of 3
catalog repos have no star count"
Not taken from #118129: the per-repo REST fallback and the payload-shape
validation. The transport was never the problem (the same expired token 401s
on REST too), and one GraphQL request per run is the rate-limit rule this
script documents.
Fixes#118113
credit: @fangliquanflq #118129 (honest fetched_at on a failed probe + ::warning; slim redo)
main extracted the launchd backend and the setup wizard out of hermes_cli/gateway.py. The
eight launchd functions pm-clean had changed are ported into gateway_launchd.py in its
_gw() style: runtime_command/installation_command for the gateway argv (no VIRTUAL_ENV in
the plist, XML-escaped args), _prepare_service_launcher before every plist write,
utf-8-sig plist reads, and launchd_restart's refresh-first + bounded bootstrap revival.
The systemd service-unit cluster stays in the facade (no gateway_service_unit sibling).
backup.py takes main's browser_profiles backup-only exclusion on our profile_root_entry
shape. main.ts takes main's attach-first backend block with our explicit types.
The README section was gated on `readme: true` in the catalog YAML and no entry set it, so
all 222 plugin pages shipped without one. READMEs now render for every GitHub/GitLab entry
(fetched at the reviewed sha, subdir first then repo root, common casings and docs/README.md
as fallbacks); `readme: false` opts an entry out. Build proof: 222/222 READMEs rendered.
Branch semantics kept where main and PM disagree: update_cmd_deps.py,
constraints-termux.txt, the Electron update-api-check module and the
post-swap hand-off test stay deleted; the pending-fleet-restart catch-up
and the local_runtime tag/download ladder stay retired (PM owns engines).
Ported from main onto the branch's shape: profile_scoped_chore for the
auto-archive and plugin-update housekeeping chores, the local-runtime
cross-process boot lock and residency cap, the checkpoint tmp_pack sweep,
the cua daemon-liveness status probe, the remote-served Desktop update
flag (posix.sh / windows.ps1), sign-in for env-pinned remote gateways
(urlDisabled on RemoteSetupFields), the uvloop extra split (uvicorn
without [standard]), and the umask-scoping spawn test.
uv.lock regenerated with pm.build_env --lock-only; new utf-8 reads from
main switched to utf-8-sig (check-windows-footguns).
Two optional, submitter-controlled fields on a catalog entry feed the
entry's own page at /docs/plugins/<name>:
- `screenshots:` — up to 6 https URLs on GitHub hosts (same host rule as
`image`, so the site never fetches from third-party hosts and a raw URL
pinned to the sha is as immutable as the code).
- `readme: true` — the docs build renders the README from the PINNED
commit (raw.githubusercontent.com / gitlab.com raw at <sha>), never live
content, so what a user reads is what the reviewer read.
Validator rejects malformed values (admission), the loader parses and
drops off-host screenshots with a warning (client), and the extractor
emits `screenshots`, `readme`, `readmeUrl` and a `maintainerSlug` for the
author pages. Tests on all three.
extract-plugins.py derives addedAt/updatedAt per entry from one `git log
--name-status` over plugin-catalog/ (committer dates, so rebase-merged PRs
are stamped when they land; renames carry the original addedAt). A shallow
checkout or missing git yields null dates instead of wrong ones, and
deploy-site.yml now checks out with fetch-depth: 0 so the deploy has the
history.
The /docs/plugins page gains a Sort control (Most starred / Newest /
Recently updated) and an "Added … · Updated …" line on every card; Updated
is hidden while it equals Added.
Conflicts resolved toward the PM model: main's lazy_deps/update_cmd_deps/npm
stamp machinery stays deleted (PM + scripts/build/node-deps.mjs own it), the
systemd ExecStop stop-mark rides the installation launcher, legacy
linux_only/macos_only/windows_only markers are rewritten to platforms(), and
finalize_update_receipt carries pending manual-serve obligations forward
again (lost when the ContextVar receipt rewrite crossed c0aa3ce354).
Test harness: the real-home I/O guard exempts /proc/<pid>/fd metadata reads
(deleted-WAL holder scans) and run_tests.sh drops ~/.hermes PATH entries so
shutil.which() cannot trip the tripwire.
The catalog hero showed three trust chips ("Reviewed by the Hermes team",
"Installs exactly the version we reviewed", "One click from Hermes Desktop")
and the hero copy repeated the review claim. Teknium asked for the review
claim to go; the row goes with it since the remaining two chips only made
sense as a set.
Ranking pinned the official tier above every community entry, so a 5-star
official plugin sat above a 215-star community one and the page looked
unsorted. Tier no longer ranks; stars desc, unknown stars last, name as the
tiebreak. The Official/Community filter pills still exist for anyone who
wants the tier view.
Conflicts resolved toward the branch: PM owns dependency preparation, the
Windows shim re-exec/hand-off path stays retired (main's shim-parent wait,
gateway-resume env token and update_cmd_deps tests dropped), docs describe
the PM update flow. The docker workflow parks install-stamp.json around the
toolchain step instead of deleting it so tests/docker can compare provenance.
Docs under website/docs were linked with Docusaurus site routes
(`](/getting-started/installation)`, `](/docs/user-guide/x)`), which GitHub's
file viewer resolves as repository paths and 404s (#114428). Relative Markdown
file links (`](../user-guide/x.md#anchor)`) are followed by both GitHub and
Docusaurus, so that becomes the authoring convention:
- website/scripts/check_doc_links.py lints hand-authored EN + zh-Hans pages
for route-style links (`--fix` rewrites them, refusing any route that maps
to no doc file); wired into the Docs Site Checks workflow and
tests/website/test_check_doc_links.py.
- generate-skill-docs.py emits the same relative form for related-skill and
catalog links instead of `/docs/user-guide/skills/...`.
- src/remark/relativeDocLinks.js rewrites `./x.md`/`../x.md` to
content-root-absolute `/x.md` before Docusaurus resolves links, so a
relative link still resolves in the zh-Hans build when source and target
sit on different sides of the translation fallback (Docusaurus resolves
`./`/`../` only against the source file's own directory).
- website/README.md states the convention and points at the checker.
Reconcile plugin declarations and validation through PM's atomic generation publication; preserve external runtimes, target markers, and conflict refusal. Keep one source-update completion owner and port upstream lifecycle changes to the PM desktop/runtime paths.
The heading-sequence and code-block-count tests pinned the English page's exact shape, so any English-only edit of bot-mode.md (e.g. #113411 adds a heading) turned main red until the translation was re-synced. Replace them with two invariants of the translation itself: the page exists and routes like the English page (front matter id/slug agree), and every zh-Hans code block is a byte-accurate copy of a block that still exists in the English page, so only a genuinely stale command/config in the translation fails.
The salvaged translation (#94377) mirrored the English guide as of its
base commit; the English page has since gained 147 lines. Translate the
drift so zh-Hans readers see the same guide:
- Bots pane: row-click/tab-caption behaviour, the rewritten "Active now"
filter, and the archive-retires-a-Bot-Chat paragraph
(`sessions.auto_archive`).
- New section "Organize bots into sections" (`ui_meta`).
- Avatars: the geometric-face live-turn pose wording.
- Groups: queued-message lead paragraph, editable room identity
(picture + Group settings), room ordering (Move up/down), and the new
room bullets (one visible conversation, `@hermes` handoff, needs-you
prompts, rooms keep running via `groups.capabilities`, plugin hook
`on_room_member_activity`); member sessions no longer named
`Group: <name>`.
- Bot-to-bot messaging: live delivery ownership paragraph
(`runtime/bot_live_delivery/`), the "Staying silent" bullet, the
`SESSION_NOT_OWNED`/`target_busy` and quiet-CLI-turn paragraphs, the
side-by-side delivery sentence (`bot_mode.envelope_ttl_seconds`).
- `hermes peer run/status/stop` commands and paragraph, the NAT note.
- New sections "Transferring hosted room authority" (JSON-RPC payloads
kept byte-identical) and "Warm Bot Backends".
- Turning it off: Capabilities → Plugins → Bots Desktop switch.
Tests: drop the file-exists and internal-link checks (the Docusaurus
build already fails on broken links); keep heading parity and
code-block parity as the two invariants.
website/docs/user-guide/bot-mode.md had no zh-Hans mirror, so readers on
that locale silently fell back to the English page after #89250 wired up
Bot Mode's docs entry points. Translate the guide in full, preserving
heading order, commands, config keys, paths, and internal links, and add
a structural-parity test so the two stay in sync.
Fixes#94366
The 40-hex sha stays the release, but nobody reads one. Entries may now add
`version: "1.4.0"` (free-form, <=32 chars, never parsed) and `image:` (an https
URL on raw.githubusercontent.com / github.com / *.githubusercontent.com).
Why GitHub-only: the Desktop catalog browser deliberately never fetches from
third-party hosts, and a raw URL pinned to the entry commit is as immutable as
the sha it decorates.
Readers updated together: PluginCatalogEntry + entry_from_mapping (drop with a
warning, entry survives), validate_plugin_catalog.py (admission error), the
site extractor (drop, never fatal), the /docs/plugins card (banner + version
pill + "1.4.0 @ abcd1234" pin), the CLI table/info (pin_label), the TUI-gateway
plugin row (catalog_version -> Desktop "Update to 1.4.0"), and the Desktop
catalog detail header (image).
Aligns the star ranking with the rule the skills index already follows:
GitHub is consulted only by the twice-daily skills-index.yml schedule, whose
artifact every docs deploy reuses. deploy-site.yml now runs
fetch-plugin-stars.py without --probe (reuse-only: artifact → live site copy →
disk → empty) and cannot call the API at all, so a same-day merge train adds
zero requests regardless of cache age.
The probe itself collapses from one REST call per repo (~26 today, growing
with the catalog) to a single GraphQL query with aliased repository fields,
so the scheduled run costs one request no matter how big the catalog gets. A
failed probe (rate limit, renamed repo, bad token) keeps the previous counts.
Catalog entries sort official → stars desc → name, both in browse shelves and
filtered grids, with a ★ pill on each card linking to the repo's stargazers.
Rate-limit discipline is the design constraint: the docs site deploys many
times a day and shares one GitHub App API budget with every other workflow
(tonight's merge train got rate-limited on unrelated uploads). So
website/scripts/fetch-plugin-stars.py first fetches the live site's own
plugin-stars.json (a CDN GET, not the API); if that cache is under 24h old it
is reused verbatim and GitHub is never called. Only a stale cache triggers one
GET /repos/{owner}/{repo} per unique catalog repo, and a 403/429 mid-run keeps
the previous counts instead of zeroing them. extract-plugins.py merges the
cache into plugins.json (`stars`) and plugins-meta.json (`starsFetchedAt`), and
the page footnote says when the ranking was last refreshed.
Teknium's call: most community submissions are Desktop panes, so an entry
without a category lands on the Desktop shelf; "other" becomes "general" for
plugins that genuinely span areas. Shelf order puts Desktop first. The six
entries merged today (pets-all, newswire, auto-titler, live-voice,
metamask-wallet, web-octen) get explicit categories.
The catalog page was one undifferentiated grid filtered only by tier, so a
memory provider sat between two Desktop panes. Entries now carry an optional
``category`` (memory | desktop | platform | web | tools | voice | automation |
models | other, default other) that the loader, the admission validator and
the site extractor all understand.
/docs/plugins renders one shelf per category in browse mode, a category pill
row under the tier pills, a clickable category chip on every card, and a
results bar (active category, count, clear) when a filter or search flattens
the view. ``hermes plugins catalog`` gains a Category column and groups by it.
All 18 shipped entries are categorised. Unknown categories fail admission
(same contract as tier) so a typo cannot create a phantom shelf.
A bare substring check let a short alias (/q, /v, /bg, /hb) count as
documented whenever a longer command that starts with the same letters
appears anywhere in the doc, so direction 1 of the contract could pass
while the alias's own command had no row.
Two-direction contract test (tests/website/test_slash_commands_doc_parity.py):
every CommandDef must be documented under its name or an alias, and every
doc table row must resolve to a registered command. Ported from IronClaw's
doc-fact contract tests (nearai/ironclaw#7378), adapted from their clap
--help parser to our COMMAND_REGISTRY single source of truth.
Real drift it caught, fixed here: /loop (alias /proactive) shipped with a
full feature page (user-guide/features/loops.md) and CLI+gateway handlers
but never got a row in the slash-commands reference. Added to both the CLI
Session table and the messaging table, plus the both-surfaces note.
Activation reaches plugin discovery before the application dependencies
exist. Give PM its own locked Python project and runtime so it can install
or repair the application without importing that dependency tree.
Keep PM outside the application workspace. A shared uv workspace resolves
the application graph and cannot provide this isolation. Route mutations
through an isolated worker and preserve transaction callbacks, cancellation,
custom package registrations, and correlated receipts.
Use the same runtime builder for source installs and packaged payloads.
Keep offline wheelhouse support in that builder. Nix builds the independent
PM lock as a separate derivation. Refuse lazy-disabled bootstrap before
installing tools or dependencies.
Move first-party YAML readers and writers to ruamel. Keep the application
lock's transitive PyYAML requirements for third-party packages.
Verification:
- Focused canonical Python suite: 177 passed, 1 host-gated skip.
- Electron backend probes: 12 passed. Electron typecheck passed.
- Both uv locks, scoped lint, Bash syntax, and whitespace checks passed.
- Cold activation, corrupt-app repair, offline staging, and relocation ran.
- Built and exercised the Nix PM runtime and standalone YAML merge script.
Six broader caller test files retain the same 24 failing test IDs as an
archive of HEAD. The existing real-home guard blocks those tests before
they can exercise the affected paths. No full-suite pass is claimed.
Native Windows signing and full Bionic package execution remain unverified.
Keep upstream's reviewed catalog as the only plugin name index.
Catalog pins and custom update sources share staged PM validation.
Publish code and dependencies with recovery after process death.
Reject a concurrent enablement change before publishing disabled code.
Use the manifest loader's supported version in the installer. Keep
probe cooldowns for timeouts, not TLS failures that a CA change fixes.
Preserve the backup, uninstall, browser and memory-provider repairs.
Verified with the canonical runner on native Windows ARM64, real Git
repositories, local TLS endpoints and UV dependency generations.
Desktop catalog tests and both TypeScript checks pass. The full suite
and native release builds were not run. No remote push.
538+570+296+216 lines of change-detectors → 4 files of contract tests:
seed catalog valid, bad entries skipped, kill list name-or-repo, live
fallback+union; real-git pinned install + sidecar + re-pin; kill list
blocks CLI/dashboard/TUI with only the CLI bypass; dashboard merge via
sidecar; probe get_config default; extractor live document.
Gate the POSIX-only and symlink-only tests with the linux_only and
require_symlinks markers. Fix the real cross-platform bugs:
- file_operations: use the translate_path flag, send snippets as base64, and
use sys.executable (the MS Store python3 stub and the list2cmdline
backslash collapse both broke snippets)
- approval: treat backslash as a Windows path separator, not an escape
- registry, browser_registry, secret_sources, plugins: normalize scope-key case
- checkpoint_manager, plugins_cmd: clear read-only bits before delete
- deadline: make MAX_SAFE_TIMEOUT_S fit the Windows limit
- image_routing, acp, cua_backend, daytona: fix Windows and POSIX paths
- hermes_state: match backslash in the retag LIKE clause
- kanban, disk-cleanup: match drive paths and split command arguments
- scripts: emit host separators through as_posix
207 test files are gated or isolated.
The section list decided membership as well as order, so it drifted as the
docs grew: 109 of 204 pages were absent from the index every LLM reads to
learn what Hermes does — Bot Mode, the desktop app, computer use, web search,
skins, Mixture of Agents, and 22 messaging platforms among them.
Enumerate the docs tree instead. SECTIONS now curates only which pages lead a
section; anything it does not name is absorbed under its path, and a page
matching no section lands in "More" rather than falling out. This also picks
up the three .mdx pages the .md-only glob never saw, points section landing
pages at the directory URL Docusaurus actually serves, and drops a curated row
still aimed at a guide moved to developer-guide/plugins in #59613.
Tests hold both directions against the filesystem rather than the enumerator,
so a page cannot go missing and a link cannot point at a page that moved.
Two fixes for the Skills Hub "View source" links on ClawHub skills:
1. Source URL generation was missing the required {owner} segment —
https://clawhub.ai/skills/{slug} → 404. Correct format is
https://clawhub.ai/{owner}/skills/{slug}. When the owner handle is
unavailable, source_url is now "" (card omits the button) instead of
emitting a broken link.
2. _fetch_owner_handle() previously delegated to _get_json() which
returned None on any non-200 response with no retry. Under HTTP 429
rate-limiting the "50 consecutive failures" safety rail in
enrich_owners() fired immediately — the documented claim "Respects
HTTP 429 rate-limit responses with exponential backoff" was not
actually implemented. Now has its own retry loop: 3 attempts, honours
Retry-After on 429, exponential backoff on 5xx/transport errors, no
retry on 4xx.
Changes:
- tools/skills_hub.py: _coerce_skill_payload carries owner from top-level
response; inspect() captures owner from detail API; _fetch_owner_handle()
added with bounded retry/backoff; enrich_owners() batch method with
safety rails (30 workers, early termination at 50 consecutive failures).
- website/scripts/extract-skills.py: _source_url() reads extra["owner"]
for ClawHub.
- scripts/build_skills_index.py: batch enrichment step after crawling.
- tests: 35 URL/enrichment tests + 7 retry tests (42 total).
Signed-off-by: dongjiang <dongjiang1989@126.com>
Skills discovery surfaced ~136 of 88k skills in the CLI and gave community
skills no clickable source on the docs page. Three coupled fixes:
CLI browse:
- hermes skills browse capped at 50 because the per-source limit dict had no
'hermes-index' key — when the centralized index is available the router
skips external APIs and serves only the index, so the default-50 fallthrough
silently truncated the whole hub. Add hermes-index: 5000. Browse now loads
5367 (269 pages) instead of 136.
- Add an Identifier column + install/inspect hint to the browse table so users
can act on what they see without a second 'search'.
- Route the TUI browse_skills() helper through parallel_search_sources so it
inherits the same index-aware source-skip (was double-counting); expose
identifier in its output.
Docs Skills Hub page:
- Synthesize a sourceUrl for every community skill (github tree URL, clawhub /
skills.sh / lobehub / browse.sh detail pages), preferring the adapter's
explicit extra.detail_url/source_url/repo_url. Expanded cards now show
'View source' for community skills (was nothing) and keep 'View full
documentation' for built-in/optional. 99% coverage.
- Add a Copy button on the install command.
- Add a loading state instead of flashing '0 skills / No skills found' while
the 45MB catalog fetches.
Category cleanup:
- _guess_category fell back to tags[0] verbatim, producing ~430 junk one-off
categories (version strings, brand names: '0.10.7 Dev', 'Doramagic Crystal').
Now only curated buckets are accepted; unknowns fold into 'Other'. Widen the
tag->category map so common community tags route to real buckets. 430 -> 173
categories, top 20 all meaningful.
Tests: tests/website/test_extract_skills.py covers _source_url synthesis +
precedence and _guess_category curation (13 tests). All 27 skills-hub CLI
tests still pass. Docusaurus build verified; expanded cards confirmed in
browser for both community (View source) and built-in (View full docs).
Defensive: when the generator encounters a fenced code block containing
Unicode box-drawing characters, wrap it in `<!-- ascii-guard-ignore -->`
markers so the docs-site-checks lint (which scans inside code fences)
can't reject the page for a skill's own diagram.
Plain bash/python code blocks stay uncluttered — only blocks with box
chars get wrapped. Skill authors no longer have to remember to add the
ignore markers in every SKILL.md with ASCII art.
Fixes#15305.