Skip to content

Commit ffff7e6

Browse files
Lexus2016teknium1GodsBoybenbarclayethernet8023
authored
sync: catch up ~469 commits from upstream (owner review) — resolves #562 escalation (#576)
* chore: add benbenlijie to AUTHOR_MAP for PR #47205 salvage * fix(learn): teach /learn the full CONTRIBUTING.md skill standards (#52372) The /learn authoring prompt taught a subset of the HARDLINE skill rules, and stated the <=60-char description rule without making the model enforce it — so generated descriptions overshot (up to 202 chars), which the 60-char system-prompt skill index then silently truncates. - description: add the index-truncation rationale, a count-and-trim self-check, and a good/bad length example so the model actually hits <=60. - add platforms-gating rule (OS-bound primitives -> declare platforms:). - add author-credits-human-first rule. - round out the Hermes-tool framing with the full wrapped-tool mapping and references/templates layout. Closes #52367. * fix(agent): gate verify-on-stop nudge off for messaging surfaces The verify-on-stop guard (PRs #52296, #52297) defaulted ON for every session, so on gateway messaging surfaces (Telegram, Discord, etc.) the model complied with the nudge by writing a hermes-verify temp script and emitting an ad-hoc verification summary, which the gateway delivered to the end user as chat noise. Resolve a surface-aware default instead. The DEFAULT_CONFIG value becomes the sentinel "auto", which verify_on_stop_enabled() resolves to ON for interactive coding surfaces (CLI, TUI, desktop) and programmatic callers, and OFF for conversational messaging surfaces. The surface is read from HERMES_SESSION_PLATFORM (what the gateway actually binds), with HERMES_SESSION_SOURCE and HERMES_PLATFORM as fallbacks, matching the sibling resolution in skill_commands.py and prompt_builder.py. An explicit HERMES_VERIFY_ON_STOP env var or a boolean agent.verify_on_stop config still overrides in either direction. The passive evidence ledger and the call site are untouched. * fix(gateway): harden scale-to-zero dormancy guards (#52359) Block scale-to-zero suspend while background async delegations are active, and restore runtime status to running on real inbound after a dormant wake.\n\nAdd regression coverage for both review findings. * fix(ci): run CI on all PRs to anywhere fixes stacked PRs no-checks bug where main < a < b a merges into main b is retargeted to main but b doesn't run checks since it's not considered a new pr to main now b will simply already have passing ci :) * fix(desktop): ad-hoc sign macOS self-update rebuilds The desktop self-updater rebuilds and re-signs the .app on each user's own machine (`hermes desktop --build-only` -> electron-builder `--dir`). With CSC_IDENTITY_AUTO_DISCOVERY on (its default), electron-builder signs the type=distribution, hardened-runtime bundle with whatever identity is in that user's keychain -- typically a personal "Apple Development" cert -- which stalls/fails the sign step (no Developer ID, no provisioning profile) or clobbers the original notarized signature with an unusable one, tripping Gatekeeper on every post-update launch. Force ad-hoc signing for the local packaged rebuild instead: deterministic, and exactly what _desktop_macos_relaunchable_fixup already finishes off. No-op for source runs, off-macOS, when a real identity is configured (CSC_LINK / APPLE_SIGNING_IDENTITY), or when the caller already pinned the flag. * fix(agent): close tool-call sequence on all interrupt aborts, not just finalize_turn #48879 closed the tool-call sequence on interrupt inside finalize_turn so a /stop after a tool no longer persists a `tool` tail that the next user message turns into a `tool -> user` role-alternation violation (which strict providers like Gemini/Claude react to by hallucinating a continuation and ignoring prior context — what users see as "lost context after stop"). But the retry-wait, error-handling, and post-error retry-wait interrupt aborts in conversation_loop return early and never reach finalize_turn, so they still persisted and returned a raw `tool` tail. Interrupting during provider backoff/rate-limiting (common under heavy work) hit exactly this path. Extract the close into a shared close_interrupted_tool_sequence helper and apply it at every interrupt abort (finalize_turn + the three early returns) so the whole bug class is fixed, not just the one site. * fix(tui_gateway): queue mid-turn prompts instead of dropping them on a busy retry A prompt sent while a turn was in flight got rejected with 4009 "session busy", which pushed clients (the desktop app) into a deadline-bounded busy-retry. When turn teardown outlived that deadline — e.g. the user hits stop while a slow, non-interruptible tool (web_search, read_file, an MCP call) is mid-flight, since the sequential executor only checks the interrupt flag between tools — the resubmitted message was silently dropped: "it just doesn't listen". Wire the previously-dead display.busy_input_mode config into prompt.submit: instead of rejecting, apply the policy and queue the message to run as the next turn (drained in run()'s tail, ahead of goal/notification follow-ups). Modes: interrupt (default) interrupts the live turn so it winds down promptly then runs the queued message; queue runs it after the current turn finishes; steer injects it into the live turn when accepted, else queues. The queued slot pins the sender's transport and losslessly merges a second arrival. No client deadline, no dropped sends. * fix(terminal): improve sudo -S password delivery and cache invalidation Pipe one password line per sudo invocation in compound commands so a correct password is not rejected on the second `sudo` in `sudo a && sudo b`. Drop the session cache when sudo returns Authentication failed, surface sudo_auth_failed in the tool result, and add hints for interactive sessions. * test(terminal): cover sudo cache invalidation and multi-invocation piping * refactor: lightweight sudo count + drop chatty multi-sudo tip Replace _count_real_sudo_invocations (which called _rewrite_real_sudo_invocations and discarded the rewritten string) with a lightweight token scan that reuses the same tokeniser but skips string building. Remove the agent-facing tip about nested sudo in heredocs — the cache-cleared warning is enough. * fix(utils): unify YAML list indent across all config writers (#31999) atomic_yaml_write used default yaml.dump which emits indentless sequences (list items at column 0), while atomic_roundtrip_yaml_update (ruamel.yaml) emits 2-space-indented sequences. Cross-path writes to the same config.yaml toggled indentation on every save, eventually producing a mixed-indent file that js-yaml rejects with 'bad indentation of a mapping entry', silently dropping custom_providers and breaking model switching. Add IndentDumper SafeDumper subclass that forces indentless=False, route atomic_yaml_write through it. Route tui_gateway._save_cfg and the Telegram adapter's config writer through atomic_yaml_write so all paths emit the same 2-indent layout. Salvaged from #32034 by @xxxigm. Adapted to current main which already has allow_unicode=True (from #51356) but was missing IndentDumper. Closes #31999 * fix(mcp): run OSV malware preflight off the event loop with a bounded timeout (#29184) During stdio MCP server startup, _run_stdio (an async method) called the synchronous check_package_for_malware() inline. That makes a blocking urllib HTTPS POST to api.osv.dev whose own timeout doesn't reliably cover a stalled SSL handshake, so an intermittent network issue froze the entire asyncio event loop for up to ~120s — blowing past the TUI/gateway's 15s startup budget and showing "gateway startup timeout". Run the check via asyncio.to_thread (off the loop) AND bound it with asyncio.wait_for(timeout=_OSV_MALWARE_CHECK_TIMEOUT_S=12s). The malware check is fail-open, so on timeout we log and proceed rather than blocking startup. Salvaged from #29190 by @qdaszx (re-applied on current main — the call site moved since the PR was opened), combining the to_thread approach also proposed in #29192 by @ygd58. Two load-bearing tests: event-loop-not-blocked-during- check and timeout-fails-open — both mutation-verified to fail against the old inline blocking call. Closes #29184. Co-authored-by: ygd58 <buraysandro9@gmail.com> * fix(tools): defensive type coercion in todo_tool for malformed LLM input (#14185) todo_tool crashed with `AttributeError: 'str' object has no attribute 'get'` when the LLM emitted the `todos` param as a JSON-encoded string instead of an array, or as a list containing non-dict items (observed intermittently on Claude 4.5/4.6/4.7, and after a prior tool-call rejection where the model "self-corrects" by wrapping the list in json.dumps). Three additive guards, no behavior change for well-formed input: - todo_tool(): if `todos` is a str, json.loads it; reject unparseable strings and non-list values with a clear tool_error instead of crashing downstream. - _validate(): non-dict items return a {id:"?", content:"(invalid item)"} placeholder rather than calling .get() on a str/int/None. - _dedupe_by_id(): non-dict items get a synthetic key so _validate handles them. Salvaged from #14785 by @Tranquil-Flow (authorship preserved via cherry-pick). Comprehensive tests: JSON-string coercion (parse / unparseable / non-list / non-string), non-dict list items (str/None/int/mixed), and a well-formed- unchanged regression class — both guards mutation-verified to fail without them. Closes #14185. Supersedes #14187, #22505, #14350 (same fix, less/no test coverage) and #16952 (bundled unrelated scope-creep). * fix(gateway): dedupe user turns on transient failure (#47237) When the gateway persists a user message after a transient provider failure (429/timeout/auth error), subsequent retries of the same Telegram message could stack duplicate user turns in the transcript, causing the agent to fall behind by 1-2 messages. Add has_platform_message_id() to SessionDB (using the existing idx_messages_platform_msg_id partial index) and a SessionStore wrapper. The gateway's transient-failure path checks this before append_to_transcript -- if the platform_message_id is already persisted, the duplicate write is skipped. Salvaged from #47869 by @davidgut1982. Adapted to current main which has additional append sites and an existing content-based dedupe in the exception handler path. Closes #47237 * perf(desktop): make session switching fast under load Switching sessions in the desktop app could freeze the whole UI for several seconds on heavy, tool-rich chats. Root causes and fixes: - Cold `session.resume` built the AIAgent (MCP discovery, prompt/skill build) *before* returning, and the desktop awaits that RPC before it paints — so the entire switch blocked on the build. Add an opt-in `defer_build` resume path (the contract `session.create` already uses): return the full display transcript immediately, register an upgradable live session, and pre-warm the agent on a short timer. The persisted runtime identity (model/provider/base_url/api_mode/reasoning/tier) is restored on the deferred build so it can't drop the provider. - Nothing bounded how many in-memory agents accumulate; a user who reconnects often piled up detached sessions for the full 6h TTL. Add a soft LRU cap (`max_live_sessions`, default 16) that evicts the least-recently-active DETACHED sessions (no live client) — never a running, awaiting-input, mid-build, or live-transport one. Reopening re-resumes from disk. - On the prefetch-hit cold-resume path, skip rebuilding a throwaway merged-message array (and its 1000-entry Map) when the prefetch already painted the exact transcript; the downstream sameMessageList guard already drops the publish, so it was pure main-thread cost. The desktop opts into `defer_build` for every non-watch cold resume; the eager path stays for CLI/TUI and existing callers. * perf(desktop): make deferred resume the default, not an opt-in flag Per review: gating the faster path behind a `defer_build` flag that the only caller always sends is pointless. Flip it — `session.resume` now defers the agent build by default for every caller (desktop + Ink TUI); a caller that needs the agent built synchronously passes `eager_build: true` (used by the build-race test). The desktop no longer sends a flag. While verifying the flip, fixed two real parity gaps the deferred path had vs the old eager (`_init_session`) path: - `_enable_gateway_prompts()` was never called on a deferred resume, so approvals/clarify wouldn't route through the gateway prompt callbacks. - `_start_agent_build` never wired `background_review_callback` / `memory_notifications`, so a deferred-built session's self-improvement "💾 …" summary leaked to stdout instead of rendering in-transcript. Wiring it there also fixes it for `session.create` sessions, which build through the same path. ACP is unaffected (it uses its own session_manager, not this RPC); the Ink TUI already consumes the same lazy `info` shape from session.create and upgrades on the later `session.info` event. * refactor(tui_gateway): DRY the deferred-session paths Collapse the duplicated cold-resume / lazy-watch / create scaffolding into shared helpers: _deferred_session_record (the live-session dict minus the agent), _lazy_resume_info (the not-yet-built session.info), _claim_or_reuse_live (lock + double-checked register-or-reuse), and _schedule_agent_build (the pre-warm timer). Net -12 lines, three copies of the ~30-key session dict and the lazy-info block down to one each. No behavior change. * fix(desktop): show statusbar item tooltips on hover Statusbar items declared a 'title' string (e.g. YOLO, gateway health, agents, cron, version, context usage) that was populated by use-statusbar-items.tsx but never forwarded to the rendered DOM in StatusbarControls — so every statusbar button/menu/text/link had no hover hint. Wrap the four render branches (menu trigger, text, link, action) in the existing 'Tip' component from components/ui/tooltip.tsx. Tip is self-contained (carries its own Provider), instant (delayDuration=0), themed (bg-foreground/text-background, auto-inverts per theme), and already in use elsewhere in the desktop shell. Renders the child untouched when label is falsy, so items without a title stay zero-cost. * test(tui_gateway): pin synchronous-build resume tests to eager_build These three assert the eager build contract — stored runtime overrides / profile db reach _make_agent synchronously, and the agent binds to the compression tip. Under deferred-by-default the build runs off-thread, so they raced the timer (green in CI, flaky locally). Pin them to eager_build; deferred coverage lives in the protocol tests. * fix(gateway): pass session_db to compress temp agents so persistence works Manual /compress and session hygiene auto-compress both create temporary AIAgent instances to run compression. These agents were created without a session_db, so compress_context computed the compressed messages in memory, rotated the session ID, and reported success — but never wrote to the database. The next user message reloaded the original full transcript, making compression appear to do nothing. Fix: pass session_db=self.session_store._db to both temp agents so the session rotation is properly persisted. Also set _end_session_on_close on the /compress temp agent (already done in hygiene path) to prevent cleanup from ending the newly rotated session. * fix: use self._session_db directly + add regression test - Replace getattr(self.session_store, '_db', None) with self._session_db (the GatewayRunner's own SessionDB, consistent with existing usage in slash_commands.py L240/L499). - Remove verbose comment referencing a branch name as an issue number. - Update stale comment in run.py that said 'today it has no session_db'. - Add regression test verifying session_db is passed and rotated session is persisted (adapted from #51624 by @LeonSGP43). - Add _session_db=None to _make_runner fixtures in test_compress_command, test_compress_focus, and test_compress_plugin_engine. * fix(config): quote env values containing hash * krea * fix shape * fix(tools): let session_search match session titles * fix(learn): name distilled skills as author Hermes, not the host OS user (#52388) /learn told the agent to fill the skill `author` field, and the system prompt environment probe surfaces the OS login name (user=$(whoami) in prompt_builder.py), so the model wrote the host username into published SKILL.md frontmatter — a privacy leak the user never opted into, and inconsistent run to run as the most-salient identity changed. The /learn authoring prompt now sets `author` to the literal value `Hermes` and explicitly forbids deriving it from the host environment (OS/login user, git config, or any probeable identity). The skill names itself as the tool that wrote it. Closes #52368. * fix: persist non-NULL system prompt on fresh turn setup (#45499) (#52616) build_turn_context() created the DB session row via _ensure_db_session() before the system prompt was restored/built, so a fresh API/gateway agent carrying client-managed history inserted a row with system_prompt=NULL. That tripped the misleading 'stored system prompt is null; rebuilding from scratch ... investigate the previous turn's write path' warning and a guaranteed first-turn prefix cache miss. Move row creation to after _cached_system_prompt is populated. Verified live (OpenRouter + claude-sonnet-4.5): persistent-agent turns show cache_read jumping to the full prefix on turn 2+ (write 24411 -> read 24411), and the persisted system_prompt is non-NULL so fresh-agent restore keeps the prefix cache warm. Tests: turn-context ordering regression asserting _ensure_db_session runs after _cached_system_prompt is populated. * fix(gateway): read compaction result flag not config flag in hygiene guard (#50098) Salvage of #50098 by @srojk34, cherry-picked onto current main. The hygiene auto-compress guard and the /compress slash command both read compression_in_place (config flag — is in-place mode enabled?) instead of _last_compaction_in_place (result flag — did in-place compaction actually succeed?). Both agents are built without a session_db, so archive_and_compact always fails silently and _last_compaction_in_place stays False. Reading the config flag makes the guard think in-place succeeded, triggering rewrite_transcript() which replaces the original messages with only the compressed summary — permanent data loss. Co-authored-by: srojk34 <srojk34@users.noreply.github.com> * feat(compression): flip in_place default to True (#38763) [2/2] In-place compaction (single durable session id, non-destructive soft-archive) becomes the default. Rotation is now the opt-out fallback via compression.in_place: false. Prerequisite: #50098 (hygiene guard reads result flag not config flag) merged first — without it, flipping the default causes permanent transcript loss on gateway hygiene-compress and /compress when no session_db is available. Blast radius (empirically measured on current main): 7 rotation-asserting tests broke and are pinned to in_place=False in the companion test commit: - tests/agent/test_compression_concurrent_fork.py (2) - tests/agent/test_compression_logging_session_context.py (1) - tests/agent/test_compression_rotation_state.py (1) - tests/run_agent/test_compression_boundary_hook.py (2 _make_agent helpers) - tests/gateway/test_compression_concurrent_sessions.py (2) Rotation stays as a working fallback and deserves continued coverage. Plan: .hermes/plans/in-place-compaction-38763.md * test(compression): pin rotation-fallback tests to in_place=False ahead of default flip These 7 test sites assert rotation behavior (fork, child sessions, lock contention, logging session-context follows id rotation, boundary hooks fire on rotation). Pin each builder to in_place=False explicitly so they keep exercising the retained rotation fallback regardless of the global default (flipped to True in #38763). Rotation stays a working opt-out fallback and deserves continued coverage — these are NOT deleted. Pinned sites: - test_compression_concurrent_fork._build_agent_with_db - test_compression_logging_session_context._build_agent_with_db - test_compression_rotation_state._build_agent_with_db - test_compression_boundary_hook._make_agent (2 helpers: CompressionBoundaryHook + SessionCompressEvent) - test_compression_concurrent_sessions._build_agent_with_db * chore(release): map srojk34 legacy prefix-less noreply in AUTHOR_MAP (#50098) * fix(auxiliary): treat 403 subscription and session-usage-limit errors as payment errors for fallback Ollama Cloud (and similar) return 403 with bodies like "this model requires a subscription, upgrade for access" or "you have reached your session usage limit, upgrade for higher limits". These are capacity/billing conditions semantically identical to credit exhaustion, but _is_payment_error() did not recognize them (403 missing from the status set; keywords missing), so the configured fallback_chain was never tried and compression failed outright. Adds 403 to the status set and the subscription/session-usage keywords. Salvaged from #49076 by @herbalizer404. * fix: include rate-limit in auxiliary capacity-error fallback gate Rate-limit (429) errors on explicit-provider auxiliary tasks were silently failing instead of triggering the fallback chain. The is_capacity_error gate only checked payment and connection errors, excluding rate limits — so when a configured provider like openai-codex hit its rate limit, auxiliary tasks (kanban_decomposer, vision, web_extract, approval, etc.) had zero resilience. Add _is_rate_limit_error() to is_capacity_error at both call sites (sync and async paths) so rate limits trigger fallback regardless of whether the provider was auto-detected or explicitly configured. Fixes #52228 * fix(auxiliary): honor fallback chain when compression provider auth is unavailable When an explicit aux provider cannot build a client before any request is sent (missing raw env key, exhausted/unavailable OAuth or credential-pool auth, resolver returning (None, None)), call_llm raised a misleading "no API key was found" error and bypassed the configured fallback_chain entirely. A provider authenticated through Hermes auth / the credential pool (e.g. ollama-cloud) whose pool entry is exhausted hit this path, so compression failed instead of routing to the configured fallback. Adds _try_configured_fallback_for_unavailable_client() and wires it into both sync and async call_llm before the raise, and into the startup compression feasibility check. Salvaged from #51835 by @herbalizer404. * fix(auxiliary): screen fallback chain by context window for compression (#52392) The runtime auxiliary fallback chain (_try_configured_fallback_chain and _try_main_fallback_chain) returned the first reachable candidate without checking whether the candidate's context window was large enough for the task. For task='compression' this meant a reachable but undersized fallback (e.g. 32K) could be selected and then fail, even when a later larger-context fallback was available. This adds two small helpers: _task_minimum_context_length(task) Returns MINIMUM_CONTEXT_LENGTH (64K) for compression, None for other tasks (vision, web_extract, etc.). _candidate_context_window(provider, model, ...) Thin wrapper around get_model_context_length that returns None on probe failure so unknown/custom endpoints pass through unchanged (preserves the existing fallback surface). Both fallback loops now skip reachable candidates whose resolved context is below the task minimum and continue iterating. The success path (first viable candidate wins) is unchanged. Return shape and ordering for healthy candidates are preserved. Six regression tests cover: L2 configured chain skips too-small candidate L2 chain continues after skipping, returns last viable L3 main chain skips too-small candidate L4 unknown-context candidate passes through L5 non-compression task is not filtered L6 minimum constant matches MINIMUM_CONTEXT_LENGTH (64K) 3/6 fail on upstream/main without the production change (verified); all 6 pass with the fix. Full test_auxiliary_client.py suite (231 tests) and related compression tests (130 tests) remain green. * fix(auxiliary): fall back when a route can't run the model at all (400 capability mismatch) The salvaged context-window screen (#52392) skips fallback candidates that are too small, and the rate-limit/403 fixes skip candidates that are at capacity. A third hard failure remained uncovered: a fallback that builds a client fine but returns a 400 because it structurally cannot run the model. The canonical case is a configured openai-codex / ChatGPT-account fallback asked to compress a glm-5.2 conversation: 400 - {'detail': "The 'glm-5.2' model is not supported when using Codex with a ChatGPT account."} This is a request-validation error, so should_fallback was False and the explicit-provider gate blocked it — the auxiliary task (compression) aborted every turn, dropping middle turns without a summary and churning the session, which is exactly what destroys the prompt cache. Adds _is_model_incompatible_error() (400 + capability phrasing, excluding not-found and billing 400s which the sibling predicates own) and treats it as a fallback-worthy capacity error in both sync and async call_llm, so the chain skips the incapable route and continues to the next viable candidate. * chore: add herbalizer404 + pyxl-dev to AUTHOR_MAP for auxiliary fallback salvage * fix(telegram): auto-rich pipe tables and topic routing for sendRichMessage Pipe-only markdown tables now use sendRichMessage even when rich_messages is off, and resumed DM-topic sends route via direct_messages_topic_id without requiring a reply anchor. Rich finalize edits forward topic kwargs. * test(telegram): cover table auto-rich and topic routing Assert bare tables upgrade to sendRichMessage under default/opt-out config, DM-topic resumed sends without reply anchors, and rich finalize edits carry forum topic routing metadata. * fix(fuzzy-match): preserve boundary space after whitespace-normalized match The trailing-whitespace expansion in _map_normalized_positions unconditionally consumed whitespace after the matched region — including the word-boundary space that separates the match from the next token. This caused silent file corruption when the fuzzy matcher fell back to the whitespace_normalized strategy. Guard the expansion on the normalized match actually ending with whitespace (i.e. the original had a run of spaces that were collapsed). When the match ends with a non-space character, the first whitespace in the original is a boundary and must not be consumed. Fixes #52491 * fix(cron): restore [SILENT] silence + suppress empty-turn explainer on Telegram Scheduled jobs delivering to Telegram/etc. started posting a literal '⚠️ No reply: the model returned empty content…' message instead of staying silent. Two interacting causes: 1. The turn-completion explainer (#34452) replaces an empty model turn with a user-facing '⚠️ No reply…' string. In a cron context that is not a silence marker, so the scheduler delivered it — a regression from the previously-silent empty turn. run_job now detects the explainer text deterministically (via the same formatter that produced it) for abnormal-empty turn_exit_reasons and strips it to empty, so the existing empty-response suppression + soft-fail guard apply. The explainer is unchanged on CLI/gateway. 2. The cron suppression used a loose 'SILENT_MARKER in ...upper()' substring check. It leaked bracketless near-markers the model emits ('SILENT', 'NO_REPLY', 'NO REPLY' — #51438, #46917) and wrongly swallowed a real report that merely quoted '[SILENT]' mid-sentence. Replaced with _is_cron_silence_response(): suppresses a canonical token as the whole response, its own first/last line, or the documented bracketed '[SILENT] <note>' prefix — while a token buried mid-sentence in a genuine report is delivered. Preserves the intentional cron trailing/prefix tolerance (existing tests unchanged). Tests: bracketless-variant suppression, mid-sentence-quote delivery, direct matcher contract, and explainer-strip + defensive real-report delivery. * feat(moa): expose MoA presets as selectable virtual models (#46081) * feat(moa): expose MoA presets as selectable virtual models Reconstructed onto current main (PR #46081's base had diverged with no common ancestor, marking the PR dirty so CI never dispatched). MoA is now a virtual provider: each named preset is a selectable model under provider 'moa', and the preset's aggregator is the acting model that answers and calls tools. Reference models fan out in parallel via a bounded ThreadPoolExecutor (the same batch pattern delegate_task uses) — all references dispatched at once, collected when every one finishes, then handed to the aggregator. Output order is preserved, failures and the MoA-recursion guard stay isolated per reference. - Removed the old mixture_of_agents model tool and moa toolset. - Added moa as a virtual provider in the provider/model inventory. - /moa is shortcut behavior over model selection (default preset / named preset / one-shot prompt). - Dashboard + Desktop manage named presets; presets appear in model pickers. - Parallel reference fan-out in agent/moa_loop.py with regression test. * fix(moa): thread moa_config through _run_agent to _run_agent_inner The reconstructed gateway MoA wiring declared moa_config on _run_agent (the profile-scoping wrapper) and used it inside _run_agent_inner, but the wrapper never forwarded it — _run_agent_inner had no such parameter, so the runtime hit NameError: name 'moa_config' is not defined on the compression-failure session sync path. Add moa_config to _run_agent_inner's signature and forward it from both wrapper call sites (multiplex and non-multiplex). Caught by tests/gateway/test_compression_failure_session_sync.py on CI shard test(4). * fix(moa): classify moa as a virtual provider in the catalog The moa virtual provider has no PROVIDER_REGISTRY/ProviderProfile entry, so provider_catalog() fell through to the default auth_type="api_key" with no env vars — tripping two catalog invariants: - test_provider_catalog: api_key providers must expose a credential env var - test_provider_parity: every hermes-model provider must be desktop-configurable moa already declares auth_type="virtual" in HERMES_OVERLAYS; consult that overlay as an auth_type fallback so the catalog reports moa as virtual (no real credential, no network endpoint). Exempt virtual providers from the desktop parity union check the same way 'custom' is exempt — derived from the catalog, not a hardcoded slug, so future virtual providers are covered too. * fix(desktop): reject cross-wired runtime-id cache on session resume resumeSession's warm-cache fast-path trusted the storedSessionId -> runtimeId -> ClientSessionState mapping without checking the cached state still BELONGS to the session being resumed. A pooled profile backend that gets idle-reaped and respawned (pruneSecondaryGateways) re-mints runtime ids, so a recycled id can resolve to a live-but-DIFFERENT session's cache entry. The only existing guard was a session.usage 404 -- that catches a fully-dead runtime id, but a recycled id still 200s, so the fast-path happily painted the wrong transcript under the current route (open chat A, chat B loads). Fold the belongs-to check into a single takeWarmCache() helper used at BOTH cache reads -- the early transcript-keep decision and the fast-path itself -- so a cross-wired entry can't even briefly flash a stale transcript before the full resume repaints. On a mismatch the helper purges both stale map entries and reports a miss, falling through to a full resume that rebinds a correct runtime id. The full-resume path already guards its final paint with isCurrentResume(), so only the cached fast-path was missing the belongs-to check. Pre-existing bug from the initial desktop app (#20059); not introduced by the session-switch perf work (#49807), which left these lines untouched. Tests: two cases in use-session-actions.test.tsx driven through a harness that owns the two cache maps -- a cross-wired mapping is rejected + purged (the bug), and a correctly-wired cache still serves from memory with no needless refetch (no perf regression). Supersedes #50464 by @professorpalmer, reimplemented to also guard the early transcript-keep read (whole-class fix, not just the fast-path). Co-authored-by: professorpalmer <professorpalmer@users.noreply.github.com> * fix(desktop): recover the root error boundary from transient render races A stale-index render race in assistant-ui (a just-shrunk thread rendered at an old message index during a session switch / teardown) throws errors like "tapClientLookup: Index N out of bounds", "Cannot read properties of undefined (reading 'type')", or "Tried to unmount a fiber that is already unmounted". These bubble to the root ErrorBoundary and latch the WHOLE desktop app on the "Reload window" fallback even though the next render against fresh state would be fine. Teach the root boundary to treat that small set of known-transient renderer errors as recoverable: log them and schedule a next-tick reset() so React re-renders against current state instead of stranding the user on the fallback. Auto-recovery is BOUNDED -- at most MAX_RECOVERIES (3) attempts within a 5s window -- so a genuinely persistent error can't spin the boundary in a reset -> throw -> reset loop; after the budget is spent the fallback is left up for the user. Manual retry (the button) resets the budget. Only the root boundary auto-recovers; scoped boundaries keep their own fallbacks, and unrecognized errors are never swallowed. Tests: transient race recovers (fallback never sticks), a persistent recoverable error stops at the cap and surfaces the fallback (proving the loop is bounded), and neither a non-root boundary nor an unrecognized root error auto-recovers. Closes #41693. Supersedes #41787 by @izumi0uu, reimplemented with a bounded recovery budget so a non-transient error can't loop forever. Co-authored-by: izumi0uu <izumi0uu@gmail.com> * feat(sessions): record git workspace metadata * feat(projects): add per-profile project store * feat(kanban): link tasks to project worktrees * feat(gateway): build authoritative project tree * feat(tools): add project workspace tools * fix(tools): isolate per-session worktree cwd * fix(agent): require code for coding posture * feat(desktop): add git worktree and review IPC * feat(desktop): add shared project UI primitives * feat(desktop): add project and coding stores * feat(desktop): render backend-authoritative projects sidebar * feat(desktop): add composer coding rail and worktree flow * feat(desktop): add Codex-style review pane * feat(desktop): keep active sessions aligned with cwd * feat(desktop): wire project settings and shell chrome * i18n(desktop): add project and worktree strings * chore(desktop): update package lock * fix(cli): register project command beside MoA * fix(windows): suppress console flashes and harden gateway restarts * test(windows): align gateway restart CI coverage * fix(hermes_state): persist billing provider/base_url after mid-session /model switch The session database records billing_provider and billing_base_url using COALESCE(column, ?) in update_token_counts(), making them write-once. When a user switches models mid-session via /model, the runtime (agent.provider, agent.base_url) updates correctly, but the session row never reflects the new provider. This causes the dashboard Models page to display a stale provider badge and misattributes token usage / cost analytics. Fix: add update_session_billing_route() that unconditionally sets billing_provider, billing_base_url, and billing_mode (no COALESCE), and call it from switch_model() in agent_runtime_helpers.py after the swap succeeds. This follows the same pattern as update_session_model() which already unconditionally updates the model column (added for the identical COALESCE problem on the model field). Closes #48248 * test(hermes_state): cover update_session_billing_route overwrite + prompt null Regression for the salvaged #48254 fix: billing route is first-writer-wins via update_token_counts (COALESCE), so a mid-session provider switch left the dashboard attributing cost to the original provider. Asserts the new update_session_billing_route() overwrites unconditionally, nulls system_prompt so the next turn rebuilds Model:/Provider:, and preserves billing_mode when omitted (COALESCE on None). * fix(desktop): clarify branch convert actions Open checked-out branches, switch the primary checkout for the default branch, and create linked worktrees only for non-trunk free branches. * fix: stop reporting cache-hit rate and cost across all UI surfaces (#52717) * fix: stop reporting cache-hit rate and cost across all UI surfaces Cost estimates and cache read/write token reporting are unreliable on providers that don't surface cached_tokens (e.g. ollama-cloud, which doesn't implement prompt_tokens_details.cached_tokens), producing misleading near-zero 'cache hit' readouts and cost figures. Remove cost + cache-hit reporting from every user-facing surface; keep input/output/total token counts (provider-agnostic and accurate) and the Nous account billing UI (real account money, separate from per-conversation estimates). Surfaces: - CLI /usage + model-info: drop cost lines + cache read/write token lines - Gateway /usage + /model: drop cost + cache lines - tui_gateway/server.py: stop emitting cost_usd / cache_read in usage and subagent.complete payloads - TUI (Ink): drop cost from status bar (+ showCost plumbing), /usage panel, thinking rollup, agents overlay (incl. compare view); keep token counts - Desktop Command Center: drop cost stat, per-model cost, actual-cost hint Underlying estimate_usage_cost / format_cost / insights cost columns are left intact but no longer surfaced (display-only change, reversible). * test: update TUI + gateway + CLI tests for removed cost/cache-hit reporting - CLI /usage test asserts cost/cache lines are absent, tokens present - gateway /usage test drops cost + cache asserts; removes cost-included test - TUI subagentTree summary expectation drops the cost segment - useConfigSync + appChrome status-rule tests drop showCost prop/state * fix(cron): add default retention to per-run job output (#52383) (#52646) * fix(cron): add default retention to per-run job output to bound disk usage (#52383) Per-run cron output (cron/output/<job>/<timestamp>.md) is written once per execution and was never pruned, so a frequently-scheduled job on a long-running deploy accumulates one file per run indefinitely and can fill the volume ('no space left on device'). save_job_output() now keeps the most recent N output files per job and removes older ones. N defaults to 50 and is configurable via cron.output_retention; a non-positive value disables pruning for operators who manage cleanup externally. Salvaged from #52402 by @0xDevNinja. Closes #52383 * fix(config): add cron.output_retention to DEFAULT_CONFIG Follow-up to #52383: the retention config key was functional via get()-with-default but missing from DEFAULT_CONFIG, so the deep-merge wouldn't auto-populate it for new installs. Add it explicitly. --------- Co-authored-by: 0xDevNinja <manmit0x@gmail.com> * fix(update): default pre-update backup to off (#52729) The pre-update HERMES_HOME zip shipped on by default (DEFAULT_CONFIG + runtime fallback both True), so every `hermes update` zipped the entire ~/.hermes — sessions DB, caches, skills — adding minutes to each update. The shipped cli-config.yaml.example, the --backup help, and the example config all already said "off by default," so the live default contradicted its own documentation. Flip the default to off everywhere: DEFAULT_CONFIG, the runtime `.get(..., False)` fallback in _run_pre_update_backup, and the stale --backup help string. Users who want the #48200 safety net opt in via updates.pre_update_backup: true or --backup for a single run. Updated test_default_enabled_creates_backup -> test_default_disabled_is_silent to assert the new default (silent no-op, no zip). * fix(state): resolve compression chain tip in resolve_resume_session_id After context compression, the parent session holds pre-compression messages and a child (or deeper descendant) holds the continuation. resolve_resume_session_id() short-circuited when the input session already had messages (row is not None -> return session_id), causing REST API endpoints, gateway resume, and CLI resume to serve stale parent messages. Remove the early-return. Walk the full descendant chain, record the deepest node that has messages (best), and return best if not None else the original session_id (preserving the empty-chain fallback). Callers (api_server.py, web_server.py, cli_agent_setup_mixin.py, cli_commands_mixin.py) all use the resolved != input -> redirect pattern and are transparent to this change. * chore: rename test to reflect new semantics of resolve_resume_session_id * fix(desktop): resume latest compression continuation * fix(state): exclude delegate/branch/tool children from resume walk + reconcile salvaged fixes Follow-up to the salvage of #45035 + #48682. The two PRs touched different functions (resolve_resume_session_id vs get_compression_tip) but #45035's descendant walk followed ANY parent_session_id child, so a delegate/subagent child could hijack the resume target. Apply the same _branched_from / _delegate_from / source!='tool' exclusion the rest of hermes_state.py uses, so the resume walk only follows genuine compression continuations. Also updates the unrealistic delegation test fixture to carry the real _delegate_from marker, and updates 3 list_sessions_rich test mocks for the order_by_last_active kwarg #48682 added. AUTHOR_MAP: map PINKIIILQWQ + ailang323 salvage authors. * desktop: bundle main.cjs for electron fixes simple-git not found * fix(approval): fold Windows absolute home paths in dangerous-command detection The detector folds absolute home / Hermes-home prefixes into their canonical ~/ and ~/.hermes/ forms so static patterns catch /home/alice/.bashrc the same way they catch ~/.bashrc (abd69b81). On native Windows this fold never fired, so terminal commands writing to shell startup files, ~/.ssh/authorized_keys, or ~/.hermes/config.yaml / .env returned "safe" and skipped the approval prompt — and config.yaml carries the approval policy itself. Two compounding causes: 1. The fold ran after the backslash-escape strip (r\m -> rm), which dissolves the backslash separators in a Windows path (C:\Users\alice\.bashrc -> C:Usersalice...) before the fold could match. It now runs before the strip. 2. The fold only recognized POSIX absolute paths and only the home prefix, leaving multi-segment backslash suffixes (\.ssh\authorized_keys) to be mangled by the strip. Consolidated into _home_prefix_fold_regex / _fold_home_prefixes: match a home prefix with either separator, capture the rest of the path token, and normalize its separators to / so multi-segment patterns match. The degenerate-path guard generalizes count("/") >= 2 to "at least two components below the root" (also rejecting a bare drive root C:\). HOME is consulted directly because Windows' expanduser ignores it; the more specific Hermes home is folded first, longest candidate first, so neither fold clobbers the other. POSIX behavior unchanged; the r\m -> rm anti-obfuscation strip still runs. Adds TestWindowsAbsolutePathFolding, which monkeypatches a Windows-style HOME/HERMES_HOME so the behavior is also exercised on the CI runner. * feat(desktop): in-app spot editor for the file preview pane Adds a CodeMirror 6 spot editor to the right-rail file preview so users can make quick edits in-app without leaving for an IDE. Entering edit mode is a pure in-place swap of the read view — same fixed-height header, same gutter geometry/typography (mirrors SourceView 1:1) so nothing shifts — toggled via the Edit button, a bare `e` when the pane is hovered/focused, or the tab. - Save path is transport-agnostic (writeDesktopFileText): local Electron IPC or a new hardened POST /api/fs/write-text on the dashboard server (path validation, parent-must-exist, regular-files-only, size cap, atomic temp-file + os.replace), behind the existing auth middleware. - Stale-on-disk guard re-reads before writing and offers overwrite vs discard-and-reload instead of clobbering external/agent edits. - VS Code-style modified dot on the tab; ⌘/Ctrl+S and ⌘/Ctrl+Enter save, Esc cancels; GitHub highlight style matched to the read view's Shiki theme. - Typing stays render-free (draft in a ref; dirty flips once at the boundary). * feat(desktop): vertical resize for the bottom-row terminal pane Extends the pane store with heightOverride (alongside widthOverride) and a get/set/clear API, and wires the pane shell + desktop controller so the bottom-row terminal pane can be resized on the Y axis with its size persisted. * fix(desktop): make the tab modified dot amber with a separating ring Use the app's amber warn color for the unsaved-edits tab dot (was inheriting the label text color) and add a tab-bg ring + soft drop shadow so it stays legible where it overlaps the filename. * feat(desktop): add $backgroundResume store for parked delegate_task Track top-level delegate_task work that dispatches in the background and re-enters as a fresh turn. $backgroundResume returns {count, activity} for the active session while idle — count of parked tasks plus the primary child's latest stream line (tool/progress/thinking) when readable. * feat(desktop): show a calm "will resume" notice for background delegate_task When idle with a top-level delegate_task still in flight, render a static, shimmering system-note at the transcript tail instead of a spinner (which reads as "stuck"). Reuses the shared steer / slash-status chrome (centered, 0.6875rem, muted, Codicon) so it sits in the thread like every other meta line, and mirrors the primary child's latest stream line, falling back to generic copy. i18n across en/ja/zh/zh-hant; markdown prose/heading rhythm tuned so a re-entered turn breathes. * feat(tui): add width-budgeted "resumes when subagent finishes" status segment When idle with a background subagent still in flight, append a tail status segment spelling out that the agent resumes on its own. Width-budgeted like every tail segment, so it drops first on a tight terminal where the ⛓ count already carries the signal. * feat(cli): note background delegate_task dispatch in _on_tool_complete A top-level delegate_task dispatches in the background and re-enters as a fresh turn when done. Print a one-line dispatch-time note — no spinner, nothing to poll — so the idle prompt doesn't read as "nothing happened." * fix(cron): detect partial job loss in restore_cron_jobs_if_emptied (#52144) The desktop scheduler can overwrite cron/jobs.json with its own small set of internally-tracked crons after an update/restart, causing partial loss of tool-created cron jobs. The previous guard only checked for total loss (live_count == 0), missing the case where live_count > 0 but less than the pre-update snapshot count. Compare live_count against snap_count instead of checking for zero, so both total loss (0 vs N) and partial loss (1 vs 19) trigger restoration. Salvaged from #52161 by @liuhao1024. Closes #52144 * fix(telegram): persistent heartbeat loop to detect CLOSE-WAIT polling sockets When a Telegram long-poll TCP socket enters CLOSE-WAIT (remote sent FIN but httpx hasn't noticed), epoll still reports it readable so no exception is raised. PTB's error_callback never fires, the reconnect ladder never engages, and the gateway silently stops receiving messages while the process stays alive — until a manual systemctl restart. The existing recovery only covers two cases: error_callback-driven reconnects (which require an exception PTB never gets) and a one-shot _verify_polling_after_reconnect probe (which runs only right after an explicit reconnect). A socket that wedges during steady-state operation is never detected. Add _polling_heartbeat_loop: a background asyncio.Task started in connect() (polling mode only) that probes get_me() every 90s on the general request pool (not the getUpdates pool, so healthy long-polls are never interrupted). On asyncio.TimeoutError/OSError it hands off to the existing _handle_polling_network_error ladder; other errors are swallowed. disconnect() cancels and awaits the task. Worst-case detection window ~105s. Complementary to #51541 (general-pool keepalive limits / fd leak) — that recycles idle pooled connections; this detects a wedged active read. Fixes #48495 Co-authored-by: agt-user <267614622+agt-user@users.noreply.github.com> * fix(telegram): heartbeat loop exits cleanly when bot has no get_me CI shard test_telegram_conflict.py timed out (140s) because the new _polling_heartbeat_loop, started by connect(), busy-spun under those tests: they monkeypatch asyncio.sleep to instant and pass a bot double with no get_me(), so the probe raised AttributeError (swallowed) and the loop re-entered immediately with no real pacing, starving the event loop. Guard the loop to return when bot.get_me is not callable — a real PTB Bot always exposes it, so this only triggers on a torn-down app or a test double, where there is nothing to probe. Also cancel the heartbeat task in the conflict tests that call connect() without disconnect(), matching the production disconnect() teardown. Verified: test_telegram_conflict.py now runs in ~4.5s; the 22 heartbeat/reconnect tests still pass; E2E confirms a hanging get_me still fires the reconnect ladder while a missing get_me exits without spinning. * chore(release): map agt-user noreply email for #48496 salvage * fix(gateway): defer cross-process cache cleanup off the cache lock (#52197) (#52761) The #45966 cross-process coherence guard popped the stale cached agent and then called the blocking _cleanup_agent_resources (memory-provider shutdown, tool-resource teardown, async-client teardown) while still holding _agent_cache_lock, on the gateway event-loop thread. While that ran, _sweep_idle_cached_agents (driven by _session_expiry_watcher) blocked acquiring the same lock and the asyncio loop stalled for minutes, tripping repeated Discord 'heartbeat blocked' warnings. Fix mirrors the cap-enforcer / idle-sweep paths: pop the stale entry under the lock, release it, then schedule the SOFT release on a daemon thread. The soft path (_release_evicted_agent_soft) is also more correct here than the hard teardown the regression used — the same session rebuilds a fresh agent immediately after invalidation, so its terminal sandbox / browser / bg processes (keyed on task_id) must be preserved for the rebuilt agent to inherit, not torn down. Verified the cross-process site was the only cleanup-under-lock instance; the other _cleanup_agent_resources call sites run outside the lock. * fix(agent): detect thinking-timeout for reasoning models and surface actionable guidance instead of misleading file-write advice Two-part fix: Part 1 (classifier override at agent/error_classifier.py:720-738): A transport disconnect on a reasoning model — even on a large session — now routes to FailoverReason.timeout instead of context_overflow. Without this, large-session reasoning-model disconnects route to the compression branch and silently delete conversation history on a phantom context-length error. The override is strictly targeted: non-reasoning models (gpt-4o, claude-3-5-sonnet, llama-3.3-70b, etc.) still route to context_overflow on large sessions — the existing intentional behavior for chat models whose proxy doesn't idle-kill during prefill/generation. Part 2 (new agent/thinking_timeout_guidance.py + integration at agent/conversation_loop.py:3488-3567): New is_thinking_timeout() and build_thinking_timeout_guidance() helpers. When a known reasoning model (NVIDIA Nemotron 3 Ultra, OpenAI o1/o3, Anthropic Opus 4.x thinking, DeepSeek R1, Qwen QwQ, xAI Grok reasoning) hits a transport-kill on a small session (classifier says timeout directly) or after Part 1 routes correctly (large session), the user now sees reasoning-specific guidance with three actionable workarounds in priority order: 1. Set providers.<provider>.models.<model>.stale_timeout_seconds: 900 in ~/.hermes/config.yaml (Hermes's built-in floor is already 600s for known reasoning models; raise further if upstream is even tighter). 2. Lower reasoning_budget or set reasoning_effort: medium on this model if the provider supports it. 3. Use a smaller / faster reasoning model if the task doesn't require deep thinking. The new guidance takes precedence via if/elif over the existing _is_stream_drop block, so a reasoning-model user with a transport-kill message sees actionable advice instead of the misleading "try execute_code with Python's open() for large files" advice (which is correct for the unrelated large-file-write stream-drop case but actively wrong for the thinking-timeout case). Verified: - 478 tests passing across 9 directly-relevant files (49 new + 429 existing, zero regressions). - Ruff lint clean on all 4 modified/new files. - Negative test: 6 parametrized regression guards confirm non-reasoning models still route to context_overflow on large sessions; 4 parametrized gates confirm non-timeout classifier reasons never trigger the guidance; 5 parametrized cases confirm non-transport messages never trigger it. - Regression guard: new guidance message does NOT contain "execute_code" or "open()" — the misleading advice is fully replaced, not appended alongside. - Cross-vendor dual review via agy -p: - Gemini 3.5 Flash (Medium) — passed: true, zero blockers, one SHOULD-FIX (vprint block duplication — fixed by extracting detection into a helper module). - GPT-OSS 120B (Medium) — passed: true, zero blockers, two nits (test placement — adopted at tests/agent/test_thinking_timeout_guidance.py; primary-model capture — accepted as non-issue per Flash's nit). Dependency note for maintainers: This PR includes agent/reasoning_timeouts.py (the reasoning-model allowlist module from PR #52238) because the Layer 1 override is load-bearing on get_reasoning_stale_timeout_floor(). After PR #52238 lands on main, this PR's duplicate agent/reasoning_timeouts.py should be rebased away. Either PR can land first; the other rebase is mechanical. Fixes #52271. * chore(release): add DavidMetcalfe to AUTHOR_MAP for PR #52272 salvage * fix(docker): skip symlinked stage2 chown targets (#52789) Prevents stage2-hook.sh recursive chown from following a symlinked $HERMES_HOME/home (or profiles/cron) and destroying the host user's home directory. Also guards top-level state-file chowns and refuses first-boot seeding through symlinks. Fixes #52781. Co-authored-by: harjoth <harjoth.khara@gmail.com> * fix(auth): write rotated Codex/xAI pool grant through to global root (#48415) (#52760) CredentialPool._sync_device_code_entry_to_auth_store rotated single-use OAuth refresh tokens but wrote the new chain only into the active profile store. When a profile resolves a grant from the global-root fallback (read_credential_pool, #18594) and the pool then refreshes it, root was left holding a now-revoked refresh token — every other profile reading the stale root grant subsequently died with refresh_token_reused / invalid_grant once its access token expired. This is the credential-pool analog of #43589 (which fixed the non-pool xAI refresh path in _save_xai_oauth_tokens). Detect the read-from-root case (profile lacks its own providers.<id> block) BEFORE the profile save and, after it, write the rotated chain back to the global root via a best-effort, seat-belted write-through. A profile that genuinely shadows root (owns the block) is untouched; classic mode (profile == root) is a no-op; a failed root write never breaks the profile's own save. Covers openai-codex (reported), xai-oauth, and nous through the shared sync path. * fix(desktop): show remote backend updates without counts * fix(gateway): scale-to-zero never armed — arm-gate counted disabled placeholder platforms (#52831) The scale-to-zero idle watcher never started on a correctly-opted-in, relay-only instance, so the gateway never ran its idle decision, never called go_dormant(), and never sent going_idle to the connector. Fly's autostop still suspended the machine on traffic-idle, but the connector never flipped the instance to buffered-only — so an inbound DM took the live delivery path, found no live session for the suspended machine, and was dropped fail-closed with no wake poke. The machine slept and never woke. Root cause: _scale_to_zero_should_arm() passed list(config.platforms.keys()) to messaging_is_relay_only_or_absent(). config.platforms is pre-seeded with a DISABLED placeholder PlatformConfig for every known platform (telegram, discord, slack, matrix, …), so the key set is always the full ~20-entry catalog regardless of what the instance actually runs. The relay-only check discarded "relay", saw the disabled placeholders as live direct-socket platforms, and returned False — so should_arm() was False and the watcher was never created. Verified live on a staging instance: config.platforms keys = [telegram, discord, slack, mattermost, matrix, relay] with only relay enabled=True; should_arm() = False. Fix: filter config.platforms to ENABLED entries before the relay-only check, mirroring the adapter-connect loop which already gates on `if not platform_config.enabled: continue`. This arms off the same notion of "active platform" the rest of start() already uses — no parallel concept. Also add a one-line not-armed diagnostic: when an instance IS opted in (the HERMES_SCALE_TO_ZERO stamp is set) but the watcher still doesn't arm, log why (relay_only_or_absent, the enabled platforms, wake_url present/missing). A non-opted instance stays silent. The arm path previously logged only on success, so a failed arm was invisible. Tests: the existing pure-helper tests passed bare names so they never exercised the call site that feeds the placeholder-laden config. Add behaviour-contract tests against the REAL _scale_to_zero_should_arm with a realistic config.platforms (relay enabled + others disabled). The F25 regression test (relay-only + disabled placeholders must arm) and the no-platform case are RED without this fix, GREEN with it; the genuinely-enabled-direct-platform / not-opted-in / no-wake-url cases stay correctly non-arming so the filter can't over-broaden. Wake mechanism itself verified healthy independently (direct wakeUrl GET resumed a suspended staging instance in 1.15s, clean resume signature). * fix(email): reject spoofed From: header for authorization (GHSA-rxqh-5572-8m77) The email adapter authorized senders entirely off the From: header, which is attacker-controlled and unauthenticated by IMAP. An attacker could forge From: an-allowlisted-address and pass both the adapter's EMAIL_ALLOWED_USERS pre-filter and the gateway's allowlist authz (both key on the same spoofable sender_addr), getting unauthorized commands executed by the agent. Verify the From: domain against the trusted Authentication-Results header the receiving mail server stamps (SPF/DKIM/DMARC) before trusting it for authorization. Enforced only when an allowlist is in effect and allow-all is off — fail-closed. Operators whose server does not stamp the header can opt out via platforms.email.require_authenticated_sender: false (or EMAIL_TRUST_FROM_HEADER=true). * fix(state): detect and repair FTS write corruption that silently drops gateway history (#52798) A readable state.db can still reject every message write through the messages_fts* triggers when the FTS5 index is corrupt: base-table reads and PRAGMA integrity_check pass, but INSERT INTO messages fails with 'database disk image is malformed'. The gateway reloads conversation_history from disk each turn, so a silently-failed write hands the next turn stale/empty history even though the same cached AIAgent still holds the live transcript — causing immediate same-session amnesia. (#50502) - hermes_state.py: _db_opens_cleanly() now drives a rolled-back message write through the FTS triggers, so write-only corruption (which the read-only probe reported healthy) is detected. repair_state_db_schema() gains an in-place FTS5 'rebuild' strategy (tier 0) before the dedup/drop tiers, plus an already_healthy short-circuit. Both 'hermes sessions repair' and 'hermes doctor' route through these, so the fix covers the whole class. - hermes_cli/doctor.py: the state.db check runs the write-health probe even on the success (readable) path and repairs in place with --fix. - gateway/run.py: _select_cached_agent_history() prefers the cached agent's longer live _session_messages over a shorter persisted transcript, so an FTS write failure can't wipe in-session context. - tests: regressions for write-health detection, in-place repair preserving rows + resuming writes, the already_healthy shortcut, and the gateway guard. Combines the approaches from #50504 (@0-CYBERDYNE-SYSTEMS-0, issue author), #52165 (@davidgut1982), and #50576 (@trevorgordon981). * fix(gateway): add init-time provider fallback to _make_agent When the primary provider raises AuthError (e.g. expired OAuth token), _make_agent now walks the configured fallback_providers/fallback_model chain before giving up — matching the behavior that cron/scheduler.py and cli_agent_setup_mixin.py already have. Fixes #47627 * fix(telegram): preserve Bot API update queue on watcher reconnect After a prolonged outage the in-process network-error ladder escalates to fatal and GatewayRunner._platform_reconnect_watcher rebuilds a fresh adapter that reconnects through the bootstrap path. That path called start_polling(drop_pending_updates=True), discarding every update Telegram queued during the outage — all messages sent while the bot was down were silently lost. The in-process ladder and 409-conflict handler already passed drop_pending_updates=False; only bootstrap did not distinguish a cold first boot from a reconnect. Thread an is_reconnect signal from the watcher through _connect_adapter_with_timeout into adapter.connect(). The base BasePlatformAdapter.connect() gains a keyword-only is_reconnect=False so every adapter inherits a tolerant signature (no per-platform breakage when the runner forwards the kwarg). Telegram translates is_reconnect into drop_pending_updates=not is_reconnect on both the polling and webhook bootstrap calls. Cold boot still drops the stale queue; a watcher reconnect preserves it. Fixes #46621. Co-authored-by: annguyenNous <annguyen@nousresearch.com> Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com> Co-authored-by: Kewe63 <Kewe63@users.noreply.github.com> * feat(kanban): typed block reasons + unblock-loop breaker (#52848) * feat(kanban): typed block reasons + unblock-loop breaker Stops the kanban blocked-task loop: a worker blocks a task, a cron unblocks it, the worker re-blocks for the same reason, repeat forever. block_task now takes a typed kind and a persistent block_recurrences counter on the tasks table: - kind=dependency routes to todo (parent-gated, auto-resumed), never the human 'blocked' bucket a cron would keep unblocking. - needs_input/capability/transient/untyped land in blocked; each same-cause re-block after an unblock increments block_recurrences, and at BLOCK_RECURRENCE_LIMIT (default 2) the task routes to triage for a human instead of blocked. - unblock_task no longer resets block_recurrences (the amnesia that let the loop run unbounded); complete_task clears it on success. Wired through the worker kanban_block tool (new kind arg) and the hermes kanban block --kind CLI flag, both reporting where the task actually landed. Docs + 11 new tests; 536 existing kanban tests green. * test(kanban): make second-block notify test use a distinct block cause test_notifier_second_blocked_delivers blocked the same task twice with the same (untyped) reason, which now trips the new unblock-loop breaker and routes the second block to triage instead of blocked — so only one 'blocked' notification fired. The test's actual intent is that TWO distinct block cycles each notify; give the two cycles different kinds (needs_input then capability) so they're genuinely separate blocks. The same-cause loop→triage path is covered by test_kanban_block_kinds.py. * fix(desktop): WSL2 clipboard image paste + Linux titlebar overlay WSLg bridges clipboard text but not images — pull host screenshots via PowerShell. Disable titleBarOverlay on plain Linux; gate…
1 parent 29ac896 commit ffff7e6

