Holographic has a standalone third-party maintainer (itpartypattaya) and a
reviewed catalog entry, so it leaves core now rather than on Oct 15. Restores
9e827685f2 exactly: reverts the revert (435b7bd3a7) and its health waivers
(b9ecc727e9). byterover and retaindb have no maintainer and stay bundled until
the announced date.
This reverts commit 9e827685f2 (#136424). It landed before the announced
October 15, 2026 date for holographic leaving core; it re-lands on or after
that date. Restores plugins/memory/holographic and its tests/docs.
Holographic now ships from itpartypattaya/hermes-plugin-holographic through the
plugin catalog (plugin-catalog/holographic.yaml, v0.6.1 @ 46b6f908, a fork of
Nous's archived handoff copy). It keeps the provider name, memory_store.db and
plugins.hermes-memory-store, and memory_provider_migration already lists
holographic in _LEFT_CORE. Deleting the in-tree copy and its tests lets
`hermes update` and agent start install the catalog plugin for every home that
has memory.provider: holographic.
Host code that still releases memory-store handles on profile unserve/delete
resolves the store through import_provider_module, the same way profiles.py
already does, and no longer imports plugins.memory.holographic. Tests that used
holographic as "a bundled provider" now use byterover. The journal-mode
contract drops the holographic row because the plugin owns that store now.
People who installed the archived Nous repo by URL have no catalog record, so
`hermes plugins update` kept pulling the archive. read_catalog_sidecar now
adopts an install from that repo as the holographic catalog entry, and update
re-pins it to the maintained repo at the reviewed sha. That catalog re-pin
also needed the reviewed-pin scan trust that catalog installs already get. Without
it, update_plugin scanned the reviewed tree as an unreviewed community source
and blocked on caution findings, and that blocked every catalog re-pin of
holographic (the reviewed 46b6f908 tree has 14 caution findings).
The spotify toolset (plugins/spotify) and its loopback-PKCE login (hermes_cli/auth_spotify.py)
now live in NousResearch/hermes-spotify, installed from the plugin catalog. Same pattern as the
Home Assistant move (fb9fc8a3dd): a LEFT_CORE row installs the plugin for every home that was
using it (a Spotify login in auth.json, or the spotify toolset in platform_toolsets) on
`hermes update` / first start; the toolset name, tool names, schemas, env vars and stored login
(providers.spotify) are unchanged, so nobody re-authenticates.
The one user-visible change is the login command: plugins cannot extend `hermes auth`, so the
plugin registers `hermes spotify login|status|logout`. LeftCoreFeature gains a `cli` field and
moved_command_hint() turns `hermes auth [status|logout] spotify`, and `hermes spotify` while the
plugin is not loaded, into a line naming the new command and the install/enable step for this
home. The dashboard's "needs login" hint names the new command too.
Core drops the Spotify auth constants/dispatch, the CONFIGURABLE_TOOLSETS row and post-setup
step, the core `spotify` entries in the shared-metrics toolset enums, its tests, and the docs
(en + zh-Hans) now point at `hermes plugins install spotify` / `hermes spotify login`.
Conflicts, all in apps/desktop/src/i18n:
- de/en/es/fr/zh.ts, types.ts: import blocks only. Main added the
*_projects section split, this branch the *_onboarding one; kept both.
- en_shared_metrics, zh_shared_metrics, ar_common, zh-hant (usage-stats
consent copy): kept this branch's rewritten copy. Main's only change on
its side since the merge base was a prettier reflow of
collectedReliability (2943ee6f19), which the branch's rewrite replaces;
types_shared_metrics on HEAD already matches the branch's keys.
Follow-through: main's Undo-hide toast (sidebar.projects.hiddenFromSidebar /
undoHide) arrived without ar or zh-hant strings, which this branch's
overlay-completeness test rejects. Added both to ar_chrome / zh-hant_chrome
and regenerated locales/_keys.desktop.json.
Follow-up to the review of this PR:
- The startup pruner returned early (no .worktrees/, or no tree past 24h) before the
reclaim ran, so a tree removed by hand kept its state until some other tree aged out.
_prune_stale_worktrees now runs the reclaim in a finally around the prune phases.
- A missing recorded path is not proof of deletion: a container sharing the data root
(-v ~/.hermes:/opt/data) cannot see the host's checkout, and gc there deleted the
host's live dependency environment. A state is an orphan only when its checkout sat
under the data root that owns installs/, or directly in a .worktrees/ dir that still
exists.
- PM runtime generation leases (pm-runtime/generations/*/.leases) now count as held,
so a worker still running from a deleted checkout's runtime keeps it.
Every checkout that boots Hermes commits its own dependency state under
<home>/installs/<sha256(path)[:16]>/ (venv, test venv, PM runtime; ~200 MB
each) and nothing removed it when the checkout went away. On a host that
runs `hermes -w` campaigns the directory held 499 entries / 92 GB, of which
402 (80 GB) belonged to worktrees and scratch clones that no longer exist.
The state dir already records its checkout in inputs/.project-root. A new
pm/install_states.py lists entries whose recorded checkout is gone and
removes them unless something could still be reading them (install lock
taken, or a generation lease held). `pm gc` runs it and reports the count;
the startup worktree pruner runs it after its own reaping so a removed
worktree's state follows the tree instead of outliving it.
Agent Plugins v1 has no trust field, and Hermes's portable mcp.json reader
accepts only type/url/headers for a remote server, so a package server always
ran at the default trust: full. A package whose tools trade, spend or send
(senpi resolving trade approvals on a user's Hyperliquid account) could only
ask the model to check with the user; Hermes itself never prompted.
plugin.json's Hermes extension now accepts
extensions.com.nousresearch.hermes.servers.<name>.trust: untrusted
which is applied to that mcp.json server exactly like a config.yaml
'trust: untrusted': every write-capable call asks first and fails closed
when unattended. 'full' (the default) is accepted and changes nothing, any
other value refuses the package, so a package can narrow access but never
widen it, and a config.yaml server with the same name still replaces the
package's entry. 'hermes plugins validate' lists the gated servers.
OpenViking now ships from Volcengine's own repo through the plugin catalog
(plugin-catalog/openviking.yaml, pinned in #131267). Delete the in-tree copy
and its tests; memory_provider_migration already lists openviking in
_LEFT_CORE, so homes configured for it get the catalog plugin installed on
hermes update / first agent start.
The catalog pin starts openviking-server with the full Hermes environment
(the bundled copy filtered it). The docs now say so and point at
volcengine/OpenViking#5553; start openviking-server yourself to avoid it.
Slack caps an app at 50 slash commands and main fills all 50. 9c4eecb53d
made room for /initiate-setup by demoting /commands to /hermes commands.
Setup is the rarer command on Slack (the first-message pointer to it only
ships in the Store desktop app), so it takes the /hermes-only slot instead
and /commands is native again.
The first-contact note spells the command per platform from one table:
/initiate_setup on Telegram, /hermes initiate-setup on Slack.
Docs: the onboarding config section and the /initiate-setup row describe
the profile offer as the default and the setup pointer as the Store app's.
The name was in the /initiate-setup facts block and in the scripted name card's saved arguments, so it left the machine even when the user typed another name or skipped the card. facts() drops it, and setup_choose adds the suggested row itself after the model's call is saved, the way it fills the tour and fork rows. The result's label carries the name when the user picks it.
Brings the onboarding branch up to main 0c2ff854a5. Where a Wave A PR
already landed a reviewed version of a piece of this branch (free tier
#134049, keyless web #134048, app resolver #134050, bundle and uninstall
#134051, catalog card and toolsets #134052, built-in tour #134054, stdin
guard #134055, retired key #126146), main's version wins. The PM long-path
files also take main's version: that work lands through #130430.
Kept from the branch on top of main:
- the setup and start_chat toolsets, declared with main's platforms key
(desktop only), and the setup_choose server request contract;
- the setup-profile marker file (.setup-profile.json) instead of the
profile.yaml role, as c3f85bc54f decided;
- the first-contact note's message argument beside main's internal flag;
- the setup-profile gate on the shared-metrics consent dialog;
- window sizing (window-growth) in place of chat-onboarding-window.
Desktop i18n follows main's per-section sibling files. The onboarding
strings go into them (modelMenu.localSetup, handoffTour.localTitle and
localText) and setupChoose/startChat stay in en.ts. guidedGreeting is
gone. The shared-metrics copy uses the branch's rewrite in English, with
the pre-opt-in sentence corrected for the fresh-install note
(d3674513c8); locales keep main's translations, with installId and
consentWindow folded into the new sending key.
The built-in tour reads local-model eligibility from the local-setup
offer store, the one place that rule lives on this branch. The gateway
contract and the onboarding skill docs pages are regenerated.
tirith ran `tirith check` on every terminal command, default-on with fail-open, as a
pinned PM binary auto-downloaded at startup. Measured against the shipped 0.4.2 binary
it caught 14/16 attack commands but flagged 12/24 everyday agent commands
(`echo $((6*7))`, `"$VAR/x.sh"`, `gh api | python3`, LAN curls, `.dev` hosts,
`pip install` via analysis_incomplete), and in cron/-q deny mode each of those is a
hard block. It only ever saw terminal strings (execute_code, writes and the browser
bypass it), fail-open meant no protection wherever the binary could not install
(Windows, Termux, x64 musl), and it produced a steady issue/PR stream (fail_open
semantics four times, install loops filling disks, a SIGSEGV hang, session-approval
scoping, third-party promo text on approval cards).
Removed: tools/tirith_security.py, the tirith branches of the command guard (the
is_tirith tuple, session-only "always" downgrade, import-error/fail-open paths,
unattended tirith pass), the CLI and gateway startup installers, the PM package and
lock pins, the nix runtime dep, security.tirith_* defaults, the CLI warning string
and three tirith tips (gaps refilled from the tail so translations survive), plus
the gateway's "no automated assessor" warning that only existed to point at tirith.
Config v50 drops security.tirith_* and enables nothing in its place. Hermes ships
no tirith plugin; if tirith upstream publishes one, it goes through normal catalog
admission under their name.
tools-reference.md: gui_tour row and Tours section describe bare start, preset quick|full, and custom tours.
tour_tool.py: preset together with steps is an error instead of being ignored.
test_tour_tool.py: through the registered handler, an invalid preset and preset+steps are rejected and a valid preset reaches the request.
Toolsets that exist only for one kind of session now say so on their spec:
`platforms=frozenset({"desktop"})`. `TOOLSET_SESSION_PLATFORMS` is derived
from the specs that carry the key, `all` / `*` / no selection skip those
toolsets, and `toolsets.agent_tool_drops(agent)` removes their tools from any
agent whose own session platform is not listed. `agent_init._load_tools` and
`tools/mcp_tool_agent.py` (MCP refresh and prefix restore) call that one
helper. `tool_search._DIRECT_SURFACE_TOOLSETS` is derived from
`CLIENT_SURFACE_TOOLSETS` and `TOOLSET_SESSION_PLATFORMS`.
This replaces the profile-role gate: `profile_role_toolsets`, the role code in
`_with_session_toolsets` and the explicit-pin branch, the strip step in
`_select_tool_names`, and main's `setup` toolset (whose only tool moves out)
are deleted. `manage_catalog` moves into a new `catalog` toolset, which the
desktop client folds into every desktop chat like `project` and `desktop_ui`.
It is deferred behind tool_search by default (`config_defaults` defer list).
The shared-metrics schemas' toolset enum follows (`setup` out, `catalog` in).
The install card works outside the setup chat (#129650, with review fixes):
- Parallel installs: `_set_plugin_enabled` holds a per-home lock around the
read-then-admit section, so rows enabled at once no longer refuse each other.
- A catalog plugin already on disk and not enabled is enabled in place, so Try
again after a refused enable can finish.
- A failed catalog row counts as resolved (`contract.resolves`), so it no longer
holds the turn to the 300 s deadline. The snapshot carries `resolved`, and the
card reads it instead of a hand copy of the backend's rule.
- Each card draws its own operation by op id, because models reuse call ids.
- The row carries the approved non-secret choices (`approved`: force, enable,
ref) and the chosen profile, so Try again after the card settled repeats them.
- Install phases are ids (`InstallPhase`, wire `CatalogInstallPhase`) and the
installed-row notes are fields (`enabled`, `missing_env`, `server_errors`,
`already_installed`, `requires_hermes`); the card words them in English
strings (translations follow later). Failure reasons stay raw text, and
`detail` keeps the English notes for the model.
- Installs and search target the calling chat's own home, carried as a path;
the row's profile name is only a label, with no `'default'` fallback in
Python or TypeScript.
- `dashboard_install_plugin` is split into `_resolve_source`, `_place_tree` and
`_enable_placed`.
Desktop English strings for the card move to `en_catalog_install.ts` /
`types_catalog_install.ts`, which offsets the growth of `en.ts` and `types.ts`.
A deployment reported that write_file -> read_file (full) -> patch ->
write_file refuses the last write with stale_write_blocked and "exists
but this task has not seen its full current content", in 50 of 50 trials.
No other writer took part. When the model does not reread before the turn
ends, the refusal surfaces as the "File-mutation verifier: N file edit(s)
FAILED" footer.
Mechanism. The write_file baseline is an exact file version: (st_dev,
st_ino, st_size, st_mtime_ns, st_ctime_ns, sha256)
(tools/file_tools_read_tracking.py _file_version, _mark_full_write_baseline,
_has_full_write_baseline). write_file_tool marks a fresh baseline from the
bytes it wrote. patch_tool only ran _note_edited and
_reset_patch_failures, so the task's own patch changed the version and
left the baseline it held stale. _stale_overwrite_blocker then treated the
task like an external writer.
Invariant: after a task's own successful patch, the task may replace the
file whole exactly when it held a current full baseline for the bytes the
patch read and the bytes the patch wrote are what is on disk.
Lineage. a8af57fcd2 added the guard. dddefaefae counted paged, extracted
and post-compaction reads. e636aedebf kept a baseline across narrower
rereads of unchanged bytes and bound it to the sha256. That is the change
that made this sequence refuse (its parent lets the write through). Own
patches are the missing case of e636aedebf's principle that unchanged
knowledge survives.
Shape:
- Under the per-path locks, before the patch reads, patch_tool records
the sha256 of each path whose baseline still matches disk
(_known_full_content_sha256).
- read_file_raw and patch_replace report the sha256 of the exact bytes
they read, BOM included. Each file write records (path, read sha256,
written sha256) in PatchResult._writes for replace, V4A Update and V4A
Add.
- _carry_full_write_baselines marks the baseline only when the read
sha256 equals the known one (or the patch created the file). The mark
goes through _mark_full_write_baseline with the written sha256 as
expected_sha256. A writer that lands before the patch reads leaves a read
sha256 the task never knew. One that lands after the patch writes fails
the expected-sha check. Neither case gets a baseline. Several operations
on one file in one V4A patch chain in order.
- A blind patch (no baseline, or a partial view) is noted per task in
blind_patches. While disk still holds exactly those bytes, the refusal
says "Your patch changed this file after a partial view. Read the
current file in full before a whole-file overwrite, or continue with
targeted patches." Sibling, external and never-read refusals keep their
wording.
Remote backends: the baseline logic already stats the host path. On a
sandbox backend (non-LocalEnvironment env, verified with the
remote-binary-guard transport double), read_file records no baseline,
and _stale_overwrite_blocker lets the write through because the host path
does not exist. The carry hashes the same host path, so it records
nothing there. Paths the backend rewrites (translated mounts) do not match
the resolved host path and are skipped. Behaviour on those backends is
byte-identical to before.
Hint contradiction: after three failed replace patches, option (3) said
"use write_file to replace the entire file", which is the call the guard
refuses after a patch. It now says "re-read the file in full, then use
write_file to replace it".
Out of scope: enforcing recovery from a stale_write_blocked refusal
before a turn ends. The stale_write_blocked receipt and the file-mutation
footer are unchanged.
plugin-catalog/mem0.yaml (#131267) points at mem0ai/mem0's own
integrations/hermes-plugin-mem0 at a reviewed pin. The entry stays dormant
while a bundled copy exists (bundled providers win in find_provider_dir), so
deleting the in-tree copy is what lets hermes_cli/memory_provider_migration.py
install it for every home whose memory.provider is "mem0". The vendor copy
keeps the provider name, mem0.json, the MEM0_* keys and the setup wizard.
Also dropped: the hermes-agent[mem0] extra, its win-arm64 platform row and
exclude-newer row, the pm/extras.py anchor (uv lock removes only mem0ai,
posthog, qdrant-client and sqlalchemy), the mem0-specific doctor check (the
generic check now names `hermes plugins install mem0`), and the tests that
imported plugins.memory.mem0. Prose that lists the bundled set now names mem0
as a catalog provider. MEM0_* env hints and redaction stay: the catalog copy
still reads them.
Plugins can now call ctx.register_automation_blueprint(key, *, title,
description, schedule_template, prompt_template, category, slots,
deliver_default, skills, tags). The fields mirror the built-in
AutomationBlueprint, and slots are BlueprintSlot field dicts, so the call
also crosses the plugin-host boundary.
- One lookup, cron.blueprint_catalog.list_blueprints() / get_blueprint(),
merges the built-ins with the active profile's plugin blueprints at call
time. /blueprint (CLI/TUI/messengers), /api/cron/blueprints and the
instantiate route all use it. Each dashboard route resolves inside the
?profile= scope, off the event loop.
- Keys are namespaced <plugin>:<key>, so a plugin cannot shadow a built-in.
A ':' in the key, a duplicate key, an unknown placeholder, a bad slot type
or a schedule that does not parse is warned and ignored at registration.
- Catalog entries carry source/plugin. Desktop fetches the catalog for the
profile it creates jobs in, keyed per profile, and shows the plugin name
on the blueprint row and in the "Start from" select. The dashboard shows
a plugin badge.
- A created job is an ordinary cron job with the prompt and schedule already
filled in, so it keeps running if the plugin is later disabled.
- The ctx content registrars (register_skill, now joined by
register_automation_blueprint) move to hermes_cli/plugins_content.py.
The dashboard's blueprint types move to web/src/lib/automation-blueprints.ts.
Closes#132373. Requested by @enckequity.
The root file is project context and was 38.7k chars, past the context-file budget
(20k floor, 30,720 on a 128k window), so every session lost its middle: routing table,
testing rules, code-shape rules. It now keeps the two invariants, a condensed rubric
(every rule kept), environment, structure, one-line rules that apply everywhere and the
routing table (11.8k).
Moved verbatim to where they load: Testing -> new tests/AGENTS.md; dependency pinning and
PM ownership -> new pm/AGENTS.md; machine facts -> new hermes_platform/AGENTS.md;
session-scoped tool availability -> tools/AGENTS.md; the full rubric with its real-close
examples -> developer-guide/contributing.md. Pointers in lint, checker messages, docs
and test comments name the new homes.
The guide still told users to "leave authserv_id unset" when their
server's host varies, which now drops every message that needs an
authenticated From:. State that the pin is required, how to find it
(the token before the first ';' of the topmost Authentication-Results
header your server writes), what to do when it seems to vary (RFC 8601
authserv-id names the administrative domain, so configure a stable one
and pin it), and that the only alternative is the explicit
require_authenticated_sender: false / EMAIL_TRUST_FROM_HEADER=true
opt-out. Mirror the reference tables, plugin.yaml prompt and zh-Hans
translation.
Co-authored-by: JiuYue0820 <287222957+FirmaSpring@users.noreply.github.com>
Trust only the topmost Authentication-Results field from the explicitly configured receiving MTA. Fail closed for allowlisted senders when no exact pin is configured, preventing injected or lower matching headers from authorizing a sender.
(cherry picked from commit e013fda3facc005e8f4d5f81e20c2d21aee698df)
A named export still shipped credential stores the list did not name, most with the secret intact:
home/ (the subprocess HOME, where git, ssh, gh, npm and skill CLIs such as xurl keep credentials;
.git-credentials and private keys have no suffix the scrub edits), weixin/accounts/ (WeChat bot and
context tokens), proxy/ (iron-proxy CA key and proxy tokens), chrome-debug/ (the /browser connect
Chrome profile), .copilot_jwt.json and runtime/photon-sidecar.json. They are on the list now.
The recovery copies (backups/, state-snapshots/, auth.json.*, .env.bak*, config.yaml writer copies)
move into profile_path_is_private, so a distribution can no longer plant a state snapshot or an
auth.json.corrupt either; before, only the export dropped them. config.yaml copies match the writers'
formats, so a hand-named config.yaml.bak-my-note (user-owned per config_backups) is exported again,
and the export scrub now covers config.yaml.* names so its secrets are redacted like config.yaml's.
The three invariant tests become two: the export test runs the real recovery writers and checks
case-folded names; the distribution test covers plant and replace for the legacy and
distribution_owned layouts.
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
(cherry picked from commit 2bd63066f48798a849394df131860ed3140d1597)
Remove the "Source channels and install identity" section from Updating &
Uninstalling, and stop documenting stable/canary as selectable for source
installs. `main` is the only valid source channel right now, so the
updating, CLI reference, and stable-releases pages say that instead.
`activate` is bash syntax, so fish users had no way to take on the dev
environment. `activate.fish` does what `activate` does: snapshots the keys it
will change, applies the composed env, defines a `hermes` function that
refuses outside the worktree, adds the prompt prefix, and defines
`deactivate`. It shells out to the same bash library, so composition is not
reimplemented.
`pm.environments --format fish` emits the env as `set -gx` statements.
fish's read-only names (`PWD`, `SHLVL`, `_`, ...) are skipped because
`set -gx PWD` aborts the script, and values named `*PATH` stay lists.
`hermes_compose_env` takes the dialect (`sh` or `fish`) as its second argument.
`activate` doubled as the thing scripts sourced non-interactively, so it
carried `--` argument guards and every caller had to tolerate its prompt and
`hermes` function. Composition (setup sync, bootstrap Python, pm env) now
lives in scripts/_activation.sh, and there are two consumers:
- `activate`: interactive, reversible (`deactivate`).
- new `scripts/run-in-hermes-env CMD...`: applies the env to one process and
execs into it. Replaces `scripts/_hermes-python`; every Python script's
shebang and `scripts/run_tests.sh` (which re-executes itself under it,
however it was launched) use it.
`pm.environments --format sh` emits the env as shell exports, so the inline
JSON-to-export Python is gone from `activate`. An install that did not build
the test environment now reads as stale for every consumer.
Tests are split along the same seams: activation support fixtures, bash/ps1
activation, env-script quoting, and the runner's staleness.
memory-providers.md gets a "Plugin catalog" note on the Supermemory section,
`hermes plugins install supermemory` in its setup, and a "Migrating from
bundled Supermemory" section that mirrors the Hindsight one. memory.md,
overview.md, integrations/index.md, cli-commands.md and the developer guide
no longer list Supermemory as bundled.
MDX reads a bare <machine> as an unclosed JSX tag. #129646 left two of them in prose (onboarding-recommendations.md, the setup_choose row in tools-reference.md), and the Docusaurus build fails on onboarding/next. Write them as <machine>, the form the generated skill pages use.
`hermes status` now prints the one-screen summary (model, provider, connected
providers, gateway, platforms, jobs); `--full` (alias `--all`, which never
changed the output) and `--deep` print every section as before. Hermes
Console keeps the full report since it accepts no flags.
The summary reads the canonical sources instead of re-deriving them:
- Providers: list_authenticated_providers, the list the /model picker offers
(cached catalogs only, no custom-endpoint probes), so it honours
model_catalog.excluded_providers, pools and plugin providers for free.
- Platforms: GatewayConfig.get_connected_platforms, the gateway's own
decision about which adapters to start (it also rejects placeholder tokens).
The full Messaging Platforms section now uses the same verdict for plugin
rows. It called check_fn, a passive "SDK importable" probe that is True for
every bundled plugin, so a fresh install listed Discord, Slack, Matrix,
Teams, WhatsApp and ~9 more as "configured" with no credentials.
Compact, secret-free summary of the component status: model, provider,
connected providers, gateway, messaging platforms, and scheduled jobs.
The provider list is registry-driven (PROVIDER_REGISTRY API-key rows,
refresh-free OAuth getters, plugin-mirrored OAuth providers, the
OpenRouter rung, configured custom endpoints), so new providers and
endpoints surface without touching the short renderer. Plugin-platform
configuredness prefers the is_connected hook over the deps probe (same
rule as hermes setup and the #102183 fix); every lookup is fail-soft,
so a failing probe degrades one row, never the command.
Full `hermes status` output and the console_engine call shape are
unchanged. Docs: website/docs/reference/cli-commands.md. Tests: 8 new
cases in tests/hermes_cli/test_status.py (25/25 pass via run_tests.sh).
One Skip broke a tester's whole onboarding. The provider named every
tool call "call_0", and the desktop bound each setup card to its
request by call id alone. After the apps card was skipped, every later
card with "call_0" showed that old "Skipped" answer instead of its
picker. Composer text was also saved as a pick, so "hey" became a
plugin and a complaint became the layout.
- The agent renames a tool call whose id is already used earlier in
the session (call_0 -> call_0_d2), before the call is stored or run.
Ids already in history never change, so the prompt cache holds.
- The card's request binding is keyed by session, message and call id.
- Composer text on a card with fixed rows counts as a pick only when it
names a row by id or label. Otherwise setup_choose returns
outcome "typed" with the words in `said`, records no card facts, and
tells the model to reply and send the card again. The settled card
shows the typed words.
Vercel's WAF denies some residential IP ranges for the whole docs domain,
which leaves the Bot Mode Skills Hub picker on a 403 block page with no
alternate origin (#118203). Probe the primary picker URL before each
browse session and fall back to the equivalent GitHub Pages deployment
when it is unreachable, refuses us, or hangs past an 8s deadline; picker
installs are accepted from either trusted origin but still gated on our
own frame's contentWindow. Documented as an FAQ troubleshooting entry.
Re-lands the surviving half of #118206 (its Capabilities picker target
was since deleted by the native catalog).
Fixes#118203
Co-authored-by: KoNit-K <konit.block@protonmail.com>
* feat(onboarding): the first task chat delivers a small result in minutes
Live runs on an RTX Spark showed the task chat surveying the whole machine,
asking a plan question, and installing the picked plugins 11.5 minutes after
setup. Its suggestions ignored the picks and the scan, and the handoff text
said "Call me Sid." and "Start from this; look at my computer again...".
- New optional skill first-task. start_chat appends it to the task chat's
first message, after the ask and the "What setup learned" block, with a
JSON block of the connector and plugin ids picked in setup. No system prompt
changes. The history projection cuts the message at its marker, so no
surface shows the skill. Its rules: connect and install the picks first,
work visible in 1 minute and a finished result in 5, big asks cut to a
first slice, three options only for a vague ask, machine setup as a short
card interview.
- setup_choose records the picked ids and the fork pick. The fork card is
filled when shown: two first tasks from the picks and the scan (a daily
brief from the picked apps, "Install a few apps for this Spark", a small
page), then "I have something in mind", "Help me set up this <machine>",
"Let's figure it out together". The machine-first "Something else" step,
"automate" and "skip" rows are gone; "figure" hands off at once.
- The handoff template is the ask only; no "Call me", no install or plan
paragraphs. start_chat drops the "Start from this" line.
- initiate-setup SKILL.md keeps only the setup beats (20.5 KB -> 10 KB).
- Desktop shows the "What setup learned" block folded under the first
message; the model still gets the full text.
* fix(setup): one unexpected shutdown reads as singular
* fix: setup cards tell the model the name the user saw, and an empty apps or plugins list shows no card
The accent card showed "Ultraviolet", but the result only carried "#8a2be2", so
the model guessed a CSS name ("BlueViolet, done."). The card now answers with the
label of each picked row next to its id, the backend passes it through for the
app-owned lists it cannot name itself (accent, theme, layout, connectors,
plugins), and the tool description tells the model to say the label.
A session with no Nous identity (own key, guest sign-up rate limited at boot)
got an apps card that said "This list isn't available right now". Now
setup_choose kind:"connectors" checks connectors_available() in the setup
session's own profile scope before any card: with no identity and the free tier
on, it makes one guest sign-up attempt through ensure_portal_identity (the mint
memo's cooldown still applies), and if connectors are still closed it returns
no_answer with a one-line notice and the next card, without showing a card. An
empty plugins list returns the same way. The skill says one line about the free
Nous account and moves on.
The renderer re-reads a cached "unavailable" connector catalog when the card
mounts, so a guest minted by the card is not hidden behind the prefetch taken at
the start of setup.
* fix: the setup intro holds the thread until its spring settles
The backend prelude writes the first assistant row and the name card while the
intro copy is still typing. The copy's cover uses the chat surface colour, which
is transparent under glass, so the card showed through mid-motion. While the copy
plays ($introHoldsThread, true only for the 'playing' stage), the thread under it
is hidden; it shows when the spring settles, and at once with reduced motion
(the stage goes straight to 'landed'). The backend prelude is unchanged: its
tool call waits for the answer.
* feat(setup): the backend fills the tour and fork cards and preselects scanned apps
The welcome model fills only the required parameters of setup_choose, so
cards that needed options arrived as bare text boxes in play 4. options is
now required ([] means the app's list, or free text for a question).
Two new kinds, tour and fork, take their rows from the backend whatever
the model sends. The fork rows come from the facts the /initiate-setup turn
embedded, recorded next to the setup state; "Something else" on a
machine-first fork opens the rest in the same call. The connectors and
plugins cards start with the apps the scan saw in use, and Blender when it
is here, already picked. The result names a picked row (label), so the
settled card shows labels for rows that are not in the call's args.
* fix(setup): the setup skill answers honestly about the scan and always reaches a handoff
Card lines carry options and use the app-owned tour and fork kinds. When
asked what Hermes knows, the model answers from the facts and names the
scan instead of denying it. A skipped fork goes to a first-task card built
from the scan, a second skip offers one concrete task, and a card left for
an off-flow question is re-sent. Dead fork-building and fallback text is
cut, which keeps the skill under 25 KB.
* fix(setup): card facts per setup conversation; the backend steers each beat and carries the handoff
The saved card facts were one file per setup profile, so a second
/initiate-setup overwrote the first conversation's fork rows. They are now
keyed by the session id under the bound Hermes home, and setup_choose reads
its own session's block. A fork card with no recorded facts (skill loaded
through the inline-shell hook) collects them fresh.
setup_choose now returns `next` for the flow's following step (the next
card's exact arguments, the skip ladder from the fork on, a re-send of the
pending card after typed off-flow text) and, from the fork, `handoff`: the
message parts and the plan, cut to this machine, from templates/handoff.md.
That moves about 6 KB of handoff and plan text out of the first turn.
Rows the model sends for tour, fork and the new machine_use kind are
dropped before validation, so a malformed copy can no longer fail the card.
The connectors and plugins cards are always multi-select, so preselected
rows are never lost to a single-select confirm. The off-desktop voice rule
is back in the skill.
* docs: regenerate the initiate-setup skill page
* feat(initiate-setup): one "I noticed" line before the fork shapes the first tasks
* feat(setup): the backend plays the desktop opening (hello, name, accent) as real setup_choose calls
A desktop /initiate-setup turn whose history has no setup_choose call now runs a
scripted prelude before its first model call: the intro text with the name card,
then a line with the name and the accent card. Each step goes through the unchanged
run_tool_round (row persisted before the tool runs, interim text and tool events
emitted, tool hooks fired); the same turn then continues with the model, whose first
words start at the apps card. No system prompt change, no user row added, no API call
counted. CLI, TUI and the messaging gateway keep the model-only opening.
Also: the first-contact note never fires in the marked setup profile, and a stored
[/initiate-setup] row projects as /initiate-setup instead of the whole skill body.
* fix(setup): a relaunch mid-opening replays only the unanswered cards, with one recovery owner
The desktop intro sends /initiate-setup again when a relaunch cut the opening off. The prelude
used to skip as soon as the history held any setup_choose call, so the model had to ask the rest
(after the name answer it skipped the accent card). Now the prelude plays only the opening cards
with no answer, and does not repeat a line already in the chat. It matches lines by text because
a resumed history keeps an unanswered card's row but drops its call.
The same relaunch also scheduled the desktop auto-continue of the cut-off turn. That continuation
raced the app's resend: the resend became a redirect, its row lost display_kind hidden (a visible
/initiate-setup bubble after reload) and a "resumed interrupted turn" row appeared. While the
setup intro is unseen the app owns recovery of that turn, so auto-continue clears its marker.
Docs: the setup offer line never fires in the setup profile; the desktop app asks the opening
questions itself.
* fix: userscan tmux and zellij socket probes pass the /tmp literal check
The four lines read where tmux and zellij put their sockets (and, on macOS,
measure the user's own temp dir). The POSIX fallback for those directories
is /tmp, so the literal is the fact being probed, not a scratch path. Mark
each line so the blocking lint step in lint.yml passes.
* fix: setup-profile and start_chat RPCs no longer block the RPC reader
onboarding.ensure_setup_profile / ensure_setup_session / reset_setup_profile
can run create_profile (a skill-tree copy), state.db writes and the first
import of the setup scanner. session.start_chat creates a session and runs
prompt.submit. Inline on the reader thread they left session.interrupt and
approval.respond unread in the pipe, the failure the _LONG_HANDLERS comment
names. profiles.create was already on the list for the same reason.
* fix: userscan children run with stdin closed and the served profile's env
The /initiate-setup builder runs the user scan on a backend thread, so
every child the scan spawned inherited the JSON-RPC stdin pipe (#14036)
and the launch profile's os.environ. HostAccess.run, copy_locked and the
PowerShell batch now pass stdin=subprocess.DEVNULL, and every spawn takes
its env from HostAccess.child_env(). host_facts passes
served_profile_child_env() into the run; the standalone run.py keeps the
process env.
check_subprocess_stdin.py now scans optional-skills/: the backend loads
initiate-setup's scripts in-process. The nested-dir skip matches paths
relative to the context dir, so a skill's own scripts/ folder is in
scope. Three model-run optional-skill scripts it flagged get stdin closed.
* refactor: split the userscan spec modules over 2,000 lines by topic
Six spec modules under initiate-setup's userscan passed the 2,000-line
gate. Each keeps its shared helpers and first families; the rest move
verbatim into <stem>_<topic>.py siblings that import the helpers they
use from the base module:
apps_dev -> apps_dev_agents, apps_dev_devenv
browser_files -> browser_files_comms, browser_files_content
usage_gaming -> usage_gaming_games, usage_gaming_hardware
linux_system -> linux_system_security, linux_system_hardware
linux_apps -> linux_apps_dev, linux_apps_browser, linux_apps_files
darwin_apps -> darwin_apps_dev, darwin_apps_browser, darwin_apps_files
linux_system keeps health and usage: install_age reads usage's _wtmp and
usage reads health's _kdump_dirs. References through the ad./bf./ug.
aliases point at the module that now defines the name. specs/__init__
imports each sibling right after its base, so per-OS registration order
and the registry keys stay the same.
No behaviour change: `run.py --list` is byte-identical before and after,
and a temp-home scan under --os darwin/linux/windows gives the same fact
statuses, errors and insights.
* fix: the setup scan never patches process globals or sys.path in the backend
The /initiate-setup builder runs the user scan on a backend thread. It put
the scanner dir at sys.path[0] of the backend (so a top-level `run` or
`userscan` import anywhere resolved to the scanner), and it loaded
host.py's deny_reads_under (swaps builtins.open, io.open, sqlite3.connect
and subprocess.Popen) and override_env (rewrites os.environ HOME and
HERMES_HOME) into the backend's import graph.
- userscan/cli.py now holds override_env, the read guard, the aggregate
HostAccess and scan_accounts. Only run.py imports it; runner.run() no
longer retargets os.environ (overrides stay l0 data), and run.py applies
override_env around run() for --home and friends.
- The package uses relative imports, and host_facts loads it once per
process as initiate_setup_userscan, without touching sys.path.
Live: after an in-process scan sys.path, os.environ and the four patched
callables are unchanged and no top-level userscan module exists (before:
sys.path[0] was the scanner dir and userscan.host was importable).
`run.py --list` is unchanged; --all-users and --home give the same output.
* refactor: userscan runner runs each layer as a named phase
runner._run was one closure-heavy function at cyclomatic complexity 66.
Its state moves onto a small _Pass object whose run_l1 / run_l2 (with
_run_python_l2) / run_l3 methods each hold one layer, moved verbatim;
_run composes them and builds the output. Highest CC in the file is now
21 (run_l3).
No behaviour change: temp-home scans under --os darwin/linux/windows give
the same fact ids, statuses, methods, tiers, coverage, insights and
output keys as before.
* chore: name the setup intro flag writer, document gui_tour presets, trim media.ts
- hermes_cli/setup_profile.py::mark_seen becomes mark_intro_seen. It
writes the setup profile's intro state; agent/onboarding.py::mark_seen
flips a config.yaml hint flag, and tui_gateway uses both. The RPC name
onboarding.mark_seen is unchanged.
- tools-reference: gui_tour's `start` without `steps` runs the built-in
tour (`preset` quick or full, default full).
- apps/desktop/src/lib/media.ts: drop the blank line at EOF (git diff --check).
* docs: describe the setup chat's automatic machine scan
The scan runs without a consent step when the desktop app creates the
setup profile (the owner kept it automatic). The desktop user guide now
says where it runs (the backend's machine), what it reads (T1: counts and
categories; finer values are dropped before storage), what reaches the
model (the derived fact block in the first setup message, sent to the
setup chat's inference provider) and where the cache lives
(insights/profile.json in the setup profile's home, 0600, 24 h).
The skill's own docs page is generated from SKILL.md, which is the model's
prompt, so the user-facing text lives in the desktop guide.
* fix: overlapping setup-profile RPCs no longer race on the RPC pool
ensure_setup_profile, ensure_setup_session and reset_setup_profile moved off the reader thread, so two kickoffs could overlap: both saw no setup profile, and the losers failed on the half-made profile (5 of 6 barrier-started calls returned ENOENT), or both saw an empty setup chat and seeded it twice. One module lock restores the one-at-a-time order; the prescan stays outside it.
* fix: the Linux locale probe reads date formats in a child, not via setlocale
locale.user ran setlocale(LC_TIME) on the scan thread inside the backend. setlocale is process-wide and not thread-safe, so strftime in other session threads briefly used the user's LC_TIME. The probe now runs 'locale d_fmt t_fmt' under LC_ALL=<locale> through HostAccess.run (new env overlay on child_env), after checking 'locale -a', because locale prints C formats for a locale that is not installed.
* docs: link the setup scan from the /initiate-setup and onboarding references
The desktop guide's scan section now says which search-engine names are kept and that the scan runs in the CLI and the gateway too. The /initiate-setup row and the profile_build bullet link to it.
* fix(setup): manage_catalog leaves the setup chat; installs go to the caller's profile
- The setup profile disables the catalog toolset. The desktop session
folded it in as a client-surface toolset, so manage_catalog was still on
the setup chat's eager tool list.
- Delete default_target_profile: a catalog install and search target the
calling chat's own profile, the setup profile included.
- /initiate-setup still needs "the launch profile when run from the setup
home"; that moves to hermes_cli.setup_profile.primary_profile().
- The manage_catalog description and the reference docs drop the setup
chat exception.
* feat(setup): drop setup_choose intent; apps and plugins cards say they set up later
- setup_choose loses `intent` (the now / when needed / just note it choice)
in the tool, the wire contract and the cards. The result is
{outcome, picked}. Contracts regenerated.
- The apps and plugins pickers carry one footnote, "We'll set these up
when you start.", replacing the plugins-only "Nothing connects or
installs yet" note. Other locales translated; the desktop key list is
regenerated (it was also behind en.ts on appTour and sharedMetrics).
* fix(desktop): an install card stays drawn when a connect card follows it
The renderer keeps one connection operation per session. When a turn asks
twice (a manage_catalog install card, then a manage_connections connect
card), the second operation replaced the first in the store, and the
settled install card fell back to a plain tool row.
A settled operation that the next one replaces now moves to a store keyed
by its tool call, and the catalog and connector cards read their own row's
operation from there. Both cards stay stacked in the turn.
* fix(setup): setup_choose inline calls stop passing intent; /initiate-setup reads the launch home at call time
The inline executor still mapped intent into setup_choose_tool, which no
longer takes it, so every desktop setup_choose call raised TypeError.
primary_profile now gets server._launch_home() instead of the import-time
_hermes_home.
* fix(desktop): keep one card per kind: the latest install card and the latest connect card
The replaced-operation cache kept every settled operation by tool call, so
consecutive manage_connections calls drew one connect card each. It now
keeps, per session, only the settled operation of the other kind (install
vs connect) that the current one replaced. An install card and a connect
card stay stacked in either order, and a later call of the same kind still
takes over its card. clearConnectionRequest clears both caches, and the MCP
setup card reads the same per-row selector, so sessionConnectionRequest is
gone.
When start_chat runs from the marked setup profile's chat and opens the
new chat in another profile, the backend now writes completed_at and
intro: seen into the setup profile's onboarding marker. The write goes
through a new hermes_cli.setup_profile.mark_completed(), next to
mark_seen(), so onboarding.state reports completed_at unchanged.
Calls from any other profile write nothing. A call from the setup
profile into itself writes nothing. Rejected calls write nothing. A
reset of the setup profile still writes the fresh state, so it clears
completed_at.
The tools reference row for start_chat states the new effect.
Linear: NS-1018
The setup bot's two tools now render as native cards in the desktop chat.
setup_choose goes through the clarify card path instead of a second card:
the renderer answers the setup_choose server request, parks it in the
clarify store (so the needs-input dot, the notification, the composer's
type-to-answer skip and resume restore all cover it), and draws it with
the clarify card's shell, key handling and confirm bar. Kind question
uses the clarify question block. Accent, theme and layout reuse today's
onboarding swatches, the settings mode labels and the layout preview
cards, and apply the pick live. Connectors (read on the account owner,
since the setup profile has no manage_connections) and plugins use the
onboarding chips, with a now / later / save control per picked row when
intent is on. The answer returns {picked, intent} on P15's wire; Skip
returns {} (cancelled).
start_chat shows the new chat's title and profile with an Open button.
A live completion moves the user into the new chat only when the calling
chat is in the foreground; history and background completions never
move them. start_chat joins the backend lifecycle UI set so the card
shows with tool progress off.
Open decisions for review: accent and theme picks apply live only (the
accent override is not persisted, and the theme mode lands on the setup
profile); the theme card offers light, dark and system.
Slack sits at its 50-slash cap, so /initiate-setup pushed /usage out.
Demote /commands (a lookup /help also covers) to /hermes on Slack.
The TUI/desktop dispatch now sends display=/initiate-setup so the user
row is the command, not the skill body. Stub the first-contact note in
the CLI snapshot test surface. Drop two stale doc phrases.