feat(desktop): custom endpoint settings (supersedes #42745) - #67759
Conversation
Salvages PR #42745 (elashera:custom-endpoints-desktop), which could no longer merge cleanly against main. Re-integrated the work onto current main and reconciled the conflicts: - Settings nav: wired the new 'Custom Endpoints' provider sub-view into main's data-driven navGroups/OverlayNav layout (PR predated that refactor) and added it to PROVIDER_VIEWS. - providers-settings: kept BOTH main's LocalEndpointRow affordance and the PR's fuller CRUD panel; unified ProvidersSettingsProps to carry onClose + onConfigSaved + onMainModelChanged. - web_server: kept main's _normalize_main_model_assignment + api_key propagation AND the PR's provider base_url lookup in _apply_model_assignment_sync. - model_switch: dropped the PR's bare direct-custom-config picker block; main already implements it (source='model-config', with live model discovery). Updated the salvaged test to assert main's behavior. - Merged additive import/type blocks in hermes.ts and types/hermes.ts. Backend endpoints, i18n labels (en/ja/zh/zh-hant), and the custom-endpoints-settings.tsx panel carried over. 28 custom-endpoint tests pass. Co-authored-by: elashera <emilio.jesus.lasheras.romero@nttdata.com>
Salvage of #42745 (superseded by #67759) preserves @elashera's authorship, whose corporate commit email had no contributor mapping. Adds contributors/emails/ mapping so check-attribution passes. Verified: GitHub user 'elashera' id=135239963 matches their own noreply commit email (135239963+elashera@users.noreply.github.com).
CI fixThe initial Fixed by adding the mapping file All checks now green — |
…1 python/runtime; desktop frozen (#152) * chore(contributors): map s0xn1ck@proton.me -> s0xn1ck * feat(desktop): list config-defined command TTS/STT providers in settings The Settings > Voice provider dropdowns (tts.provider / stt.provider) only offer the built-in providers plus whatever value is currently set. Custom `type: command` providers declared in config.yaml aren't selectable — and once you switch away from one it drops off the list, so you can only return to it by hand-editing config. enumOptionsFor now merges in the names of any `type: command` entries under the tts/stt config sections, so local command-backed engines appear alongside the built-ins and can be switched freely from the UI. Enumeration mirrors the runtime's own resolution so the dropdown can only offer a name the runtime would actually honour: the canonical `<section>.providers.<name>` location plus the back-compat top-level `<section>.<name>` block, the optional `type:` discriminator, and the built-in-name guard. The guard compares against the runtime's built-in sets rather than the ENUM_OPTIONS display list, which is not a substitute — it already omits `deepinfra` (TTS) and `deepinfra`/`local_command` (STT), so a `providers.deepinfra` command block would otherwise be offered as selectable while the runtime dispatches to the native backend instead. - helpers.ts: add commandProviderNames() + the built-in guard; merge for tts.provider + stt.provider - helpers.test.ts: cover both sections, incl. that non-command config blocks aren't offered and that built-ins absent from the display list are never offered as command providers * feat: surface all xAI TTS params in desktop GUI config - Add speed, auto_speech_tags, text_normalization, optimize_streaming_latency, sample_rate, bit_rate to DEFAULT_CONFIG tts.xai block (backend schema source) - Add field labels, descriptions, and section keys in frontend constants.ts for all 7 xAI TTS fields - Update i18n translations (ja, zh, zh-hant) - Fix stale tts.provider options in web_server.py schema overrides (was missing xai, minimax, mistral, gemini, kittentts, piper) * fix(gui): add xAI prefix to all xAI-specific TTS field labels Consistent naming across the xAI TTS settings section. Speed and sampleRate are shown only when xAI is the selected provider, so they get the prefix too. * fix(desktop): drop tts.xai.text_normalization — not honored by the xAI TTS backend Follow-up to the salvaged #56724: the runtime's _generate_xai_tts reads voice_id, language, speed, auto_speech_tags, optimize_streaming_latency, sample_rate, and bit_rate — but never text_normalization, and the xAI /v1/tts payload builder has no such field. Surfacing it in the desktop GUI would be a dead knob, so remove it from DEFAULT_CONFIG, constants.ts (labels/descriptions/SECTIONS), and the ja/zh/zh-hant locale catalogs. The other six xAI keys are all verified against tools/tts_tool.py. * fmt(js): `npm run fix` on merge (#67419) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(credentials): suppress re-seeding when a pool entry is deleted via API (#55217) (#67429) * fix(gateway): per-session turn lease + conversation-scope funnel (#64934) (#67401) * fix(gateway): serialize concurrent turns per resolved session_id with a turn lease Closes the serialization half of #64934. The busy guards are keyed by routing key, but the durable transcript is owned by session_id — and switch_session() makes the key→id mapping many-to-one (/resume from a second chat/topic, CLI-continuity rebinding, async-delegation pinning, topic-binding tip-walks). Two routing keys mapped to one session_id ran concurrent turns on two different agent objects, invisible to every per-key guard: flushes persisted in completion order, the identity-marker dedup swallowed rows, and the second turn ran on a stale history base — leaving a permanent user;user alternation wedge. The fix: an asyncio lease keyed by RESOLVED session_id (gateway/turn_lease.py), acquired in _handle_message_with_agent after session resolution is final (post switch_session/tip-walk), immediately before the transcript load, and released in _handle_message's finally on every exit path. Tokens are granted per (routing key, run generation) so a stale unwind can never release a newer turn's lease (#28686 ownership lesson). Same-key messages never reach the acquisition point mid-turn (both routing-key guards hold them), so the lock is uncontended outside the alias-key route — where the second turn now waits for the first turn's flush and logs one WARNING naming the session and both routing keys (pairs with the #67371 tripwire). Fail-open: a stuck holder degrades to today's unserialized behavior with a loud ERROR after agent.gateway_timeout — never a wedged session; a degraded token holds nothing and can't steal the lease. Registry is size-capped and never evicts a live lease. Persist-disabled review forks never dispatch through _handle_message, so they cannot contend. Known limits (tracked on #64934): CLI-continuity cross-process pairs need a DB-level lease; mid-turn compression rotation leaves a small alias window for a follow-up at the binding-sync sites. Validation: 8 behavior tests (alias-key wait + flush order, no cross-session contention, generation-scoped idempotent release, timeout fail-open without lease theft, bounded registry, bare-runner-safe release wiring) + E2E against a real SessionStore reproducing the issue's switch_session alias route — strict alternation and arrival order preserved. * refactor(gateway): conversation-scope funnel + mid-turn lease rebind Completes the #64934 system beyond the point fix. Two structural changes, both eliminating whole bug classes rather than instances: 1. _clear_conversation_scope — THE single conversation-boundary funnel. /new, /resume, auto-reset, expiry finalization, and the compression-exhausted reset each carried a hand-copied pop-list of the per-session dicts, and the lists drifted every time a new dict was added (#48031, #58403, #10702, #35809 were all 'boundary X forgot dict Y' bugs). All five sites now make one funnel call driven by the _CONVERSATION_SCOPED_STATE registry; adding a new conversation-scoped dict means adding one name to the registry, and every boundary picks it up automatically. Scope rules documented at the registry: turn-scoped state, the monotonic generation counter, and the agent cache are deliberately excluded (different lifecycles). 2. SessionTurnLeaseRegistry.rebind — the held turn lease now FOLLOWS mid-turn compression rotation. Both rotation sites (session-hygiene pre-compression, agent-result session_id swap) alias the same _SessionLease object under the new id, so an alias routing key resolving the fresh child (topic tip-walk) still serializes against the in-flight turn. Closes the rotation-alias window flagged as a known limit on #64934. Ownership-checked like release; when the target id already has a live lease the rebind fails open with a loud WARNING (never a mid-turn deadlock). Tests: 3 new rebind behavior tests + 5 funnel behavior tests (including a real-setter drift guard); the two AST change-detector pins in test_10710/test_48031 were re-pointed at the funnel and the #58403 pin converted to a behavioral test. E2E: rotation-alias scenario against a real SessionStore + SessionDB — turn B on the fresh child waits behind the rotated holder, sees its rows, alternation intact. * fix(desktop): resolve session color for repo-root-only sessions liveSessionProjectId bailed the instant a session had no cwd, so an older/imported session carrying only a git_repo_root — which the backend still groups under its project — got no project and rendered a grey idle dot instead of the project color ("grouped but grey"). Anchor on the repo root when cwd is absent, matching how the sidebar grouped the row, and keep the sibling-worktree guard for the cwd-present case. * feat(desktop): let inherited projects set color and icon Auto-detected git repos ("inherited" projects) have no projects.db row, so their menu hid appearance/rename/etc. entirely and they could never be themed. Add appearance to the auto-project menu: the first color/icon choice adopts the repo as a real project (folder = repo root, name = its label) carrying that look, after which it themes in place like any explicit project. Routes both explicit and auto edits through one setProjectAppearance helper; the picker closes on adopt so a stale second write can't double-create. * bench(desktop): systematized perf harness; sunset 12 one-off scripts (#67466) Replaces the dozen ad-hoc measure-*/profile-* scripts (each reinventing the CDP client — 4 different copies — plus its own arg parsing, stats, output path, and none with a baseline) with one framework under scripts/perf/: - lib/cdp.mjs one CDP client + target discovery + typing + CPU-profile wrapper + DOM selectors - lib/stats.mjs percentiles, histograms, CPU-profile self-time ranking - lib/baseline.mjs load/compare/update baseline + regression gate (new capability) - lib/launch.mjs attach, OR spawn a fully ISOLATED instance - scenarios/* one module per measurement, registered in scenarios/index.mjs - run.mjs / serve.mjs, baseline.json, README.md Isolation solves the long-standing measurement blocker: a running `hgui` held the Electron single-instance lock, so a second instance quit. `--spawn` / `perf:serve` launch with their own --user-data-dir (separate lock scope), their own HERMES_HOME (separate backend/sessions, config seeded from ~/.hermes so it reaches a chat view without onboarding), and their own --remote-debugging-port. Synthetic scenarios drive $messages via window.__PERF_DRIVE__, so no LLM credits. Scenario -> sunset script mapping: stream <- measure-synthetic-stream, profile-synth-stream, profile-long-stream stream --real <- measure-real-stream, profile-real-stream keystroke <- measure-latency, profile-typing, leak-typing transcript <- (new: long-transcript mount cost) submit <- measure-submit, measure-jump session-switch <- profile-session-switch profile-switch <- measure-profile-switch CPU profiling is now a cross-cutting --cpuprofile flag, not 5 separate scripts. CI-tier scenarios (stream, keystroke, transcript) need no backend/credits and are gated against baseline.json (seed values; re-capture with --update-baseline on a reference device). Backend-tier scenarios are report-only. perf-probe.tsx gains loadTranscript() for the transcript scenario. No core files touched; isolation is via CLI args, not env-gated app changes. Verified: node --check all modules, tsc, eslint, and a unit smoke of the stats + regression-gate logic. The end-to-end GUI run (which opens a window) is left to run interactively via `npm run perf -- --spawn`. * fmt(js): `npm run fix` on merge (#67474) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(desktop): Windows browser-setup journey — console flash, idempotent setup, Nous Portal activation (#67473) * fix(windows): suppress console-window flash in tools post-setup subprocess spawns The desktop GUI runs post-setup hooks via a detached, console-less 'hermes tools post-setup <key>' child (spawned with windows_detach_flags). But the hook implementations in tools_config.py ran their inner installers (npm install, agent-browser install, uv/pip installs, ensurepip, cua-driver version probes and installer) without Windows creationflags — and on Windows a console-less parent spawning a console/.cmd child materializes a brand-new console window, the 'terminal flash' reported on the Capabilities > Browser Automation setup journey. Add _post_setup_no_window_flags(), a local wrapper around windows_hide_flags() (CREATE_NO_WINDOW only — DETACHED_PROCESS would sever stdio and break capture_output), and pass it at every post-setup subprocess call site. Spawns that stream live output to the user's console (verbose cua-driver install) only hide when stdout is not a tty, so interactive CLI installs keep their output. POSIX behavior is unchanged (the helper returns 0 off-Windows). * fix(desktop): make Capabilities post-setup idempotent — Installed state instead of unconditional Run setup The GUI panel rendered the primary 'Run setup' CTA whenever a provider declared post_setup, ignoring the server-computed readiness status the config endpoint already serves. Users on Windows clicked 'Run setup' on an already-installed Local Browser and watched it 'install' again. Frontend: PostSetupRunner now takes installed (provider.status === 'ready') and renders an 'Installed' pill + small 'Re-run setup' text button in that state; onComplete still refetches the toolset config, so a fresh install flips the row to Installed once the endpoint reports ready. Backend: - _POST_SETUP_READY extended: agent_browser now tracks the FULL local install (_local_browser_runnable: CLI + Chromium-or-Lightpanda) instead of the bare CLI check; new entries for the cloud 'browserbase' hook (CLI only — cloud rows host their own Chromium) and camofox (npm package present). - _run_post_setup prints distinct 'already installed, nothing to do' messages for the agent-browser/Chromium/Camofox early-exits so the GUI action log tells the truth on re-runs vs fresh installs. i18n: new postSetupInstalled/postSetupRerun/postSetupInstalledHint strings in en, ja, zh, zh-hant + types. * fix(desktop): let managed Nous Subscription rows activate from the GUI via the Portal sign-in flow PUT /api/tools/toolsets/{name}/provider intentionally skips the Nous Portal auth gate the CLI runs inline (ensure_nous_portal_access) — but no desktop surface handled it. Selecting 'Nous Subscription (Browser Use cloud)' from Capabilities wrote browser.cloud_provider=browser-use + use_gateway=true and then silently never activated: _is_provider_active requires feature.managed_by_nous, which stays false without the entitlement, and the credential was never used. Backend: after apply_provider_selection, the endpoint now checks the managed row's entitlement (get_nous_subscription_features force_fresh + the same per-category coverage gate the CLI applies) and reports the gap with additive response fields {needs_nous_auth: true, feature}. The selection is still persisted — activation is what's gated. Frontend: handleSelect surfaces a 'Sign in to Nous Portal' warning toast with a Sign-in action instead of the misleading success toast. The action drives the EXISTING Nous Portal OAuth device-code flow (provider id 'nous' in _OAUTH_PROVIDER_CATALOG): POST /api/providers/oauth/nous/start, open verification_url, poll /poll/{session}; on approval the panel refetches the toolset config so is_active/status flip. i18n: nousAuthNeeded*/nousAuthSignIn/nousAuthDone*/nousAuthFailed strings in en, ja, zh, zh-hant + types. * feat(desktop): per-job model picker in the cron create/edit dialog (#67472) The cron backend has always supported per-job model/provider pins (the dashboard web UI and the cronjob tool expose them), but the desktop app's cron editor had no way to set one — every job silently ran on the global default model. - Cron editor gains an optional Model select, grouped by provider, fed by the same model.options catalog as the chat model picker (configured providers with available models only, curated order preserved). - Resetting to 'Default (global model)' clears a previous pin (model and provider written as null); script-only (no_agent) jobs never touch the model fields since the scheduler ignores overrides for them. - A pinned model that has since left the catalog stays visible and re-selectable instead of rendering Radix's blank trigger. - Job detail pane shows the pinned model when one is set. - ui/select grows SelectGroup + SelectLabel primitives for the grouped list. - CronJob/CronJobCreatePayload/CronJobUpdates types carry model/provider; en/ja/zh/zh-hant locales add the two new labels. The cronjob model tool schema is intentionally unchanged — model selection stays a user-facing UX affordance, not an agent-facing tool parameter. * fmt(js): `npm run fix` on merge (#67486) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * feat(config): surface custom and plugin voice providers in config schema * fix(web): compute voice provider schema options per-request, align guards with desktop (#40338 follow-up) Refactor the cherry-picked #40338 backend half: - Move option merging from import-time _SCHEMA_OVERRIDES mutation to a per-request overlay in GET /api/config/schema — options now reflect the current config.yaml (no restart needed) and the module-level CONFIG_SCHEMA is never mutated. The endpoint gains an optional ?profile= param scoped via _config_profile_scope. - Keep builtin display order first, customs appended (drop the sorted(set(...)) re-sort) — matches desktop enumOptionsFor. - Only command-type provider blocks count (type absent or 'command' plus non-empty command string), enumerated from the canonical <kind>.providers.* location AND the legacy top-level <kind>.<name> fallback — the same dual resolution as _get_named_provider_config / _get_named_stt_provider_config. Builtin-name collisions are excluded case-insensitively against the RUNTIME builtin sets (not the display shortlist), mirroring apps/desktop/src/app/settings/helpers.ts commandProviderNames (#67209). - Drop the plugin.yaml 'provides: [tts]' manifest scan — that convention does not exist (manifests carry provides_tools/provides_hooks only); plugin TTS/STT providers register at runtime via ctx.register_tts_provider(). Instead, opportunistically include names from agent.tts_registry / agent.transcription_registry when plugins happen to be loaded in this process. - Current tts.provider/stt.provider value preserved in options. - Tests: custom command provider merge (tts+stt), builtin-order preservation, EDGE collision exclusion, non-command block exclusion, current-value preservation, per-request freshness, legacy top-level block support. * feat(desktop): five Capabilities-tab UX fixes from live testing — hints, vision link, web split, key deep-links (#67482) * fix(desktop): stop contradicting the Ready pill with the one-time-install hint When a provider's server-computed status is 'ready' (post_setup install verifiably satisfied, e.g. cua-driver on PATH), the PostSetupRunner row still said 'This backend needs a one-time install (…)'. Swap the copy for a muted installed-confirmation one-liner and keep the Run setup button for repair re-runs. Gated purely on the provider status prop so it composes with the server-driven resting state work in the sibling lane. * feat(tools): surface the web search/extract capability split in the Capabilities UI The runtime has dispatched web_search and web_extract to independently configurable backends for a long time (web.search_backend / web.extract_backend overrides with web.backend as the shared fallback), but the Capabilities tab still presented one monolithic 'Web Search & Extract' choice that only wrote web.backend. Backend: - GET /api/tools/toolsets/web/config now returns active_search_backend / active_extract_backend resolved via the REAL runtime getters (tools.web_tools._get_search_backend/_get_extract_backend), plus each provider row's web_backend key and supported capabilities (from the registry's supports_search/supports_extract flags). - PUT /api/tools/toolsets/web/provider accepts an optional capability ('search'|'extract') that writes web.<capability>_backend without touching web.backend; validates the provider actually supports the requested capability (ddgs/brave-free are search-only). Omitted → unchanged legacy apply_provider_selection path. - New tools_config.web_provider_capabilities() helper reads the plugin registry's capability flags. Frontend: 'Search: <backend>' / 'Extract: <backend>' pills above the web provider matrix, per-row 'Search backend'/'Extract backend' assignment pills, and 'Use for Search'/'Use for Extract' actions gated on each backend's declared capabilities. Tests: endpoint tests assert the runtime getters resolve to the written backend (searxng for search, firecrawl for extract) after the endpoint write; vitest covers badges, capability-gated buttons, and non-web toolsets staying untouched. * feat(desktop): deep-link Capabilities key rows to Settings → API Keys Set env-var rows in the toolset config panel now offer 'Manage in API Keys' in the row actions menu — an internal route change to /settings?tab=keys&key=<ENV_KEY>. KeysSettings consumes the ?key= param via the shared useDeepLinkHighlight hook (same mechanism as the command palette's ?field= config deep links and ?session= archived-session links): scrolls the credential card into view, flashes it, and expands it. Applies generically to every env-var row, and only when the key is set (unset keys are managed inline via Set). i18n in en/zh/zh-hant/ja. * feat(desktop): point the vision Capabilities detail at Settings → Models The vision toolset has no TOOL_CATEGORIES provider matrix — its provider/model resolution runs through the auxiliary model config (agent/auxiliary_client.py), so the Capabilities detail pane looked empty with no hint of where the model choice lives. Add a short explainer + an internal deep link (/settings?tab=config:model&aux=vision) rendered only for toolset.name === 'vision'. ModelSettings consumes the ?aux= param via the shared useDeepLinkHighlight hook and scrolls/flashes the matching auxiliary task row (rows now carry aux-task-<key> anchor ids). No external URLs. i18n in en/zh/zh-hant/ja. * test(desktop): use type-alias imports for the react-router mock (lint) * chore: drop accidentally committed node_modules symlinks * chore: drop remaining committed node_modules symlinks (apps/desktop, apps/shared) * fmt(js): `npm run fix` on merge (#67491) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(desktop): profile-scope all cron REST calls Salvaged from #49948 by @helix4u: every desktop cron API call (list/get/runs/create/update/pause/resume/trigger/delete) now carries profileScoped(), so global-remote mode routes the request to the profile the UI is acting for instead of silently hitting the primary backend's default profile. * fix(cron): resolve provider with the job's effective model; default dashboard cron creates to the backend's own profile Two follow-ups to the per-job model pin surface (#67472 / #49948 review): - cron/scheduler.py: pass target_model=<effective job model> to resolve_runtime_provider() on the primary path, so providers with model-specific api_mode routing derive the mode from the model the job actually runs (per-job pin > env > config default) instead of the stale persisted default. The auth-fallback path already did this for its fb_model. - hermes_cli/web_server.py: POST /api/cron/jobs (and its sync worker) no longer hardcodes profile="default" when the request carries no profile param. A pool backend scoped to a named profile now resolves its own profile via get_active_profile_name(), so pre-profileScoped desktop clients can't write a named profile's job into ~/.hermes. Unscoped / custom HERMES_HOME keeps the legacy default fallback. Tests: target_model capture test on run_job; two profile-default tests on the create endpoint. * test(cron): accept target_model kwarg in codex-path resolver stub run_job now passes target_model to resolve_runtime_provider; the codex 401-refresh test stubbed it with a requested-only lambda. Widen to **kwargs like every other cron resolver stub. * test(desktop): contract test — every cron helper is profile-scoped Salvaged from #59888 by @isfttr: the profileScoped() fix itself landed via #67493 (salvaged from the earlier #49948), but this PR contributed a contract test locking all 9 cron helpers to the active gateway profile — omitted when none is set (single-profile users unaffected), attached when one is active. Keeps the multi-profile/remote cron routing from silently regressing. * feat(delegation): live-viewable subagent transcripts — tail your subagents while they work (#67479) * feat(delegation): live-viewable subagent transcripts for delegate_task Each child now streams an append-only, human-readable log to <hermes_home>/cache/delegation/live/<delegation_id>/task-<n>.log while it runs, and the dispatch return includes the paths so the caller can tail them immediately instead of waiting blind for the consolidated summary. - New tools/delegation_live_log.py: LiveTranscriptWriter (per-event append + flush, one-line rendering with truncation, never raises into the agent loop), wrap_progress_callback (tees the child's existing tool_progress_callback events into the log, preserves the _flush contract), dispatch-time creation with pre-headered files so tail -f attaches immediately, manifest.json (goals/task count/per-task status), and 7-day retention pruning on new dispatches. - delegate_task: wraps each child's progress callback with the writer; sync results and background dispatch responses gain live_transcripts (+ hint field on dispatch); per-task result entries carry live_transcript; transcripts finalized with exit-reason markers. - async_delegation: dispatch_async_delegation_batch accepts an optional delegation_id so the live/ dir name matches the returned handle; the completion event carries live_transcripts. - process_registry: consolidated batch-completion block references each task's live transcript path. - Tool schema description documents the live_transcripts return surface; docs gain a 'Live Transcripts' section with a tail -f example. Placement under cache/delegation means the logs are mounted read-only into remote terminal backends for free. Side-channel only: zero changes to message content, so prompt caching is unaffected. Transcript-OUT only — no overlap with the subagent control surfaces of PR #66046. * fix(delegation): label the kickoff transcript line as user — it is the child's one user message * fix(desktop): scope the cron jobs list to the active profile Salvaged from #42654 by @digitalbase (earliest report of the leak, June 9): the desktop sidebar and cron overlay showed EVERY profile's jobs because GET /api/cron/jobs defaults to profile=all and the desktop never sent the param — profileScoped() (landed in #67493) routes the backend process but adds no endpoint filter on local pools. - hermes.ts: getCronJobs(profile?) appends ?profile= when given; omitting the arg keeps the legacy unfiltered path. profileScoped() still rides along for process routing. - use-session-list-actions.ts: sidebar cron refresh passes the sidebar's profile scope (concrete profile → own jobs; ALL_PROFILES → 'all'). - app/cron/index.tsx: the cron overlay's refresh uses the same scope so the overlay and sidebar (shared $cronJobs atom) always agree. - Tests: list ?profile= contract in hermes-cron-scope.test.ts; sidebar scoping in use-session-list-actions.test.tsx. Reworked onto current main per the sweeper review: threaded through the existing profileScoped()/list-param seams instead of the original PR's pre-refactor call sites (DesktopController has since delegated to use-session-list-actions). * feat(agent): adaptive thinking for Kimi-family Anthropic endpoints Kimi's Anthropic-compatible endpoints (api.moonshot.cn/anthropic, api.kimi.com/coding) implement the adaptive thinking contract — they accept thinking.type=adaptive + output_config.effort (all of low, medium, high, xhigh, max verified live) and return thinking blocks, and the replay-validation 400s that originally motivated dropping the parameter (#13848) no longer occur. _supports_adaptive_thinking() now returns True for Kimi-family models, so they get thinking={type: adaptive, display: summarized} + output_config.effort via ADAPTIVE_EFFORT_MAP instead of nothing, and the blanket drop of the thinking parameter for Kimi-family endpoints is removed. MiniMax and other non-adaptive third parties keep the manual budget_tokens path; Claude behavior is unchanged. * fix(desktop): support spaced Windows Git paths in review simple-git's custom-binary validation rejects paths containing spaces, so the default Windows Git install (C:\Program Files\Git\cmd\git.exe) made every Review pane git call throw and the pane silently showed 'No diffs'. The binary is resolved inside the Electron main process from known install locations or PATH — never renderer/user input — so for spaced paths we opt into simple-git's supported unsafe.allowUnsafeCustomBinary escape hatch rather than falling back to PATH (often absent in GUI-launched apps). Simplified from PR #64713 by @unsupportedpastels; supersedes the 8.3 short-path approaches in #55337/#60156. Fixes #54888 * bench(desktop): make --spawn work + capture a real baseline (#67670) - Resolve the vite CLI via vite/package.json `bin` (Vite 8's exports block importing vite/bin/vite.js directly — --spawn failed with ERR_PACKAGE_PATH_NOT_EXPORTED). - Add a post-launch settle so cold-start contention (vite dep pre-bundling, first backend-connect attempts) doesn't contaminate the first scenario. - Drop the raw autolink from the default stream chunk (resolvable URLs trigger link-embed DNS lookups unrelated to render cost). - Replace seed baseline with real numbers from a darwin-arm64 --spawn run. keystroke + transcript are clean; stream is a clean single-run capture (the isolated backend may not connect, and its reconnect churn inflates frame pacing — re-capture on a connected instance for tighter tolerances). * refactor(desktop): tidy session-color pass (#67671) - sessionColorFor: drop the no-op `?? undefined` (the map read is already string | undefined). - sessionProjectColor: fix a now-stale doc line — a rootless (no cwd AND no git_repo_root) row returns null, not any cwd-less row (repo-root-only rows resolve since the grouped-but-grey fix). - ProjectMenu.applyAppearance: await instead of a .then block; flatten the auto-branch's nested ternary. * feat(desktop): per-session color override (#66565 layer 2) (#67681) Add a color picker to the session menu (an Appearance submenu of reusable ColorSwatches, in both the dropdown and right-click flavors). The pick is a per-session override that wins over the inherited project color; clearing falls back to it. Storage is desktop-local like pins ($sessionColorOverrides persistentAtom), keyed by the DURABLE lineage id so a color survives auto-compression's id rotation. Precedence folds into the existing $sessionColorById resolver, so sidebar rows AND pane tabs pick it up with no changes to either — the payoff of the shared store. To take this to the TUI later, promote this one atom to a backend SessionInfo.color field; the resolver and picker stay put. * bench(desktop): trustworthy --spawn stream numbers + real baseline (#67694) Chased the "stream frame p95 = 60ms with ZERO longtasks" mystery to its actual cause: the default stream chunk had no paragraph breaks, so it grew into one giant ~22KB block that re-rendered fully every flush — defeating the block memoization real streaming relies on. Plain text = 21ms; realistic chunk with `\n\n` breaks (blocks settle, only the tail re-renders) = 23ms. Fixed the default chunk to model real LLM output; a break-less `--chunk` remains available as a single-block worst-case stress. Also hardened the isolated instance so measurements reflect real cost: - Wait for the gateway socket to actually connect before measuring (a booting/ absent backend's reconnect backoff churns the main thread). Exposed via a new __PERF_DRIVE__.connected() probe reading $gateway.connectionState. - Focus emulation + anti-throttle/occlusion flags so a backgrounded perf window isn't frame-throttled (no OS focus stealing). - Generation-guarded the rAF frame recorder so repeated runs don't leave overlapping recorders polluting frame intervals. Baseline re-captured as the median of 5 --spawn runs (darwin-arm64); all three CI scenarios now green and stable. Absolute values are dev-build (noted in _meta) — regression guards, not shipped numbers. * bench(desktop): measure the full picture — prod build, cold-start, first-token (#67697) Stop drip-feeding scenarios: extend the harness to cover the latencies that actually dominate perceived speed, and measure them on a REAL production build. - --prod: build a production renderer with the probe included (VITE_PERF_PROBE=1, off in normal builds) and launch it from dist/. Measures minified React, so numbers are representative shipped figures instead of ~3x-inflated dev ones. - cold-start scenario (tier "cold"): launch → CDP → driver → first paint, via a fresh isolated spawn per run. Captures spawn_to_cdp_ms, spawn_to_driver_ms, fcp_ms. - first-token scenario (backend tier): Enter → first assistant token painted — the TTFT latency an agent app is uniquely judged on. - run.mjs gained --prod (build once), cold-start fresh-spawn loop, and gates ci+cold tiers against the baseline. Baseline re-captured on a PRODUCTION build (median of 5), darwin-arm64 — all green. Representative numbers: cold-start spawn→interactive ~1.6s, FCP ~0.5s stream frame p95 22ms, 1 longtask keystroke p50 2ms, p95 8.7ms transcript mount 145ms, 82ms longtask (400-msg open) The prod build also settled the open question from the dev numbers: the transcript-mount "lead" (221ms longtask in dev) is only ~72-82ms in prod — not actionable. Measurement did its job. * fix(dashboard): don't let a provider-name query hide the selected provider's models (#65374) (#65413) Co-authored-by: Simplicio, Wesley (ext) <wesley.simplicio.ext@siemens-energy.com> * fix(dashboard): opaque MoA presets modal (stop page bleed-through) (#67410) * fix(dashboard): make MoA presets modal opaque and readable Card defaults to bg-background-base/80 glass, so the Mixture of Agents dialog let the Models page bleed through — especially on Cyberpunk/mobile. Portal an opaque dialog shell above the z-2 dashboard column, and ignore Escape while the nested model picker is open. * test(web): lock dashboard modal shell to opaque panel classes Guard the MoA/dialog shell contract so glass Card defaults cannot quietly return to modal panels, and Escape stays picker-aware. * bench(desktop): trustworthy cold-start measurement (code-splitting is not the lever) (#67720) * bench(desktop): measure the full picture — prod build, cold-start, first-token Stop drip-feeding scenarios: extend the harness to cover the latencies that actually dominate perceived speed, and measure them on a REAL production build. - --prod: build a production renderer with the probe included (VITE_PERF_PROBE=1, off in normal builds) and launch it from dist/. Measures minified React, so numbers are representative shipped figures instead of ~3x-inflated dev ones. - cold-start scenario (tier "cold"): launch → CDP → driver → first paint, via a fresh isolated spawn per run. Captures spawn_to_cdp_ms, spawn_to_driver_ms, fcp_ms. - first-token scenario (backend tier): Enter → first assistant token painted — the TTFT latency an agent app is uniquely judged on. - run.mjs gained --prod (build once), cold-start fresh-spawn loop, and gates ci+cold tiers against the baseline. Baseline re-captured on a PRODUCTION build (median of 5), darwin-arm64 — all green. Representative numbers: cold-start spawn→interactive ~1.6s, FCP ~0.5s stream frame p95 22ms, 1 longtask keystroke p50 2ms, p95 8.7ms transcript mount 145ms, 82ms longtask (400-msg open) The prod build also settled the open question from the dev numbers: the transcript-mount "lead" (221ms longtask in dev) is only ~72-82ms in prod — not actionable. Measurement did its job. * bench(desktop): trustworthy cold-start measurement (code-splitting is NOT the lever) Investigated code-splitting the ~22MB renderer bundle to cut cold start. It is the wrong fix on both counts: 1. Intentional design: vite.config disables codeSplitting because Shiki emits thousands of dynamic chunks and electron-builder OOMs scanning them — a packaging/installer constraint, not an oversight. 2. The data says it wouldn't help. Fixing the cold-start measurement to be trustworthy and reading the boot composition (prod build): spawn → interactive ~1.5s renderer nav → DOMInteractive ~0.8s, → DOMContentLoaded ~1.06s so the whole 22MB bundle EVAL is only ~0.27s (DCL − DOMInteractive) of the ~1.5s. The dominant costs are Electron/window startup and React app mount — neither touched by splitting. The measurement fixes (the real content of this PR — no app change, since the optimization was rejected): - Drop HERMES_DESKTOP_BOOT_FAKE from spawned instances — it injected artificial per-phase boot-overlay sleeps that inflated cold-start (and slowed every run). - Unique debug/dev port per cold-start run — a just-killed instance can hold :9222 briefly, so reusing it made CDP attach to the DYING instance and report garbage (spawn_to_cdp of ~4ms). Stepping the port per run fixes the race. - Richer boot marks (dom_interactive, dom_content_loaded, main-script size) so cold-start composition is visible, not just a single number. - Forward all numeric boot marks from the cold-start loop. - Re-baseline cold-start with the clean numbers. A real cold-start win would target Electron startup / app-mount (e.g. V8 code cache, deferred non-critical mount) — a future pass, now that it's measurable. * bench(desktop): measure representative (warm-cache) cold start (#67733) Profiling the boot answered "is there a real cold-start win?": no wasteful hotspot — the renderer does only ~tens of ms of work at mount, no heavy library (shiki/mermaid/katex/d3/motion) initializes at startup; the rest is Electron runtime + waiting, near the Electron floor. It also exposed that the cold-start number was pessimistic: a fresh --user-data-dir per run means a COLD V8 code cache and worst-case bundle recompile every launch. Real users reuse their profile. Measured delta: fresh (cold cache): spawn→interactive ~1.48s reused (warm cache): ~1.0s So representative launch is ~1.0s; only first-launch-after-install pays ~+400ms. - coldStartSamples() reuses one profile (run 0 warms the cache, discarded; runs 1..N are warm samples), stepping ports + pausing so the single-instance lock releases. `--cold-fresh` measures the first-launch worst case. - Re-baselined cold-start with the representative warm numbers. Net: nothing high-ROI left to optimize. The only lever is shipping a pre-warmed V8 code cache to make first launch match warm (~400ms, once per update) — real packaging complexity for a marginal win, deliberately not pursued. * perf(desktop): stop per-token sidebar + tool-row re-renders during streaming Two real render-cost wins found by inspection (no behavior change): 1. Sidebar re-rendered on every stream token. $sessionStates is republished on every message delta (tens/sec during a turn), and the derived ID computeds ($workingSessionIds, $attentionSessionIds, $backgroundRunningSessionIds) allocated a fresh array each time. nanostores notifies on !==, so the whole ChatSidebar + every mounted row re-rendered per token even when the working/ attention/background set was unchanged. Return the previous array reference when the contents match → nanostores skips the notify unless the set actually changes. Turns streaming from O(visible rows)/token into O(0) for the sidebar. 2. Tool rows normalized the FULL uncapped detail every render. `looksRedundant` (lowercase + whitespace-collapse over the entire read_file/terminal payload) ran twice in the ToolEntry render body, so every completed tool re-normalized its whole output on every stream tick of the running message. Memoize on the view fields so it recomputes only when the tool's content changes. Both are correctness-preserving (stable refs + memoization). The CI stream scenario drives $messages directly, not the publishSessionState path, so it won't reflect #1 — verified by inspection. * fix(desktop): stop hard-failing pack on non-git checkouts + fix ZIP-path autocrlf (supersedes #67643) (#67730) * fix(desktop): allow write-build-stamp from non-git checkouts Stop hard-failing npm pack when neither GITHUB_SHA nor git HEAD is available (ZIP installs / broken .git). Emit an explicit fallback stamp instead so local Windows desktop builds can finish (#50823). * fix(desktop): treat fallback stamps as unpinned; harden Windows install Keep all-zero fallback commits out of -Commit/--commit pins and fetch install.ps1 by branch instead. After bootstrap, pin the marker to the checkout HEAD so isBootstrapComplete accepts it. On Windows, force ZIP checkout, seed GITHUB_SHA (ASCII-only install.ps1), and avoid the pack stamp failure. * fix(install): pin core.autocrlf=false before ZIP-path checkout (#50823 review) The ZIP-fallback path added in #67643 runs `git checkout -f FETCH_HEAD` before core.autocrlf gets pinned (which only happened later, on the shared clone-path config). On Git for Windows -- where core.autocrlf defaults to true -- that renormalizes the repo's LF text files to CRLF in the working tree during checkout, leaving the freshly-created managed checkout dirty versus HEAD and aborting the next `hermes update`. That is the exact "dirty tree the user never touched" failure the surrounding code already guards against (install.ps1:1461-1469, 1750-1753). Move the `config core.autocrlf false` pin to run immediately after `git init`, before the fetch/checkout. The later idempotent pin on the shared clone path is retained so git-clone installs are unaffected. Addresses teknium1's review on #67643 and supersedes it, preserving the original author's two commits. Co-authored-by: HexLab98 <8422520+HexLab98@users.noreply.github.com> * chore(contributors): map austinpickett commit email for attribution The check-attribution CI gate flagged austinpickett@users.noreply.github.com as an unmapped commit-author email (introduced by the autocrlf fix commit on this PR). Add the per-email mapping file as the gate instructs (the legacy AUTHOR_MAP in scripts/release.py is frozen). --------- Co-authored-by: HexLab98 <liruixinch@outlook.com> Co-authored-by: austinpickett <austinpickett@users.noreply.github.com> Co-authored-by: HexLab98 <8422520+HexLab98@users.noreply.github.com> * fix(desktop): preserve new-chat selector choices (#67729) Salvaged and rebased from #66354 by @UnathiCodex onto current main. Fixes a fresh-chat race in Hermes Desktop where a model, reasoning-effort, or Fast selection made before the first Send could be replaced by an in-flight profile refresh, or read only after the profile handshake yielded. Send is now the linearization point: the visible selector state is snapshotted before awaiting profile readiness, and intent-generation guards make older config/model responses stand down after a picker/toggle action. Adds the contract-v4 session-create wire contract for explicit Fast=false. Conflict resolution vs the original branch (use-model-controls.ts / .test.tsx): combined main's catalog-aware keepManualPick() sticky-pick logic with the PR's profileRefreshEpoch + composerSelectionGeneration staleness guards so both a removed-from-catalog reseed and the in-flight-picker race are handled. Verified on current main: apps/desktop tsc --noEmit clean; 80 affected UI/store tests pass (use-model-controls, use-hermes-config, use-session-actions, model-edit-submenu, model-presets, updates). Co-authored-by: UnathiCodex <theunathi@gmail.com> * feat(x_search): default model grok-4.20-reasoning -> grok-4.5 (#67719) grok-4.5 is xAI's newest release (their versioning is non-monotonic: 4.5 > 4.20) and is the model xAI's own docs use for the server-side x_search tool. Users who explicitly pinned x_search.model keep their choice; everyone else picks up the new default via the config deep-merge — no _config_version bump needed. - tools/x_search_tool.py: DEFAULT_X_SEARCH_MODEL - hermes_cli/config.py: DEFAULT_CONFIG x_search.model + comment - agent/reasoning_timeouts.py: 300s stale-timeout floor entry for grok-4.5 (grok-4.20-reasoning entry kept for pinned users) - docs: x-search.md en + zh-Hans (config sample + troubleshooting) - tests: default-model assertion + timeout-floor positive case * fix(docs): fix broken image and video in TUI docs (#43501) * fix(docs): fix video tag self-closing in tui.md * fix(docs): fix image and video paths, fix self-closing video tag * fix: speed up CLI /model picker by skipping non-current custom provider probing (#65652) * fix: speed up CLI /model picker by skipping non-current custom provider probing The CLI /model picker calls build_models_payload() with default probe_custom_providers=True, which live-fetches /v1/models from every saved custom endpoint on every open. The GUI/desktop picker already passes probe_custom_providers=False for snappiness. Match the GUI behavior: skip probing non-current custom providers, but still probe the current one so its model list stays accurate. Users can force a full re-fetch with /model --refresh. Fixes #65650 Related: #63583 * fix(cli): forward force_refresh to model picker probe flags When /model --refresh is used, the CLI model picker must probe all custom providers to refresh their model lists — not skip them. Normal bare /model still skips non-current probes for speed. Mirrors the existing desktop/TUI behavior. Add regression test for both normal and refresh flag forwarding. Fixes #65650 * fix: auto-save discovered models to config for discover-once caching After a successful /v1/models probe, persist the discovered model list back to config.yaml under the matching custom_providers entry. This makes discover_models: false meaningful out of the box — users get a populated cache after the first probe instead of a stale 1-model list. - Add _save_discovered_models_to_config() helper - Call after successful fetch_api_models in section 4 probe path - Skip config write when model list hasn't changed - Idempotent — no-op on empty api_url or model_ids Tests: 4 new tests covering auto-save, empty-probe skip, unchanged skip, and no-op-on-empty-args. All 4 pass. Refs: #65652, #65650 --------- Co-authored-by: ajzrva-sys <302567740+ajzrva-sys@users.noreply.github.com> * fix(tui): recognize standard DSR cursor position reports (supersedes #48762) (#67731) * fix(tui): recognize standard DSR cursor position reports in input parser The CURSOR_POSITION_RE regex only matched DECXCPR reports (CSI ? row;col R) but not standard DSR reports (CSI row;col R without the ? marker). Terminals that respond to CSI ? 6 n with the plain DSR form had their cursor position reports fall through to parseKeypress, where they were inserted as literal text — garbling the composer input with escape sequences like ESC[22;1R. Fix: make the regex match both forms. For the standard form (no ?), only treat it as a cursor position report when row > 1, since modified F3 keys (Shift+F3 = CSI 1;2 R, etc.) always use row 1 and are genuinely ambiguous with row-1 cursor reports. * fix(tui): reject invalid row-zero DSR cursor position reports Follow-up to the standard-DSR recognition fix. The row guard rejected only row === 1, which let CSI 0;col R (row 0, no ? marker) through and misclassified it as a cursorPosition report. Terminal coordinates are 1-indexed, so row 0 is an invalid DSR report and must remain unclassified. Change the guard to row <= 1 to match the stated 'row > 1' semantics, and add a boundary test asserting CSI 0;col R is not emitted as a response. Supersedes #48762; incorporates review feedback from that PR. --------- Co-authored-by: Alex Yates <43525405+yatesjalex@users.noreply.github.com> * fmt(js): `npm run fix` on merge (#67749) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * feat(desktop): custom endpoint settings (supersedes #42745) (#67759) * feat(desktop): add custom endpoint settings (supersedes #42745) Salvages PR #42745 (elashera:custom-endpoints-desktop), which could no longer merge cleanly against main. Re-integrated the work onto current main and reconciled the conflicts: - Settings nav: wired the new 'Custom Endpoints' provider sub-view into main's data-driven navGroups/OverlayNav layout (PR predated that refactor) and added it to PROVIDER_VIEWS. - providers-settings: kept BOTH main's LocalEndpointRow affordance and the PR's fuller CRUD panel; unified ProvidersSettingsProps to carry onClose + onConfigSaved + onMainModelChanged. - web_server: kept main's _normalize_main_model_assignment + api_key propagation AND the PR's provider base_url lookup in _apply_model_assignment_sync. - model_switch: dropped the PR's bare direct-custom-config picker block; main already implements it (source='model-config', with live model discovery). Updated the salvaged test to assert main's behavior. - Merged additive import/type blocks in hermes.ts and types/hermes.ts. Backend endpoints, i18n labels (en/ja/zh/zh-hant), and the custom-endpoints-settings.tsx panel carried over. 28 custom-endpoint tests pass. Co-authored-by: elashera <emilio.jesus.lasheras.romero@nttdata.com> * chore(contributors): map elashera's commit email Salvage of #42745 (superseded by #67759) preserves @elashera's authorship, whose corporate commit email had no contributor mapping. Adds contributors/emails/ mapping so check-attribution passes. Verified: GitHub user 'elashera' id=135239963 matches their own noreply commit email (135239963+elashera@users.noreply.github.com). --------- Co-authored-by: elashera <emilio.jesus.lasheras.romero@nttdata.com> * fmt(js): `npm run fix` on merge (#67771) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * refactor(desktop): DRY the computed-dedup into stableArray + freeze One shared `stableArray(prev, next)` helper replaces the duplicated element-equal/keep-prev logic in both stores, and freezes the shared ref so a future in-place mutation fails loud instead of silently corrupting the cache. Computed return type is now `readonly string[]` (it always was, immutably). * perf(agent): drop per-call base64 re-serialization from request-size estimate Every API iteration computed `total_chars = sum(len(str(msg)) ...)`, which str()-serializes the ENTIRE history — including base64 images and large tool results — just to take its length, then called estimate_request_tokens_rough, which walked the messages a SECOND time (it re-runs estimate_messages_tokens_rough internally, already computed one line above). Now derive both from one image-stripped message estimate: approx_tokens = estimate_messages_tokens_rough(api_messages) # once request_pressure_tokens = approx_tokens + tools_tokens # == old value total_chars = approx_tokens * 4 # log/metric only request_pressure_tokens is byte-identical to the old estimate_request_tokens_rough(api_messages, tools=agent.tools or None) (no system_prompt arg → messages + tools). total_chars only feeds a verbose log and the pre-api-request hook's request_char_count, so a rough proxy is fine and it no longer balloons on image turns. On the TTFT critical path for every call. tests/agent/test_model_metadata.py + test_compressor_image_tokens.py green. * style(agent): tighten request-estimate comment * fmt(js): `npm run fix` on merge (#67793) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * perf(desktop): virtualize the review-pane diff (no more full-Shiki freeze) Selecting a large changed file in the review pane froze it: FileDiffPanel with no fullText + no showLineNumbers rendered SyntaxDiff over EVERY line — a full Shiki highlight + thousands of mounted DOM nodes — because windowing was tied to showLineNumbers/fullText and the review call had neither. Decouple windowing from the gutter: - `windowed = showLineNumbers || virtualized`; windowed paths always render the fixed-row chunked body (TokenizedDiffBody chunked / PreviewDiffRows), never SyntaxDiff, so only visible rows mount. - New `virtualized` prop → windowed scroller WITHOUT the line-number gutter. - Review passes `virtualized` + the preview's fill className. Preview (showLineNumbers + fullText) and tool-card (compact) render byte-for-byte as before — the gutter body just reads the same chunked window it already used, and the no-fullText+highlight case (previously SyntaxDiff) now windows too. tsc + eslint clean. Visual paths preserved by construction; needs an in-app eyeball on a large review diff. * refactor(desktop): merge the two windowed diff returns into one * perf(desktop): stop the file tree going sticky during agent edit bursts revalidateTree runs on every $workspaceChangeTick (mutating-tool completion, coalesced ~500ms). Two costs per tick, gone: 1. clearProjectDirCache() wiped the gitroot + gitignore caches. But listings are read fresh every time (readProjectDir never caches them), so the wipe bought nothing except forcing a full re-read of every ancestor .gitignore — each a full readdir — for every loaded dir, every tick. Dropped; a .gitignore edit is still picked up on the next full refresh (cwd/connection change / manual). 2. reconcile awaited each child dir serially, crawling a wide/deep tree one dir at a time. Now Promise.all over siblings (order preserved), recursing per loaded subfolder. use-project-tree.test.ts + right-sidebar/index.test.tsx green (15). tsc + eslint clean. * style(desktop): tighten revalidateTree comments * perf(desktop): targeted file-tree revalidation instead of whole-tree rescan Rewrite of the paradigm, not just a cheaper version of it. Before, any file mutation bumped a contentless $workspaceChangeTick and the tree re-read EVERY loaded directory to diff — the parent state was never told what actually changed. Now the mutation carries its path: - workspace-events accumulates the changed dir(s) (dirname of an absolute tool path) and exposes consumeWorkspaceChange(); an opaque mutation (terminal, or a relative/unresolvable path) sets `full` instead. - gateway-event passes toolChangedPath(payload) through on tool.complete. - revalidateTree(cwd, change) re-reads ONLY the changed dirs that are loaded and patches just those subtrees — root + untouched folders never hit the FS or re-render. Full recursive reconcile is kept as the fallback for `full`. So a write in one folder no longer crawls the whole tree; the opaque terminal case still self-heals via the full path. Safe fallback everywhere a path can't be resolved, so no change is ever missed. typecheck + eslint clean; use-project-tree / right-sidebar / gateway-events tests green. * perf(desktop): rAF-coalesce pane + console sash resizes Both drag handlers wrote to nanostores on every pointermove — the pane sash via setPaneWidth/HeightOverride / setTreeSplitWeights (relayouts the whole pane tree), the preview console sash via consoleState.setHeight (reflows webview + split). pointermove outpaces 60fps, so that's several store-driven relayouts per frame during a drag. Stash the latest clamped value and apply it once per frame in a requestAnimation- Frame (the same pattern drag-session.ts / use-popout-drag.ts already use); cleanup cancels the pending frame and commits the final position. Behavior identical, just one relayout per frame instead of per event. typecheck + eslint clean; preview-pane tests green. * refactor(desktop): extract shared rafCoalesce helper for sash drags * perf(desktop): stop eagerly JSON.stringify-ing every tool's args + result buildToolView ran prettyJson (JSON.stringify + clamp) on part.args AND part.result for EVERY tool row, on every rebuild: - rawArgs was dead — assigned + typed, never read anywhere. Removed. - rawResult is only rendered by the web_search raw-JSON drilldown, yet was serialized for read_file/terminal/every tool. Moved to a memoized, web_search- only computation in the consumer (fallback.tsx), so a 100KB read_file result is no longer stringified just to be discarded. No behavior change (web_search drilldown identical; clamp still applies via prettyJson). The oversized-result guard test retargets from view.rawResult to prettyJson (its real layer now). typecheck + eslint clean; fallback-model tests green (26). * perf(desktop): stop tool rows re-rendering on session/cwd change + memo leaves Two tool-render wins during streaming / on session switch: 1. Every ToolEntry did useStore($activeSessionId)+useStore($currentCwd), so any session or cwd change re-rendered *every* mounted tool row — but they're only read inside the preview-artifact effect. Read .get() at fire time instead (the effect only runs when a previewable target appears); no subscription. 2. memo() AnsiText + CompactMarkdown. Their text props are string values (value-equal across renders), so memo skips the re-render — and the per-tick ANSI parse / Streamdown re-run — when a parent ToolEntry re-renders on an unrelated stream delta. No behavior change. typecheck + eslint clean; tool fallback tests green (30). * test(desktop): widen Testing Library async deadline to de-flake UI panels (#67849) findBy*/waitFor default to a 1000ms deadline, which is too tight for async-heavy settings panels (radix menus + refetch chains) when the full suite runs under xdist CPU contention in CI. toolset-config-panel.test.tsx has reddened unrelated PRs multiple times with `Unable to find ...` timeouts that pass on re-run — the textbook contention flake. Bump asyncUtilTimeout to 5000ms in the shared ui setup. Success still resolves the instant the node appears; the wider deadline only absorbs a starved runner, so happy-path speed is unchanged and only genuine failures wait longer. * perf(desktop): idle-mount boot-hidden panes off the cold-start critical path (#67857) * perf(desktop): idle-mount boot-hidden panes off the cold-start critical path The layout tree keeps a chrome-hidden pane's content MOUNTED behind display:none (so toggling back is instant) — but that means files, preview, review (Shiki diff) and logs all mount their real content during first paint even though none are visible at launch (fresh profile: no cwd, review off, no preview target, logs not in the default tree). First paint only needs sessions + workspace + statusbar; the rest is pure app-mount tax, the one cold-start lever that's actually in our code (Electron startup and the un-splittable bundle eval are not). Wrap those four pane renders in <IdleMount>: mount on requestIdleCallback (2s timeout fallback), then stay mounted. Idle fires within a frame of first paint, so a hidden pane is warm before it can be revealed — zero UX change, the instant-toggle contract intact. Degrades to eager mount where rIC is absent (jsdom/tests), so no behavioral fork. * refactor(desktop): collapse the four idle-mount wrappers into one idle() helper * fix(desktop): scope multi-pane model UI and stabilize tile chrome (#67855) * fix(desktop): scope multi-pane model UI and stabilize tile chrome Composer model controls were still keyed off the primary session globals, so every tile showed the same model and a busy primary blocked switches in idle panes. Bind the pill/menu/select path to SessionView, force lone session-tile headers (incl. after tab cycle), and persist strip order so add/remove/switch stops scrambling adjacent panes. * fix(desktop): scope preset effort/fast writes per surface, simplify tile order sync A tile's model pick still pushed effort/fast onto the primary composer globals via applyModelPreset — scope it to the surface (primary → globals, tile → its session slice). Tile order persistence drops the before-stamping walk for a plain sort by tree encounter order; restore replays the array sequentially so array order is strip order. * test(desktop): cover tile strip-order + selection-home; fix stale docs Extract syncTileStripOrder's sort into a pure `orderTilesByTree` and the selection listener's guard into `selectionHomesToWorkspace` (same shape as the PR's lone-header extraction), then unit-test both — the two store behaviors that shipped without coverage. Correct the `anchor`/`before` docs (now persisted, not in-memory) and note that a tile's effort/fast edit still writes the shared per-model preset even though the session write is scoped. * fix(desktop): drop forbidden import() type annotations in model tests `importOriginal<typeof import('…')>()` trips consistent-type-imports (error) and reddens the desktop lint job. Switch to the repo's accepted top-level `import type * as X` + `typeof X` form, matching skills/index.test.tsx. * fix(desktop): retry OAuth cookie read on cold-start jar race (#67769) A `persist:` partition's cookie store hydrates lazily, so the first cookies.get() on a fresh launch can return empty for a signed-in user. That false-negative made hasLiveOauthSession() throw "not signed in", which on the no-retry initial boot path surfaced as the transient "Hermes couldn't start" OAuth overlay that always cleared on Retry. hasLiveOauthSession now reads once (no added latency on the happy path); only on an empty read does it warm the store (flushStorageData + a throwaway get, memoized) and re-read with a bounded ~180ms backoff before trusting the negative. Genuinely signed-out users still resolve false quickly and get the overlay. Fixes the whole class: the same function backs the reconnect path and the Settings connected indicator. * fix(gateway): don't spend a redelivery attempt when the platform is down The delivery ledger durably records a final response before the send so a crash between finalize and platform ACK can redeliver it on the next boot. attempts is that redelivery budget, capped at MAX_ATTEMPTS=3. sweep_recoverable() claims every dead-owner row and increments attempts before the caller knows whether it can send. self.adapters only holds a platform after its connect() succeeded, so when the platform failed to connect this boot _redeliver_pending_obligations() hits its "adapter is None" branch and continues WITHOUT sending — but the attempt is already spent. Three such boots and the row abandons, having never been sent once. That is the loss the ledger exists to prevent, and the trigger correlates with the crash that created the obligation: the network trouble that killed the send tends to still be there on the next boot. Worse, the message stays lost — once abandoned it is never retried even after the platform recovers. Reproduced against the real runner with an unconnected adapter: boot 1: claimed=1 state='attempting' attempts=1 (0 sends attempted) boot 2: claimed=1 state='attempting' attempts=2 (0 sends attempted) boot 3: claimed=1 state='attempting' attempts=3 (0 sends attempted) boot 4: claimed=0 state='abandoned' attempts=3 (0 sends attempted) Let the caller declare which platforms it can send on, and skip claiming rows for the others. attempts then only ever buys a real send. Rows for a platform that never returns are still bounded by the stale cutoff, so nothing accumulates. The parameter is keyword-only and optional — omitting it keeps the previous claim-everything behaviour for other callers. * fix(config): whitelist Hermes-owned roots doctor falsely flagged Hermes writes known_plugin_toolsets via tools_config and bridges group_sessions_per_user / thread_sessions_per_user in gateway/config, but doctor treated them as unknown top-level keys. Add them to _EXTRA_KNOWN_ROOT_KEYS so validation matches keys Hermes itself uses. * test(config): cover doctor allowlist for Hermes-written root keys Regression for known_plugin_toolsets / group_sessions_per_user / thread_sessions_per_user so validate_config_structure no longer false-positives on keys Hermes owns. * fix(config): widen doctor allowlist to all gateway-bridged top-level keys Salvage of PR #67447 — the original PR fixed 3 of 7 missing keys. gateway/config.py reads 4 more top-level keys (stt_echo_transcripts, reset_triggers, always_log_local, filter_silence_narration) that produced the same false 'Unknown top-level config key' warning. Add all 4 and extend the regression test to cover them. * fix(compression): stop the progress floor from splitting a tool group _find_tail_cut_by_tokens aligns cut_idx away from tool-call/result boundaries (_align_boundary_backward), and both tail anchors re-align after moving it. The final statement then raised the result to head_end + 1 so compression always claims at least one message — without that floor the caller's compress_start >= compress_end guard turns the pass into a no-op that re-runs forever. That raise discarded the alignment. When the floor land…
Overlay panes each set their own top padding, so the Settings sidebar and Panel headers sat at different heights than System/Agents and the close X (the NousResearch#67759 regression). Hoist the shared beside-the-X clearance into OVERLAY_TOP_CLEARANCE, keep the taller pad only on OverlayMain (which sits under the X), tighten OverlayMain's gutters, and drop the one-off Settings override. Also give PanelAction a `primary` variant so a detail header can promote its main action to a filled button.
#25) * fix(checkpoints): bind an empty orphan preview to an empty deletion allowlist Follow-up for salvaged PR #69141, addressing the last open review point: cmd_prune() only set orphan_allowlist inside 'if orphans or pre_v2_orphans', so a zero-orphan preview passed the unrestricted None sentinel down to prune_checkpoints(), authorizing deletion of any project that became orphaned between the preview and the rescan — with zero confirmation calls. The allowlist is now bound unconditionally for every non-force run (empty preview => empty allowlist); --force keeps None. Adds the zero-orphan-preview timing regression plus allowlist-identity tests. * fix(agent): cache static system prompt prefixes * fix(prompt-caching): inject cache breakpoints after message normalization The conversation loop normalizes message text right before the API call so the request prefix is byte-identical across turns -- the stated reason is KV cache reuse on local inference servers and better cache hit rates on cloud providers. Cache breakpoints were injected *before* that pass, which defeats it. `_apply_cache_marker` rewrites a plain-string `content` into a `[{"type": "text", ...}]` block. The normalization pass is guarded on `isinstance(content, str)`, so every message that just got marked is silently skipped by it and keeps its raw leading/trailing whitespace. A message is only marked while it sits in the last-3 window, so: turn N in the window -> marked, content "file1\nfile2\n" turn N+1 rolled out -> plain, content "file1\nfile2" The same logical message is sent with different bytes on consecutive turns. The prefix stops matching at that position -- which is inside the span the breakpoints were placed to protect -- so the reusable prefix collapses back toward the system breakpoint on every turn. Tool results carry a trailing newline almost by default (any shell command output), so this is the common case, not an edge case. Move the injection below every message mutation. Besides fixing the whitespace divergence this stops breakpoints from being spent on messages that the orphan sweep or the thinking-only drop is about to remove or merge away -- a marker on a dropped message is a wasted breakpoint out of the four available. Nothing between the old and new call sites reads `cache_control`, and the mutators now see the plain-string shapes they were written against. * fix(caching): reconstruct static system prefix on session restore and post-compression reuse Follow-up to the cherry-picked #68258 base: the cross-session-stable prefix (_cached_system_prompt_static) was only recorded on fresh builds, so two paths silently degraded to the legacy single-breakpoint layout (flagged in review of #68258/#69341/#69704): - Session restore: gateway surfaces build a fresh AIAgent per turn and restore the persisted prompt verbatim from the session DB; the static prefix stayed None from turn 2 onward, flip-flopping the wire layout. - Post-compression cached-prompt reuse: _invalidate_system_prompt() clears the static prefix, and the keep-cached-prompt branch never restored it. Both sites now reconstruct the stable tier and adopt it ONLY when the authoritative prompt string literally startswith() it — stable-tier drift (skills edited, identity changed) falls back to the legacy layout with the stored bytes untouched. Fail-open on any builder error. The restore-path rebuild is gated on _use_prompt_caching so non-Anthropic routes skip it entirely. Refs #68191 Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com> Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com> Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> * fix(config): preserve opaque .env values The .env sanitizer inferred missing newlines from known KEY= substrings inside existing values. Plain secrets containing those bytes could therefore be split into synthetic assignments and rewritten to disk. Treat each physical line as the only assignment boundary and keep bytes after the first equals sign opaque for boundary discovery. Preserve safe formatting, null-byte removal, BOM handling, and normal one-assignment-per-line parsing. Cover direct loading, dotenv loading, sanitization, writers, and migration with behavioral regressions. Fixes #29155 * fix(web): resolve per-profile gateway state for ?profile= in /api/status When ?profile=<name> was passed to /api/status, the handler used _config_profile_scope to set the HERMES_HOME contextvar override, but the gateway liveness check (get_running_pid_cached) and runtime status read (read_runtime_status) both resolve _get_process_hermes_home(), which deliberately ignores contextvar overrides (issue #56986) — it always reads os.environ['HERMES_HOME'] or the platform default. A named profile's gateway identity files (~/.hermes/profiles/<name>/gateway.pid, gateway_state.json) were therefore never found and the endpoint always reported the profile's gateway as stopped. Fix: when ?profile=<name> is requested, resolve the profile directory and pass explicit profile-scoped paths: - get_running_pid_cached(pid_path=profile_dir / 'gateway.pid') - read_runtime_status(path=profile_dir / 'gateway_state.json') - get_runtime_status_running_pid(..., expected_home=profile_dir) This is the same explicit-path pattern _collect_profile_gateway_topology already uses for per-profile gateway state, and it works within the #56986 constraint (no HERMES_HOME env mutation; read-only cross-profile access). Plain /api/status without ?profile= keeps the exact zero-arg calls, so its behavior — including the pid-cache signature and runtime-status fallback — is byte-for-byte unchanged. Fixes #69143 * test(web): pin per-profile gateway state scoping on /api/status Follow-up for the salvaged #70498 fix: replace the original PR's mock-signature churn (28 lambda **kw edits, needed only because it changed the no-profile call shape) with two targeted regression tests: - ?profile=<name> must pass the profile's gateway.pid / gateway_state.json paths and expected_home to the gateway status readers (HOME-anchored per-profile state under ~/.hermes/profiles/<name>/) - ?profile=<unknown> must 404 via _resolve_profile_dir The production change keeps plain /api/status on the exact zero-arg calls, so every pre-existing test passes unmodified. * test: accept the new profile-scoped kwargs in status fakes /api/status?profile= now passes pid_path=/path=/expected_home= to the PID and runtime-status readers; the profile-unification fakes had zero-arg signatures and raised TypeError. Plain /api/status call shapes are unchanged (pinned by the existing zero-arg tests in test_web_server.py). * fix(gateway): prevent reconnect watcher wedge after network-loss fatal error (#70344) Three-part fix for the gateway going silently deaf after a retryable fatal adapter error (e.g. httpx.ConnectError on Telegram): 1. **Detach-on-timeout in _connect_adapter_with_timeout** — Replaced plain asyncio.wait_for with the task-detach pattern used by _await_adapter_cleanup_with_timeout. asyncio.wait_for cancels the overdue task but then waits for it to exit, so a connect() that catches CancelledError can block recovery forever. The detach pattern releases the runner at the deadline via consume_detached_task_result. 2. **Ensure reconnect watcher always runs after escalation** — Added _ensure_reconnect_watcher_running(), called after queueing a retryable fatal error. If the reconnect watcher task has died (exhausted restart budget, terminal exception), it is respawned so queued platforms are never permanently stranded. 3. **Faulthandler at gateway startup** — Enabled faulthandler + SIGUSR2 dump to a rotating file under HERMES_HOME/logs/ for post-mortem diagnosis of future event-loop freezes. Tests added for _ensure_reconnect_watcher_running (alive, dead, not-started, not-running), fatal-error integration (retryable calls ensure, non-retryable does not), and _connect_adapter_with_timeout (timeout raises, success returns). * fix: explicit encoding for faulthandler file open (ruff PLW1514) * fix(gateway): stay alive on mixed retryable + non-retryable startup failures When connected_count == 0 and at least one platform failed with a non-retryable error, the runner exited with GATEWAY_FATAL_CONFIG_EXIT_CODE (78) even if OTHER platforms failed for merely transient reasons. Real-world shape (NS-609, hosted instance): WhatsApp enabled but never paired (non-retryable whatsapp_not_paired) + Telegram TimedOut during polling startup (retryable) => exit 78 => the gateway either goes permanently down (supervisors honoring the exit-78 contract via RestartPreventExitStatus / the s6 finish->125 translation from #51228) or crash-loops (anything else). Either way Telegram never gets its retry and the dashboard drops with every exit, so a single unpaired platform plus one network blip disconnected every channel on the instance. Now exit 78 is reserved for the case where ALL startup failures are non-retryable (true config error, nothing to wait for). With mixed failures the gateway stays alive in degraded state: the reconnect watcher recovers the retryable platforms and the misconfigured ones stay fatal-parked and visible in runtime status. * fix: gate SIGUSR2 faulthandler registration behind POSIX check signal.SIGUSR2 and faulthandler.register() don't exist on Windows; the bare reference raised AttributeError at import time per the windows-footgun checker. faulthandler.enable() still covers fatal-error dumps on all platforms. * fix(gateway): detect and escape silent event-loop freezes - A self-rescheduling 5s call_later floor timer, armed before any adapter connects, guarantees the selector always has a finite timeout, so the existing async defenses (polling heartbeat, timeout guards) regain a chance to run after a zero-pending-timer stall. - A resident daemon-thread liveness watchdog probes the loop via call_soon_threadsafe every 30s; after 3 consecutive 10s-timeout misses (~120s of total unresponsiveness) it dumps all thread tracebacks and exits with the established GATEWAY_SERVICE_RESTART_EXIT_CODE (75) so a supervisor restarts the gateway - async-level recovery cannot run on a frozen loop. - stop() disarms both guards before any teardown await so a busy shutdown is never misjudged as a freeze. HERMES_GATEWAY_LOOP_WATCHDOG=0 disables; _INTERVAL/_TIMEOUT/_STRIKES tune the thresholds. Fixes #69089 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gateway): close watchdog shutdown race against final-strike exit - Re-check stop_event after a missed probe (before the strike increment) and again on entering the final-strike branch (before the critical log, dump, and hard exit), so a normal stop() landing between the last timeout check and the exit path can no longer be misclassified as a freeze and trigger a supervisor restart. - Deterministic boundary tests pin both re-checks independently (mutation-verified: removing either check turns its own test red); frozen-loop semantics are unchanged. Addresses the shutdown-race review on #69164. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gateway): recheck stop immediately before watchdog hard exit - A stop() landing while the final diagnostics (critical log, traceback dump) are executing could still reach os._exit(75) after the pre-diagnostic check. Add a third stop_event recheck immediately before the hard exit: diagnostics may complete, but a disarmed watchdog never exits. - Deterministic regressions for both windows (stop triggered from inside logger.critical and from inside faulthandler.dump_traceback); mutation-verified (removing the check turns both red). Frozen-loop semantics unchanged. Addresses the second round of the shutdown-race review on #69164. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(gateway): gate loop-liveness watchdog via config.yaml, drop HERMES_* env knobs Follow-up to the salvaged #69164 commits: policy forbids introducing new HERMES_* environment variables, so the four watchdog env knobs (HERMES_GATEWAY_LOOP_WATCHDOG / _INTERVAL / _TIMEOUT / _STRIKES) are replaced with a single config.yaml boolean: gateway: loop_watchdog: true # default; false disables both guards - gateway/config.py: new GatewayConfig.loop_watchdog field (default True), parsed from top-level or nested gateway: form, round-trips via to_dict/from_dict. - gateway/run.py: _start_loop_liveness_guards() checks config.loop_watchdog before arming the floor timer + watchdog (getattr-guarded for bare object.__new__ runners). - gateway/shutdown_watchdog.py: start_loop_liveness_watchdog() no longer reads the environment; probe interval/timeout/strikes are module constants (30s/10s/3 — ~90s to restart, matching the systemd watchdog layer's posture). - hermes_cli/config.py: documented gateway.loop_watchdog default so 'hermes config set gateway.loop_watchdog false' validates. - tests: env-knob tests replaced with config-gate + round-trip tests; the final-strike boundary test injects its probe via max_strikes directly instead of patching the removed env helper. * fix: getattr-guard _stop_loop_liveness_guards in GatewayRunner.stop Teardown-path tests build bare runners via object.__new__ without the liveness-guard machinery; the unguarded call raised AttributeError in 8 tests. Same guard pattern as the start path. * fix(desktop): close cross-session leak windows in composer + session refs (#59305) Two React passive-effect timing bugs let a session switch land in the wrong chat: activeSessionIdRef/selectedStoredSessionIdRef (use-session-state-cache) and the composer's attachment-scope swap (use-composer-draft) both mirrored their source props via useEffect, which fires one commit AFTER the new session's view has already painted — a synchronous read/submit in that window observed the outgoing session's ids/attachments. - use-session-state-cache.ts: mirror the session refs synchronously during render instead of a useEffect, guarded to fire only when the prop itself changed (not unconditionally) so an imperative pin from submit.ts / use-session-actions (e.g. a freshly resumed runtime id, intentionally not synced to the source atom) survives an unrelated re-render. - use-composer-draft.ts: the per-thread attachment-scope-swap effect is now a useLayoutEffect, closing the window before paint. - submit.ts / session-context-drift.ts: add a 3rd drift prong comparing the composer's loaded scope (SubmitTextOptions.composerScope) against the submit target, resolved into the same lineage-root domain (resolveComposerSessionKey) the composer itself uses — comparing against the raw tip id would false-positive-abort every submit into any session that has ever auto-compressed. - routes.ts / chat/index.tsx: the primary composer's durable scope key now prefers the route over a possibly-stale store selection (primaryRouteSelectedSessionId). - use-composer-draft.ts: redacted [composer-rehydrate] diagnostic log (counts/kinds/scope only, never raw refs) for future reports in this class. - chat-runtime.ts: normalize attachment id values (url/path) before hashing so a re-attach with a trailing slash or backslash path dedupes correctly. 16 files, 286 tests across the touched/dependent suites (17 files) green, including new regression coverage for each fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(desktop): satisfy eslint import-order rules in use-composer-draft.test.tsx CI's check:lint failed on two perfectionist rule violations introduced by the new test file: type import ordering and missing blank line between the parent-relative and same-directory import groups. No behavior change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(agent): prevent shared OpenAI client FD-recycle corruption from stale stream watchdog The streaming stale watchdog was calling _replace_primary_openai_client() from its polling thread, which closes the shared client's connection pool. Worker threads from previous stale-killed attempts may still be unwinding their SSL BIOs, causing TLS application-data to overwrite SQLite file headers via FD reuse. This is the same corruption vector documented in #67142 for Anthropic, where the fix was to never close the shared client from a non-owner thread. Apply the same pattern to the OpenAI-wire path: - Stale stream watchdog: skip shared client replacement - Mid-tool-retry cleanup: skip shared client replacement - Stream retry cleanup: skip shared client replacement The request-local client is already closed via _close_request_client_once. The shared client is replaced lazily by _ensure_primary_openai_client on the next request, which runs on the owning thread. Closes #70773. * fix(agent): retire replaced shared OpenAI clients instead of cross-thread pool close Widen the #70773 fix beyond the three in-request cleanup sites removed in the cherry-picked commit: every remaining path that swaps out the shared OpenAI client could still hard-close its pool from a thread that doesn't own the in-flight sockets (credential rotation/refresh on the turn thread, dead-connection cleanup, gateway cache eviction, transport recovery) — the same FD-recycle corruption vector, just rarer. Add AIAgent._retire_shared_openai_client(): shutdown(SHUT_RDWR) all pooled sockets (FD-safe from any thread, unblocks in-flight readers) but never call client.close() — FD release is deferred to GC, which cannot run until every borrowing thread has unwound its SSL BIO. Refcounting is the ownership handshake; with no borrowers the FDs are released immediately. Wired into: - _replace_primary_openai_client (rotation/refresh/dead-conn cleanup) - try_recover_primary_transport (primary_recovery) - release_clients (gateway cache_evict) agent.close() keeps the hard close: full teardown is a real session boundary where no request may be in flight. Tests: new tests/run_agent/test_70773_shared_client_fd_corruption.py covers the three watchdog/retry sites plus retire semantics; existing close-assertions updated to pin retire-not-close. * test: update credential-refresh tests for retire-not-close contract The three refresh tests asserted the replaced shared client gets close()d — the exact cross-thread close #70773 removes. They now pin the new contract: close() is NOT called from the refresh path; the old client is retired (sockets shutdown, FD release deferred to GC). * fix(doctor): UTF-8/latin-1 fallback when scanning .env Prefer UTF-8 for ~/.hermes/.env provider scans, then latin-1 for cp1252/Notepad files. Add regression test for invalid UTF-8 bytes. * fix: handle non-UTF-8 files in OpenClaw migration script * fix: decode config and state files as UTF-8 on non-UTF-8 locales Several file-I/O call sites still use open() / Path.read_text() / Path.write_text() without an explicit encoding, so they fall back to the platform default. On Windows CN/JP/KR locales (GBK/CP932/CP949) any non-ASCII byte in a config/state/user-content file raises UnicodeDecodeError or UnicodeEncodeError and crashes the caller. to the remaining hot paths: - agent/copilot_acp_client.py: fs/read_text_file and fs/write_text_file (Copilot's read_file / write_file tools, directly reported in #18637 bug 2) - agent/model_metadata.py: context-length YAML cache load + two save sites (context probing is on the call path of every model invocation) - agent/nous_rate_guard.py: cross-session rate-limit JSON state (read + atomic write via os.fdopen) - cron/scheduler.py: user config.yaml read in run_job - gateway/delivery.py: cron output writes for AI-generated content, very likely non-ASCII yaml.dump call sites also gain allow_unicode=True so the emitted YAML preserves non-ASCII chars as-is instead of emitting \u escape sequences. Adds regression tests that monkeypatch builtins.open / Path.read_text / Path.write_text to simulate a GBK locale: each test raises UnicodeDecodeError / UnicodeEncodeError unless the caller explicitly passes encoding='utf-8'. Verified that the tests fail on main and pass with this change, on Linux as well as on Windows. Refs #18637 * fix(cli): add explicit encoding to read_text/write_text calls Path.read_text() and Path.write_text() without explicit encoding default to the system locale encoding. On Windows this is typically cp1252, which causes UnicodeDecodeError for UTF-8 content (JSON configs, user data, service scripts). Add encoding="utf-8" to all read_text() and write_text() calls across 8 CLI files, matching the pattern established in PR #50534 (security_audit_startup.py) and ruff rule PLW1514. Fixed files: - main.py: 4 read_text calls - auth.py: 3 read_text calls - banner.py: 1 read_text + 1 write_text - service_manager.py: 1 read_text + 4 write_text - container_boot.py: 1 read_text + 4 write_text - doctor.py: 3 read_text calls - uninstall.py: 2 read_text calls - gateway.py: 1 write_text call * fix(core,cli,gateway,plugins): add encoding='utf-8' to read_text() calls Path.read_text() without an explicit encoding uses the platform's default encoding. On Windows this is typically cp1252 or mbcs, which causes UnicodeDecodeError or silent data corruption when reading UTF-8 content (JSON files, user text, config with non-ASCII chars). This is the read-side companion to the write_text() encoding fix. Fixed the most critical locations that read JSON data, user content, and config files across 14 files with 31 call sites. Pattern: .read_text() → .read_text(encoding='utf-8') json.loads(path.read_text()) → json.loads(path.read_text(encoding='utf-8')) * fix(install): emit UTF-8 from skills_sync on non-UTF-8 Windows locales On Windows with a non-UTF-8 system locale (e.g. CP936/GBK on zh-CN), Python defaults stdout/stderr to the active codepage. tools/skills_sync.py prints glyphs such as checkmark (U+2713) and up-arrow (U+2191) that GBK cannot encode, raising UnicodeEncodeError mid-run. The installer (scripts/install.ps1) captures this script's stdout and the Rust bootstrap parses it as UTF-8 expecting a JSON result frame. A GBK byte stream (or the traceback it triggers) surfaces as: WARN stdout read error: stream did not contain valid UTF-8 stage=config-templates state=Failed error=install.ps1 -Stage config-templates produced no JSON result frame (exit=Some(0)) i.e. the stage fails even though the script exits 0. install.ps1 already sets [Console]::OutputEncoding = UTF8, but that does not propagate to the python.exe child (Python reads PYTHONIOENCODING / locale, not the console encoding). Fix in two places for defense in depth: - tools/skills_sync.py: reconfigure sys.stdout/stderr to UTF-8 at import so output is valid UTF-8 regardless of caller or active codepage. - scripts/install.ps1: set PYTHONIOENCODING=utf-8 and PYTHONUTF8=1 (scoped to the call, restored afterwards) around the skills_sync.py invocation. * test(install): add UTF-8 regression guard for skills_sync child path Addresses hermes-sweeper review on PR #54866: the installer runs tools/skills_sync.py as a child python.exe whose PYTHONIOENCODING / PYTHONUTF8 the scoped install.ps1 block sets, but there was no regression test for this child-Python UTF-8 path. The existing test_child_process_inherits_utf8_mode covers a different (bootstrap entry-point) flow. Add TestSkillsSyncUtf8Guard: three subprocess tests that import skills_sync (triggering its import-time stdout/stderr reconfigure) and assert the checkmark/up-arrow glyphs the script prints at tools/skills_sync.py:596,675 emit valid UTF-8 and exit 0 even when the child env is left unset or explicitly hostile (gbk). A third test proves the guard is load-bearing by reproducing the crash without it. Also keep the new install.ps1 comment ASCII-only (the checkmark spelled out as U+2713) per the file's PS 5.1 parser-compatibility contract at scripts/install.ps1:79-80; the literal glyph in the comment violated that contract. * fix: add encoding="utf-8" to Path.write_text() calls (P1) Path.write_text() without encoding defaults to system locale encoding. On Windows (cp1252), this silently corrupts non-ASCII content written to JSON files, config files, and cache files. This is the write-side counterpart to the read_text() encoding fix (PR #56115). PLW1514 only covers open() calls — Path methods are unguarded by ruff. 39 instances across 16 files, all passing py_compile. Files changed: - agent/copilot_acp_client.py (1) - tools/web_tools.py (1) - tools/xai_http.py (1) - tools/skills_hub.py (8) - gateway/slash_commands.py (1) - gateway/run.py (5) - gateway/dead_targets.py (1) - gateway/delivery.py (2) - gateway/platforms/qqbot/adapter.py (1) - hermes_cli/gateway.py (1) - hermes_cli/banner.py (1) - hermes_cli/service_manager.py (5) - hermes_cli/container_boot.py (5) - hermes_cli/uninstall.py (1) - hermes_cli/main.py (2) - hermes_cli/profiles.py (3) * fix(hindsight): specify UTF-8 encoding for file I/O on Windows On Windows with CJK locales (e.g. Chinese/GBK), pathlib.Path.read_text() defaults to the system encoding instead of UTF-8, causing UnicodeDecodeError when reading .env or .json config files that contain non-ASCII characters. Explicitly pass encoding='utf-8' to all read_text() and write_text() calls in the hindsight memory provider plugin. * fix(memory): read/write .env as UTF-8 in mem0 and hindsight setup The mem0 and hindsight memory-provider setup routines round-trip the user's ~/.hermes/.env: they read existing lines, update the keys they manage, and rewrite the whole file preserving every other line verbatim. Both used env_path.read_text() / write_text() with no encoding. read_text()/write_text() with no encoding fall back to the system locale (cp1252/GBK on Windows), so on a non-UTF-8 host the preserved lines get mangled or the call crashes on any non-ASCII value, and — because the reader never strips a BOM — a Notepad-edited .env makes the first key fail the in-place match and get duplicated instead of updated. Match the canonical .env readers in hermes_cli/config.py: read with encoding='utf-8-sig' (BOM-tolerant) and write with encoding='utf-8'. mem0/_setup.py already pins utf-8 for mem0.json, so this just aligns the .env path in the same file. Fixes both memory plugins in one class fix. Adds regression tests: a BOM'd .env updates the first key in place (locale-independent, fails without the fix) and non-ASCII existing lines survive the round-trip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(memory): cover the remaining setup-time .env reads with utf-8-sig Follow-up to review feedback: - mem0 _prompt_api_key read .env with the locale default, so a Notepad BOM hid the first key from the masked current-value lookup; read it with utf-8-sig + errors=replace like the canonical readers in hermes_cli/config.py. - hindsight _load_simple_env used plain utf-8; it also parses the Hermes .env during post_setup, where a BOM stuck to the first key. Switch to utf-8-sig + errors=replace. - Add hindsight regressions: BOM key matching in _load_simple_env and in the cloud post_setup writer, plus non-ASCII round-trip preservation, and a mem0 regression for the BOM'd masked-key lookup. The BOM tests fail without the fix on any platform. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(profile): read .env as utf-8-sig in the distribution-install preview `_render_distribution_plan` reads the target profile's `.env` to decide whether a required env var is already set (so it doesn't nag the user), using `Path.read_text()` with no encoding. Two bugs: 1. `Path.read_text()` defaults to the system locale (cp1251/GBK on Windows), which raises `UnicodeDecodeError` on any non-ASCII byte. The surrounding `except OSError` does NOT catch that — `UnicodeDecodeError` is a `ValueError` — so a mis-encoded `.env` aborts the entire install preview. 2. Even on a UTF-8 locale, a Notepad-added BOM prefixes the first key (`KEY`), so the very first required env var is mis-reported as "needs setting" when it is actually present. `.env` is written as UTF-8 everywhere in the codebase. Read it as `utf-8-sig` (tolerates the BOM) and also catch `UnicodeDecodeError` so a genuinely un-decodable file skips the pre-check instead of crashing. Regression tests: a BOM-prefixed `.env` whose first key must still read as "set", and an invalid-UTF-8 `.env` that must not abort the preview. * fix: add UTF-8 encoding to read_text/write_text in tools/ and agent/ Path.read_text() and Path.write_text() without encoding= default to the system locale (cp1252 on Windows), which corrupts non-ASCII JSON content. Coverage-gap fix for files not addressed by prior encoding PRs: - tools/skills_hub.py: 6 read_text + 8 write_text (cache, index, lock files) - tools/skills_sync.py: 1 read_text (lock file) - tools/xai_http.py: 1 read_text + 1 write_text (auth store, marker) - agent/shell_hooks.py: 1 read_text (allowlist) - gateway/status.py: 1 read_text (PID file) - hermes_cli/banner.py: 1 read_text + 1 write_text (update cache) All sites read/write JSON or short text. No behavioral change on Linux (already UTF-8); fixes silent data corruption on Windows. * fix(skills): tolerate non-UTF-8 bytes in hub lock.json _read_hub_installed_names() reads ~/.hermes/skills/.hub/lock.json with a strict utf-8 decode. Hub skill descriptions can carry Windows-1252 typographic bytes (em-dash 0x97, smart quotes, bullets) as single high bytes; read_text(encoding="utf-8") then raises UnicodeDecodeError, which is a ValueError sibling not caught by the function's except (OSError, json.JSONDecodeError). It escapes and 500s the whole /api/skills endpoint, blanking the desktop Skills panel. Decode with errors="replace" so the offending byte degrades to U+FFFD and the structurally valid JSON — and every other skill — stays readable. Fixes #68053 * fix(cli): read .worktreeinclude and .gitignore as UTF-8 in worktree setup _setup_worktree read both files with the locale default encoding. On a cp1251/GBK Windows machine a UTF-8 include list either decodes to mojibake paths (non-ASCII entries silently not copied) or raises UnicodeDecodeError, which the enclosing handler logs at DEBUG and swallows — no include is copied at all, so the worktree starts without .env/keys and the agent breaks invisibly. A Notepad BOM likewise glues to the first include entry on every platform, and to the first .gitignore line, defeating the '.worktrees/' membership check and appending a duplicate entry on each run. Read both files with utf-8-sig + errors=replace, matching the canonical .env readers in hermes_cli/config.py (utf-8-sig because Notepad adds a BOM) and the UTF-8 append this same block already performs on .gitignore. Regression tests exercise the real cli._setup_worktree: the two BOM tests fail without the fix on any platform, the non-ASCII include test additionally reproduces the Windows locale failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills): read OOXML parts as bytes and form JSON as UTF-8 in office skill scripts The bundled office skills (#68595) read user documents and agent-authored payloads with the locale-default codec: - docx/powerpoint validators/base.py opened OOXML part XML in text mode before handing it to lxml. On Windows (cp1251/GBK) the bytes decode to mojibake that lxml then parses, so validation runs against silently corrupted document text; on locales where the UTF-8 bytes don't decode the validator crashes with UnicodeDecodeError instead of validating. Opening as bytes lets lxml honor the encoding declared in the XML prolog. - The pdf form scripts (fill_fillable_fields, fill_pdf_form_with_annotations, create_validation_image, check_bounding_boxes) read the fields JSON the agent authors — UTF-8 by construction — with the locale codec, so non-ASCII form values (any Cyrillic/CJK/accented input) either crash or get written into the user's PDF as mojibake. The json.dump writers use ensure_ascii=True and were already safe; only the readers needed pinning. Adds a contract test asserting every document/payload reader is locale-independent, plus a live regression test that runs check_bounding_boxes.py on a non-ASCII fields.json under a forced non-UTF-8 locale — it fails without the fix on both POSIX (C locale) and Windows (cp1251 chokes on the 0x98 byte of U+2018). * fix(windows): sweep remaining bare read_text/write_text sites + linter rule AST-driven pass over every Path.read_text()/write_text() without an explicit encoding= across non-test code: 71 sites in 34 files (skills_hub, hermes_cli/main+profiles+service_manager+container_boot, mem0/hindsight/honcho plugins, achievements dashboard, release/CI scripts, productivity+comfyui skill helpers, agent/*). Verified zero positional-encoding collisions before insertion; per-file compile() check after. Adds a check-windows-footguns rule flagging bare single-line read_text/write_text (multi-line forms stay covered by the AST guard test from #38985). Together with the salvaged contributor commits this retires the ~169-site bare file-I/O class (#37423's long tail). * fix: restore utf-8-sig BOM tolerance at .env readers the sweep normalized The cherry-pick auto-resolution + AST sweep applied plain utf-8 at three .env reader sites where the salvaged PRs (#62617, #62123) deliberately use utf-8-sig — a Notepad BOM must not hide/duplicate the first key. Restore the contract (tests pin it). * chore: contributor email mappings for the file-I/O salvage * refactor(desktop): add shared Field form-dialog primitive Dialog forms each hand-rolled their own label+control+hint stack (or borrowed the settings-surface ListRow), so gaps and hint styling drifted between the profile, cron, and webhook dialogs. Add a single Field / FieldHint primitive for label-over-control dialog fields and adopt it in the create/rename profile dialogs as the first consumers. * fix(desktop): unify overlay-pane padding and add primary PanelAction Overlay panes each set their own top padding, so the Settings sidebar and Panel headers sat at different heights than System/Agents and the close X (the #67759 regression). Hoist the shared beside-the-X clearance into OVERLAY_TOP_CLEARANCE, keep the taller pad only on OverlayMain (which sits under the X), tighten OverlayMain's gutters, and drop the one-off Settings override. Also give PanelAction a `primary` variant so a detail header can promote its main action to a filled button. * refactor(desktop): fold cron Blueprints into the New Job dialog Blueprints lived behind a separate Jobs/Blueprints tab with its own card gallery — a bespoke surface no other overlay uses. Remove the tab and make blueprints a "Start from" dropdown at the top of the New Job dialog (default "Custom" = the manual editor); picking one swaps the form for that blueprint's typed slots. Also promote the detail-view "Trigger now" button to a primary action and adopt the shared Field primitive. * refactor(desktop): webhooks create form uses shared Field; drop status pill The create dialog used the settings-surface ListRow/ToggleRow inside a modal, which read differently from every other form dialog, and the detail header carried an enabled/disabled pill that rendered as a stray dash. Switch the form to the shared Field primitive (+ Switch) and remove the pill. * fmt(js): `npm run fix` on merge (#71099) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(dashboard): add lightweight /api/health liveness endpoint /api/status is the only public liveness route, and its handler loads the gateway config, probes gateway health, and counts sessions before it can answer. That work is wrong for a readiness probe: a caller that only needs to know the process is up pays for a cold plugin import tree. Add /api/health, which returns process liveness, version, and the auth-gate shape and touches nothing else. * fix(desktop): probe /api/health for boot readiness, and survive a stalled loop Desktop boot polls /api/status, so readiness waits on gateway config and a cold plugin import tree. On Windows that regularly outlives the probe and Desktop kills a backend that is already listening, respawns it, and re-pays the same import cost — the reported crash loop. Probe /api/health instead, falling back to /api/status only for the missing-route shapes the fetch helpers emit (404, or HTML from the SPA), so an older remote backend still connects. Timeouts and server errors keep polling health rather than dropping to the heavyweight route. A cheap route is not enough on its own. Warming the gateway import holds the GIL, so the event loop can stall for tens of seconds and starve /api/health too. At the default 15s socket timeout only three attempts fit in the 45s budget; give each probe 5s so the loop keeps retrying across the stall. Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> Co-authored-by: DESXIE <78300229+DESXIE@users.noreply.github.com> Co-authored-by: frohsinnllc <231045016+frohsinnllc@users.noreply.github.com> * fix(state): decode display_metadata at every message read path get_messages(), get_messages_around() and get_anchored_view() returned the raw display_metadata column instead of the dict every caller expects. The desktop paints a resumed transcript from the REST prefetch, which reads through get_messages(), so any session holding an async_delegation_complete event failed resume with "Cannot use 'in' operator to search for 'task_count'" — on every such session, not just corrupted ones. Route all four read paths through one shared codec that also unwraps rows carrying a second JSON layer, so sessions already broken on disk recover on read rather than needing a migration. Co-authored-by: Studio729 <Studio729@users.noreply.github.com> Co-authored-by: aml1973 <aml1973@users.noreply.github.com> Co-authored-by: xxxigm <xxxigm@users.noreply.github.com> * fix(state): stop double-encoding display_metadata on write export_session() reads through get_messages(), so before the read fix an already-serialized string went straight back into _insert_message_rows() and got re-dumped — an export/import round trip permanently corrupted the row. Guard the three write paths the same way tool_calls already is: parse a string argument before storing it, and drop metadata that isn't an object rather than persisting something no reader can use. Co-authored-by: xxxigm <xxxigm@users.noreply.github.com> Co-authored-by: aml1973 <aml1973@users.noreply.github.com> * fix(desktop): tolerate unparsed display_metadata from an older backend The desktop and the Hermes backend it talks to version independently — a remote VM running an older build still serves display_metadata as JSON text. Indexing into that string with `in` threw and failed the whole resume, so narrow the type to admit a string and parse it before reading task_count. Falling back to the generic label keeps a delegation event renderable even when the metadata is unusable. Co-authored-by: xxxigm <xxxigm@users.noreply.github.com> Co-authored-by: Studio729 <Studio729@users.noreply.github.com> * fix(checkpoints): don't prune a project whose volume is merely unmounted Orphan pruning decides a project is gone from a single probe: if delete_orphans and (not workdir or not Path(workdir).exists()): reason = "orphan" then deletes its ref, index, and metadata — the project's entire checkpoint history. `Path.exists()` is False for a deleted directory, but it is equally False for one whose storage is not attached right now: an unplugged external drive, a share behind a downed VPN, a bind-mount absent from this container, an offline Windows mapped drive. The project is fine; only our view of it is. This is not an opt-in maintenance command. `maybe_auto_prune_checkpoints` runs unattended at startup from both `cli.py` and `gateway/run.py`, with `delete_orphans=True` by default. So starting Hermes once while the drive is unplugged silently destroys the restore points for every project on it — the one thing checkpoints exist to provide, and there is nothing to restore from afterwards. Reproduced against the real store: a project registered under an unmounted path and one on local disk, then a startup prune — prune: {'scanned': 2, 'deleted_orphan': 1} unreachable project index still on disk: False The legacy pre-v2 branch has the same flaw plus a second one: a `HERMES_WORKDIR` marker that exists but cannot be read leaves `workdir = None`, which the same condition treats as an orphan. Failing to read a file is not evidence that a project was deleted. Require corroboration before deleting: the workdir's parent must be present, so its absence is something we actually observed. A missing parent means the volume is not there and we know nothing, so the entry is left alone — and an unreadable marker never deletes at all. Genuinely abandoned projects are still reclaimed, both by the unchanged orphan path (parent present, project gone) and by the retention/stale rule, which runs off `last_touch` rather than a filesystem probe. tests/tools/test_checkpoint_manager.py: a project whose whole mount disappears keeps its history; controls prove a genuinely deleted project is still pruned and a live project is untouched. The data-loss test fails on main; both controls pass there. 81 passed across the checkpoint suites (2 failures in test_checkpoint_manager.py are pre-existing and fail identically on clean main). * fix(checkpoints): an empty surviving mount point is not evidence of deletion Addresses @egilewski's review: the parent-directory check still deleted checkpoint history for the most common unmount layout. Detaching storage removes the parent outright in some layouts (`/Volumes/Ext/proj` on macOS, `/media/<user>/<label>/proj`), which the first commit handles. But in the classic static layout — `/mnt/volume/proj`, an fstab entry, a container bind-mount — unmounting removes the contents and leaves the mount point behind as an empty directory. `parent.is_dir()` is then true, the project is absent, and the startup sweep deletes its ref, index and metadata: exactly the case this PR set out to protect. Reproduced against the real predicate before this commit: mount root vanished (macOS) -> False ok empty surviving mount point -> True <-- history deleted really deleted (siblings) -> True ok An empty parent carries no information: it looks identical whether the volume was detached or the project was deleted. So require the parent to actually say something — it holds some other entry (we observed a populated directory that does not contain the project), or it is itself a live mount point (the volume is attached right now and demonstrably does not hold the project). The cost is that a project deleted out of an otherwise-empty parent is no longer reclaimed by the orphan rule. It is not leaked: the retention rule reads `last_touch` rather than probing the filesystem and still collects it, so reclamation is deferred, not lost. That is the right direction for a predicate whose false positive destroys a user's restore points unattended. `_dir_has_any_entry` stops at the first entry via `os.scandir` instead of materializing a listing, since a project root can hold a large tree. tests/tools/test_checkpoint_manager.py: `test_surviving_empty_mountpoint_ keeps_its_checkpoints` pins the reviewed case, and `test_empty_parent_project_ is_still_reclaimed_by_retention` pins the deferral above so the safety valve cannot silently regress into a leak. Both fail on the previous commit. The real-orphan control now seeds a sibling so it exercises a populated parent rather than the ambiguous empty one. 80 passed in the checkpoint suite; the 2 remaining failures (`TestGitEnvIsolation`, `TestClearFunctions`) fail identically on clean main. * fix(checkpoints): require positive volume-attachment evidence before orphan classification Follow-up to the cherry-picked #69063: egilewski's review found that the _dir_has_any_entry(parent) guard treats ANY entry in the mount point's parent as proof the volume is attached — but unmounting exposes the UNDERLAY directory's own files (e.g. a .keep placeholder), so a populated underlying mount-point dir still classified the project as an orphan and deleted its ref/index/metadata. Reproduced on both main and the PR head. Attachment evidence is now positive instead of circumstantial: * _volume_evidence() records the parent directory's (st_dev, st_ino) identity in the project's metadata while the workdir is observably live (at _register_project/_touch_project time). A mount point resolves to the mounted filesystem's root while attached and to the underlay directory after detach — same path, different directory, different identity. * _workdir_is_observably_gone() now requires the parent visible at prune time to match that recorded identity before the populated-parent check can classify an orphan. A mismatch means a different directory (the underlay) is showing through — a detached volume, not an observed deletion. * Metadata without a recorded identity (written by older versions) is never orphan-classified — unsure never deletes; the retention/stale rule still reclaims genuinely abandoned projects off last_touch. * The frozen pre-v2 layout has no metadata channel for the identity, so it keeps the structural checks only (require_parent_identity=False). * A failed evidence probe on re-registration preserves the previously recorded identity — stale evidence can only make pruning MORE conservative. Windows: st_dev/st_ino of 0 (filesystems without file IDs, some network shares) is treated as "no evidence recorded", which falls into the conservative never-orphan path. os.path.ismount and Path.stat are cross-platform; no POSIX-only calls added. tests/tools/test_checkpoint_manager.py: adds egilewski's exact regression (checkpoint history for mnt/volume/project, detach exposes mnt/volume/.keep, prune with orphan deletion enabled → NOT deleted; fails on the bare cherry-pick, passes with this fix), plus no-recorded-identity conservatism and probe-failure identity preservation. His absent-parent/empty-parent/retention/genuine-deletion/ live-project controls all still pass. Reported-by: egilewski (review on #69063) * fix(telegram): require initial polling readiness Use wall deadlines for deleteWebhook and start_polling, then fail cold startup unless getUpdates proves progress. This lets the gateway discard partial PTB state and retry with a fresh adapter.\n\nRefs #67498 * fix(gateway): allow Telegram readiness budget Give Telegram a 180s default outer connect budget so cold polling can prove getUpdates readiness. Preserve the 30s default for other platforms and all explicit config/env overrides.\n\nRefs #67498 * fix(telegram): bind strict cold-start readiness to its own polling generation Follow-up hardening for the salvaged #69240 readiness gate (#67498): - _start_polling_once now returns its (generation, progress_event) pair so the strict cold-start gate binds to exactly the generation it started, instead of re-reading self._polling_progress_event which a concurrent recovery task may have replaced with a newer generation's event (the G1/G2 race flagged in the #69240 review). - Strict cold start no longer schedules background polling recovery: a polling error during the readiness wait is captured by a strict callback and fails the connect attempt immediately with a loud OSError, so GatewayRunner disposes the partial adapter and retries with a fresh one — no more waiting out the full readiness deadline on a generation that already errored, and no G2-on-partial-app healing. - After readiness is proven the strict callback delegates every later polling error to the real background-recovery callback, preserving the existing degraded/reconnect semantics for the polling lifetime. - The readiness-timeout error message now states the deadline and that the gateway will retry with a fresh adapter (loud failure, not a silent wait). - Regression tests: current-generation progress connects; a polling error during strict cold start fails fast without scheduling background recovery (the #67498 idle-threads shape); stale-generation progress is rejected. Progresses #67498 * test: record getUpdates progress in mocked cold-connect polling flows The strict cold-start readiness gate (#67498) means adapter.connect() no longer returns True until the mocked start_polling records a successful getUpdates round trip for its generation. Update the conflict-suite Application mocks accordingly: - fake_start_polling side effects call adapter._record_polling_progress(adapter._polling_generation) on the initial connect (retry generations intentionally do NOT auto-progress where a test asserts the conflict count survives an unproven retry). - _build_polling_app takes the adapter so its start_polling mock can record progress. Without this, the cold connects in these tests wait out the full 60s readiness deadline and fail — which is exactly the fail-closed behavior the gate is supposed to provide when polling shows no progress. * fix(config): add a collision-safe env var name for custom endpoint keys Both the Desktop panel and the CLI setup flow need somewhere in .env to put a custom endpoint's API key. Deriving the name from the endpoint's hostname collapses two servers on one machine onto a single slot, and every IP-based local endpoint slugs to a digit-leading name that save_env_value rejects outright. Key off the endpoint's own identity and keep a fixed prefix. Co-authored-by: asorry75 <33794789+asorry75@users.noreply.github.com> Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> * fix(windows): verify rebuilt Hermes.exe integrity before shipping it as an update (#69179) The desktop self-update chain (Desktop -> hermes-setup --update -> hermes update -> hermes desktop --build-only -> relaunch) rebuilds Hermes.exe on the user's machine and declared success on bare file EXISTENCE. A truncated PE (corrupt cached Electron zip / interrupted extraction or rcedit rewrite / full disk) or a wrong-architecture unpacked tree therefore shipped as the 'updated' app, which Windows refuses to load with 'This app can't run on your computer' (此应用无法在你的电脑上运行) — and the previous working build had already been wiped by before-pack.mjs, leaving nothing to fall back to. Fix, in three parts: - hermes_cli/main.py: post-build integrity gate on Windows (_ensure_desktop_exe_launchable). Parses the PE header of the freshly built Hermes.exe — MZ/PE magic, section-table completeness vs file size (catches truncation), and COFF machine vs the host arch (catches arm64/x64 mixups). On failure it purges the (likely corrupt) cached Electron zip, invalidates the content-hash build stamp so the updater's retry-once genuinely re-downloads and rebuilds, restores the previous build from the .bak tree when one exists (keeping the corrupt tree as .corrupt for diagnostics), tells the user the update was aborted and their old version kept, and exits nonzero. _desktop_packaged_executable also now prefers a host-loadable PE over pure newest-mtime when multiple win-*-unpacked trees coexist. - apps/desktop/scripts/before-pack.mjs: on win32, the previous unpacked tree is preserved as <appOutDir>.bak (only when it holds the product exe — partial/corrupt trees still get the plain wipe) instead of being destroyed, providing the rollback material for the gate above. Non-Windows behavior is unchanged. - Behavior-contract tests: tests/hermes_cli/test_desktop_exe_integrity.py (23 tests — synthetic PE fixtures for truncation/non-PE/arch-mismatch, rollback semantics, and the build-only exit contract) and 6 new vitest cases in before-pack.test.mjs for the .bak preservation rules. Progresses #69179 * fix(desktop): persist the whole discovered model list when saving an endpoint Test enumerates a custom provider's catalogue and the panel holds the result in discoveredModels, but the save payload never carried it, so only the one model the user hand-typed reached providers.<id>.models. Every downstream picker reads that map straight from config.yaml with no live probe, which is why a proxy serving 18 models offered exactly one. Send the discovered list and merge it onto the entry, so models already known keep their context lengths. Fixes #69988 Co-authored-by: asorry75 <33794789+asorry75@users.noreply.github.com> * fix(web_server): keep Desktop custom endpoint API keys out of config.yaml The Custom Endpoints panel wrote the raw key to providers.<id>.api_key, so the credential sat in plaintext in a file users routinely share and commit. The input is masked, so nothing warned them. Write the key to .env and reference it via key_env, the same indirection built-in providers use and that runtime_provider already resolves. The read side has to move with it: reporting has_api_key from api_key alone would show "no API key" for every migrated endpoint, and activate copying only api_key would drop the credential entirely. Delete now clears the .env slot too, and an entry still carrying a pre-fix plaintext key is migrated on its next save so existing users get cleaned up without re-entering anything — unless the key is a hand-written ${VAR} template, which is already safe and must not be duplicated into a second env var. Fixes #69449 Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> Co-authored-by: asorry75 <33794789+asorry75@users.noreply.github.com> * fix(cli): store custom endpoint API key in .env instead of config.yaml hermes model's custom-endpoint flow is the other write path that produced a plaintext key, on both the model block and the custom_providers entry. Route it through the same .env indirection as the Desktop panel, and swap an existing entry's inline key for the reference when the URL is re-saved. Co-authored-by: liuhao1024 <sunsky.lau@gmail.com> * test: cover custom endpoint key storage and model-list persistence Bug-class coverage for both fixes: the full catalogue survives Save, context lengths are preserved, the key never lands in config.yaml on either write path, blank clears it, a pre-fix plaintext key migrates while a ${VAR} template is left alone, two endpoints on one host keep separate credentials, and an IP-derived name is still a valid POSIX env var. The two delete tests asserted on the plaintext mirror; they now assert the same invariants against the credential reference. * fix(desktop): persist @image: refs instead of the vision-enrichment text The desktop gateway passed the vision-enriched, model-only message text (carrying an `image_url:<path>` hint) straight into run_conversation as the persisted user turn. The renderer only parses `@image:<path>`, so it could not rebuild the attachment from history: after a restart the image was gone and only the caption survived, and on a live session switch the warm cache disagreed with the authoritative text and the frontend "rescued" the image by appending it after the caption. run_conversation already supports persist_user_message for exactly this "what the model sees" vs "what gets stored" split; it was simply never wired up for the attachment path. * fix(desktop): keep cached attachment refs on session resume Persisted history carries no attachment metadata for non-image refs, so resume reconciliation dropped `@file:` chips off a user turn whose text matched. Carry the warm cache's refs forward when the resumed message has none of its own, never replacing refs that are already present. (cherry picked from commit eac5b0a8ac39eab242a5d571531e386ec70e2435) * fix(desktop): quote persisted @image: paths so spaced paths render The unquoted alternative in the directive pattern is `\S+`, so a ref built by string interpolation truncates at the first space and strands the tail as loose text next to a broken thumbnail. Composer images live in the app's userData dir, which on macOS is `~/Library/Application Support/<App>/` — so every pasted or dropped image hit this. Adds format_reference_value next to REFERENCE_PATTERN, mirroring formatRefValue in the desktop's directive-text.tsx, and covers the round-trip through the parser. * fix(desktop): persist the image ref for natively-vision-capable models too A turn routed to a model that takes pixels directly sends `content` as a parts list, and the session store deliberately ignores a plain-string persist override for a list payload — a text override must not erase a turn's image summary. So the override was dropped for every user on a vision-capable main model, and the durable row kept only the caption plus a literal `[Image attached at: ...]` / `[screenshot]`, which the renderer cannot turn back into an image. Only vision-preprocessed (text-mode) turns were actually fixed. Mirror the shape instead: swap the text part for the `@image:` ref form and keep the image parts, so the model still has the pixels for the rest of the session, and drop the `[screenshot]` stand-in on the way into the bubble when a ref was lifted from the same message. * refactor(desktop): memoize the directive image-segment filter Matches the two derived values above it and fixes the indentation. * fix(desktop): lead persisted image turns with the caption Session previews are the first 60 characters of the first user message, so persisting the @image: directives ahead of the caption labelled the session with a truncated file path in the sidebar, session switcher, and command palette. Clients lift the refs out of the body line by line, so moving them after the caption changes nothing about how the turn renders. * test(desktop): cover attached-image resume end to end The unit tests cover each layer in isolation, but nothing exercised the whole chain the bug lived in: the real gateway persisting an attachment, SessionDB holding it after the process exits, and the renderer rebuilding a thumbnail from the stored turn. Seeds a session through the real gateway with an image attached, then launches desktop against it — so the first render is already the relaunch case. Pins native image routing (the majority path, and the one where a text-only persist override is dropped) and stages the file behind directory and file names with spaces, mirroring the macOS composer's Application Support path. * fix(models): resolve custom provider model ids Map picker-prefixed custom provider selections back to their configured model IDs before validation, persistence, and API requests. Fixes #68347 * chore(contributors): map jevin@jevin.org to ijevin Attribution check needs a mapping for the cherry-picked commit's author so release notes credit them correctly. * fix(relay): normalize forwarded Discord interactions to leading-slash commands (#71048) A real APPLICATION_COMMAND interaction forwarded over the relay arrived slash-less: _discord_interaction_to_event set text = data['name'] ("new", not "/new"), MessageType.TEXT, and dropped options entirely — so a registered /new dispatched as plain chat instead of a command (MessageEvent.is_command() is text.startswith("/")). Port the connector's Slack slash-command precedent (normalizeSlackCommand builds `${command} ${args}`.trim() with a leading slash and explicit command type): for type-2 interactions build "/" + name, append rendered options space-separated (scalar options contribute their value, matching the native adapter's f"/model {name}" shape; SUB_COMMAND/ SUB_COMMAND_GROUP contribute their name then recurse into nested options), and set MessageType.COMMAND. Type-3 (custom_id) and other interaction types are unchanged. This implements the interaction->command sub-design previously flagged as deferred in the _on_passthrough docstring. Companion connector fix in gateway-gateway: fix(relay): strip own-mention prefix so addressed slash commands dispatch. * feat(desktop): add session link title resolver Resolve @session:<profile>/<id> reference values to the session's title: the in-memory sidebar list answers most lookups, and an unknown id falls back to GET /api/sessions/{id}. Cache, in-flight dedupe, and subscriber fan-out mirror the external-link title resolver. An untitled row resolves to empty rather than "Untitled session" so the caller's short-id fallback stays the chip label. * feat(desktop): show resolved titles on @session chips Route session refs in the transcript through the title resolver so a dropped session reads as its title instead of a truncated id, and use Tabler's funnel for the session chip icon. * feat(desktop): render agent-written @session links as chips Assistant text goes through the markdown renderer, not DirectiveContent, so a session reference an agent wrote came out as literal text. Rewrite bare refs into `#session/<value>` links during markdown preprocessing and dispatch that href to the shared chip in MarkdownLink, alongside the existing media and preview hrefs. Preprocessing already skips code fences and inline code, so a ref being discussed in code stays literal. The pure parsing/href helpers move to session-refs.ts to keep the resolver's React and API imports out of the per-flush preprocess path. * fix(sessions): export delegate cascade before deletion * refactor: extract lineage_is_logical local + document TOCTOU re-query Follow-up cleanup for PR #71123: - Extract getattr(args, 'lineage', 'single') == 'logical' to a local (appeared 3x in the export block) - Document that the double _collect_delegate_child_ids traversal in delete_session is an intentional TOCTOU guard inside the write txn * feat(session-search): give the agent a link to hand back Asked to link to a session, the agent had no way to know the @session reference syntax exists — every mention in the tool schema described consuming a link the user dropped, never writing one — so it answered with the title and timestamp as prose and the desktop had nothing to render. Every result now carries a ready-to-copy `link`, and the schema says to write it inline instead of restating the title around it. The profile segment is omitted when the active profile can't be named confidently; a bare id still resolves. Also skip linkifying a ref a model already wrapped in a markdown link, which would otherwise rewrite into a nested link. * fix(tui_gateway): retain failed turns as replayable inflight snapshots A turn that ended in error cleared inflight_turn and emitted its terminal frame in the same breath. If the client was disconnected during that window (the exact case for a failure like a network drop), the frame went to the detached drop-transport and the in-memory state was already gone — the desktop reconnected to a session with no trace of the failure. Failed turns now retain a compact error snapshot (user prompt, partial assistant text, error, recoverable) that session.resume's inflight payload carries to a reconnecting client. Covers all three loss sites: the returned-error result path, the turn exception path (which now closes with the same status:"error" message.complete frame shape instead of a bare error event), and agent-init failure. The snapshot lives until the next turn starts or the session closes; _run_prompt_submit replaces a retained error leftover instead of appending onto it. Co-authored-by: Reza Sayar <rsayar@uvic.ca> * feat(desktop): crash-survivable in-flight turn journal The renderer's session-state cache is memory-only and the backend's inflight snapshot dies with the backend process, so nothing survived a full app or machine death mid-turn: reopening the session showed the transcript up to the last committed turn and silently dropped everything the crashed turn had streamed. While a turn runs, the visible tail (user prompt + streamed assistant rows, tool calls included) is now journaled to localStorage — throttled off the delta-flush hot path, bounded (24 entries / 7 days), cleared the moment the turn settles. Session resume folds the journaled tail back onto the restored transcript. When the backend also has a live text-only inflight projection for the same turn, the journal overlays its richer structure onto that row (longer text wins, base row id kept so live deltas keep landing) instead of treating it as caught up — the ordering defect that dropped locally recorded tool progress in the original PR. Co-authored-by: Omar Baradei <omar@kostudios.io> * fix(desktop): surface terminal error frames as failed bubbles message.complete frames with status "error" were detected only by a text regex heuristic, which misses the gateway's "Error: <detail>" texts and partial-text failures — a failed turn rendered as a healthy reply. The structured e…
* test(runtime): cover managed SQLite cutover (E-949)
* fix(runtime): preserve cutover lifecycle on retry (E-949)
* fix(runtime): request minor line for SQLite runtime repair + tests
Follow-up on the #70186 salvage. The cherry-picked repair pinned the
candidate to the exact current CPython patch (e.g. 3.11.14). Verified
live with uv 0.11.19: every published python-build-standalone artifact
for 3.11.14 links vulnerable SQLite 3.50.4 — even with --reinstall — so
the exact-patch pin made the repair permanently impossible on the
installs that need it most (repair_vulnerable_runtime returned
'failed: could not provision a fixed private Python runtime').
Request the minor line (3.11) instead — the same resolution a fresh
'uv python install' would make, still inside requires-python — and
tighten the drift gate to 'same minor, no downgrade'. E2E-verified
end-to-end on a real vulnerable venv: repair_vulnerable_runtime()
provisioned 3.11.15, built + smoke-tested the sibling venv, cut over,
and reported SQLite 3.50.4 → 3.53.1 with the old venv parked for
rollback.
* test: accept kwargs in managed_uv fixture fakes
The runtime-repair change passes repair_observer= to update_managed_uv/
ensure_uv; the autouse fixture fakes had zero-arg signatures and raised
TypeError through the mock. Sibling test file to the PR's own suite.
* fix(compression): recover rotated session lineage
* chore: map contributor ruizanthony
* fix: pre-lease drift guard must not fire on in-place compaction or mutated snapshots
The salvaged drift check compared durable rows to the in-memory snapshot
by content and ran in both modes. Two problems:
1. In-place compaction (the default) archives non-destructively — drift
cannot lose data there, and the strict-prefix content comparison
failed against seeded histories, aborting every in-place compaction
(5 test failures in test_in_place_compaction.py).
2. Content equality wedges on sessions with legal in-memory mutation of
past turns (multimodal compression, retry replacement) — the same
permanent-abort shape as #14694.
Now rotation-only and length-based: abort only when the durable parent
has MORE rows than the snapshot (a writer committed in the lease window).
Dead helper _durable_history_matches_snapshot removed.
* test: order compression-tip fixtures around the closed-parent write guard
Two compression-tip hydration tests simulated legacy state by emptying
the parent AFTER end_session(compression) — exactly the durable write
the new closed-parent guard refuses. Reordered: empty first, close
second. The tests' actual contract (old id hydrates from the live tip)
is unchanged and still pinned.
* fix(checkpoints): never auto-delete orphans on unattended startup sweep
Builds on this PR's diagnosis by @Frowtek: a missing workdir is
ambiguous (deleted project vs. an unmounted external volume / network
share / VPN not yet up), so it's not safe evidence for a destructive
GC sweep — especially one that runs unattended at startup.
- cli.py / gateway/run.py: the startup auto-maintenance sweep now
always passes delete_orphans=False to maybe_auto_prune_checkpoints().
It still prunes by retention_days, size cap, and legacy archives —
none of which require guessing whether a project was deleted or is
just temporarily unreachable.
- hermes_cli/config.py: drop the now-unused delete_orphans default.
- hermes_cli/checkpoints.py: `hermes checkpoints prune` (the explicit,
human-invoked path) now previews the orphan project list and asks
for confirmation before deleting, unless -f/--force is passed.
- Docs updated (EN + zh-Hans) to match.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(checkpoints): include pre-v2 shadow repos in orphan preview
store_status()["projects"] only ever covered v2 metadata, so the
`hermes checkpoints prune` confirmation prompt was blind to pre-v2
base/<hash>/HEAD shadow repos that prune_checkpoints() deletes
separately via shutil.rmtree — a pre-v2-only or mixed store could
lose checkpoint history without ever hitting the confirmation.
Extract the pre-v2 scan into _pre_v2_shadow_repos() and have both
store_status() (preview, new pre_v2_projects key) and
prune_checkpoints() (deletion) read from it, so the CLI prompt can
no longer diverge from what actually gets removed.
Addresses review from egilewski on #69141.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* test(checkpoints): cover prune decline/accept/--force for pre-v2-only and mixed stores
Requested by egilewski on #69141: the orphan confirmation flow had no
test coverage at all before this. Exercises hermes_cli.checkpoints.cmd_prune
directly against pre-v2-only and mixed (v2 + pre-v2) fake stores —
decline aborts with nothing deleted, accept deletes both layouts,
--force and --keep-orphans skip the prompt as expected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(checkpoints): bind orphan confirmation to previewed identities
Address P1 from PR review: cmd_prune()'s y/N preview reads
store_status() but the confirmed deletion re-scans both the v2 and
pre-v2 layouts from scratch. A workdir that goes missing while the
human is answering the prompt gets swept in as if it had been shown
and approved.
prune_checkpoints() now accepts orphan_allowlist — a set of v2 project
hashes and/or pre-v2 shadow repo paths. When set, only orphans whose
identity is in the set are deleted; anything newly orphaned since the
scan survives the run. cmd_prune() builds this set from the exact
projects it just displayed and passed confirmation for. --force still
passes None (no preview shown, so nothing to bind to).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* doc(checkpoints): add cyberpunk infographic for startup sweep safety
* doc(checkpoints): update infographic to show 8 files
* fix(checkpoints): bind an empty orphan preview to an empty deletion allowlist
Follow-up for salvaged PR #69141, addressing the last open review point:
cmd_prune() only set orphan_allowlist inside 'if orphans or pre_v2_orphans',
so a zero-orphan preview passed the unrestricted None sentinel down to
prune_checkpoints(), authorizing deletion of any project that became
orphaned between the preview and the rescan — with zero confirmation
calls. The allowlist is now bound unconditionally for every non-force
run (empty preview => empty allowlist); --force keeps None. Adds the
zero-orphan-preview timing regression plus allowlist-identity tests.
* fix(agent): cache static system prompt prefixes
* fix(prompt-caching): inject cache breakpoints after message normalization
The conversation loop normalizes message text right before the API call so
the request prefix is byte-identical across turns -- the stated reason is
KV cache reuse on local inference servers and better cache hit rates on
cloud providers. Cache breakpoints were injected *before* that pass, which
defeats it.
`_apply_cache_marker` rewrites a plain-string `content` into a
`[{"type": "text", ...}]` block. The normalization pass is guarded on
`isinstance(content, str)`, so every message that just got marked is
silently skipped by it and keeps its raw leading/trailing whitespace. A
message is only marked while it sits in the last-3 window, so:
turn N in the window -> marked, content "file1\nfile2\n"
turn N+1 rolled out -> plain, content "file1\nfile2"
The same logical message is sent with different bytes on consecutive
turns. The prefix stops matching at that position -- which is inside the
span the breakpoints were placed to protect -- so the reusable prefix
collapses back toward the system breakpoint on every turn. Tool results
carry a trailing newline almost by default (any shell command output), so
this is the common case, not an edge case.
Move the injection below every message mutation. Besides fixing the
whitespace divergence this stops breakpoints from being spent on messages
that the orphan sweep or the thinking-only drop is about to remove or
merge away -- a marker on a dropped message is a wasted breakpoint out of
the four available.
Nothing between the old and new call sites reads `cache_control`, and the
mutators now see the plain-string shapes they were written against.
* fix(caching): reconstruct static system prefix on session restore and post-compression reuse
Follow-up to the cherry-picked #68258 base: the cross-session-stable
prefix (_cached_system_prompt_static) was only recorded on fresh
builds, so two paths silently degraded to the legacy single-breakpoint
layout (flagged in review of #68258/#69341/#69704):
- Session restore: gateway surfaces build a fresh AIAgent per turn and
restore the persisted prompt verbatim from the session DB; the static
prefix stayed None from turn 2 onward, flip-flopping the wire layout.
- Post-compression cached-prompt reuse: _invalidate_system_prompt()
clears the static prefix, and the keep-cached-prompt branch never
restored it.
Both sites now reconstruct the stable tier and adopt it ONLY when the
authoritative prompt string literally startswith() it — stable-tier
drift (skills edited, identity changed) falls back to the legacy layout
with the stored bytes untouched. Fail-open on any builder error. The
restore-path rebuild is gated on _use_prompt_caching so non-Anthropic
routes skip it entirely.
Refs #68191
Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com>
Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
* fix(config): preserve opaque .env values
The .env sanitizer inferred missing newlines from known KEY= substrings
inside existing values. Plain secrets containing those bytes could therefore
be split into synthetic assignments and rewritten to disk.
Treat each physical line as the only assignment boundary and keep bytes after
the first equals sign opaque for boundary discovery. Preserve safe formatting,
null-byte removal, BOM handling, and normal one-assignment-per-line parsing.
Cover direct loading, dotenv loading, sanitization, writers, and migration
with behavioral regressions.
Fixes #29155
* fix(web): resolve per-profile gateway state for ?profile= in /api/status
When ?profile=<name> was passed to /api/status, the handler used
_config_profile_scope to set the HERMES_HOME contextvar override, but the
gateway liveness check (get_running_pid_cached) and runtime status read
(read_runtime_status) both resolve _get_process_hermes_home(), which
deliberately ignores contextvar overrides (issue #56986) — it always reads
os.environ['HERMES_HOME'] or the platform default. A named profile's
gateway identity files (~/.hermes/profiles/<name>/gateway.pid,
gateway_state.json) were therefore never found and the endpoint always
reported the profile's gateway as stopped.
Fix: when ?profile=<name> is requested, resolve the profile directory and
pass explicit profile-scoped paths:
- get_running_pid_cached(pid_path=profile_dir / 'gateway.pid')
- read_runtime_status(path=profile_dir / 'gateway_state.json')
- get_runtime_status_running_pid(..., expected_home=profile_dir)
This is the same explicit-path pattern _collect_profile_gateway_topology
already uses for per-profile gateway state, and it works within the #56986
constraint (no HERMES_HOME env mutation; read-only cross-profile access).
Plain /api/status without ?profile= keeps the exact zero-arg calls, so its
behavior — including the pid-cache signature and runtime-status fallback —
is byte-for-byte unchanged.
Fixes #69143
* test(web): pin per-profile gateway state scoping on /api/status
Follow-up for the salvaged #70498 fix: replace the original PR's
mock-signature churn (28 lambda **kw edits, needed only because it changed
the no-profile call shape) with two targeted regression tests:
- ?profile=<name> must pass the profile's gateway.pid / gateway_state.json
paths and expected_home to the gateway status readers (HOME-anchored
per-profile state under ~/.hermes/profiles/<name>/)
- ?profile=<unknown> must 404 via _resolve_profile_dir
The production change keeps plain /api/status on the exact zero-arg calls,
so every pre-existing test passes unmodified.
* test: accept the new profile-scoped kwargs in status fakes
/api/status?profile= now passes pid_path=/path=/expected_home= to the
PID and runtime-status readers; the profile-unification fakes had
zero-arg signatures and raised TypeError. Plain /api/status call shapes
are unchanged (pinned by the existing zero-arg tests in
test_web_server.py).
* fix(gateway): prevent reconnect watcher wedge after network-loss fatal error (#70344)
Three-part fix for the gateway going silently deaf after a retryable
fatal adapter error (e.g. httpx.ConnectError on Telegram):
1. **Detach-on-timeout in _connect_adapter_with_timeout** — Replaced
plain asyncio.wait_for with the task-detach pattern used by
_await_adapter_cleanup_with_timeout. asyncio.wait_for cancels the
overdue task but then waits for it to exit, so a connect() that
catches CancelledError can block recovery forever. The detach
pattern releases the runner at the deadline via
consume_detached_task_result.
2. **Ensure reconnect watcher always runs after escalation** — Added
_ensure_reconnect_watcher_running(), called after queueing a
retryable fatal error. If the reconnect watcher task has died
(exhausted restart budget, terminal exception), it is respawned
so queued platforms are never permanently stranded.
3. **Faulthandler at gateway startup** — Enabled faulthandler +
SIGUSR2 dump to a rotating file under HERMES_HOME/logs/ for
post-mortem diagnosis of future event-loop freezes.
Tests added for _ensure_reconnect_watcher_running (alive, dead,
not-started, not-running), fatal-error integration (retryable calls
ensure, non-retryable does not), and _connect_adapter_with_timeout
(timeout raises, success returns).
* fix: explicit encoding for faulthandler file open (ruff PLW1514)
* fix(gateway): stay alive on mixed retryable + non-retryable startup failures
When connected_count == 0 and at least one platform failed with a
non-retryable error, the runner exited with GATEWAY_FATAL_CONFIG_EXIT_CODE
(78) even if OTHER platforms failed for merely transient reasons.
Real-world shape (NS-609, hosted instance): WhatsApp enabled but never
paired (non-retryable whatsapp_not_paired) + Telegram TimedOut during
polling startup (retryable) => exit 78 => the gateway either goes
permanently down (supervisors honoring the exit-78 contract via
RestartPreventExitStatus / the s6 finish->125 translation from #51228) or
crash-loops (anything else). Either way Telegram never gets its retry and
the dashboard drops with every exit, so a single unpaired platform plus
one network blip disconnected every channel on the instance.
Now exit 78 is reserved for the case where ALL startup failures are
non-retryable (true config error, nothing to wait for). With mixed
failures the gateway stays alive in degraded state: the reconnect watcher
recovers the retryable platforms and the misconfigured ones stay
fatal-parked and visible in runtime status.
* fix: gate SIGUSR2 faulthandler registration behind POSIX check
signal.SIGUSR2 and faulthandler.register() don't exist on Windows;
the bare reference raised AttributeError at import time per the
windows-footgun checker. faulthandler.enable() still covers
fatal-error dumps on all platforms.
* fix(gateway): detect and escape silent event-loop freezes
- A self-rescheduling 5s call_later floor timer, armed before any
adapter connects, guarantees the selector always has a finite
timeout, so the existing async defenses (polling heartbeat, timeout
guards) regain a chance to run after a zero-pending-timer stall.
- A resident daemon-thread liveness watchdog probes the loop via
call_soon_threadsafe every 30s; after 3 consecutive 10s-timeout
misses (~120s of total unresponsiveness) it dumps all thread
tracebacks and exits with the established
GATEWAY_SERVICE_RESTART_EXIT_CODE (75) so a supervisor restarts the
gateway - async-level recovery cannot run on a frozen loop.
- stop() disarms both guards before any teardown await so a busy
shutdown is never misjudged as a freeze.
HERMES_GATEWAY_LOOP_WATCHDOG=0 disables; _INTERVAL/_TIMEOUT/_STRIKES
tune the thresholds.
Fixes #69089
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gateway): close watchdog shutdown race against final-strike exit
- Re-check stop_event after a missed probe (before the strike
increment) and again on entering the final-strike branch (before the
critical log, dump, and hard exit), so a normal stop() landing
between the last timeout check and the exit path can no longer be
misclassified as a freeze and trigger a supervisor restart.
- Deterministic boundary tests pin both re-checks independently
(mutation-verified: removing either check turns its own test red);
frozen-loop semantics are unchanged.
Addresses the shutdown-race review on #69164.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gateway): recheck stop immediately before watchdog hard exit
- A stop() landing while the final diagnostics (critical log,
traceback dump) are executing could still reach os._exit(75) after
the pre-diagnostic check. Add a third stop_event recheck immediately
before the hard exit: diagnostics may complete, but a disarmed
watchdog never exits.
- Deterministic regressions for both windows (stop triggered from
inside logger.critical and from inside faulthandler.dump_traceback);
mutation-verified (removing the check turns both red). Frozen-loop
semantics unchanged.
Addresses the second round of the shutdown-race review on #69164.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(gateway): gate loop-liveness watchdog via config.yaml, drop HERMES_* env knobs
Follow-up to the salvaged #69164 commits: policy forbids introducing new
HERMES_* environment variables, so the four watchdog env knobs
(HERMES_GATEWAY_LOOP_WATCHDOG / _INTERVAL / _TIMEOUT / _STRIKES) are
replaced with a single config.yaml boolean:
gateway:
loop_watchdog: true # default; false disables both guards
- gateway/config.py: new GatewayConfig.loop_watchdog field (default True),
parsed from top-level or nested gateway: form, round-trips via
to_dict/from_dict.
- gateway/run.py: _start_loop_liveness_guards() checks config.loop_watchdog
before arming the floor timer + watchdog (getattr-guarded for bare
object.__new__ runners).
- gateway/shutdown_watchdog.py: start_loop_liveness_watchdog() no longer
reads the environment; probe interval/timeout/strikes are module
constants (30s/10s/3 — ~90s to restart, matching the systemd watchdog
layer's posture).
- hermes_cli/config.py: documented gateway.loop_watchdog default so
'hermes config set gateway.loop_watchdog false' validates.
- tests: env-knob tests replaced with config-gate + round-trip tests;
the final-strike boundary test injects its probe via max_strikes
directly instead of patching the removed env helper.
* fix: getattr-guard _stop_loop_liveness_guards in GatewayRunner.stop
Teardown-path tests build bare runners via object.__new__ without
the liveness-guard machinery; the unguarded call raised
AttributeError in 8 tests. Same guard pattern as the start path.
* fix(desktop): close cross-session leak windows in composer + session refs (#59305)
Two React passive-effect timing bugs let a session switch land in the wrong
chat: activeSessionIdRef/selectedStoredSessionIdRef (use-session-state-cache)
and the composer's attachment-scope swap (use-composer-draft) both mirrored
their source props via useEffect, which fires one commit AFTER the new
session's view has already painted — a synchronous read/submit in that window
observed the outgoing session's ids/attachments.
- use-session-state-cache.ts: mirror the session refs synchronously during
render instead of a useEffect, guarded to fire only when the prop itself
changed (not unconditionally) so an imperative pin from submit.ts /
use-session-actions (e.g. a freshly resumed runtime id, intentionally not
synced to the source atom) survives an unrelated re-render.
- use-composer-draft.ts: the per-thread attachment-scope-swap effect is now a
useLayoutEffect, closing the window before paint.
- submit.ts / session-context-drift.ts: add a 3rd drift prong comparing the
composer's loaded scope (SubmitTextOptions.composerScope) against the
submit target, resolved into the same lineage-root domain
(resolveComposerSessionKey) the composer itself uses — comparing against
the raw tip id would false-positive-abort every submit into any session
that has ever auto-compressed.
- routes.ts / chat/index.tsx: the primary composer's durable scope key now
prefers the route over a possibly-stale store selection
(primaryRouteSelectedSessionId).
- use-composer-draft.ts: redacted [composer-rehydrate] diagnostic log
(counts/kinds/scope only, never raw refs) for future reports in this class.
- chat-runtime.ts: normalize attachment id values (url/path) before hashing
so a re-attach with a trailing slash or backslash path dedupes correctly.
16 files, 286 tests across the touched/dependent suites (17 files) green,
including new regression coverage for each fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(desktop): satisfy eslint import-order rules in use-composer-draft.test.tsx
CI's check:lint failed on two perfectionist rule violations introduced by the
new test file: type import ordering and missing blank line between the
parent-relative and same-directory import groups. No behavior change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(agent): prevent shared OpenAI client FD-recycle corruption from stale stream watchdog
The streaming stale watchdog was calling
_replace_primary_openai_client() from its polling thread, which closes
the shared client's connection pool. Worker threads from previous
stale-killed attempts may still be unwinding their SSL BIOs, causing
TLS application-data to overwrite SQLite file headers via FD reuse.
This is the same corruption vector documented in #67142 for Anthropic,
where the fix was to never close the shared client from a non-owner
thread. Apply the same pattern to the OpenAI-wire path:
- Stale stream watchdog: skip shared client replacement
- Mid-tool-retry cleanup: skip shared client replacement
- Stream retry cleanup: skip shared client replacement
The request-local client is already closed via _close_request_client_once.
The shared client is replaced lazily by _ensure_primary_openai_client
on the next request, which runs on the owning thread.
Closes #70773.
* fix(agent): retire replaced shared OpenAI clients instead of cross-thread pool close
Widen the #70773 fix beyond the three in-request cleanup sites removed in
the cherry-picked commit: every remaining path that swaps out the shared
OpenAI client could still hard-close its pool from a thread that doesn't
own the in-flight sockets (credential rotation/refresh on the turn thread,
dead-connection cleanup, gateway cache eviction, transport recovery) —
the same FD-recycle corruption vector, just rarer.
Add AIAgent._retire_shared_openai_client(): shutdown(SHUT_RDWR) all pooled
sockets (FD-safe from any thread, unblocks in-flight readers) but never
call client.close() — FD release is deferred to GC, which cannot run until
every borrowing thread has unwound its SSL BIO. Refcounting is the
ownership handshake; with no borrowers the FDs are released immediately.
Wired into:
- _replace_primary_openai_client (rotation/refresh/dead-conn cleanup)
- try_recover_primary_transport (primary_recovery)
- release_clients (gateway cache_evict)
agent.close() keeps the hard close: full teardown is a real session
boundary where no request may be in flight.
Tests: new tests/run_agent/test_70773_shared_client_fd_corruption.py
covers the three watchdog/retry sites plus retire semantics; existing
close-assertions updated to pin retire-not-close.
* test: update credential-refresh tests for retire-not-close contract
The three refresh tests asserted the replaced shared client gets
close()d — the exact cross-thread close #70773 removes. They now pin
the new contract: close() is NOT called from the refresh path; the
old client is retired (sockets shutdown, FD release deferred to GC).
* fix(doctor): UTF-8/latin-1 fallback when scanning .env
Prefer UTF-8 for ~/.hermes/.env provider scans, then latin-1 for cp1252/Notepad files. Add regression test for invalid UTF-8 bytes.
* fix: handle non-UTF-8 files in OpenClaw migration script
* fix: decode config and state files as UTF-8 on non-UTF-8 locales
Several file-I/O call sites still use open() / Path.read_text() /
Path.write_text() without an explicit encoding, so they fall back to
the platform default. On Windows CN/JP/KR locales (GBK/CP932/CP949)
any non-ASCII byte in a config/state/user-content file raises
UnicodeDecodeError or UnicodeEncodeError and crashes the caller.
to the remaining hot paths:
- agent/copilot_acp_client.py: fs/read_text_file and fs/write_text_file
(Copilot's read_file / write_file tools,
directly reported in #18637 bug 2)
- agent/model_metadata.py: context-length YAML cache load + two
save sites (context probing is on the
call path of every model invocation)
- agent/nous_rate_guard.py: cross-session rate-limit JSON state
(read + atomic write via os.fdopen)
- cron/scheduler.py: user config.yaml read in run_job
- gateway/delivery.py: cron output writes for AI-generated
content, very likely non-ASCII
yaml.dump call sites also gain allow_unicode=True so the emitted
YAML preserves non-ASCII chars as-is instead of emitting \u escape
sequences.
Adds regression tests that monkeypatch builtins.open / Path.read_text
/ Path.write_text to simulate a GBK locale: each test raises
UnicodeDecodeError / UnicodeEncodeError unless the caller explicitly
passes encoding='utf-8'. Verified that the tests fail on main and
pass with this change, on Linux as well as on Windows.
Refs #18637
* fix(cli): add explicit encoding to read_text/write_text calls
Path.read_text() and Path.write_text() without explicit encoding
default to the system locale encoding. On Windows this is typically
cp1252, which causes UnicodeDecodeError for UTF-8 content (JSON
configs, user data, service scripts).
Add encoding="utf-8" to all read_text() and write_text() calls
across 8 CLI files, matching the pattern established in PR #50534
(security_audit_startup.py) and ruff rule PLW1514.
Fixed files:
- main.py: 4 read_text calls
- auth.py: 3 read_text calls
- banner.py: 1 read_text + 1 write_text
- service_manager.py: 1 read_text + 4 write_text
- container_boot.py: 1 read_text + 4 write_text
- doctor.py: 3 read_text calls
- uninstall.py: 2 read_text calls
- gateway.py: 1 write_text call
* fix(core,cli,gateway,plugins): add encoding='utf-8' to read_text() calls
Path.read_text() without an explicit encoding uses the platform's
default encoding. On Windows this is typically cp1252 or mbcs, which
causes UnicodeDecodeError or silent data corruption when reading
UTF-8 content (JSON files, user text, config with non-ASCII chars).
This is the read-side companion to the write_text() encoding fix.
Fixed the most critical locations that read JSON data, user content,
and config files across 14 files with 31 call sites.
Pattern: .read_text() → .read_text(encoding='utf-8')
json.loads(path.read_text()) → json.loads(path.read_text(encoding='utf-8'))
* fix(install): emit UTF-8 from skills_sync on non-UTF-8 Windows locales
On Windows with a non-UTF-8 system locale (e.g. CP936/GBK on zh-CN),
Python defaults stdout/stderr to the active codepage. tools/skills_sync.py
prints glyphs such as checkmark (U+2713) and up-arrow (U+2191) that GBK
cannot encode, raising UnicodeEncodeError mid-run.
The installer (scripts/install.ps1) captures this script's stdout and the
Rust bootstrap parses it as UTF-8 expecting a JSON result frame. A GBK
byte stream (or the traceback it triggers) surfaces as:
WARN stdout read error: stream did not contain valid UTF-8
stage=config-templates state=Failed
error=install.ps1 -Stage config-templates produced no JSON result frame
(exit=Some(0))
i.e. the stage fails even though the script exits 0. install.ps1 already
sets [Console]::OutputEncoding = UTF8, but that does not propagate to the
python.exe child (Python reads PYTHONIOENCODING / locale, not the console
encoding).
Fix in two places for defense in depth:
- tools/skills_sync.py: reconfigure sys.stdout/stderr to UTF-8 at import so
output is valid UTF-8 regardless of caller or active codepage.
- scripts/install.ps1: set PYTHONIOENCODING=utf-8 and PYTHONUTF8=1 (scoped
to the call, restored afterwards) around the skills_sync.py invocation.
* test(install): add UTF-8 regression guard for skills_sync child path
Addresses hermes-sweeper review on PR #54866: the installer runs
tools/skills_sync.py as a child python.exe whose PYTHONIOENCODING /
PYTHONUTF8 the scoped install.ps1 block sets, but there was no
regression test for this child-Python UTF-8 path. The existing
test_child_process_inherits_utf8_mode covers a different (bootstrap
entry-point) flow.
Add TestSkillsSyncUtf8Guard: three subprocess tests that import
skills_sync (triggering its import-time stdout/stderr reconfigure)
and assert the checkmark/up-arrow glyphs the script prints at
tools/skills_sync.py:596,675 emit valid UTF-8 and exit 0 even when
the child env is left unset or explicitly hostile (gbk). A third
test proves the guard is load-bearing by reproducing the crash
without it.
Also keep the new install.ps1 comment ASCII-only (the checkmark
spelled out as U+2713) per the file's PS 5.1 parser-compatibility
contract at scripts/install.ps1:79-80; the literal glyph in the
comment violated that contract.
* fix: add encoding="utf-8" to Path.write_text() calls (P1)
Path.write_text() without encoding defaults to system locale encoding.
On Windows (cp1252), this silently corrupts non-ASCII content written
to JSON files, config files, and cache files.
This is the write-side counterpart to the read_text() encoding fix
(PR #56115). PLW1514 only covers open() calls — Path methods are
unguarded by ruff.
39 instances across 16 files, all passing py_compile.
Files changed:
- agent/copilot_acp_client.py (1)
- tools/web_tools.py (1)
- tools/xai_http.py (1)
- tools/skills_hub.py (8)
- gateway/slash_commands.py (1)
- gateway/run.py (5)
- gateway/dead_targets.py (1)
- gateway/delivery.py (2)
- gateway/platforms/qqbot/adapter.py (1)
- hermes_cli/gateway.py (1)
- hermes_cli/banner.py (1)
- hermes_cli/service_manager.py (5)
- hermes_cli/container_boot.py (5)
- hermes_cli/uninstall.py (1)
- hermes_cli/main.py (2)
- hermes_cli/profiles.py (3)
* fix(hindsight): specify UTF-8 encoding for file I/O on Windows
On Windows with CJK locales (e.g. Chinese/GBK), pathlib.Path.read_text()
defaults to the system encoding instead of UTF-8, causing UnicodeDecodeError
when reading .env or .json config files that contain non-ASCII characters.
Explicitly pass encoding='utf-8' to all read_text() and write_text() calls
in the hindsight memory provider plugin.
* fix(memory): read/write .env as UTF-8 in mem0 and hindsight setup
The mem0 and hindsight memory-provider setup routines round-trip the
user's ~/.hermes/.env: they read existing lines, update the keys they
manage, and rewrite the whole file preserving every other line verbatim.
Both used env_path.read_text() / write_text() with no encoding.
read_text()/write_text() with no encoding fall back to the system locale
(cp1252/GBK on Windows), so on a non-UTF-8 host the preserved lines get
mangled or the call crashes on any non-ASCII value, and — because the
reader never strips a BOM — a Notepad-edited .env makes the first key
fail the in-place match and get duplicated instead of updated.
Match the canonical .env readers in hermes_cli/config.py: read with
encoding='utf-8-sig' (BOM-tolerant) and write with encoding='utf-8'.
mem0/_setup.py already pins utf-8 for mem0.json, so this just aligns the
.env path in the same file. Fixes both memory plugins in one class fix.
Adds regression tests: a BOM'd .env updates the first key in place
(locale-independent, fails without the fix) and non-ASCII existing lines
survive the round-trip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(memory): cover the remaining setup-time .env reads with utf-8-sig
Follow-up to review feedback:
- mem0 _prompt_api_key read .env with the locale default, so a Notepad
BOM hid the first key from the masked current-value lookup; read it
with utf-8-sig + errors=replace like the canonical readers in
hermes_cli/config.py.
- hindsight _load_simple_env used plain utf-8; it also parses the Hermes
.env during post_setup, where a BOM stuck to the first key. Switch to
utf-8-sig + errors=replace.
- Add hindsight regressions: BOM key matching in _load_simple_env and in
the cloud post_setup writer, plus non-ASCII round-trip preservation,
and a mem0 regression for the BOM'd masked-key lookup. The BOM tests
fail without the fix on any platform.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(profile): read .env as utf-8-sig in the distribution-install preview
`_render_distribution_plan` reads the target profile's `.env` to decide
whether a required env var is already set (so it doesn't nag the user),
using `Path.read_text()` with no encoding. Two bugs:
1. `Path.read_text()` defaults to the system locale (cp1251/GBK on Windows),
which raises `UnicodeDecodeError` on any non-ASCII byte. The surrounding
`except OSError` does NOT catch that — `UnicodeDecodeError` is a
`ValueError` — so a mis-encoded `.env` aborts the entire install preview.
2. Even on a UTF-8 locale, a Notepad-added BOM prefixes the first key
(`KEY`), so the very first required env var is mis-reported as
"needs setting" when it is actually present.
`.env` is written as UTF-8 everywhere in the codebase. Read it as
`utf-8-sig` (tolerates the BOM) and also catch `UnicodeDecodeError` so a
genuinely un-decodable file skips the pre-check instead of crashing.
Regression tests: a BOM-prefixed `.env` whose first key must still read as
"set", and an invalid-UTF-8 `.env` that must not abort the preview.
* fix: add UTF-8 encoding to read_text/write_text in tools/ and agent/
Path.read_text() and Path.write_text() without encoding= default to the
system locale (cp1252 on Windows), which corrupts non-ASCII JSON content.
Coverage-gap fix for files not addressed by prior encoding PRs:
- tools/skills_hub.py: 6 read_text + 8 write_text (cache, index, lock files)
- tools/skills_sync.py: 1 read_text (lock file)
- tools/xai_http.py: 1 read_text + 1 write_text (auth store, marker)
- agent/shell_hooks.py: 1 read_text (allowlist)
- gateway/status.py: 1 read_text (PID file)
- hermes_cli/banner.py: 1 read_text + 1 write_text (update cache)
All sites read/write JSON or short text. No behavioral change on Linux
(already UTF-8); fixes silent data corruption on Windows.
* fix(skills): tolerate non-UTF-8 bytes in hub lock.json
_read_hub_installed_names() reads ~/.hermes/skills/.hub/lock.json with a
strict utf-8 decode. Hub skill descriptions can carry Windows-1252
typographic bytes (em-dash 0x97, smart quotes, bullets) as single high
bytes; read_text(encoding="utf-8") then raises UnicodeDecodeError, which
is a ValueError sibling not caught by the function's
except (OSError, json.JSONDecodeError). It escapes and 500s the whole
/api/skills endpoint, blanking the desktop Skills panel.
Decode with errors="replace" so the offending byte degrades to U+FFFD
and the structurally valid JSON — and every other skill — stays readable.
Fixes #68053
* fix(cli): read .worktreeinclude and .gitignore as UTF-8 in worktree setup
_setup_worktree read both files with the locale default encoding. On a
cp1251/GBK Windows machine a UTF-8 include list either decodes to
mojibake paths (non-ASCII entries silently not copied) or raises
UnicodeDecodeError, which the enclosing handler logs at DEBUG and
swallows — no include is copied at all, so the worktree starts without
.env/keys and the agent breaks invisibly. A Notepad BOM likewise glues
to the first include entry on every platform, and to the first
.gitignore line, defeating the '.worktrees/' membership check and
appending a duplicate entry on each run.
Read both files with utf-8-sig + errors=replace, matching the canonical
.env readers in hermes_cli/config.py (utf-8-sig because Notepad adds a
BOM) and the UTF-8 append this same block already performs on
.gitignore.
Regression tests exercise the real cli._setup_worktree: the two BOM
tests fail without the fix on any platform, the non-ASCII include test
additionally reproduces the Windows locale failure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skills): read OOXML parts as bytes and form JSON as UTF-8 in office skill scripts
The bundled office skills (#68595) read user documents and agent-authored
payloads with the locale-default codec:
- docx/powerpoint validators/base.py opened OOXML part XML in text mode
before handing it to lxml. On Windows (cp1251/GBK) the bytes decode to
mojibake that lxml then parses, so validation runs against silently
corrupted document text; on locales where the UTF-8 bytes don't decode
the validator crashes with UnicodeDecodeError instead of validating.
Opening as bytes lets lxml honor the encoding declared in the XML prolog.
- The pdf form scripts (fill_fillable_fields, fill_pdf_form_with_annotations,
create_validation_image, check_bounding_boxes) read the fields JSON the
agent authors — UTF-8 by construction — with the locale codec, so
non-ASCII form values (any Cyrillic/CJK/accented input) either crash or
get written into the user's PDF as mojibake. The json.dump writers use
ensure_ascii=True and were already safe; only the readers needed pinning.
Adds a contract test asserting every document/payload reader is
locale-independent, plus a live regression test that runs
check_bounding_boxes.py on a non-ASCII fields.json under a forced
non-UTF-8 locale — it fails without the fix on both POSIX (C locale)
and Windows (cp1251 chokes on the 0x98 byte of U+2018).
* fix(windows): sweep remaining bare read_text/write_text sites + linter rule
AST-driven pass over every Path.read_text()/write_text() without an
explicit encoding= across non-test code: 71 sites in 34 files
(skills_hub, hermes_cli/main+profiles+service_manager+container_boot,
mem0/hindsight/honcho plugins, achievements dashboard, release/CI
scripts, productivity+comfyui skill helpers, agent/*). Verified zero
positional-encoding collisions before insertion; per-file compile()
check after.
Adds a check-windows-footguns rule flagging bare single-line
read_text/write_text (multi-line forms stay covered by the AST guard
test from #38985). Together with the salvaged contributor commits this
retires the ~169-site bare file-I/O class (#37423's long tail).
* fix: restore utf-8-sig BOM tolerance at .env readers the sweep normalized
The cherry-pick auto-resolution + AST sweep applied plain utf-8 at three
.env reader sites where the salvaged PRs (#62617, #62123) deliberately
use utf-8-sig — a Notepad BOM must not hide/duplicate the first key.
Restore the contract (tests pin it).
* chore: contributor email mappings for the file-I/O salvage
* refactor(desktop): add shared Field form-dialog primitive
Dialog forms each hand-rolled their own label+control+hint stack (or
borrowed the settings-surface ListRow), so gaps and hint styling drifted
between the profile, cron, and webhook dialogs. Add a single Field /
FieldHint primitive for label-over-control dialog fields and adopt it in
the create/rename profile dialogs as the first consumers.
* fix(desktop): unify overlay-pane padding and add primary PanelAction
Overlay panes each set their own top padding, so the Settings sidebar and
Panel headers sat at different heights than System/Agents and the close X
(the #67759 regression). Hoist the shared beside-the-X clearance into
OVERLAY_TOP_CLEARANCE, keep the taller pad only on OverlayMain (which sits
under the X), tighten OverlayMain's gutters, and drop the one-off Settings
override. Also give PanelAction a `primary` variant so a detail header can
promote its main action to a filled button.
* refactor(desktop): fold cron Blueprints into the New Job dialog
Blueprints lived behind a separate Jobs/Blueprints tab with its own card
gallery — a bespoke surface no other overlay uses. Remove the tab and make
blueprints a "Start from" dropdown at the top of the New Job dialog
(default "Custom" = the manual editor); picking one swaps the form for that
blueprint's typed slots. Also promote the detail-view "Trigger now" button
to a primary action and adopt the shared Field primitive.
* refactor(desktop): webhooks create form uses shared Field; drop status pill
The create dialog used the settings-surface ListRow/ToggleRow inside a
modal, which read differently from every other form dialog, and the detail
header carried an enabled/disabled pill that rendered as a stray dash.
Switch the form to the shared Field primitive (+ Switch) and remove the
pill.
* fmt(js): `npm run fix` on merge (#71099)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(dashboard): add lightweight /api/health liveness endpoint
/api/status is the only public liveness route, and its handler loads the
gateway config, probes gateway health, and counts sessions before it can
answer. That work is wrong for a readiness probe: a caller that only needs
to know the process is up pays for a cold plugin import tree.
Add /api/health, which returns process liveness, version, and the auth-gate
shape and touches nothing else.
* fix(desktop): probe /api/health for boot readiness, and survive a stalled loop
Desktop boot polls /api/status, so readiness waits on gateway config and a
cold plugin import tree. On Windows that regularly outlives the probe and
Desktop kills a backend that is already listening, respawns it, and re-pays
the same import cost — the reported crash loop.
Probe /api/health instead, falling back to /api/status only for the
missing-route shapes the fetch helpers emit (404, or HTML from the SPA), so
an older remote backend still connects. Timeouts and server errors keep
polling health rather than dropping to the heavyweight route.
A cheap route is not enough on its own. Warming the gateway import holds the
GIL, so the event loop can stall for tens of seconds and starve /api/health
too. At the default 15s socket timeout only three attempts fit in the 45s
budget; give each probe 5s so the loop keeps retrying across the stall.
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: DESXIE <78300229+DESXIE@users.noreply.github.com>
Co-authored-by: frohsinnllc <231045016+frohsinnllc@users.noreply.github.com>
* fix(state): decode display_metadata at every message read path
get_messages(), get_messages_around() and get_anchored_view() returned the
raw display_metadata column instead of the dict every caller expects. The
desktop paints a resumed transcript from the REST prefetch, which reads
through get_messages(), so any session holding an async_delegation_complete
event failed resume with "Cannot use 'in' operator to search for
'task_count'" — on every such session, not just corrupted ones.
Route all four read paths through one shared codec that also unwraps rows
carrying a second JSON layer, so sessions already broken on disk recover on
read rather than needing a migration.
Co-authored-by: Studio729 <Studio729@users.noreply.github.com>
Co-authored-by: aml1973 <aml1973@users.noreply.github.com>
Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
* fix(state): stop double-encoding display_metadata on write
export_session() reads through get_messages(), so before the read fix an
already-serialized string went straight back into _insert_message_rows() and
got re-dumped — an export/import round trip permanently corrupted the row.
Guard the three write paths the same way tool_calls already is: parse a
string argument before storing it, and drop metadata that isn't an object
rather than persisting something no reader can use.
Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
Co-authored-by: aml1973 <aml1973@users.noreply.github.com>
* fix(desktop): tolerate unparsed display_metadata from an older backend
The desktop and the Hermes backend it talks to version independently — a
remote VM running an older build still serves display_metadata as JSON text.
Indexing into that string with `in` threw and failed the whole resume, so
narrow the type to admit a string and parse it before reading task_count.
Falling back to the generic label keeps a delegation event renderable even
when the metadata is unusable.
Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
Co-authored-by: Studio729 <Studio729@users.noreply.github.com>
* fix(checkpoints): don't prune a project whose volume is merely unmounted
Orphan pruning decides a project is gone from a single probe:
if delete_orphans and (not workdir or not Path(workdir).exists()):
reason = "orphan"
then deletes its ref, index, and metadata — the project's entire checkpoint
history. `Path.exists()` is False for a deleted directory, but it is equally
False for one whose storage is not attached right now: an unplugged external
drive, a share behind a downed VPN, a bind-mount absent from this container,
an offline Windows mapped drive. The project is fine; only our view of it is.
This is not an opt-in maintenance command. `maybe_auto_prune_checkpoints`
runs unattended at startup from both `cli.py` and `gateway/run.py`, with
`delete_orphans=True` by default. So starting Hermes once while the drive is
unplugged silently destroys the restore points for every project on it — the
one thing checkpoints exist to provide, and there is nothing to restore from
afterwards.
Reproduced against the real store: a project registered under an unmounted
path and one on local disk, then a startup prune —
prune: {'scanned': 2, 'deleted_orphan': 1}
unreachable project index still on disk: False
The legacy pre-v2 branch has the same flaw plus a second one: a
`HERMES_WORKDIR` marker that exists but cannot be read leaves `workdir = None`,
which the same condition treats as an orphan. Failing to read a file is not
evidence that a project was deleted.
Require corroboration before deleting: the workdir's parent must be present,
so its absence is something we actually observed. A missing parent means the
volume is not there and we know nothing, so the entry is left alone — and an
unreadable marker never deletes at all. Genuinely abandoned projects are still
reclaimed, both by the unchanged orphan path (parent present, project gone)
and by the retention/stale rule, which runs off `last_touch` rather than a
filesystem probe.
tests/tools/test_checkpoint_manager.py: a project whose whole mount disappears
keeps its history; controls prove a genuinely deleted project is still pruned
and a live project is untouched. The data-loss test fails on main; both
controls pass there. 81 passed across the checkpoint suites (2 failures in
test_checkpoint_manager.py are pre-existing and fail identically on clean
main).
* fix(checkpoints): an empty surviving mount point is not evidence of deletion
Addresses @egilewski's review: the parent-directory check still deleted
checkpoint history for the most common unmount layout.
Detaching storage removes the parent outright in some layouts
(`/Volumes/Ext/proj` on macOS, `/media/<user>/<label>/proj`), which the first
commit handles. But in the classic static layout — `/mnt/volume/proj`, an
fstab entry, a container bind-mount — unmounting removes the contents and
leaves the mount point behind as an empty directory. `parent.is_dir()` is then
true, the project is absent, and the startup sweep deletes its ref, index and
metadata: exactly the case this PR set out to protect.
Reproduced against the real predicate before this commit:
mount root vanished (macOS) -> False ok
empty surviving mount point -> True <-- history deleted
really deleted (siblings) -> True ok
An empty parent carries no information: it looks identical whether the volume
was detached or the project was deleted. So require the parent to actually say
something — it holds some other entry (we observed a populated directory that
does not contain the project), or it is itself a live mount point (the volume
is attached right now and demonstrably does not hold the project).
The cost is that a project deleted out of an otherwise-empty parent is no
longer reclaimed by the orphan rule. It is not leaked: the retention rule
reads `last_touch` rather than probing the filesystem and still collects it,
so reclamation is deferred, not lost. That is the right direction for a
predicate whose false positive destroys a user's restore points unattended.
`_dir_has_any_entry` stops at the first entry via `os.scandir` instead of
materializing a listing, since a project root can hold a large tree.
tests/tools/test_checkpoint_manager.py: `test_surviving_empty_mountpoint_
keeps_its_checkpoints` pins the reviewed case, and `test_empty_parent_project_
is_still_reclaimed_by_retention` pins the deferral above so the safety valve
cannot silently regress into a leak. Both fail on the previous commit. The
real-orphan control now seeds a sibling so it exercises a populated parent
rather than the ambiguous empty one. 80 passed in the checkpoint suite; the 2
remaining failures (`TestGitEnvIsolation`, `TestClearFunctions`) fail
identically on clean main.
* fix(checkpoints): require positive volume-attachment evidence before orphan classification
Follow-up to the cherry-picked #69063: egilewski's review found that the
_dir_has_any_entry(parent) guard treats ANY entry in the mount point's
parent as proof the volume is attached — but unmounting exposes the
UNDERLAY directory's own files (e.g. a .keep placeholder), so a populated
underlying mount-point dir still classified the project as an orphan and
deleted its ref/index/metadata. Reproduced on both main and the PR head.
Attachment evidence is now positive instead of circumstantial:
* _volume_evidence() records the parent directory's (st_dev, st_ino)
identity in the project's metadata while the workdir is observably
live (at _register_project/_touch_project time). A mount point
resolves to the mounted filesystem's root while attached and to the
underlay directory after detach — same path, different directory,
different identity.
* _workdir_is_observably_gone() now requires the parent visible at
prune time to match that recorded identity before the populated-parent
check can classify an orphan. A mismatch means a different directory
(the underlay) is showing through — a detached volume, not an
observed deletion.
* Metadata without a recorded identity (written by older versions) is
never orphan-classified — unsure never deletes; the retention/stale
rule still reclaims genuinely abandoned projects off last_touch.
* The frozen pre-v2 layout has no metadata channel for the identity, so
it keeps the structural checks only (require_parent_identity=False).
* A failed evidence probe on re-registration preserves the previously
recorded identity — stale evidence can only make pruning MORE
conservative.
Windows: st_dev/st_ino of 0 (filesystems without file IDs, some network
shares) is treated as "no evidence recorded", which falls into the
conservative never-orphan path. os.path.ismount and Path.stat are
cross-platform; no POSIX-only calls added.
tests/tools/test_checkpoint_manager.py: adds egilewski's exact
regression (checkpoint history for mnt/volume/project, detach exposes
mnt/volume/.keep, prune with orphan deletion enabled → NOT deleted;
fails on the bare cherry-pick, passes with this fix), plus
no-recorded-identity conservatism and probe-failure identity
preservation. His absent-parent/empty-parent/retention/genuine-deletion/
live-project controls all still pass.
Reported-by: egilewski (review on #69063)
* fix(telegram): require initial polling readiness
Use wall deadlines for deleteWebhook and start_polling, then fail cold startup unless getUpdates proves progress. This lets the gateway discard partial PTB state and retry with a fresh adapter.\n\nRefs #67498
* fix(gateway): allow Telegram readiness budget
Give Telegram a 180s default outer connect budget so cold polling can prove getUpdates readiness. Preserve the 30s default for other platforms and all explicit config/env overrides.\n\nRefs #67498
* fix(telegram): bind strict cold-start readiness to its own polling generation
Follow-up hardening for the salvaged #69240 readiness gate (#67498):
- _start_polling_once now returns its (generation, progress_event) pair
so the strict cold-start gate binds to exactly the generation it
started, instead of re-reading self._polling_progress_event which a
concurrent recovery task may have replaced with a newer generation's
event (the G1/G2 race flagged in the #69240 review).
- Strict cold start no longer schedules background polling recovery: a
polling error during the readiness wait is captured by a strict
callback and fails the connect attempt immediately with a loud
OSError, so GatewayRunner disposes the partial adapter and retries
with a fresh one — no more waiting out the full readiness deadline on
a generation that already errored, and no G2-on-partial-app healing.
- After readiness is proven the strict callback delegates every later
polling error to the real background-recovery callback, preserving
the existing degraded/reconnect semantics for the polling lifetime.
- The readiness-timeout error message now states the deadline and that
the gateway will retry with a fresh adapter (loud failure, not a
silent wait).
- Regression tests: current-generation progress connects; a polling
error during strict cold start fails fast without scheduling
background recovery (the #67498 idle-threads shape); stale-generation
progress is rejected.
Progresses #67498
* test: record getUpdates progress in mocked cold-connect polling flows
The strict cold-start readiness gate (#67498) means adapter.connect() no
longer returns True until the mocked start_polling records a successful
getUpdates round trip for its generation. Update the conflict-suite
Application mocks accordingly:
- fake_start_polling side effects call
adapter._record_polling_progress(adapter._polling_generation) on the
initial connect (retry generations intentionally do NOT auto-progress
where a test asserts the conflict count survives an unproven retry).
- _build_polling_app takes the adapter so its start_polling mock can
record progress.
Without this, the cold connects in these tests wait out the full 60s
readiness deadline and fail — which is exactly the fail-closed behavior
the gate is supposed to provide when polling shows no progress.
* fix(config): add a collision-safe env var name for custom endpoint keys
Both the Desktop panel and the CLI setup flow need somewhere in .env to put
a custom endpoint's API key. Deriving the name from the endpoint's hostname
collapses two servers on one machine onto a single slot, and every IP-based
local endpoint slugs to a digit-leading name that save_env_value rejects
outright. Key off the endpoint's own identity and keep a fixed prefix.
Co-authored-by: asorry75 <33794789+asorry75@users.noreply.github.com>
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
* fix(windows): verify rebuilt Hermes.exe integrity before shipping it as an update (#69179)
The desktop self-update chain (Desktop -> hermes-setup --update ->
hermes update -> hermes desktop --build-only -> relaunch) rebuilds
Hermes.exe on the user's machine and declared success on bare file
EXISTENCE. A truncated PE (corrupt cached Electron zip / interrupted
extraction or rcedit rewrite / full disk) or a wrong-architecture
unpacked tree therefore shipped as the 'updated' app, which Windows
refuses to load with 'This app can't run on your computer'
(此应用无法在你的电脑上运行) — and the previous working build had
already been wiped by before-pack.mjs, leaving nothing to fall back to.
Fix, in three parts:
- hermes_cli/main.py: post-build integrity gate on Windows
(_ensure_desktop_exe_launchable). Parses the PE header of the freshly
built Hermes.exe — MZ/PE magic, section-table completeness vs file
size (catches truncation), and COFF machine vs the host arch (catches
arm64/x64 mixups). On failure it purges the (likely corrupt) cached
Electron zip, invalidates the content-hash build stamp so the
updater's retry-once genuinely re-downloads and rebuilds, restores
the previous build from the .bak tree when one exists (keeping the
corrupt tree as .corrupt for diagnostics), tells the user the update
was aborted and their old version kept, and exits nonzero.
_desktop_packaged_executable also now prefers a host-loadable PE over
pure newest-mtime when multiple win-*-unpacked trees coexist.
- apps/desktop/scripts/before-pack.mjs: on win32, the previous unpacked
tree is preserved as <appOutDir>.bak (only when it holds the product
exe — partial/corrupt trees still get the plain wipe) instead of
being destroyed, providing the rollback material for the gate above.
Non-Windows behavior is unchanged.
- Behavior-contract tests: tests/hermes_cli/test_desktop_exe_integrity.py
(23 tests — synthetic PE fixtures for truncation/non-PE/arch-mismatch,
rollback semantics, and the build-only exit contract) and 6 new vitest
cases in before-pack.test.mjs for the .bak preservation rules.
Progresses #69179
* fix(desktop): persist the whole discovered model list when saving an endpoint
Test enumerates a custom provider's catalogue and the panel holds the result
in discoveredModels, but the save payload never carried it, so only the one
model the user hand-typed reached providers.<id>.models. Every downstream
picker reads that map straight from config.yaml with no live probe, which is
why a proxy serving 18 models offered exactly one.
Send the discovered list and merge it onto the entry, so models already
known keep their context lengths.
Fixes #69988
Co-authored-by: asorry75 <33794789+asorry75@users.noreply.github.com>
* fix(web_server): keep Desktop custom endpoint API keys out of config.yaml
The Custom Endpoints panel wrote the raw key to providers.<id>.api_key, so
the credential sat in plaintext in a file users routinely share and commit.
The input is masked, so nothing warned them.
Write the key to .env and reference it via key_env, the same indirection
built-in providers use and that runtime_provider already resolves. The read
side has to move with it: reporting has_api_key from api_key alone would
show "no API key" for every migrated endpoint, and activate copying only
api_key would drop the credential entirely. Delete now clears the .env slot
too, and an entry still carrying a pre-fix plaintext key is migrated on its
next save so existing users get cleaned up without re-entering anything —
unless the key is a hand-written ${VAR} template, which is already safe and
must not be duplicated into a second env var.
Fixes #69449
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: asorry75 <33794789+asorry75@users.noreply.github.com>
* fix(cli): store custom endpoint API key in .env instead of config.yaml
hermes model's custom-endpoint flow is the other write path that produced a
plaintext key, on both the model block and the custom_providers entry. Route
it through the same .env indirection as the Desktop panel, and swap an
existing entry's inline key for the reference when the URL is re-saved.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
* test: cover custom endpoint key storage and model-list persistence
Bug-class coverage for both fixes: the full catalogue survives Save, context
lengths are preserved, the key never lands in config.yaml on either write
path, blank clears it, a pre-fix plaintext key migrates while a ${VAR}
template is left alone, two endpoints on one host keep separate credentials,
and an IP-derived name is still a valid POSIX env var.
The two delete tests asserted on the plaintext mirror; they now assert the
same invariants against the credential reference.
* fix(desktop): persist @image: refs instead of the vision-enrichment text
The desktop gateway passed the vision-enriched, model-only message text
(carrying an `image_url:<path>` hint) straight into run_conversation as
the persisted user turn. The renderer only parses `@image:<path>`, so it
could not rebuild the attachment from history: after a restart the image
was gone and only the caption survived, and on a live session switch the
warm cache disagreed with the authoritative text and the frontend
"rescued" the image by appending it after the caption.
run_conversation already supports persist_user_message for exactly this
"what the model sees" vs "what gets stored" split; it was simply never
wired up for the attachment path.
* fix(desktop): keep cached attachment refs on session resume
Persisted history carries no attachment metadata for non-image refs, so
resume reconciliation dropped `@file:` chips off a user turn whose text
matched. Carry the warm cache's refs forward when the resumed message has
none of its own, never replacing refs that are already present.
(cherry picked from commit eac5b0a8ac39eab242a5d571531e386ec70e2435)
* fix(desktop): quote persisted @image: paths so spaced paths render
The unquoted alternative in the directive pattern is `\S+`, so a ref built
by string interpolation truncates at the first space and strands the tail
as loose text next to a broken thumbnail. Composer images live in the app's
userData dir, which on macOS is `~/Library/Application Support/<App>/` — so
every pasted or dropped image hit this.
Adds format_reference_value next to REFERENCE_PATTERN, mirroring
formatRefValue in the desktop's directive-text.tsx, and covers the
round-trip through the parser.
* fix(desktop): persist the image ref for natively-vision-capable models too
A turn routed to a model that takes pixels directly sends `content` as a
parts list, and the session store deliberately ignores a plain-string
persist override for a list payload — a text override must not erase a
turn's image summary. So the override was dropped for every user on a
vision-capable main model, and the durable row kept only the caption plus a
literal `[Image attached at: ...]` / `[screenshot]`, which the renderer
cannot turn back into an image. Only vision-preprocessed (text-mode) turns
were actually fixed.
Mirror the shape instead: swap the text part for the `@image:` ref form and
keep the image parts, so the model still has the pixels for the rest of the
session, and drop the `[screenshot]` stand-in on the way into the bubble
when a ref was lifted from the same message.
* refactor(desktop): memoize the directive image-segment filter
Matches the two derived values above it and fixes the indentation.
* fix(desktop): lead persisted image turns with the caption
Session previews are the first 60 characters of the first user message, so
persisting the @image: directives ahead of the caption labelled the session
with a truncated file path in the sidebar, session switcher, and command
palette. Clients lift the refs out of the body line by line, so moving them
after the caption changes nothing about how the turn renders.
* test(desktop): cover attached-image resume end to end
The unit tests cover each layer in isolation, but nothing exercised the whole
chain the bug lived in: the real gateway persisting an attachment, SessionDB
holding it after the process exits, and the renderer rebuilding a thumbnail
from the stored turn.
Seeds a session through the real gateway with an image attached, then launches
desktop against it — so the first render is already the relaunch case. Pins
native image routing (the majority path, and the one where a text-only persist
override is dropped) and stages the file behind directory and file names with
spaces, mirroring the macOS composer's Application Support path.
* fix(models): resolve custom provider model ids
Map picker-prefixed custom provider selections back to their configured model IDs before validation, persistence, and API requests.
Fixes #68347
* chore(contributors): map jevin@jevin.org to ijevin
Attribution check needs a mapping for the cherry-picked commit's author so
release notes credit them correctly.
* fix(relay): normalize forwarded Discord interactions to leading-slash commands (#71048)
A real APPLICATION_COMMAND interaction forwarded over the relay ar…
… (NousResearch#67759) * feat(desktop): add custom endpoint settings (supersedes NousResearch#42745) Salvages PR NousResearch#42745 (elashera:custom-endpoints-desktop), which could no longer merge cleanly against main. Re-integrated the work onto current main and reconciled the conflicts: - Settings nav: wired the new 'Custom Endpoints' provider sub-view into main's data-driven navGroups/OverlayNav layout (PR predated that refactor) and added it to PROVIDER_VIEWS. - providers-settings: kept BOTH main's LocalEndpointRow affordance and the PR's fuller CRUD panel; unified ProvidersSettingsProps to carry onClose + onConfigSaved + onMainModelChanged. - web_server: kept main's _normalize_main_model_assignment + api_key propagation AND the PR's provider base_url lookup in _apply_model_assignment_sync. - model_switch: dropped the PR's bare direct-custom-config picker block; main already implements it (source='model-config', with live model discovery). Updated the salvaged test to assert main's behavior. - Merged additive import/type blocks in hermes.ts and types/hermes.ts. Backend endpoints, i18n labels (en/ja/zh/zh-hant), and the custom-endpoints-settings.tsx panel carried over. 28 custom-endpoint tests pass. Co-authored-by: elashera <emilio.jesus.lasheras.romero@nttdata.com> * chore(contributors): map elashera's commit email Salvage of NousResearch#42745 (superseded by NousResearch#67759) preserves @elashera's authorship, whose corporate commit email had no contributor mapping. Adds contributors/emails/ mapping so check-attribution passes. Verified: GitHub user 'elashera' id=135239963 matches their own noreply commit email (135239963+elashera@users.noreply.github.com). --------- Co-authored-by: elashera <emilio.jesus.lasheras.romero@nttdata.com>
Overlay panes each set their own top padding, so the Settings sidebar and Panel headers sat at different heights than System/Agents and the close X (the NousResearch#67759 regression). Hoist the shared beside-the-X clearance into OVERLAY_TOP_CLEARANCE, keep the taller pad only on OverlayMain (which sits under the X), tighten OverlayMain's gutters, and drop the one-off Settings override. Also give PanelAction a `primary` variant so a detail header can promote its main action to a filled button.
What does this PR do?
Supersedes #42745 (
elashera:custom-endpoints-desktop) with a conflict-free, rebased-onto-mainversion of the same work. The original PR could no longer merge —mergeable: CONFLICTING, 6 files in conflict, and it predated two structural changes onmain(the desktop settingsnavGroupsrefactor and the_apply_main_model_assignmentsignature change).Authorship is preserved: the salvage commit is authored to @elashera with a
Co-authored-bytrailer.Adds first-class Desktop settings support for OpenAI-compatible custom endpoints: a
Custom Endpointssub-view under Settings → Providers (add / edit / validate / activate / delete), backend dashboard CRUD routes, andmodel.base_urlpreservation when selecting a named custom provider from the picker.How this differs from #42745 (conflict reconciliation)
main's data-drivennavGroups/OverlayNavlayout and addedcustom-endpointstoPROVIDER_VIEWS(the PR was built on the old hand-rolled sidebar).main's newLocalEndpointRowaffordance and the PR's fuller CRUD panel; unifiedProvidersSettingsPropsto carryonClose+onConfigSaved+onMainModelChanged.main's_normalize_main_model_assignment+api_keypropagation and the PR'sproviders[...]base_urllookup in_apply_model_assignment_sync.mainalready implements it (source="model-config", with live model discovery viafetch_api_models). The PR's block was dead code behind an always-false guard. Updated the salvaged test to assertmain's behavior.hermes.ts/types/hermes.ts; i18n labels carried across en/ja/zh/zh-hant.Type of Change
Testing
TS resolutions verified by inspection (import-list merges, prop contract consistency across
index.tsx→ProvidersSettings→CustomEndpointsSettings, i18n label present in all locale files). A full desktoptype-checkshould run in CI.Closes #42745.