1,046 files changed

Lines changed: 86726 additions & 10957 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.envrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
watch_file pyproject.toml uv.lock
22
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
3-
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix
3+
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix nix/hermes-agent.nix nix/desktop.nix
44

55
use flake

.github/actions/hermes-smoke-test/action.yml

Lines changed: 0 additions & 50 deletions
This file was deleted.

.github/workflows/ci.yml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ name: CI
1212

1313
on:
1414
pull_request:
15-
branches: [main]
1615
push:
1716
branches: [main]
1817

@@ -21,6 +20,7 @@ permissions:
2120
pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment)
2221
actions: read # needed by osv-scanner (SARIF upload)
2322
security-events: write # needed by osv-scanner (SARIF upload)
23+
packages: write # needed by docker build
2424

2525
concurrency:
2626
group: ci-${{ github.ref }}
@@ -33,6 +33,7 @@ jobs:
3333
# (all lanes true) so post-merge validation is never weakened.
3434
# ─────────────────────────────────────────────────────────────────────
3535
detect:
36+
name: Detect affected areas
3637
runs-on: ubuntu-latest
3738
outputs:
3839
python: ${{ steps.classify.outputs.python }}
@@ -54,47 +55,58 @@ jobs:
5455
# Skipped workflows (if condition is false) don't spin up runners.
5556
# ─────────────────────────────────────────────────────────────────────
5657
tests:
58+
name: Python tests
5759
needs: detect
5860
if: needs.detect.outputs.python == 'true'
5961
uses: ./.github/workflows/tests.yml
62+
with:
63+
slice_count: 8
6064

