fix(s6): dot-prefix gateway staging dir so svscan ignores it mid-build (arm64 EACCES flake) - #54834
Conversation
The register path builds each profile-gateway slot in a sibling staging dir under /run/service (the scandir s6-svscan watches), then atomically renames it to the live gateway-<profile> name. The staging dir was named gateway-<profile>.tmp — a NON-dotfile — so a concurrent `s6-svscanctl -a` rescan (fired by the cont-init reconciler registering gateway-default, or by a sibling register) would supervise the half-built slot the moment it had a valid type/run: s6-supervise spawns AS ROOT and mkdirs supervise/ root-owned 0700, then the in-flight _seed_supervise_skeleton early-returns on the now-existing supervise/ and the next `mkdir supervise/event` hits PermissionError. That is the arm64-only CI flake on test_s6_unregister_removes_service_dir_in_live_container (PermissionError: /run/service/gateway-phase3test.tmp/supervise/event) — arm64-only because the native-arm runner's wider scheduling jitter lets the rescan land inside the ~ms seed window; amd64 ran 30/30 clean. Fix: dot-prefix the staging dir (.gateway-<profile>.tmp) in both register paths (S6ServiceManager.register_profile_gateway and container_boot._register_service). s6-svscan skips any scandir entry whose name begins with '.', so the half-built slot can never be supervised mid-build. The atomic rename to the dotless live name is unchanged. Verified on a real s6 image (amd64): a non-dotted staging dir is picked up by an svscanctl -a rescan (SUPERVISED owner=root) while a dot-prefixed one is ignored (NOT-SUPERVISED). Added a docker-harness regression test that asserts both, plus a unit test that the staging dir is dot-prefixed.
🔎 Lint report:
|
| Rule | Count |
|---|---|
invalid-assignment |
1 |
First entries
tests/run_agent/test_credits_notices_toggle.py:76: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to attribute `_credits_session_start_micros` of type `int`
✅ Fixed issues (2):
| Rule | Count |
|---|---|
unresolved-attribute |
2 |
First entries
run_agent.py:3040: [unresolved-attribute] unresolved-attribute: Object of type `Self@get_credits_spent_micros` has no attribute `_credits_session_start_micros`
tests/run_agent/test_credits_notices_toggle.py:76: [unresolved-attribute] unresolved-attribute: Unresolved attribute `_credits_session_start_micros` on type `AIAgent`
Unchanged: 6158 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
tonydwb
left a comment
There was a problem hiding this comment.
LGTM — Dot-prefixes the staging directory so s6-svscan ignores it during build. This fixes the arm64-only CI flake where a concurrent rescan would supervise a half-built slot, creating a root-owned supervise/ dir that EACCESes the seed. Excellent regression test with control (non-dotted IS supervised) and fix verification (dot-prefixed is NOT supervised). Both container_boot.py and service_manager.py are updated consistently.
NousResearch#54834) The register path builds each profile-gateway slot in a sibling staging dir under /run/service (the scandir s6-svscan watches), then atomically renames it to the live gateway-<profile> name. The staging dir was named gateway-<profile>.tmp — a NON-dotfile — so a concurrent `s6-svscanctl -a` rescan (fired by the cont-init reconciler registering gateway-default, or by a sibling register) would supervise the half-built slot the moment it had a valid type/run: s6-supervise spawns AS ROOT and mkdirs supervise/ root-owned 0700, then the in-flight _seed_supervise_skeleton early-returns on the now-existing supervise/ and the next `mkdir supervise/event` hits PermissionError. That is the arm64-only CI flake on test_s6_unregister_removes_service_dir_in_live_container (PermissionError: /run/service/gateway-phase3test.tmp/supervise/event) — arm64-only because the native-arm runner's wider scheduling jitter lets the rescan land inside the ~ms seed window; amd64 ran 30/30 clean. Fix: dot-prefix the staging dir (.gateway-<profile>.tmp) in both register paths (S6ServiceManager.register_profile_gateway and container_boot._register_service). s6-svscan skips any scandir entry whose name begins with '.', so the half-built slot can never be supervised mid-build. The atomic rename to the dotless live name is unchanged. Verified on a real s6 image (amd64): a non-dotted staging dir is picked up by an svscanctl -a rescan (SUPERVISED owner=root) while a dot-prefixed one is ignored (NOT-SUPERVISED). Added a docker-harness regression test that asserts both, plus a unit test that the staging dir is dot-prefixed.
NousResearch#54834) The register path builds each profile-gateway slot in a sibling staging dir under /run/service (the scandir s6-svscan watches), then atomically renames it to the live gateway-<profile> name. The staging dir was named gateway-<profile>.tmp — a NON-dotfile — so a concurrent `s6-svscanctl -a` rescan (fired by the cont-init reconciler registering gateway-default, or by a sibling register) would supervise the half-built slot the moment it had a valid type/run: s6-supervise spawns AS ROOT and mkdirs supervise/ root-owned 0700, then the in-flight _seed_supervise_skeleton early-returns on the now-existing supervise/ and the next `mkdir supervise/event` hits PermissionError. That is the arm64-only CI flake on test_s6_unregister_removes_service_dir_in_live_container (PermissionError: /run/service/gateway-phase3test.tmp/supervise/event) — arm64-only because the native-arm runner's wider scheduling jitter lets the rescan land inside the ~ms seed window; amd64 ran 30/30 clean. Fix: dot-prefix the staging dir (.gateway-<profile>.tmp) in both register paths (S6ServiceManager.register_profile_gateway and container_boot._register_service). s6-svscan skips any scandir entry whose name begins with '.', so the half-built slot can never be supervised mid-build. The atomic rename to the dotless live name is unchanged. Verified on a real s6 image (amd64): a non-dotted staging dir is picked up by an svscanctl -a rescan (SUPERVISED owner=root) while a dot-prefixed one is ignored (NOT-SUPERVISED). Added a docker-harness regression test that asserts both, plus a unit test that the staging dir is dot-prefixed.
#4) * test(reasoning-floor): isolate stale-timeout floor tests from config-module reload races (#54775) The five _resolved_api_call_stale_timeout_base integration tests reloaded hermes_cli.config + hermes_cli.timeouts via importlib.reload to clear cached config. Under xdist that mutates module-global state shared across the worker process, so a sibling test could leave the config cache in a state that made get_provider_stale_timeout return a leaked value — intermittently failing test_reasoning_floor_applies_to_opus_4_thinking (shard 6 flake, #52217 area). Patch run_agent.get_provider_stale_timeout per-test instead: floor-path tests get None (resolver falls through to the reasoning floor / env var / default), the explicit-config test gets 60.0 (priority-1 short-circuit). Same assertions, no shared-module mutation, deterministic under parallel execution. * feat(dashboard): auto-initiate portal SSO redirect on unauthenticated load When the dashboard gateway has no local session cookie, it rendered a click-through /login interstitial — even though the Nous portal's /oauth/authorize auto-approves any current member of the dashboard's org and is a silent 302 when the user already holds a portal session. For the common case (clicking a hosted-agent dashboard link while signed in to the portal) that interstitial click is pure friction. This makes the gate auto-initiate the OAuth redirect on an unauthenticated HTML document load instead of rendering the interstitial, when exactly one interactive provider is registered. A one-shot loop-guard cookie (hermes_sso_attempt, 60s TTL) ensures that a genuinely absent portal session (the portal bounces back still-unauthenticated) falls back to the /login page after exactly one bounce rather than ping-ponging forever. The marker is cleared on a successful callback and whenever the gate falls back to /login. Security: this removes a human CLICK, not a security check. The redirect lands on the existing /auth/login route and runs the unchanged PKCE auth-code flow; token verification, audience checks, redirect-URI match, and org-membership checks are all untouched. /api/* fetches still get the 401 JSON envelope (never a 302 a fetch() would follow opaquely), and with two or more providers the /login chooser still renders. Phase 1 of the cloud-auto-discovery work. * refactor(dashboard-auth): drop redundant _interactive_providers helper list_session_providers() already filters on supports_session=True, so the new helper re-filtered an already-filtered list. Call it directly at the single auto-SSO call site. * fix(session-db): enrich NULL session metadata via upsert instead of INSERT OR IGNORE The gateway's get_or_create_session() creates a bare session row (source + user_id) before the agent exists. The agent's later create_session() carries the real model/model_config/system_prompt, but _insert_session_row used INSERT OR IGNORE — silently dropping that enrichment. Gateway sessions were left with NULL model and NULL billing metadata. Switch to INSERT ... ON CONFLICT(id) DO UPDATE with COALESCE so NULL columns get backfilled while values an earlier writer already set are never overwritten (a later bare write with source='unknown' can't clobber a real source/model). Credit: original report and fix direction by @LucidPaths (#5048). * fix(security): redact browser CDP endpoint logs * fix(security): fail-closed feishu webhook rate limiter + whatsapp bridge path guard Salvages the two still-valid hardenings from #5381 onto the relocated plugin adapters (the discord/feishu/whatsapp adapters moved to plugins/platforms/ since the PR was opened, and 4 of its 6 hunks are already on main or superseded). - feishu: rate limiter now denies untracked keys when the tracking table is at capacity after pruning stale entries (was: allow through without tracking). At-capacity-with-all-fresh-entries only happens under abuse, so allowing untracked requests let an attacker who flooded the table bypass the limiter entirely. Already-tracked keys and post-prune room are unaffected. - whatsapp: absolute file paths handed back by the Baileys bridge are now validated to resolve inside a known media cache dir before being attached. A compromised/buggy bridge could otherwise return an arbitrary path (e.g. /etc/passwd) that would be sent verbatim to the model. Guard resolves symlinks and accepts both the canonical cache/<kind> and legacy <kind>_cache layouts. * fix(gateway): use last_prompt_tokens for session-reset activity check reset_had_activity gated on entry.total_tokens, which is never written (token counts migrated to agent-direct persistence) so it was always 0. That suppressed session-reset notifications for sessions that genuinely had activity. Switch to last_prompt_tokens, which is updated on every turn. * test(gateway): exercise last_prompt_tokens in reset-activity tests The reset-had-activity tests set total_tokens (dead state) to simulate activity; production records activity via last_prompt_tokens. Update the fixtures to match the field the fix and runtime actually use. * fix(security): add session-id filename sanitizer to prevent path traversal Session IDs can originate from untrusted input (e.g. the X-Hermes-Session-Id API header) and are interpolated raw into on-disk artifact filenames under ~/.hermes/sessions/. A traversal-shaped ID (../../../../etc/pwned) would let a caller write the session snapshot or request dump outside the sessions directory. _safe_session_filename_component() collapses every non [A-Za-z0-9_-] character to _, caps the length, and appends a short content hash when sanitization changed the string, always yielding a single traversal-free path segment. Closes #5958. * fix(security): wire session-id sanitizer into artifact paths + API boundary Defense-in-depth on top of _safe_session_filename_component (#5958): Sink (makes the bad write impossible regardless of entry point): - run_agent._save_session_log: sanitize session_id before building the session_{sid}.json snapshot path. - agent_runtime_helpers.dump_api_request_debug: sanitize before building the request_dump_{sid}_{ts}.json path. Boundary (clean 400 instead of a silently-hashed filename): - api_server rejects path-traversal-shaped X-Hermes-Session-Id on the session-continuation path and the explicit /api/sessions create path, reusing gateway.session._is_path_unsafe (mirrors the native gateway's entry-boundary guard). Also enforces the session-header length cap on the continuation path. Tests: traversal session_id stays contained at the write site; sanitizer always yields a traversal-free segment; the API header rejects ../, absolute, and Windows-traversal IDs with 400. * fix(gateway): neutralize untrusted session metadata in prompts * fix(gateway): also neutralize untrusted Matrix room name in prompt Widen #5961's _format_untrusted_prompt_value coverage to the Matrix room display name (**Matrix Room:**), a sibling attacker-controllable field the original fix missed. chat_name is user-settable, so an injected room name could render as literal markdown in the system prompt. Adds a regression test. * fix(s6): dot-prefix gateway staging dir so svscan ignores it mid-build (#54834) The register path builds each profile-gateway slot in a sibling staging dir under /run/service (the scandir s6-svscan watches), then atomically renames it to the live gateway-<profile> name. The staging dir was named gateway-<profile>.tmp — a NON-dotfile — so a concurrent `s6-svscanctl -a` rescan (fired by the cont-init reconciler registering gateway-default, or by a sibling register) would supervise the half-built slot the moment it had a valid type/run: s6-supervise spawns AS ROOT and mkdirs supervise/ root-owned 0700, then the in-flight _seed_supervise_skeleton early-returns on the now-existing supervise/ and the next `mkdir supervise/event` hits PermissionError. That is the arm64-only CI flake on test_s6_unregister_removes_service_dir_in_live_container (PermissionError: /run/service/gateway-phase3test.tmp/supervise/event) — arm64-only because the native-arm runner's wider scheduling jitter lets the rescan land inside the ~ms seed window; amd64 ran 30/30 clean. Fix: dot-prefix the staging dir (.gateway-<profile>.tmp) in both register paths (S6ServiceManager.register_profile_gateway and container_boot._register_service). s6-svscan skips any scandir entry whose name begins with '.', so the half-built slot can never be supervised mid-build. The atomic rename to the dotless live name is unchanged. Verified on a real s6 image (amd64): a non-dotted staging dir is picked up by an svscanctl -a rescan (SUPERVISED owner=root) while a dot-prefixed one is ignored (NOT-SUPERVISED). Added a docker-harness regression test that asserts both, plus a unit test that the staging dir is dot-prefixed. * fix(auxiliary): preserve max_tokens for NVIDIA NIM aux calls NVIDIA integrate.api.nvidia.com models such as minimaxai/minimax-m3 can return HTTP 200 with empty choices when max_tokens is omitted. Keep the output cap on auxiliary chat-completions routes, matching the main NVIDIA provider profile behavior. * test(auxiliary): cover NVIDIA NIM max_tokens in _build_call_kwargs * feat(desktop): add context usage breakdown popover Let users click the status bar context indicator to see how tokens are split across system prompt, tools, rules, skills, MCP, and conversation. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: create dev venv outside the source tree (root-cause fix for #7779) (#54862) A manually-installed venv inside the cloned repo can be destroyed by the agent running a relative-path command against its own checkout (rm -rf venv, uv venv venv, etc.), silently wiping the running runtime mid-session. Moving the canonical manual-install venv to ~/.hermes/venvs/hermes-dev means no relative path from the agent's workspace resolves to its own runtime, making the bug class impossible without any command-detection code. Closes the root cause of #7779. The managed install.sh layout is unchanged. * feat(web_extract): truncate-and-store instead of LLM summarization (#54843) * feat(web_extract): truncate-and-store instead of LLM summarization web_extract no longer runs an auxiliary LLM over scraped pages. The extract backends (Firecrawl/Tavily/Exa/Parallel) already return clean, boilerplate- stripped markdown, so we return it directly: pages within a char budget (default 15000, web.extract_char_limit) come back whole; larger pages get a head+tail window plus an explicit footer giving the stored full-text path and the read_file call to page through the omitted middle. The full clean text is written to cache/web (mounted read-only into remote backends like the other cache dirs), so nothing is lost. Inline base64 images are converted to [IMAGE: alt] placeholders (token bombs dropped) while real http(s) image URLs are preserved as links so the agent can still web_extract/vision_analyze them. Removes process_content_with_llm + the chunked summarizer + check_auxiliary_model + _resolve_web_extract_auxiliary. context_references._default_url_fetcher is updated to the truncate path and its stale data.documents shape read is fixed to results (it was silently returning empty). Live before/after eval (firecrawl, 4 URLs): 11.7x faster overall (176.6s -> 15.1s); 10-60x on large pages. Quality identical; findability 4/4 (answer recoverable from stored full text on every truncated page). web_search is unchanged. No own scraper added; no changes to web_search. * fix(web_extract): add char_limit to execute_code web_extract stub The new web_extract char_limit param must appear in the code_execution_tool _TOOL_STUBS signature (and doc line) or test_stubs_cover_all_schema_params fails — the stub schema must cover every real schema param. * feat(desktop): read-only spectator transcript for subagent watch windows Subagent session pop-outs (`watch=1`) spectate a run driven elsewhere, so editing/steering the transcript from there makes no sense. Gate the composer and the user-bubble mutations on `isWatchWindow()`: - hide the composer (folds into `showChatBar`) - user prompts become a read-only button that toggles the 2-line clamp so long prompts stay fully readable, instead of opening the edit composer - drop the stop/restore actions and the checkpoint branch-picker Keyed off the narrow `isWatchWindow()` (not `isSecondaryWindow()`), so the new-session and cmd-click pop-outs are unaffected. * fix(desktop): show Gateway statusbar tooltip via composed trigger Slots The Gateway item is the only statusbar entry with variant === 'menu'. Since da73223f4 wrapped every render branch in `Tip`, the menu branch nested `<DropdownMenu>` (a Radix Root that renders no DOM node) inside `Tip`'s `<TooltipTrigger asChild>`. With no element to attach to, Radix could never wire hover listeners, so the tooltip silently never showed. `Tip` also can't be moved inside `DropdownMenuTrigger asChild` (the shape proposed in #54859): it's a plain component, not a Slot-forwarding one, so the trigger's injected ref/handlers would land on `TooltipContent` instead of the button and break the menu's click + popper anchoring. Fix by composing both trigger Slots directly onto a single <button> (`TooltipTrigger asChild` over `DropdownMenuTrigger asChild`), the pattern already used in profile-switcher.tsx, and skip the tooltip wrapper entirely when the item has no title. Supersedes #54859. Co-authored-by: wnuuee1 <wnuuee1@users.noreply.github.com> * feat(gateway): suppress home-channel shutdown broadcast on flagged drains (#54824) Add a generic suppress_notification flag to the drain-request marker. When a drain that ends in process exit (e.g. a NAS auto-update image migration on the always-on Hermes Cloud fleet) is flagged, the gateway skips ONLY the home-channel 'gateway shutting down' broadcast — the operator-flavoured ping that would otherwise fire on every routine auto-update, dozens of times a day. The per-active-session interrupt ping is ALWAYS kept: on a drained shutdown it's empty by construction, and in the force-interrupt (deadline-exceeded) case it carries the user-valuable 'your task was cut off, message me to resume' hint. The gateway stays agnostic about WHY a drain is quiet (generic boolean, not a kind enum); the policy of which drain causes set the flag lives in the caller (NAS). Default-false so legacy/operator drains behave exactly as before. The reader reuses the NS-570 epoch-staleness check so an orphaned marker on the durable volume can never silence a fresh gateway's legitimate broadcast. - drain_control.py: write_drain_request gains suppress_notification; new drain_notification_suppressed() reader (current-epoch + truthy flag). - web_server.py: /api/gateway/drain reads + echoes the flag. - run.py: _notify_active_sessions_of_shutdown skips the home-channel loop only. Tests prove: flag round-trips; home-channel suppressed when set, kept when unset; active-session ping always fires; stale/legacy/corrupt markers never suppress. * feat(desktop): add pet roam + motion/direction store signals Opt-in $petRoam (localStorage), $petMotion (run/jump pose) and $petRoamDir (-1/0/1) feed the shared $petState only while the agent is at rest ($petAtRest), so a wander never overrides real activity. * feat(desktop): pick directional run row from travel direction roamWalkRow() prefers running-left/running-right rows, falling back to the generic running row with a mirror for pets that lack them. * feat(desktop): add surface-aware pet wander loop usePetRoam re-measures ledges from the live DOM each beat and walks/hops/falls between them, driving DOM position imperatively (no per-frame re-render). * feat(desktop): wire roaming into the floating pet * feat(desktop): let the pet perch on the status bar and profile rail Tag both bars with data-slots; the roam loop stands on the status bar's top edge (not over it) and treats the profile rail as a climbable ledge. * feat(desktop): add pet roam opt-in toggle + i18n * fix(web): remove marketing backdrop stack for lighter dashboard shell Drop the CSS lens overlay (blend modes, noise, inversion) and backdrop-blur from the ops dashboard so compositing no longer competes with xterm on /chat. Use flat theme backgrounds and direct Nous Blue palette colors instead of FG-inversion authoring. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): theme terminal foreground and restore backdrop plugin slot Make Nous Blue terminal text readable without the inversion layer, re-mount the backdrop plugin slot, and drop unused backdrop CSS vars from theme apply. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): polish dashboard sidebar chrome and model card menus Use momentum easing for sidebar transitions, switch sidebar typography to sans-serif, replace the profile native select with the DS Select, and stop clipping the Models page Use-as dropdown inside model cards. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): confirm sidebar gateway restart and use DS checkboxes Prompt before restarting from the sidebar system menu, and replace native checkboxes on the System page with the design-system Checkbox component. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): confirm sidebar Update Hermes before running Match the Restart Gateway flow with a confirm dialog that fetches cached update metadata so users see commit-behind context before applying. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(desktop): ground the roaming pet — sprite-paced walk + feet on surface Walk speed is derived from the sprite's animation loop + on-screen size (one body-width per loop) instead of a fixed px/s, so it steps rather than glides; the pet also sinks a few px so its feet meet the surface instead of hovering. * feat(desktop): roaming pet patrols the base of an open overlay When a full-screen route overlay (settings/profiles/cron/agents/command-center) is up, the pet's walkable surface swaps to a single ledge at the overlay card's bottom edge — derived from OverlayView's shared inset, not measured — so it patrols there; closing the overlay restores the normal surfaces and it drops back down. * feat(desktop): type voice.auto_tts in desktop config * feat(desktop): mirror voice.auto_tts into an $autoSpeakReplies store * feat(desktop): add read-replies-aloud composer strings * feat(desktop): add auto-speak watcher hook * feat(desktop): add read-replies-aloud toggle and wire auto-speak * fix(gateway): skip confirmed-dead delivery targets (deleted groups, blocked bots) (#55115) * fix(gateway): skip confirmed-dead delivery targets (deleted groups, blocked bots) A deleted Telegram group, kicked/blocked bot, or deactivated user keeps throwing Forbidden/not_found on every cron tick and fan-out delivery. Each retry burns a send against the platform's flood-control envelope and spams the logs, making the whole session feel broken even when the model call completed. Add a small persistent DeadTargetRegistry (per-profile JSON under HERMES_HOME) that records a target the moment a send reports a whole-chat death (forbidden / chat-level not_found), and have DeliveryRouter.deliver() short-circuit it on subsequent attempts. Self-healing: any successful send clears the flag, so a user re-adding the bot recovers with no manual cleanup. Thread/topic-level not_found is NOT recorded (adapters already self-heal that by retrying without reply_to). Transient/timeout errors are never marked dead. * infographic: dead delivery target skipping * test(gateway): cover AsyncSessionDB offload + raw-call guard (failing) * feat(gateway): add AsyncSessionDB offload facade * fix(gateway): route SessionDB calls through AsyncSessionDB * fix(gateway): route aliased SessionDB calls through AsyncSessionDB The migration's call-site sweep keyed on the literal self._session_db. spelling and missed calls bound to a local first (db = getattr(self, '_session_db', None); db.method(...)). Convert the three in async contexts: get_telegram_topic_binding in the topic-rename coroutine, and the two update_session_model sites on the model-switch path. * fix(gateway): offload the Telegram topic-recovery helper tree off the loop The topic-mode helpers (_telegram_topic_mode_enabled, _recover_telegram_topic_thread_id, _record/_sync_telegram_topic_binding, _is_telegram_topic_lane/_root_lobby, _normalize_source_for_session_key, _telegram_topic_new_header, _schedule_telegram_topic_title_rename, and the base.py _apply_topic_recovery hook) each run a synchronous SessionDB read or write. They reach the event loop through async handlers, so a contended state.db froze the loop the same way the handoff watcher did. These helpers already run off-loop in the run_sync thread-pool closure, so they are proven thread-safe there. Rather than colour them async, loop-side callers now invoke them via asyncio.to_thread(...); the executor callers are unchanged. Inside the helpers the SessionDB handle is unwrapped to the sync door (getattr(db, '_db', db)) since they always run on a worker thread, and AIAgent construction + query_session_listing are handed the sync SessionDB directly. base.py wraps its single _apply_topic_recovery call in to_thread. The guard is now alias-aware (catches db = getattr(self, '_session_db', None); db.method(...)) and enforces the offload contract: the offloaded sync helpers may never be called bare on the loop. Sibling test fixtures wrap their injected SessionDB in AsyncSessionDB to match how the gateway holds it. * test(gateway): assert interleaving safety of concurrent offloaded DB calls * fix(dashboard_auth): allow any http:// host in self-hosted OIDC redirect_uri (#55099) The self-hosted OIDC dashboard login rejected any http:// redirect_uri whose host was not localhost/127.0.0.1, surfacing "redirect_uri may only use http:// for localhost/127.0.0.1" before reaching the IDP. This broke self-hosted dashboards reached over plain HTTP (including LAN IPs, internal hostnames, and reverse proxies that terminate TLS upstream). #38827 already dropped this check from the nous provider, but the generic self-hosted provider copied the old localhost-only branch and reintroduced the bug for HERMES_DASHBOARD_OIDC_ISSUER setups. The IDP's own allowlist is authoritative on which redirect_uris are permitted; this client-side _validate_redirect_uri is only a fast-fail for obvious operator error and should not second-guess valid http:// deployments. Fix: drop the localhost-only branch on the http scheme. Validation now enforces only that the scheme is http(s) and the path ends with /auth/callback. Updated the docstring to explain the relaxed contract, and added test_allows_http_with_arbitrary_host covering an internal hostname and a LAN IP alongside the existing localhost case. * refactor(relay): adopt scope_id wire key (guild_id → scope_id dual-read/write) (#55289) Gateway half of relay-platform-parity Phase 2.5 (D-Q2.5). The relay wire's platform-neutral scope discriminator is renamed guild_id → scope_id; this is the hermes-agent side of the cross-repo wire-compatible migration. - SessionSource: scope_id is canonical; guild_id kept as @deprecated alias. __post_init__ mirrors the two so all existing SessionSource(guild_id=...) constructors across native adapters keep working unchanged. to_dict dual-WRITES scope_id+guild_id; from_dict dual-READS scope_id ?? guild_id. - relay/adapter.py: capture + outbound metadata dual-read/write scope_id. - relay/ws_transport.py: _frame_to_event dual-reads scope_id ?? guild_id. - docs/relay-connector-contract.md: document scope_id (canonical) + guild_id (deprecated alias) in the §3 SessionSource field table (conformance test). 250 relay+session+contract tests green. Solo lane (relay). * feat(ci): add CI timing report * change(ci): remove lint PR comment it's already in the job summary. having it as a comment just makes people ignore it. don't waste sapce. * fix(ci): unify amd64/arm64 docker pipelines * fix(agents.md): fix documentation on subprocess isolation in tests * feat(display): friendly human-phrased tool labels for built-in tools (#55166) * feat(display): friendly human-phrased tool labels for built-in tools Built-in tools now render ChatGPT-style status verbs ('Searching the web for ...', 'Reading <file>', 'Browsing <url>') on the CLI spinner and gateway/desktop tool-progress instead of the raw tool name. - agent/display.py: _TOOL_VERBS map + build_tool_label() + set/get friendly-labels flag (default on). Custom/plugin/MCP tools fall back to the raw preview; verbose gateway mode left untouched (debug surface). - tool_executor.py / tui_gateway / gateway: route the three spinner sites, the TUI _tool_ctx, and the gateway all/new progress line through the label. - config: display.friendly_tool_labels (default True, per-platform aware). Zero new core tool / schema footprint — pure display layer. * docs: add PR infographic for friendly tool labels * fix(display): preserve arg preview in gateway friendly labels + update tests The first gateway pass re-derived the label from the callback's `args`, which is empty ({}) at the gateway tool.started callsite — the command/query lives in the `preview` string, so terminal rendered as a bare '💻 Running' and dedup collapsed consecutive commands. Now the gateway prefixes the verb onto the already-computed preview via get_tool_verb/tool_verb_connector/verb_drops_preview, preserving the command/url/query. CLI spinner path (real args) keeps build_tool_label. Tests: update test_run_progress_topics exact-format assertions to the friendly form ('💻 Running pwd'), add a format-agnostic preview extractor for the truncation tests (works for both quoted-legacy and verb-prefixed output). * test(tui): update resume-display context to friendly tool label _tool_ctx now uses build_tool_label, so the desktop resume-view context for a search_files turn reads 'Searching files for resume' instead of the bare 'resume' preview — consistent with live tool-progress. Update the assertion. * test(tui): harden no-race worker test against sibling shard leakage test_session_create_no_race_keeps_worker_alive flaked under -j 8: a daemon build thread leaked from a prior session.create test in the same shard process fires close/unregister against its own (foreign) session_key after this test patches the global approval hooks, polluting the captured lists. Scope the assertions to this session's own session_key so the regression intent (this session's worker/notify must survive) is preserved while the test becomes immune to shard composition. Not related to friendly-tool-labels. * feat(dashboard_auth): support confidential clients (client_secret) in self-hosted OIDC (#55344) The self-hosted OIDC dashboard provider was public-client + PKCE only, with two `# TODO(confidential-client)` seams. Authentik and Keycloak commonly default a new OIDC client to *confidential*, whose token endpoint rejects an unauthenticated exchange (`invalid_client`) — so a self-hoster who accepts their IDP's default could not complete dashboard login without manually flipping the client to public. Add optional confidential-client support: - New optional `client_secret` (env `HERMES_DASHBOARD_OIDC_CLIENT_SECRET`, or `dashboard.oauth.self_hosted.client_secret`; env-wins-config, empty treated as unset). It is a credential, so docs steer operators to the `.env` file; config.yaml is supported only for precedence symmetry. - `_token_endpoint_auth()` selects `client_secret_basic` (HTTP Basic header) vs `client_secret_post` (form body) from the IDP's advertised `token_endpoint_auth_methods_supported`, defaulting to basic (the OIDC default) when absent. Applied to complete_login, refresh_session, and revoke_session (RFC 7009 §2.1). - PKCE is sent in BOTH modes — the secret is client authentication layered on top, never a replacement (OAuth 2.1 / RFC 9700 keep PKCE mandatory). - Basic header url-encodes client_id/secret before base64 per RFC 6749 §2.3.1, so reserved chars (`:`, `@`, space) round-trip correctly. Non-breaking: with no secret configured the provider is a pure public PKCE client, byte-identical to prior behaviour (no Authorization header, no client_secret in the body). The secret is never logged — register() reports only a `confidential=<bool>` flag. Tests: 16 new cases covering basic/post selection, default-when-absent, public-unchanged contract, PKCE-preserved, reserved-char url-encoding, blank-secret-is-public, refresh + revoke auth, no-secret-in-logs, and env/config register wiring. Full dashboard-auth suite (nous provider, middleware, gate, cookies, WS, 401-reauth, status endpoint) — 396 tests — green, proving no existing auth path regressed. * feat(gateway): show per-category context breakdown in /usage (#55204) Channel users get the same context split the desktop popover shows (PR #54907) — system prompt, tools, rules, skills, MCP, subagents, memory, conversation — under the existing Context line in /usage. Reuses agent.context_breakdown.compute_session_context_breakdown, so there is no new tool and no new engine. The slices are estimates (chars/4) and the block is labelled _(estimated)_; the headline Context line keeps using the provider-measured last_prompt_tokens. Rendering is fail-open: any engine error returns no breakdown and the rest of /usage is unaffected. - gateway/slash_commands.py: _context_breakdown_lines() helper + wire into _handle_usage_command - locales/*.yaml: breakdown_header, breakdown_line, and 8 category labels across all 16 locales (parity gate) - tests/gateway/test_usage_command.py: render + fail-open coverage * feat(desktop): calmer, more realistic pet roam + split roam modules The floating pet wandered almost constantly: every idle beat picked a new walk and hops fired ~45% of the time, so it read as nervous rather than alive. Make movement the exception, not the default, and split the overgrown roam hook into focused modules. Behavior (per ambient game-AI: GameAIPro ch.36 + idle/wander state machines): - Loaf, don't pace: most decision beats just keep resting (REST_CHANCE 0.62) instead of always re-walking. - Memoryless dwell: pauses now draw from an exponential distribution (mostly short rests, the occasional long loaf) instead of a uniform 1.8-5.2s window, so the cadence never reads as a metronome. - Hops dialed back 0.45 -> 0.2 (the jumpiest, noisiest motion). Structure (no god-file; a hook should own one narrow job): - roam-behavior.ts - what to do & when (dwellMs, chooseMove, pickStrollTarget) + tuning. Pure, rng-injectable. - roam-geometry.ts - where it can stand (snapshotLedges, overlayLedge, resolveLedge, overlapsX, groundTop). DOM measurement + pure ledge math. - use-pet-roam.ts - the physics/RAF loop only. Tests: deterministic, rng-seeded unit coverage for the decision + geometry helpers (behavior contracts, not snapshots). * refactor(desktop): collapse stroll-direction coin to a single draw DRY: the roomier-side bias computed its probability two ways (STROLL_TOWARD_ROOM and 1 - STROLL_TOWARD_ROOM). One draw XNOR'd against the roomier side says the same thing more plainly. * fix(slack): warn when configured token is a user token, not a bot token A Slack user/legacy token (xoxp-...) makes auth.test resolve to the installing human's member ID with no bot_id, so the adapter binds its identity (_bot_user_id / _team_bot_user_ids) to that human. Every "is this the bot?" check then misfires: that person's <@...> mentions wake the bot and are stripped as the bot's own mention, so the agent is genuinely told it was @mentioned and replies to messages merely addressed to that human (symptom: bot responds to "@trevor ..." and insists it was explicitly mentioned). There is no runtime API error to catch — a user token still sends/receives — so the only detectable moment is connect time. Add a warning-only nudge (_warn_if_not_bot_token) alongside the existing group-DM scope nudge: when auth.test resolves a user_id but no bot_id, log that the token is a user token and to use the xoxb-... Bot User OAuth Token. Warning-only: does not block a working-but-misconfigured install. Fires once per workspace per process. * feat(xai): Imagine public-URL storage, chaining & video edit/extend Add durable public-URL output and URL-based chaining to xAI Grok Imagine: - Store generated media on files-cdn with permanent public HTTPS URLs (public_url: true, no expiry by default). - Chain by URL: generate -> edit -> extend each take a prior result's public HTTPS URL (or a data URI / local file for inputs). - Add provider-specific xai_video_edit and xai_video_extend tools. - Image generation: public-URL/storage output, multi-reference edits, and ~/ local-path support for image edits. Credentials use xAI Grok device-code OAuth (separate PR). * fix(xai): suppress false-positive windows-footgun on binary image read open(..., "rb") is binary mode and needs no encoding=; the checker's regex doesn't recognize the mode. Add the documented suppression comment. * feat(claude-design): add surface-first conditioning + slop diagnostic (#55399) Port the two genuinely-novel ideas from Command Code's /design skill into our existing claude-design skill (skill-only, zero model-tool footprint): - Surface-First: commit to one of 7 surface archetypes (Monitor/Operate/ Compare/Configure/Decide/Explore/Command) before any visual tokens. Most AI design slop is compositional, not cosmetic — conditioning generation on a surface choice collapses entropy the way a CoT step does. Workflow step 3. - Slop Diagnostic: the ~10 tells that account for ~90% of the 'this is AI' signal, as a score-out-of-10 self-audit. Diagnose-then-treat: the report is context not a to-do list; repair only what fired, matched to the tell (re-layout vs recolor vs de-decorate). Workflow step 7 (Verify). Did NOT clone /design's 16-mode CLI, proprietary reference corpus, or make it a core tool. Docs page regenerated via generate-skill-docs.py. * fix(container-boot): autostart a gateway stranded in 'draining' state A gateway hard-killed while draining (a container/VM recreate SIGTERMs it before _stop_impl reaches its terminal-state persist) leaves gateway_state.json frozen at 'draining'. With no explicit desired_state to fall back to, container_boot read that transient value literally, found it not in _AUTOSTART_STATES, and left the gateway DOWN on every subsequent boot — dashboard up, messaging silently dark. Observed on a relay-opted-in staging instance (2026-06): the s6 gateway-default slot kept its 'down' marker across recreates and the gateway never came back. 'draining' is a transient sub-state of RUNNING (written by the drain watcher / scale-to-zero go-dormant path), never an operator stop and never a failed boot. Normalise it to 'running' in the gateway_state fallback so a stranded drain marker reads as the run-intent it represents. This extends gateway/run.py's #42675 handling (persist 'running' on an unexpected signal) to the case where the gateway died before persisting anything at all. 'starting'/'startup_failed' are deliberately NOT normalised — those mean a mid-boot death and must stay down to avoid the crash-loop the down-marker guard prevents. An explicit desired_state still wins verbatim, so an operator stop survives a transient 'draining' runtime value. Tests: draining named-profile + default-root autostart (both fail without the fix), plus a guard that an explicit desired_state=stopped still blocks a draining runtime. * fix(container-boot): also autostart a gateway stranded in 'degraded' degraded is the same wedge class as draining: the gateway came up with some platforms queued for retry, fell through to the running state (gateway/run.py #5196), and is serving. A hard-kill there strands gateway_state=degraded, which (like draining) is not in _AUTOSTART_STATES and is not an operator stop or a failed boot — so it would stay DOWN forever on every recreate. Add degraded to _TRANSIENT_RUNNING_STATES so the fallback path normalises it to running-intent too. * feat(gateway): per-platform typing_indicator toggle Add a generic per-platform PlatformConfig.typing_indicator flag (default True) that gates the _keep_typing refresh loop in _process_message_background. When false, the loop is never spawned, so no typing/"is thinking…" status is shown on that platform — message delivery is otherwise unchanged. Mirrors the gateway_restart_notification contract exactly: dataclass field + to_dict/from_dict (with extra-fallback resolution) + shared-key bridge in load_gateway_config, so 'slack: typing_indicator: false' under platforms works without a separate block. Generic by design — the same key works for every platform (Slack 'is thinking…', Telegram/Discord/Signal typing). Motivated by users who find Slack's assistant 'is thinking…' status noisy (it also briefly disables the compose box, via the Assistant API). * feat(desktop): flag already-installed themes in the install pickers The Cmd-K "Install theme…" palette listed Marketplace themes with no hint that you already had them, and clicking one re-downloaded + re-installed a theme you owned. The Appearance settings grid already detected this, but by parsing theme descriptions inline on every render — plumbing that never made it to the palette. Lift it into one reactive source and reuse it everywhere: - $marketplaceInstalls (computed over $userThemes): extensionId -> installed theme, derived once via marketplaceIdOf and memoized, instead of rebuilding a Set per render. - Both install surfaces now mark owned rows installed and, on click, re-activate the installed theme rather than re-fetching it. - Drops the duplicated description-parsing in settings and the per-session "installed here" state in both surfaces (the store is the source of truth, so previously-installed themes show correctly too). * fix(kanban): gate goal_mode task completion with auxiliary judge Prevents workers in goal_mode from bypassing the auxiliary judge by calling kanban_complete before acceptance criteria are met. The tool handler now synchronously invokes the goal judge against the task's title/body and the completion summary. If the verdict is not "done", the completion is rejected with actionable guidance for the agent. This keeps kanban_db.py as a pure SQLite wrapper while intercepting the bypass exactly at the agent tool-call boundary, aligning with Hermes separation of concerns. Fixes #38367 Co-authored-by: CommandCodeBot <noreply@commandcode.ai> * fix(kanban): address review feedback on goal_mode judge gate Apply naqerl's review comments on PR #38388: - Hoist `from hermes_cli.goals import judge_goal` to module-level imports so an import failure surfaces at module init, not lazily on the first goal-mode completion (no circular import: hermes_cli package init is trivial and does not load tools.kanban_tools). - Narrow the fail-open `try` to wrap only the judge_goal() call. The verdict check and its rejection `return tool_error(...)` now live outside the handler, so a failure there can no longer be swallowed by the broad except. - Pass `exc_info=True` to the logger.warning call per CONTRIBUTING.md. Update the test mock target to tools.kanban_tools.judge_goal, since the hoisted import rebinds the name into this module's namespace. * fix(kanban): make goal_mode judge gate truly fail-open Follow-up to the judge gate. judge_goal() is fail-open at the source: when no auxiliary model is reachable it returns a "continue" verdict that is indistinguishable from a real "not done yet" judgment. The gate treated any non-"done" verdict as a rejection, so an unconfigured or degraded auxiliary model would wedge every goal_mode worker — it could never close its own task. That contradicted the gate's own "fail-open" comment. Probe judge availability before enforcing (the same auxiliary client lookup judge_goal performs) and only gate when a judge is actually reachable. When none is, completion proceeds. Also fix the rejection guidance: kanban_create takes parents=[...], not parent=. Add test_complete_goal_mode_allows_when_judge_unavailable covering the fail-open path; update the rejection test to force the availability probe. * feat(learning): profile-scoped memory + learned-skill graph API Assemble a per-profile graph of memories and learned skills over time (agent/learning_graph.py) and serve it at GET /api/learning/graph (hermes_cli/web_server.py), with tests. The radial time axis the desktop renders is derived from this payload; the REST path stays under /learning for backend compatibility. * feat(desktop): Memory Graph — playable radial timeline of memories + skills A top-down Memory Graph panel: memories and skills on a radial time axis (core = oldest, outer rings = newer) with a playable / scrubbable timeline that builds the map up over time. - Reveal lives off the React tree (a ref drives the canvas, a nanostore atom drives the timeline + legend), so a play-through or scrub never re-renders the panel; paint is coalesced to one rAF and playback is abortable, so even frantic scrubbing stays responsive. - Adaptive dated rings: one equal-width ring per POPULATED calendar bucket, a "nice-tick" count scaled to the span. Constant (orthographic) core/band scale — more data grows the disk outward (more rings), never thinner. - A bucket's nodes fill the band inside their ring and ignite staggered by real timestamp across it (no end-dump), with an EVE-style warp-in; the camera steps out band-by-band as rings are reached. - ASCII "computing" core, theme-aware palette with a distinct memory hue, shared trackpad-gesture primitives. - Shareable WoW-style "loadout" codes on a generic, reusable codec (@/lib/loadout: bitstream + DEFLATE + version/checksum frame + base64url). - Opens from the statusbar and command palette; i18n across all locales. Deps: d3-force, fflate (drops unused react-force-graph-2d). * feat(agent): add pre_verify hook and verify-on-stop coding guidance Add a `pre_verify` user/plugin/shell hook fired once per turn when the agent edited code and is about to finish, after the existing verify-on-stop guard. A hook can keep the agent going one more turn (run a check, defer it, tidy the diff) by returning {"action":"continue","message":...} (the Claude-Code Stop shape {"decision":"block","reason":...} is accepted too). Hooks receive coding, attempt, final_response, and sorted changed_paths so they can self-scope and self-throttle; the path is bounded by agent.max_verify_nudges and preserves message-role alternation. Hermes still ships its default coding guidance (agent.verify_guidance, on by default), but it now rides the evidence-based verify-on-stop missing-evidence nudge instead of a separate default pre_verify continuation, so it costs no extra model turn of its own. Guidance reuses the shared utils.is_truthy_value parser rather than a local copy. * feat(agent): add configurable coding_instructions agent.coding_instructions (a string or list) is appended to the coding brief as its own stable system block, so users can pin project-wide workflow rules without editing the shipped brief. Coding-posture only and cache-safe (resolved once per session; takes effect next session). Empty by default. * refactor(desktop): split thread.tsx god file into focused modules Behavior-preserving extraction of the 1,942-line thread.tsx transcript renderer into co-located sibling modules, matching the existing flat assistant-ui/ convention: - thread-content.ts / thread-timestamp.ts: pure helpers (+ unit tests) - thread-types.ts: shared RestoreMessageTarget - thread-status.tsx: loading / stall / background-resume indicators - thread-message-parts.tsx: reasoning + tool part components - assistant-message.tsx, system-message.tsx, user-message.tsx, user-edit-composer.tsx: the message renderers thread.tsx now holds only the Thread route component (1,942 -> 119 lines). Also drops a dead readAloudAudio module variable (no references). * refactor(desktop): split sidebar/index.tsx god file into focused modules Behavior-preserving extraction of the 1,963-line ChatSidebar file into the existing sidebar/ sibling-module convention: - order.ts: add pure orderByIds / reconcileOrderIds / sameIds helpers (+ tests) - reorderable-list.tsx: the generic ReorderableList + useSortableBindings DnD primitive - section-states.tsx: SidebarSessionSkeletons / SidebarBlankState / SidebarPinnedEmptyState - sessions-section.tsx: SidebarSectionHeader + the large SidebarSessionsSection renderer + its sortable row wrappers index.tsx now holds only the ChatSidebar component (1,963 -> 1,416 lines). * refactor(desktop): extract composer pure helpers into composer-utils Pull ChatBar's module-level pure helpers, constants, and the QueueEditState type out of the 2.3k-line composer/index.tsx into a focused, testable composer-utils.ts sibling: - constants: COMPOSER_STACK_BREAKPOINT_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_FADE_BACKGROUND, DRAFT_PERSIST_DEBOUNCE_MS - helpers: pickPlaceholder, COMPLETION_ACTIONS, slashChipKindForItem, slashArgStage, slashCommandToken, cloneAttachments - type: QueueEditState Pure restructuring, no behavior change; adds unit tests for the slash helpers. (The ChatBar component itself is a single tightly-coupled megacomponent; a deeper hook-based decomposition is left for a dedicated follow-up.) * refactor(desktop): thin desktop-controller by extracting session-list actions DesktopController is a route root that had grown a controller's worth of session-list plumbing inline. Extract the cohesive fetch/paging cluster into a focused hook and a tested pure helper, per AGENTS.md's "keep route roots thin" guidance: - use-session-list-actions.ts: refreshSessions / loadMoreSessions / loadMoreSessionsForProfile / loadMoreMessagingForPlatform / refreshCronJobs (plus the private cron/messaging refreshers, sessionsToKeep, and the excluded-source constants) - desktop-controller-utils.ts: pure sameCronSignature helper (+ unit tests) Pure restructuring, no behavior change. desktop-controller.tsx: 1,441 -> 1,233. * test: make windows no-window-flag assertions immune to update-check daemon These tests patch `<module>.subprocess.run`, which is the shared `subprocess` module singleton, so the patch is process-wide. Importing `tui_gateway.server` runs `prefetch_update_check()` at import time, spawning an unnamed daemon thread (`Thread-N (_run)`) that shells out to `git ... origin` (`text=True, timeout=5`). That call races the test and lands in the captured list, intermittently failing `test_tui_gateway_fuzzy_file_listing_hides_git_windows` with either `KeyError: 'creationflags'` (the daemon's git call has no creationflags) or a call-count mismatch (3 git calls captured, not 2). It only reproduced under the parallel test harness because of the extra concurrency/timing. Filter captured calls to the distinctive argv tokens of the call under test (`--show-toplevel`, `ls-files`, `branch --show-current`, `diff`, `rg`, `taskkill`) and read `creationflags` via `.get`, mirroring the existing hardening on `test_gateway_pid_scan_hides_wmic_and_powershell_windows`. The production code is unchanged; this is a test-isolation fix. * perf(desktop): cache memory-graph paint + billboard node sizing - Sprite-atlas the orbs: render each (ink, sheen, darken) appearance once, blit it per node, instead of allocating a radial gradient every frame. - Split paint into a cached static layer + a live core scramble; the heavy scene only re-renders on real change, so an idle map costs a scramble + one drawImage rather than a full redraw. - Pause the render loop while the window is hidden/blurred; resume on focus. - Make the scramble's glyph count data-independent (constant cells to the rim, clamped size) so it's the same field on any graph; size tracks zoom. - Size nodes against the rested fit (fitScale), held stable through playback's spore-zoom — so t≈0 no longer balloons orbs into bubbles. - Wind the timeline constellation along a helix for depth. * refactor(desktop): extract use-prompt-actions standalone helpers into utils The usePromptActions hook is the textbook "god hook" AGENTS.md warns against. As a first, safe slice, pull its module-level standalone helpers (no closure over hook state) into a focused, testable use-prompt-actions-utils.ts sibling: - error classifiers: isSessionNotFoundError, isSessionBusyError, isProviderSetupError, inlineErrorMessage - session-busy retry: withSessionBusyRetry (+ its constants) - attachment IO: base64FromDataUrl, imageFilenameFromPath, readImageForRemoteAttach, readFileDataUrlForAttach, friendlyRemoteAttachError - misc: delay, isSessionIdCandidate, blobToDataUrl, renderCommandsCatalog, slashStatusText, appendText, visibleUserOrdinal, visibleUserIndexAtOrdinal, the _submitInFlight guard set, and the GatewayRequest type Pure restructuring, no behavior change; the usePromptActions and uploadComposerAttachment exports (and their import paths) are unchanged. Adds unit tests for the pure helpers. use-prompt-actions.ts: 1,956 -> 1,772. * feat(agent): restore surface-aware "auto" default for verify_on_stop #53552 flipped verify_on_stop to default OFF because the guard fired on doc/markdown/skill edits and felt like noise. That doc/markdown/skill suppression already shipped in the same change (_filter_verifiable_paths in agent/verification_stop.py), so the original noise rationale no longer holds: the guard already skips prose-only turns. Restore the surface-aware "auto" default — ON for interactive coding surfaces (CLI, TUI, desktop) and programmatic callers, OFF for conversational messaging surfaces (Telegram, Discord, etc.) where the verification narrative would reach a human as chat noise. The missing/unrecognized fallback in verify_on_stop_enabled now resolves to the same surface-aware default instead of hard OFF, so both the DEFAULT_CONFIG value and the resolver agree. Scope: this changes the shipped default for fresh installs and configs without an explicit verify_on_stop key. Existing configs that #53552/#54740 migrated to an explicit `false` are respected and unchanged — this PR does not add a force-migration of those values back to auto. * fix(desktop): center memory-graph timeline stars, surface quiet buckets Revert the helix coil to the constellation scatter, biased toward the midline (triangular vertical), and stop a packed core ring from crushing every quieter bucket into one invisible speck: sqrt-scale the per-bucket star count, floor star size to 2px, and lift the dim baseline. * refactor(desktop): add iconSize token and migrate ad-hoc icon sizes onto it Finding 1 of the desktop UI-consistency pass: SVG icon sizing had four competing conventions with no source of truth. Introduce a named icon-size scale (iconSize.xs/sm/md/lg/xl -> size-3/3.5/4/5/6) in lib/icons.ts and migrate the genuine icon deviants onto it: - desktop-install-overlay.tsx: Loader2/Check/AlertTriangle/Chevron* (h-4 w-4, h-3.5 w-3.5 -> iconSize.md/sm) - composer/controls.tsx, voice-activity.tsx, queue-panel.tsx: numeric size={N} on Tabler icons -> iconSize classes Sizes snap to the nearest scale step; the only rendered deltas are size={11} -> 12px (queue/stop glyphs, +1px) and AudioLines size={15} -> 14px (-1px, now matches its sibling toolbar icons). All other migrations are exact (12/14/16px). Out of scope (different sizing mechanisms, left untouched): non-icon h-N w-N layout (sliders, skeletons, swatches), sprite size props (PixelEggSprite), and Codicon font-icon sizing. Broader size-N -> token adoption is follow-up. * refactor(desktop): formalize row-as-button primitive (RowButton) Finding 2 of the desktop UI-consistency pass. Several surfaces intentionally make an entire row/cell the click target while hosting nested layout inside a raw <button> (each re-justifying the pattern in a local comment). Introduce a zero-style RowButton primitive (components/ui/row-button.tsx) that bakes in the shared semantics — type="button" + a stable data-slot — without imposing any styling, then migrate every genuine row-button onto it: - app/overlays/panel.tsx - app/artifacts/index.tsx - app/chat/sidebar/chrome.tsx (SidebarRowBody, SidebarRowLink) - app/settings/providers-settings.tsx - components/desktop-onboarding-overlay.tsx (PROVIDER_ROW_CLASS rows) Fully behavior-preserving: RowButton adds no classes, so each row keeps its exact layout/look (verified by a unit test asserting className passthrough). Left as-is (not row-buttons; converting would risk visual regressions): the compact bespoke buttons in shell/statusbar-controls.tsx (STATUSBAR_ACTION_CLASS, also a nested DropdownMenuTrigger asChild) and pet-generate/reference-chip.tsx. * docs(desktop): tighten iconSize doc comment * docs(desktop): tighten RowButton doc comment * refactor(desktop): share theme-repaint observer; memory-graph depth polish Extract the copy-pasted "re-resolve on theme repaint" MutationObserver into a shared hooks/use-theme-epoch (useThemeEpoch + onThemeRepaint) and consume it from the star map, image-gen placeholder, and useIsDark instead of each hand- rolling its own root observer. Keeps the post-paint read the canvas probes need (useTheme() would read stale CSS — child effects run before applyTheme). Also: light-mode band depth (inner wash), travelling-glow core scramble, and dark-only timeline bloom. * fix(desktop): make ⌘K / session-switcher HUDs ignore titlebar drag band The top-center floating HUDs (command palette + session switcher) pin at top-3, overlapping the titlebar's `[-webkit-app-region:drag]` bands. Drag regions win hit-testing over the DOM regardless of z-index, so the top of each surface — the search input — swallowed clicks, leaving only a ~2px strip focusable. Add `[-webkit-app-region:no-drag]` to the shared HUD_SURFACE so the whole surface is interactive. * tune(desktop): ignite memory-graph nodes in clusters, not 1-by-1 Within each ring band, split the time-ordered nodes into a few sub-bursts (~5 nodes each) that share an ignite moment, with a touch of per-node jitter. The build-up reads as clustered pops instead of a constant single-file trickle (or an all-at-once flood). * perf(context-refs): expand @-references concurrently Multiple @-references in one message (esp. @url: refs, each a full web_extract round-trip) were expanded in a serial `for ref in refs: await` loop. Switch to asyncio.gather over the independent _expand_reference calls, reassembling warnings/blocks in original positional order so output is byte-identical to the serial path; the token-budget check is unchanged. Generic + provider-agnostic: helps every web backend equally (exa/tavily/ firecrawl/parallel) since it's above the provider layer. RED/GREEN test: 3 url refs @ 0.2s each = 0.60s serial -> ~0.20s concurrent. * fix(web_extract): bound stored full-text size + give concrete read_file offset Two robustness gaps from the #54843 truncate-store path: - _store_full_text wrote the full clean page to cache/web with no upper bound (path.write_text(content)); a multi-MB page → unbounded per-extract disk write. Cap at MAX_STORED_TEXT_CHARS (2MB, the pre-truncate-store refusal ceiling) with a marker when capped. - The truncation footer told the model 'read_file ... offset=<line>' — a literal placeholder it had to guess. Compute the real starting line of the omitted middle (head line count + 1) so the first read_file lands in the gap. * refactor(desktop): colocate hook/component families into scoped folders Single-scoped helpers/sub-files were sitting flat in shared/grab-bag dirs. Fold each family into its own folder (index = the export, dir resolution keeps public import paths intact), dropping the now-redundant filename prefix: - session/hooks/use-prompt-actions.ts (+ -utils, + tests) -> use-prompt-actions/{index,utils}.ts (+ tests) - components/assistant-ui/thread* + assistant/system/user message renderers -> assistant-ui/thread/{index,content,status,message-parts,timestamp,types, list,timeline,timeline-data,assistant-message,system-message,user-message, user-edit-composer,user-message-text} (+ tests) - components/assistant-ui/tool-fallback(+model)/tool-approval -> assistant-ui/tool/{fallback,fallback-model,approval} (+ tests) Pure move + import rewrites; no behaviour change. App-wide shared primitives (markdown-text, directive-text, tooltip-icon-button, clarify-tool, ansi-text, message-render-boundary) stay flat. desktop-controller intentionally left in app/ (route root; foldering would churn ~80 relative imports for no gain). * fix(gateway): self-heal stale sessions.json routing at message time Detect a routing key whose session is already ended in state.db (end_reason set) inside get_or_create_session and drop the stale entry instead of silently routing the message into a closed session. Previously the only runtime cleanup of sessions.json was the startup _prune_stale_sessions_locked (#52808/#54138), which requires a restart. A session ended while the gateway stays alive — any path that finalizes the DB row without clearing sessions.json — left a live routing key pointing at a closed session. get_or_create_session never consulted end_reason, so it returned that stale entry and every subsequent message was silently dropped (no log, no error, no response) until the next restart. This is the live-gateway variant of #52804/FM9, which needed an actual gateway crash. The guard drops the stale entry and falls through to _recover_session_from_db, which reopens agent_close-ended rows and resumes the SAME session_id (transcript preserved); if the row ended for a non-recoverable reason (e.g. /new) it correctly starts a fresh session. A warning is logged so the event is visible (the field incident reported zero log output). Adds tests/gateway/test_session_store_runtime_stale_guard.py covering the _is_session_ended_in_db helper and the end-to-end routing self-heal (recover-vs-fresh, live-entry untouched, stale-wins-over-suspended, force_new short-circuit). Closes #54878. Co-authored-by: David Gutowsky <david.gutowsky@gmail.com> * refactor(desktop): split use-session-actions into folder + utils Extract the ~16 standalone helpers (message reconciliation, optimistic/resolved session upserts, stored-session resolution, runtime-info application, error classification) out of the 1,254-line god hook into a colocated, tested use-session-actions/utils.ts. index.ts keeps the hook orchestrator (the stateful action callbacks) and consumes the helpers. Pure restructuring, no behaviour change; folder index keeps the import path (`@/app/session/hooks/use-session-actions`) intact. index.ts: 1,254 -> ~950. Adds unit tests for the pure helpers. * refactor(desktop): split use-message-stream into folder + utils Extract the standalone gateway-event helpers (session-info patch derivation, completion-error detection, todo-payload routing, delegate_task -> subagent spec mapping, + the stream-flush/subagent-event constants) out of the 1,285-line hook into a colocated, tested use-message-stream/utils.ts. index.ts keeps the stateful streaming hook and consumes the helpers. Pure restructuring, no behaviour change; folder index keeps the import path intact. index.ts: 1,285 -> ~1,120. Adds unit tests for the pure helpers. * fix(cron): raise default pre-run script timeout from 120s to 1h (#55489) Cron pre-run scripts were capped at 120s by default, which surprised users running long data-collection scripts on crons (the whole point of crons being to offload long work). Raise _DEFAULT_SCRIPT_TIMEOUT to 3600s (1 hour). This bounds the script only — skill/agent jobs already run on a separate inactivity budget (HERMES_CRON_TIMEOUT, default 600s idle, 0=unlimited), not a wall-clock cap. Scripts dispatch to a persistent thread pool and do not hold the tick lock, so a long script doesn't starve other due jobs. Docs clarified to make the script-vs-agent timeout distinction explicit. env/config overrides (HERMES_CRON_SCRIPT_TIMEOUT, cron.script_timeout_seconds) unchanged and still take precedence. * refactor(desktop): extract slash dispatcher into use-prompt-actions/slash The usePromptActions body's largest unit was executeSlashCommand — a ~530-line `/command` dispatcher. Lift it into a colocated useSlashCommand sub-hook (use-prompt-actions/slash.ts): the ~13 values it closed over become a typed SlashCommandDeps object the parent passes in; the dispatcher body (and its inner runSlash recursion) moves verbatim. SlashActionCtx (slash-only) moves with it. Pure restructuring, no behaviour change (verified: full use-prompt-actions test suite still green). index.ts: 1,772 -> ~1,250. * fix(agent): persist compression backoff across resume (#54465) * fix(agent): keep cooldown and lock refresh on one authority (#54465) * fix(agent): release refreshed compression locks on every exit path (#54465) * fix(agent): prevent stale lock leases after early compression exits (#54465) * fix(agent): keep cooldown state on the active session (#54465) * fix(agent): keep unbound compressors on the fail-open path (#54465) * fix(agent): make compression lock-lease refresher tolerate transient DB blips Follow-up hardening on the salvaged #54465 backoff persistence work. The lease refresher's loop treated ANY falsy refresh as a permanent stop (`if not refreshed: break`), conflating two distinct cases: - genuine lost-ownership (rowcount 0) — correct to stop, and - a one-off transient DB error (write contention that escapes _execute_write's retry budget) — which returned False identically. A single transient blip therefore killed the lease for the rest of a multi-minute compression call, silently reintroducing the exact 300s-TTL < ~361s-call expiry wedge the PR set out to fix. Changes: - _CompressionLockLeaseRefresher._run now tolerates a bounded run of consecutive failures (_MAX_CONSECUTIVE_REFRESH_FAILURES = 3) before giving up the lease; a recovered tick resets the counter. Worst-case extra hold is cap * refresh_interval, still bounded by the acquirer's TTL. - Replace the two remaining silent `except Exception: pass` arms in the compression-failure-cooldown persist/clear helpers with debug logging, for parity with their sqlite3.Error sibling arms (a non-sqlite bug was invisible). - Document the join(timeout=1.0) quiesce bound in stop(). - Add 3 regression tests: single-blip tolerance, persistent-failure stop at the cap, and refresh-raising tolerance. * refactor(desktop): extract gateway-event dispatcher into its own sub-hook The remaining bulk of useMessageStream was handleGatewayEvent — a ~550-line event-type dispatcher. Lift it into a colocated useGatewayEventHandler sub-hook (use-message-stream/gateway-event.ts): the values it closed over (sibling streaming callbacks + the 3 stable refs the deps array omitted + options) become a typed GatewayEventDeps object; the dispatcher body moves verbatim. Pure restructuring, no behaviour change (utils tests still green). index.ts: 1,120 -> 540. * refactor(desktop): extract submit pipeline into use-prompt-actions/submit After the slash dispatcher, the next-largest body unit was submitPromptText — a ~280-line submit pipeline. Lift it into a colocated useSubmitPrompt sub-hook (use-prompt-actions/submit.ts) with a typed SubmitPromptDeps object; body moves verbatim. SubmitTextOptions moves to utils.ts (shared by submit + submitText). Pure restructuring, no behaviour change (full use-prompt-actions suite green). index.ts: 1,212 -> 937. * feat(desktop): memory-graph share dialog + core/zoom & light-mode polish - Rework share/import into one Dialog (matches rename/create): a single code field (copy to share, paste + Load to import) with a hover copy button, a Reset link beside the upload icon when viewing an imported map, and plainer copy. - Core orb: scales with the world zoom (~1.25× the inner shell), backdrop wash behind it; on focus/hover the scene composites above the orb so the active tooltip + lit lines are never covered. - fitViewport floors zoom at the reference (5-ring) extent, so big maps render at a constant scale and pan instead of shrinking every node to fit. - Light mode: flip inter-ring band shading to read as depth (not a mound), fade the core ring in from t=0, drop the timeline star glow. - Timeline: filled play glyph, crisper constellation, date moved into the legend. * fix: normalize tool_call_id whitespace in sanitizer _sanitize_api_messages() compared raw tool_call_id strings without stripping whitespace. When assistant-side IDs and tool-result IDs diverged due to surrounding whitespace, valid tool results were treated as orphaned and replaced with [Result unavailable] stub placeholders. Strip whitespace in _get_tool_call_id_static() (both call_id/id paths, dict and object) and at the two result_call_id comparison sites in sanitize_api_messages(). Adds regression tests for preserved-whitespace results a…
NousResearch#54834) The register path builds each profile-gateway slot in a sibling staging dir under /run/service (the scandir s6-svscan watches), then atomically renames it to the live gateway-<profile> name. The staging dir was named gateway-<profile>.tmp — a NON-dotfile — so a concurrent `s6-svscanctl -a` rescan (fired by the cont-init reconciler registering gateway-default, or by a sibling register) would supervise the half-built slot the moment it had a valid type/run: s6-supervise spawns AS ROOT and mkdirs supervise/ root-owned 0700, then the in-flight _seed_supervise_skeleton early-returns on the now-existing supervise/ and the next `mkdir supervise/event` hits PermissionError. That is the arm64-only CI flake on test_s6_unregister_removes_service_dir_in_live_container (PermissionError: /run/service/gateway-phase3test.tmp/supervise/event) — arm64-only because the native-arm runner's wider scheduling jitter lets the rescan land inside the ~ms seed window; amd64 ran 30/30 clean. Fix: dot-prefix the staging dir (.gateway-<profile>.tmp) in both register paths (S6ServiceManager.register_profile_gateway and container_boot._register_service). s6-svscan skips any scandir entry whose name begins with '.', so the half-built slot can never be supervised mid-build. The atomic rename to the dotless live name is unchanged. Verified on a real s6 image (amd64): a non-dotted staging dir is picked up by an svscanctl -a rescan (SUPERVISED owner=root) while a dot-prefixed one is ignored (NOT-SUPERVISED). Added a docker-harness regression test that asserts both, plus a unit test that the staging dir is dot-prefixed.
NousResearch#54834) The register path builds each profile-gateway slot in a sibling staging dir under /run/service (the scandir s6-svscan watches), then atomically renames it to the live gateway-<profile> name. The staging dir was named gateway-<profile>.tmp — a NON-dotfile — so a concurrent `s6-svscanctl -a` rescan (fired by the cont-init reconciler registering gateway-default, or by a sibling register) would supervise the half-built slot the moment it had a valid type/run: s6-supervise spawns AS ROOT and mkdirs supervise/ root-owned 0700, then the in-flight _seed_supervise_skeleton early-returns on the now-existing supervise/ and the next `mkdir supervise/event` hits PermissionError. That is the arm64-only CI flake on test_s6_unregister_removes_service_dir_in_live_container (PermissionError: /run/service/gateway-phase3test.tmp/supervise/event) — arm64-only because the native-arm runner's wider scheduling jitter lets the rescan land inside the ~ms seed window; amd64 ran 30/30 clean. Fix: dot-prefix the staging dir (.gateway-<profile>.tmp) in both register paths (S6ServiceManager.register_profile_gateway and container_boot._register_service). s6-svscan skips any scandir entry whose name begins with '.', so the half-built slot can never be supervised mid-build. The atomic rename to the dotless live name is unchanged. Verified on a real s6 image (amd64): a non-dotted staging dir is picked up by an svscanctl -a rescan (SUPERVISED owner=root) while a dot-prefixed one is ignored (NOT-SUPERVISED). Added a docker-harness regression test that asserts both, plus a unit test that the staging dir is dot-prefixed.
NousResearch#54834) The register path builds each profile-gateway slot in a sibling staging dir under /run/service (the scandir s6-svscan watches), then atomically renames it to the live gateway-<profile> name. The staging dir was named gateway-<profile>.tmp — a NON-dotfile — so a concurrent `s6-svscanctl -a` rescan (fired by the cont-init reconciler registering gateway-default, or by a sibling register) would supervise the half-built slot the moment it had a valid type/run: s6-supervise spawns AS ROOT and mkdirs supervise/ root-owned 0700, then the in-flight _seed_supervise_skeleton early-returns on the now-existing supervise/ and the next `mkdir supervise/event` hits PermissionError. That is the arm64-only CI flake on test_s6_unregister_removes_service_dir_in_live_container (PermissionError: /run/service/gateway-phase3test.tmp/supervise/event) — arm64-only because the native-arm runner's wider scheduling jitter lets the rescan land inside the ~ms seed window; amd64 ran 30/30 clean. Fix: dot-prefix the staging dir (.gateway-<profile>.tmp) in both register paths (S6ServiceManager.register_profile_gateway and container_boot._register_service). s6-svscan skips any scandir entry whose name begins with '.', so the half-built slot can never be supervised mid-build. The atomic rename to the dotless live name is unchanged. Verified on a real s6 image (amd64): a non-dotted staging dir is picked up by an svscanctl -a rescan (SUPERVISED owner=root) while a dot-prefixed one is ignored (NOT-SUPERVISED). Added a docker-harness regression test that asserts both, plus a unit test that the staging dir is dot-prefixed.
NousResearch#54834) The register path builds each profile-gateway slot in a sibling staging dir under /run/service (the scandir s6-svscan watches), then atomically renames it to the live gateway-<profile> name. The staging dir was named gateway-<profile>.tmp — a NON-dotfile — so a concurrent `s6-svscanctl -a` rescan (fired by the cont-init reconciler registering gateway-default, or by a sibling register) would supervise the half-built slot the moment it had a valid type/run: s6-supervise spawns AS ROOT and mkdirs supervise/ root-owned 0700, then the in-flight _seed_supervise_skeleton early-returns on the now-existing supervise/ and the next `mkdir supervise/event` hits PermissionError. That is the arm64-only CI flake on test_s6_unregister_removes_service_dir_in_live_container (PermissionError: /run/service/gateway-phase3test.tmp/supervise/event) — arm64-only because the native-arm runner's wider scheduling jitter lets the rescan land inside the ~ms seed window; amd64 ran 30/30 clean. Fix: dot-prefix the staging dir (.gateway-<profile>.tmp) in both register paths (S6ServiceManager.register_profile_gateway and container_boot._register_service). s6-svscan skips any scandir entry whose name begins with '.', so the half-built slot can never be supervised mid-build. The atomic rename to the dotless live name is unchanged. Verified on a real s6 image (amd64): a non-dotted staging dir is picked up by an svscanctl -a rescan (SUPERVISED owner=root) while a dot-prefixed one is ignored (NOT-SUPERVISED). Added a docker-harness regression test that asserts both, plus a unit test that the staging dir is dot-prefixed.
Infographic
Summary
Fixes the arm64-only CI flake on
test_s6_unregister_removes_service_dir_in_live_container:The root cause is a TOCTOU between the profile-gateway register staging dir and a concurrent
s6-svscanctl -arescan — not, as the test name suggests, anything in the unregister/teardown path.Root cause
register_profile_gateway(and the boot-reconciler twincontainer_boot._register_service) build each slot in a sibling staging dir inside/run/service— the scandirs6-svscanwatches — then atomically rename it to the livegateway-<profile>name. The staging dir was namedgateway-<profile>.tmp: a non-dotfile.s6-svscanskips any scandir entry whose name begins with., but a non-dotted name is fair game. So when a rescan fires while the staging dir is half-built — the cont-init reconciler registeringgateway-default, or a sibling register, both calls6-svscanctl -a—s6-svscansees a dir with a validtype/runand supervises it:s6-supervise gateway-<p>.tmpspawns as root andmkdirssupervise/root-owned0700. The in-flight_seed_supervise_skeletonthen early-returns on the now-existingsupervise/and the nextmkdir supervise/eventhitsEACCES.It's arm64-only because the native-arm runner's wider scheduling jitter lets the rescan land inside the ~millisecond seed window; amd64 ran the register/unregister cycle 30/30 clean.
Two earlier hypotheses were disproven before landing on this:
tests/docker/conftest.py'scontainer_namefixture is function-scoped (freshdocker runper test) and CI runs per-file isolation.-d4with no-t; a half-built staging dir sits unsupervised until an explicits6-svscanctl -a.Fix
Dot-prefix the staging dir (
.gateway-<profile>.tmp) in both register paths sos6-svscancan never supervise it mid-build. The atomic rename to the dotless live name is unchanged, so the published slot is identical.hermes_cli/service_manager.py—S6ServiceManager.register_profile_gatewayhermes_cli/container_boot.py—_register_serviceThis is the true fix. Making
_seed_supervise_skeleton'smkdir/chmodEACCES-tolerant would only paper over a root-ownedsupervise/— the slot would then be unusable by the unprivilegedhermesuser anyway.Verification
Proven end-to-end on a real s6 image (amd64), forcing the rescan rather than relying on the timing window:
s6-svscanctl -arescan resultgateway-raceprobe.tmp(old, non-dotted).gateway-raceprobe.tmp(fix, dotted)Tests:
tests/docker/test_s6_profile_gateway_integration.py::test_s6_dotfile_staging_dir_is_ignored_by_svscan_rescan— new docker-harness regression that asserts both the control (non-dotted IS supervised) and the fix (dotted is NOT), against real s6. Arch-independent: it forces the rescan, so it guards the fix on the amd64 job too.tests/hermes_cli/test_service_manager.py::test_s6_register_staging_dir_is_dotfile_hidden_from_svscan— unit test asserting the staging dir handed to_seed_supervise_skeletonis dot-prefixed.tests/hermes_cli/test_container_boot.pystale-.tmp-cleanup test + the no-remnants glob for the new dot-prefixed name.