6165
lint:
66+
name: Python lints
6267
needs: detect
6368
if: needs.detect.outputs.python == 'true'
6469
uses: ./.github/workflows/lint.yml
6570
with:
6671
event_name: ${{ needs.detect.outputs.event_name }}
6772

6873
typecheck:
74+
name: TypeScript
6975
needs: detect
7076
if: needs.detect.outputs.frontend == 'true'
7177
uses: ./.github/workflows/typecheck.yml
7278

7379
docs-site:
80+
name: Docs Site
7481
needs: detect
7582
if: needs.detect.outputs.site == 'true'
7683
uses: ./.github/workflows/docs-site-checks.yml
7784

7885
history-check:
86+
name: Deny unrelated histories
7987
needs: detect
8088
if: needs.detect.outputs.event_name == 'pull_request'
8189
uses: ./.github/workflows/history-check.yml
8290

8391
contributor-check:
92+
name: Check contributors
8493
needs: detect
8594
if: needs.detect.outputs.python == 'true'
8695
uses: ./.github/workflows/contributor-check.yml
8796

8897
uv-lockfile:
98+
name: Check uv.lock
8999
needs: detect
90100
uses: ./.github/workflows/uv-lockfile-check.yml
91101

92102
docker-lint:
103+
name: Lint Docker scripts
93104
needs: detect
94105
if: needs.detect.outputs.docker_meta == 'true'
95106
uses: ./.github/workflows/docker-lint.yml
96107

97108
supply-chain:
109+
name: Supply-chain scan
98110
needs: detect
99111
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true' || needs.detect.outputs.mcp_catalog == 'true')
100112
uses: ./.github/workflows/supply-chain-audit.yml
@@ -105,7 +117,7 @@ jobs:
105117
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
106118

107119
osv-scanner:
108-
needs: detect
120+
name: OSV scan
109121
uses: ./.github/workflows/osv-scanner.yml
110122

111123
# ─────────────────────────────────────────────────────────────────────
@@ -128,6 +140,10 @@ jobs:
128140
- docker-lint
129141
- supply-chain
130142
- osv-scanner
143+
# docker is NOT orchestrated here: this fork keeps docker.yml as a
144+
# STANDALONE workflow (on: push/pull_request/release, PR-gated publish),
145+
# not a reusable workflow_call job. Upstream's reusable ``docker:`` job
146+
# was intentionally dropped during the #562 catch-up to match the fork.
131147
if: always()
132148
runs-on: ubuntu-latest
133149
steps:

.github/workflows/docker-lint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Docker / shell lint
22

33
# Lints the container build inputs: Dockerfile (via hadolint) and any shell
44
# scripts under docker/ (via shellcheck). These catch the class of regression
5-
# the behavioral docker-publish smoke test can't — unquoted variable
5+
# the behavioral docker smoke test can't — unquoted variable
66
# expansions, silently-failing RUN commands, etc.
77
#
88
# Rules and ignores are documented in .hadolint.yaml at the repo root.

.github/workflows/tests.yml

Lines changed: 38 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ name: Tests
22

33
on:
44
workflow_call:
5+
inputs:
6+
slice_count:
7+
description: Number of parallel test slices
8+
type: number
9+
default: 8
510

611
permissions:
712
contents: read
@@ -12,13 +17,11 @@ concurrency:
1217
cancel-in-progress: true
1318

1419
jobs:
15-
test:
20+
generate:
21+
name: "Generate slices"
1622
runs-on: ubuntu-latest
17-
timeout-minutes: 30
18-
strategy:
19-
fail-fast: false
20-
matrix:
21-
slice: [1, 2, 3, 4, 5, 6]
23+
outputs:
24+
matrix: ${{ steps.matrix.outputs.matrix }}
2225
steps:
2326
- name: Checkout code
2427
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -27,13 +30,26 @@ jobs:
2730
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
2831
with:
2932
path: test_durations.json
30-
# main always writes a new suffix, but jobs pick the latest one with the same prefix
31-
# quote from https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#cache-hits-and-misses
32-
# If you provide restore-keys, the cache action sequentially searches for any caches that match the list of restore-keys.
33-
# If there are no exact matches, the action searches for partial matches of the restore keys.
34-
# When the action finds a partial match, the most recent cache is restored to the path directory.
3533
key: test-durations
3634

35+
- name: Generate test slices
36+
id: matrix
37+
run: |
38+
MATRIX=$(python3 scripts/run_tests_parallel.py --generate-slices ${{ inputs.slice_count }})
39+
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
40+
41+
test:
42+
name: Run tests slice ${{ matrix.slice.index }}/${{ inputs.slice_count }}
43+
needs: generate
44+
runs-on: ubuntu-latest
45+
timeout-minutes: 30
46+
strategy:
47+
fail-fast: false
48+
matrix: ${{ fromJSON(needs.generate.outputs.matrix) }}
49+
steps:
50+
- name: Checkout code
51+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
52+
3753
- name: Install ripgrep (prebuilt binary)
3854
run: |
3955
set -euo pipefail
@@ -49,7 +65,7 @@ jobs:
4965
rg --version
5066
5167
- name: Install uv
52-
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
68+
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
5369
with:
5470
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
5571
# Keyed on the dependency manifests, so the cache is reused until
@@ -78,33 +94,19 @@ jobs:
7894
# re-download, keeping the persisted cache small and fast to restore.
7995
run: uv cache prune --ci
8096

81-
- name: Run tests (slice ${{ matrix.slice }}/6)
82-
# Per-file isolation via scripts/run_tests_parallel.py: discovers
83-
# every test_*.py file under tests/ (excluding integration/ + e2e/),
84-
# then runs `python -m pytest <file>` in a freshly-spawned subprocess
97+
- name: Run tests (slice ${{ matrix.slice.index }}/${{ inputs.slice_count }})
98+
# Per-file isolation via scripts/run_tests.sh: each test file runs
99+
# in its own freshly-spawned `python -m pytest <file>` subprocess
85100
# with bounded parallelism. No xdist, no shared workers, no
86101
# module-level state leakage between files.
87102
#
88-
# Why per-file (not per-test): per-test spawn cost (~250ms × 17k
89-
# tests = 70min CPU minimum) blew the wall-clock budget. Per-file
90-
# spawn (~250ms × ~850 files = ~3.5min) fits while still giving
91-
# every file a fresh interpreter — the only isolation boundary
92-
# that matters in practice (cross-file leakage was the original
93-
# flake source; intra-file is the test author's responsibility).
94-
#
95-
# Why drop xdist entirely: xdist's persistent workers accumulate
96-
# state across files, which is exactly the leakage we wanted to
97-
# fix. ThreadPoolExecutor + subprocess.run is ~60 lines and does
98-
# the job with cleaner semantics.
99-
#
100-
# Matrix slicing (--slice I/N): files are distributed across 6
101-
# jobs by cached duration (LPT algorithm) so each job gets
102-
# roughly equal wall time. Without a cache, files default to 2s
103-
# estimate and get split roughly evenly by count — still correct,
104-
# just not perfectly balanced.
103+
# File list is pre-computed by the generate job (--generate-slices)
104+
# which runs LPT distribution once and passes the file list to each
105+
# matrix job via --files. Previously each job re-discovered files and
106+
# re-ran LPT independently — redundant N times.
105107
run: |
106108
source .venv/bin/activate
107-
python scripts/run_tests_parallel.py --slice ${{ matrix.slice }}/6
109+
scripts/run_tests.sh --files '${{ matrix.slice.files }}'
108110
env:
109111
# Ensure tests don't accidentally call real APIs
110112
OPENROUTER_API_KEY: ""
@@ -114,7 +116,7 @@ jobs:
114116
- name: Upload per-slice durations
115117
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
116118
with:
117-
name: test-durations-slice-${{ matrix.slice }}
119+
name: test-durations-slice-${{ matrix.slice.index }}
118120
path: test_durations.json
119121
retention-days: 1
120122

@@ -173,7 +175,7 @@ jobs:
173175
rg --version
174176
175177
- name: Install uv
176-
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
178+
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
177179
with:
178180
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
179181
# Keyed on the dependency manifests, so the cache is reused until

.github/workflows/typecheck.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ on:
66

77
jobs:
88
typecheck:
9+
name: Check TypeScript
910
runs-on: ubuntu-latest
1011
strategy:
1112
matrix:
@@ -22,8 +23,7 @@ jobs:
2223
# native builds. Skipping install scripts drops node-pty's node-gyp
2324
# header fetch — the transient flake that killed this job pre-`tsc` — and
2425
# is faster. retry covers the remaining registry blips.
25-
-
26-
uses: ./.github/actions/retry
26+
- uses: ./.github/actions/retry
2727
with:
2828
command: npm ci --ignore-scripts
2929
- run: npm run --prefix ${{ matrix.package }} typecheck
@@ -35,6 +35,7 @@ jobs:
3535
# users build apps/desktop from source on install/update. Run the real
3636
# `vite build` here so that class of break fails in CI instead.
3737
desktop-build:
38+
name: Build desktop app
3839
runs-on: ubuntu-latest
3940
steps:
4041
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -44,8 +45,7 @@ jobs:
4445
cache: npm
4546
# Keep install scripts here: the production build may need node-pty's
4647
# native binary. retry handles the transient install-time fetch flakes.
47-
-
48-
uses: ./.github/actions/retry
48+
- uses: ./.github/actions/retry
4949
with:
5050
command: npm ci
5151
- run: npm run --prefix apps/desktop build

0 commit comments

Comments
 (0)