chore: sync fork main with upstream NousResearch/hermes-agent - #1
Merged
Conversation
…-lane reservation (OOF-30 review) Addresses three gaps found in review of the memory-guard PR: P1a — standalone daemon was the one uncapped entry point. run_daemon() now resolves kanban.max_in_progress every tick (explicit config wins, else the memory-derived default) exactly like the gateway dispatcher and `hermes kanban dispatch`. New shared parser configured_max_in_progress() so all three entry points agree on what "explicitly configured" means. P1b — max_in_progress was enforced per board while the gateway ticks every active board, multiplying the host budget by the number of boards (2 boards x cap 2 = 4 workers on a host sized for 2). The cap is now host-level: _dispatch_once_locked() adds count_running_tasks_other_boards() to the running count before deriving the tick's spawn budget. Enforced in the shared locked path, so gateway, CLI, and daemon all inherit it. max_spawn deliberately keeps its historical per-board semantics. Fails open per board so one corrupt board can't brick dispatch on the rest. P2 — the ready loop consumed the entire shared spawn budget before the review loop ran, so a sustained ready backlog starved autonomous reviews indefinitely. When spawnable review work exists (assigned + real profile, mirroring the review loop's own gate) and the tick has budget, one slot is held back from the ready lane. Reservation is per-tick and self-releasing; the review lane still spends from the shared budget — it gains fairness, not extra capacity. 11 new tests in tests/hermes_cli/test_kanban_host_cap.py. Existing kanban suites: 278 passed (15 failures pre-existing, identical on clean main baseline). ruff clean.
…arget logging
A user typed their root password into the Desktop SSH host field
(root@IP:PASSWORD form). Three failures compounded:
1. validateSshTarget() only checked for option injection (leading dash),
control chars, and port range — commas in an IP, whitespace ("ssh "
prefix pastes), and non-numeric ":<segment>" leftovers all dialed ssh
with garbage and failed silently five times.
2. normalizeSshConfig() only strips a ":<segment>" when it is numeric, so
a pasted password stayed glued to the hostname all the way into ssh
argv and the desktop.log connect line.
3. redactSecrets() had no pattern for ssh targets, so the password landed
verbatim in desktop.log and then in a PUBLIC debug-share paste.
Changes:
- validateSshTarget(): reject whitespace, commas, non-numeric colon
segments (with a "never put a password in the host field" hint that
does NOT echo the credential), and garbage hostnames; still accepts
bare IPv6 (::1, fe80::1%eth0). Reject whitespace/@ in user.
- redactSecrets(): new pattern masks any non-numeric segment where a
port belongs in user@host:... strings — defense in depth so future
parse gaps can't leak credentials into logs or debug shares.
- normalizeSshConfig(): strip a pasted leading "ssh " prefix.
- Tests for all three, including the exact incident shapes.
`ElicitationHandler` read `params.requested_schema`, but on the pinned
`mcp==1.28.1` the model field is spelled `requestedSchema`. The getattr
always missed and returned its `{}` default, so
`_format_elicitation_schema_summary` took its no-properties branch and the
approval prompt collapsed to the generic
Approval requested by MCP server '<name>'.
for every request. The field names, types, and descriptions the summary
exists to surface never reached the user, so an elicitation asking for a
card number rendered identically to one asking for a nickname — consent
without the substance of what was being consented to.
Read both spellings rather than just correcting to the 1.x name: mcp 2.0
renames this field to `requested_schema` (it renamed every model field to
snake_case and kept camelCase only as a serialization alias, which
pydantic does not expose to attribute access), so a dual read is correct
on either SDK generation and does not go wrong again on the next bump.
Verified against real 1.28.1 and 2.0.0 installs.
Every existing test in tests/tools/test_mcp_elicitation.py builds a
duck-typed `SimpleNamespace` stand-in, which carries whatever field name
the test wrote and therefore cannot detect a mismatch with the real model.
Add one test that constructs the actual `ElicitRequestFormParams` and
asserts the requested field name reaches the consent description; it fails
on the unfixed tree. The cheap stand-ins are left alone elsewhere.
Found while porting the tree to the mcp 2.x SDK in NousResearch#76736, but independent
of it: this reproduces on the current pin with no other changes, NousResearch#76736
does not touch this line, and the two branches merge cleanly in either
order.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ow-up) Remove the cross-version heuristic from _strip_mismatched_site_packages: the subprocess env builder cannot know which Python version a child will run, so judging user PYTHONPATH entries against the backend interpreter's version deletes legitimate paths meant for a different child Python (e.g. /custom/lib/python3.13/site-packages while Hermes runs 3.11). Also fix over-strip: entries merely containing a pythonX.Y path component (e.g. /opt/tools/python3.13/bin) were stripped even though they are not site-packages. Hermes-owned entries (repo root, own venv site-packages) are now identified by path ownership, not by version. Regression tests cover both cases; user paths with any pythonX.Y component are preserved.
The gateway runs inside its own venv; if its PYTHONHOME leaks into subprocesses (terminal commands, cron no_agent scripts, TTS providers), any child interpreter redirects its stdlib search to the Hermes venv and crashes with version-mismatch errors before importing anything. PYTHONHOME is now part of _ACTIVE_VENV_MARKER_VARS so all env builders (_make_run_env, _sanitize_subprocess_env, hermes_subprocess_env, and build_subprocess_env used by cron) drop it, consistent with Hermes' existing PYTHONHOME handling in managed_uv.py and sqlite_runtime.py. execute_code already scrubbed it via _SAFE_ENV_PREFIXES. Tests cover all four builders plus the marker constant.
Adversarial review of the previous two commits (and NousResearch#78917 itself) found three ownership-boundary issues; this commit addresses them: 1. Repo direct-child over-strip (Finding A) No launcher injects <repo>/tools or another direct child as an independent PYTHONPATH entry - audited all four producers (Electron electron-main.mjs, gateway/run.py::_ensure_windows_gateway_venv_imports, cron/scheduler.py::_windows_cron_python_invocation, tui_gateway/host_supervisor.py). The depth<=1 rule deleted user paths that merely live under the repo directory; only the EXACT repo root is now stripped. 2. Windows junction/symlink alias (Finding B) The gateway launcher renders Hermes-owned paths under the configured HERMES_HOME spelling (gateway_windows.py::_preserve_hermes_home_path), which may be a junction to another drive, so it differs lexically from the resolved repo root. _hermes_repo_root_aliases now carries both the resolved and unresolved spellings; both are recognized as Hermes-owned. 3. Stale abstraction rename (Phase 4) _strip_mismatched_site_packages -> _strip_hermes_owned_pythonpath: the cross-version heuristic is gone, so the old name misdescribes the behavior (ownership-based, not version-based). Tests: direct-child now preserved; junction alias stripped (lexical pair monkeypatched); Windows-only real-semantics test added (POSIX test remains a safety test); mixed-ordering, duplicate-Hermes, and no-scrub PYTHONHOME contract tests added. Full file: 52 passed / 16 failed (identical failure set to base, all isolation-venv environment issues).
…ted inherited env Integration test for the NousResearch#84500 + NousResearch#82581 intersection: seeds a contaminated inherited PYTHONPATH (Hermes repo root + Hermes venv site-packages + user entries) through os.environ and drives execute_code to Popen. Asserts the staging tmpdir stays first, inherited Hermes site-packages never survive, the repo root is re-added exactly once for a same-env child (proving the inherited copy was stripped) and stays absent for an external-env child, and user entries survive in order.
The PYTHONPATH/PATH sanitization suite was written POSIX-centric and failed on real Windows 11 (reproduced natively: 4 failures before this change). Fix the tests to express the true per-platform contract: - test_other_major_version_site_packages_preserved / test_make_run_env_injects_hermes_bin_dir: build inputs with os.pathsep instead of hardcoded ':'. - test_make_run_env_appends_homebrew_on_minimal_path: split on os.pathsep, neutralise Git Bash dir prepending, and assert the documented Windows passthrough (_append_missing_sane_path_entries is a no-op off POSIX) instead of the Homebrew append. - test_make_run_env_real_launchd_path_gains_homebrew: mark macos_only per repo OS-marker policy (the regression is the macOS launchd PATH; the merge is a passthrough on Windows). - test_configured_home_alias_matches_launcher_output: create the configured-home link via a helper that falls back to an unprivileged directory junction (cmd /c mklink /J) when symlink creation raises WinError 1314, and skips with a clear reason if no mechanism exists. Also correct a stale comment in execute_code: the child is not always the same Python as Hermes (project mode can select an external venv), so the strip is about compatibility, not redundancy.
Confirmed on native Windows 11 with a real junction and the real startup chain: when the desktop/CLI spawns the backend with HERMES_HOME in the configured (lexical) spelling and --profile / sticky active_profile is in play, _apply_profile_override() re-homes HERMES_HOME through resolve_profile_env(), which resolves the junction under the platform default and returns the PHYSICAL spelling. tools.environments.local is imported after that mutation, so _hermes_repo_root_aliases is built from the physical home, the lexical repo-root spelling written into PYTHONPATH by the launcher (D:\hermes\hermes-agent) is not derivable, and the entry survives stripping (reproduced: cases --profile default / named / sticky active_profile / cross-drive junction all leave it in place; no-profile strips it). Two narrow changes, no heuristics, no new env vars: - hermes_cli/profiles.py::resolve_profile_env: when HERMES_HOME is set, the configured spelling IS the launch root (junction-transparent, physically identical dirs); keep it instead of re-deriving the native default. This is the same producer contract _preserve_hermes_home_path already follows. - tools/environments/local.py::_build_hermes_repo_root_aliases: when the configured home is a profile home (<root>/profiles/<name>), also derive the root spelling lexically (parent of the profiles component, same rule get_default_hermes_root uses) and run the exact-ownership mapping against it, so the launcher's lexical root is recovered after re-home without ever matching arbitrary descendants of HERMES_HOME. Regression test test_profile_rehome_keeps_junction_lexical_alias covers junction + profile re-home + inherited lexical PYTHONPATH end to end.
The junction fix made resolve_profile_env preserve the configured HERMES_HOME spelling as the launch root. Cover the four pre-existing resolution invariants so the spelling-preservation never regresses them: - root env + named profile -> <root>/profiles/<name> - profile-shaped env + named profile -> <root>/profiles/<name> (no nesting) - profile-shaped env + default -> <root> - custom root env never falls back to the platform default Plus existence/validation semantics (missing named profile still raises FileNotFoundError) and the unset-env fallback contract.
Second real-world topology reported and confirmed on native Windows 11: the repository itself is a cross-drive junction (D:\hermes\hermes-agent -> C:\...\hermes-agent) under a real HERMES_HOME directory. The editable import spelling resolves to the physical location, so _hermes_repo_root is physical while the launcher writes the lexical spelling into PYTHONPATH. The home-relative mapping cannot express a cross-drive link (commonpath raises on different drives), so the lexical repo root survives stripping; and with the repo alias missing, a lexical VIRTUAL_ENV (D:\hermes\hermes-agent\venv) also fails _validated_runtime_venv, so the venv site-packages survives too (uv-base gateway: both entries survive). Fix: after the existing home/profile-root mapping, try the single deterministic candidate <lexical root>/<repo dirname> for every trusted home candidate (configured home, plus the profile root when the configured home is a profile path) and accept it only when strict resolve proves it is the exact physical repo root (fail-closed: missing paths, real directories that are not the known repo, and unrelated spellings are never aliased). This also re-enables the VIRTUAL_ENV validation for lexical venv spellings, so uv-base gateway site-packages cleanup follows the repo alias. Tests: repo-level junction positive + negative control (same-named real directory preserved), profile-home + repo-level junction combination, lexical VIRTUAL_ENV validation after recovery (root + site-packages stripped, user entries kept), and a no-provenance lookalike preserved. The execute_code composition test now compares composed paths with os.path.normcase so a Windows case-only spelling difference (resolve() vs abspath() casing) can never fail the composition contract.
Same behavior, same coverage, less boilerplate (test file 1691 -> 1512 lines; PR diff unchanged in semantics). Production (mechanical only): - Extract _strip_hermes_owned_pythonpath_and_runtime_markers(): the three builders (_make_run_env, _sanitize_subprocess_env, hermes_subprocess_env) ran the identical strip-then-pop-markers sequence in the same order (ordering is load-bearing for VIRTUAL_ENV validation); the helper makes that explicit once instead of three times. Tests: - Non-owned preservation: 11 single-shape tests -> one parametrized matrix (user/Nix/other-version/python2.7/pythonX.Y-contained/raw spelling/empty component/empty PYTHONPATH) + one runtime-shaped matrix (other-version SP, venv-SP descendant, repo direct child, repo deep child). - Owned stripping: venv SP, repo root (independent parents[2] computation), duplicates, all-owned key removal, mixed ordering -> one matrix. - Builder integration: _make_run_env/_sanitize_subprocess_env/ hermes_subprocess_env venv-SP stripping -> one parametrized test; same for the four PYTHONHOME builders (incl. build_subprocess_env). - Junction: same-named non-owned negative control now covers both the configured-root location and an unrelated location; shared _physical_repo_root helper; profile resolution matrix (root->named, profile-shaped->named no nesting, profile-shaped->default, custom root). - Every independent proof preserved: home-level junction, repo-level junction, profile interaction, negative identity control, uv-base lexical VIRTUAL_ENV, validated/unrelated VIRTUAL_ENV, no-scrub escape hatch, NousResearch#84500 same-env/external-env composition, PYTHONHOME removal, real Windows-only semantics, POSIX fail-closed backslash paths.
Shorter, single-source ownership explanation for _strip_hermes_owned_pythonpath (the code-level Check comments already carry the per-branch detail; the docstring only needs the contract).
Two independent bugs let a deleted profile reappear / leave orphaned resources on next launch: 1. hermes_cli/profiles.py's backend-process scanner required argv[0] to resolve to an executable literally named "hermes". Electron's pool-backend spawn resolves the hermes console-script shim's path and execs it via the interpreter directly (python3 /path/to/hermes ...), so argv[0] reports as "python3" and the scanner never matched the running backend -- delete removed the profile's files but left its live backend process running (still bound to a port via uvicorn), which accumulates across repeated delete/recreate cycles. 2. The desktop sidebar's ProfileRail only refreshed its cached profile list once, on mount, so a delete/create/rename from another surface (another window, or the CLI) left a stale ghost entry until something unrelated triggered a refetch. Note: a delete via this window's own Manage-Profiles view already refreshes the shared $profiles atom ProfileRail subscribes to (confirmed by reading refreshProfiles() and handleConfirmDelete()) -- this fix only covers the cross-window/cross- process staleness gap, not a duplicate of the already-merged NousResearch#57329's Manage-Profiles rail-refresh work. Fix 1: recognize a python-interpreter argv[0] exec'ing a hermes-named console-script shim via argv[1]. Fix 2: refresh the profile list on window focus/visibilitychange, matching the existing pattern used elsewhere in the sidebar (sidebar/index.tsx, use-background-sync.ts, star-map.tsx, use-gateway-boot.ts all use the same focus+visibilitychange pattern). ## Related work already on main PR NousResearch#57329 (merged) fixed the *headline* symptom from issue NousResearch#52279 (deleted profile respawns) via a different, non-overlapping mechanism: routing profile-delete through the primary backend instead of spawning a fresh pool backend, plus a separate recreation guard in ensure_hermes_home() (NousResearch#49435, merged) that makes a backend spawned into a deleted profile's directory raise FileNotFoundError instead of silently recreating it. This PR is NOT a duplicate of that fix. Verified: even with both of those merged, a backend process that survives because of gap #1 above still holds a bound port via uvicorn -- it just can no longer resurrect the profile directory. That's real resource-hygiene, not a symptom already covered. Gap #2 touches a different file/component (ProfileRail / profile-switcher.tsx) than NousResearch#57329's rail-refresh half (which touched the Manage-Profiles view's own $profiles.ts / index.tsx) and covers a distinct staleness path (cross-window/cross-process, not same-window delete-then-refresh). Tests: tests/hermes_cli/test_profiles.py -- 156 passed (existing + regression coverage for the argv[0] python-interpreter detection case). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… scripts
External review (Fable) caught a real false-positive widening in the
original commit: the new argv[1] script-name check reused the loose
`script_name == "hermes" or script_name.startswith("hermes")` pattern
(copy-pasted from the exe_name check above it), but argv[1] can be ANY
user-invoked python script path when argv[0] is a bare interpreter --
unlike a directly-resolved executable name, where a false match on the
substring is rare. A user's own script named e.g. "hermes-notes.py" or
"hermes-unrelated-tool" run via `python3 <script>` would be misidentified
as the console-script shim and become killable by profile delete.
Match against the actual known console-script entry points instead
(pyproject.toml [project.scripts]: hermes, hermes-agent, hermes-acp),
stripping the script's extension before comparing.
Added 2 regression tests: one confirms the false-positive case is now
rejected (fails against the pre-fix loose-match code, confirmed via a
scripted revert), the other confirms the other two real entry points
(hermes-agent, hermes-acp) still match via the shebang-exec path.
Tests: tests/hermes_cli/test_profiles.py -- 158 passed (156 previous + 2
new).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… a tested hook Addresses review feedback from the hermes-sweeper (salvageability=high, keep_open): "The new focus/visibility listener behavior lacks a runtime UI regression test... no ProfileRail test." Rendering the full ProfileRail component for this would drag in drag-and-drop, dialogs, hotkeys, and i18n unrelated to what needs testing. Instead, extracted the focus/visibilitychange wiring into its own use-profile-rail-refresh-on-active hook, matching this exact directory's own established convention (use-profile-prewarm.ts is the same shape: a small side-effect hook pulled out of ProfileRail specifically so it's unit-testable in isolation). Added 6 tests covering exactly what the review asked for: refresh on mount, refresh on window focus, refresh on visibilitychange while visible, NO refresh on visibilitychange while hidden, listener cleanup on unmount, and no listener accumulation across repeated mount/unmount cycles. Verified the tests have real teeth: simulated the exact bug this PR originally fixed (dropped the cleanup return, leaving listeners attached after unmount) and confirmed 4 of 6 tests correctly fail against it -- including "no accumulate listeners" showing 7 calls instead of 1, the exact leaked-listener signature. Restored the real fix and all 6 pass. ProfileRail itself is otherwise unchanged in behavior -- this is a pure extraction (same effect, same dependencies, same cleanup), not a behavior change. Full sidebar test suite: 93 passed across 12 files (up from 87 across 11), 0 regressions. Python side unaffected: 158 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This reverts commit a7ba91e.
tests/test_lazy_secrets_dispatch.py::TestUpdatePathE2E ran the real `hermes update --check` bare, so the child's `git fetch origin main` hit github.com on every CI run. During the 2026-08-17 GitHub incident the fetch stalled past the 30s subprocess timeout and both update tests went red on main for hours with zero code change (slices seen on runs 32003200300 and 32011520139). These tests assert the lazy-crypto / no-self-lock dispatch invariants, not update connectivity. Rewrite all git remote URLs in the child to an unreachable file:// path via GIT_CONFIG_* env overrides: the update path still exercises its full parser/dispatch/fetch code, but the fetch now fails in milliseconds, deterministically, offline. Exit code 1 was already accepted by the assertions. Before/after under a blackhole proxy simulating the outage: OLD: TimeoutExpired after 20s (reproduces the CI failure) NEW: completes in 0.2s, exit=1
This reverts commit b363038.
Matrix m.audio/m.file/m.video events populate content.body with the uploaded filename when the sender adds no caption. The adapter already blanks that for m.image (PR NousResearch#16821, issue NousResearch#13482) but not for audio, file, or video msgtypes, so the filename survives into event.text and is appended after the transcript, where the model reads it as the user message rather than as transport noise. Extend the existing adapter-level blanking: add _looks_like_matrix_media_filename() with the same conservative heuristic (single token, no whitespace, no path separators, known media suffix or mimetypes audio/video match) and apply it at the media message handler for m.audio, m.file, and m.video. Salvage of NousResearch#87968 by @AiwendilInTheWoods — reworked from shared gateway path to adapter-level fix for consistency with the existing m.image blanking.
Add first-class CommandCode provider with dual API mode support: profile commandcode (chat_completions): 20+ models via OpenAI-compatible endpoint DeepSeek, Qwen, Kimi, GLM, MiniMax, StepFun, Mimo, Gemini, GPT Default: deepseek/deepseek-v4-pro (1M context) profile commandcode-anthropic (anthropic_messages): Claude models via Anthropic Messages-compatible endpoint Default: claude-sonnet-4-6 (1M context) Changes: - plugins/model-providers/commandcode/ — provider plugin - __init__.py: dual ProviderProfile classes with fetch_models - plugin.yaml: manifest - agent/anthropic_adapter.py: recognize api.commandcode.ai as Bearer auth - tests/plugins/model_providers/test_commandcode_profile.py: 28 tests - tests/providers/test_plugin_discovery.py: bump profile count 34→36 171 provider tests pass (28 new, 0 regressions)
Follow-ups on top of the salvaged CommandCode provider plugin (PR NousResearch#32909): - hermes_cli/config_defaults.py: COMMANDCODE_API_KEY setup-wizard entry - hermes_cli/doctor.py: add key to the doctor env-var scan list (health check comes free via the pluggable-profile loop) - hermes_cli/dump.py: include commandcode in debug-dump api_keys - docs: provider table row, fallback-provider table + supported lists - tests: doctor dedicated-skip test now uses exact-name checks so Bearer-authed Anthropic-COMPATIBLE gateways (CommandCode (Anthropic)) are allowed in the generic loop while native anthropic stays skipped E2E verified with real imports: profile registration, aliases, PROVIDER_REGISTRY auto-extension, bearer-auth host match (positive + negative), live /models fetch (55 models).
…r desktop parity The provider-parity contract requires every CANONICAL provider to render a card on the desktop Keys tab. /api/env rows are keyed by env var, and both CommandCode profiles shared the single COMMANDCODE_API_KEY — so the commandcode-anthropic profile had no row of its own and test_provider_parity failed on CI (slice 6/12). Fix: both profiles keep the shared API key, but each declares its own base-URL override var (COMMANDCODE_BASE_URL / COMMANDCODE_ANTHROPIC_BASE_URL), matching the sibling-provider pattern, so each renders its own card. Verified: test_provider_parity.py + commandcode + providers suites green locally (86 passed); PROVIDER_REGISTRY splits key vs base-URL vars correctly for both profiles.
Phase 2 of the MCP 2026-07-28 migration (NousResearch#69931), on top of the SDK 2.x migration (NousResearch#88180): - Protocol-era negotiation (_negotiate_session): per-server `protocol` config key — auto (default, handshake-first with server/discover fallback on -32022/-32601), stateless (discover-first), legacy (handshake only). Auto is handshake-first deliberately: zero extra round-trips and zero behavior change for the entire existing server fleet, while 2026-07-28-only servers now connect via the fallback. All four transport call sites (stdio, SSE, new HTTP, legacy HTTP) route through the one choke point, so the CLI/desktop probe path inherits it too. - SEP-2549 list caching: tools/list ttlMs/cacheScope hints are captured during discovery and bound to the lazy-startup schema cache — TTL'd entries expire and force a live re-probe; hint-less (pre-2026) servers keep the never-expires behavior. Pagination continuation now speaks both SDK generations (params= vs cursor=). - SEP-837: OAuth client metadata declares application_type=native (config-overridable), with a fallback for 1.x-era metadata models. (RFC 9207 iss validation and SEP-2352 issuer-keyed credentials are native to SDK 2.0's OAuthClientProvider — verified, no client-side gap.) - SEP-2577 deprecation posture: SamplingHandler docstring marks the Sampling feature as upstream-deprecated (12-month window) — kept fully functional, closed to new capability. - Docs: `protocol` key in the MCP config reference.
Two gaps from the Aug 2026 'hermes -w timed out after 30s' incident: 1. Atomic failure cleanup: a timed-out/failed `git worktree add` left a partially-materialized directory plus a LOCKED admin entry under .git/worktrees/ (lock pid = the live hermes process that timed out), which the startup pruner's dead-pid unlock never reaps — retries of the same name fail forever. _cleanup_failed_worktree_add sweeps dir, admin entry, and orphaned branch on every failure path (timeout, nonzero exit, remote-base retry). 2. Pack maintenance: nothing consolidated the object store; on a multi-agent box packs sprawl (39 packs / 638MB at the incident) and every object lookup scans all pack indexes until worktree creation blows its timeout. _maintain_pack_health repacks (niced, background, fail-soft) when *.pack count reaches 15, wired into the existing startup maintenance thread on both the CLI (-w) and TUI paths. gc --auto doesn't cover this: its threshold is 50 packs. Both sabotage-verified; full repack on the incident box: 39 packs -> 2, 638MB -> 287MB, worktree add 30s-timeout -> 0.5s.
The empty cronjobs list rendered both a calendar-icon placeholder blurb
("Cronjobs are recurring tasks this agent runs on a schedule.") and the
Create Cronjob button — two elements saying the same thing (empty).
Per Teknium's review, drop the generic placeholder and keep only the
create button. The filter hint ("jobs exist but are hidden by the bot
filter") still renders, since it carries real information rather than
just marking emptiness.
… boot-descriptor connectionId (salvage NousResearch#88697) compose without re-appending the twin-address primary
…ture branch — switches back when safe, warns loudly when not Live incident 2026-08-17: the source checkout was parked on a stale feature branch (claude-code-inspired/local-terminal-memory-limit, days behind main), left there by earlier tooling. 'hermes update' autostashed, refreshed lazy backends, synced skills, and printed '✓ Code updated!' / '✓ Update complete!' while the checkout stayed on the stale branch with none of main's new code. Two sessions burned time on 'the fix is missing' confusion. - Parked-branch guard: auto-switch back to the update target ONLY when the parked branch is clean and fully merged (git cherry origin/<target> shows nothing unmerged); the checkout then STAYS on the target instead of being re-parked. Otherwise: loud CODE UPDATE SKIPPED block naming the branch, behind-count, and resolution commands; exit 1; branch untouched. - The up-to-date (commit_count == 0) path no longer switches back to a fully-merged parked branch either. - Post-pull gate additionally refuses to print '✓ Code updated!' when HEAD ends up attached to a non-target branch. - Summary lines now carry the actual branch + HEAD short-sha: '✓ Update complete! [main @ 30fcf95]' — drift visible at a glance. - New config toggle updates.auto_switch_parked_branch (default true). - Real-git-fixture regression tests (init/clone/branch, no subprocess mocks): clean+merged auto-switch, dirty skip, unmerged skip, cherry-picked equivalence, config opt-out, unverifiable ref, on-main fast path, up-to-date no-repark, summary branch/sha assertions.
…suffix test_update_hangup_protection pinned the exact stdout of _print_update_completion; the new branch+HEAD suffix (parked-branch guard) broke that pin. The two receipt tests assert the action-identity contract, not the branch display, so they now neutralize _branch_head_suffix — the suffix behavior itself is covered by test_update_parked_branch_guard.py.
…parent-agent rebuilds, and child-started process notifications carry delegation attribution Control path: delegate_task(action=list/steer/stop) resolved ownership purely through the _delegate_parent_ref weakref identity chain. The CLI rebuilds its AIAgent mid-session (self.agent = None on route-signature change, credential refresh, /model, MoA one-shots), so a running child's chain pointed at a dead object and the child went invisible/unsteerable while completion delivery (durable session-id routed) still worked. Observed live 2026-08-17: deleg_88454b70 / sa-0-dc0100f4. Fix: register each child with the owning conversation's durable session id (owner_agent_session_id, the same spine delivery routes by) and add a second ownership tier that matches it against the calling parent's session_id with compression-lineage resolution on both sides. Foreign sessions still fail closed. Presentation path: background processes started BY a subagent (task_id == subagent_id) route their notify_on_complete notifications to the parent conversation by design, but arrived as anonymous raw output walls. The formatter now resolves the task_id against the live + recently-finished subagent registry (bounded retention survives child completion) and adds a provenance line (subagent id, delegation id, goal snippet), trimming the output tail for subagent-owned processes. Parent-owned process notifications are byte-identical to before.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…just the legacy scalar With multi-group membership, a bot's meta carries groups[]; the create dialog's taken-name scan must union all of them (botGroups) or a name only present as a secondary membership could be reused and resurrect that room.
…awned tasks (NousResearch#88975) test_session_chat_stream_treats_pre_existing_poisoned_row_as_no_model asserted mock_run.call_args right after the 200 status, but the stream handler runs _run_agent inside asyncio.create_task(_run_and_signal()) and response.prepare() returns the 200 before that task necessarily starts — on loaded CI runners call_args was still None (TypeError: cannot unpack non-iterable NoneType). Draining the SSE body (resp.text()) joins the stream end, which guarantees the runner task completed. test_goal_verdict_send used fixed asyncio.sleep(0.05) waits before asserting on sends/enqueues produced by spawned tasks; replaced with a bounded _drain_until() poll (5s cap, returns as soon as the condition holds) so the asserts stay exact without the fixed-delay race. These three tests red-flagged unrelated main pushes and PR runs on Aug 18 (runs 32099139396, 32101135100, 32106224479, 32101070064).
…hboring session instead of spawning a fresh draft (NousResearch#88924) nextSessionTileForWorkspace() only walked tabs stacked WITH the workspace tab. In a side-by-side layout (session tiles in their own zones — db's three-pane report), the walk found nothing, closeWorkspaceTab() skipped promotion, and requestFreshSession() dropped main to a new draft: closing a pane read as 'it gave me a new session'. A tile in any zone now promotes (its zone collapses via the tile close), so Close is also the path from N panes back to 1. Ghost panes whose tile is gone still never promote.
…mmaries (NousResearch#88925) Every USER message starts a conversation (the same boundary the turn engine's delta scoping keys on). The latest conversation renders in full; older ones collapse to a one-line summary row — head text, reply count, last-activity time — that expands/collapses on click. db's 'threads per conversation' ask, delivered as timeline folding: no log-model change, no new persistence, cross-machine sync untouched. groupChatConversations() splits the log (leading member run from a trimmed log forms a headless block); rendering extracts renderEntry() unchanged. Tests: conversation splitting (headless block, heads, startIndex) + fold source contracts. 256/256 plugin tests pass.
acc614e added a raw backslash inside a <code> span in a table row; MDX reads it as an escape and never finds the closing </code>, failing docs-site-checks on main and every open PR. Escape it as &NousResearch#92;.
… instead of truncating when no sandbox env is active
Sessions that never ran a terminal command (MCP-only, cron, gateway)
have no active sandbox environment, so maybe_persist_tool_result()
got env=None and fell through to the inline-truncate fallback --
a 467K MCP result was cut to a ~1.3K preview with no file written
('Full output could not be saved to sandbox').
Now the host-side cases (env=None or the local backend) write the
spill file directly to $HERMES_HOME/cache/spillover/<id>.txt,
alongside the other Hermes-owned caches instead of littering /tmp.
Remote backends (docker/ssh/modal/daytona) keep the in-sandbox
env.execute() write since read_file resolves in-sandbox there.
Cleanup: the gateway housekeeping loop prunes spillover hourly with
the other media caches, and a once-per-process best-effort prune on
first spill covers CLI-only installs.
The steer-survives-budget tests pinned the inline 'Truncated:' fallback shape, which only occurred because env=None persistence was broken. Now that host-side spillover succeeds, budget enforcement produces a <persisted-output> block instead. Assert the actual contract — the oversized payload was replaced (persisted OR truncated) — via a shared helper, not which replacement shape was used.
…every backend Per review: even with an active sandbox env, spilled tool results belong in $HERMES_HOME/cache/spillover with the other Hermes-owned caches — not the sandbox temp dir as primary storage. - Host-side write happens first on every backend; local/no-env sessions reference the host path directly (unchanged). - cache/spillover joins the auto-mount/sync cache-dir list (credential_files._CACHE_DIRS), so docker bind-mounts it and modal/ssh/daytona file-sync it. Remote references use the translated in-sandbox path after a readability probe. - Probe failure (persistent containers created before spillover joined the mount list, translation failures) falls back to the previous in-sandbox temp-dir copy, so nothing regresses.
…bility test test_real_aiagent_builds_section_once_and_keeps_it_out_of_static_prefix builds the system prompt twice and asserts byte equality, but the prompt embeds build_coding_workspace_block() — live git status/log output. A git call failing between the two builds (xdist contention in CI) makes the Branch/Recent-commits lines differ and fails the test on unrelated PRs (first seen on NousResearch#89027's run: diff showed only '- Branch: (detached HEAD)' and recent-commit lines). Mechanism reproduced locally: with coding posture on and cwd inside the checkout, failing git calls on the second build only => first != rebuilt; with the snapshot pinned, identical git failure => byte-equal. The real block's byte-stability is coding_context's own contract; this test is about plugin sections.
…er starts one, reply-in-thread continues it Supersedes the display-only conversation folding from NousResearch#89030 with actual threads, per Teknium's direction: guessing topic boundaries from user- message timing was wrong — tasks take many user turns. - Every room entry carries a thread id. The main composer STARTS a new thread with the whole group ('New Thread'); each open thread has its own 'Reply in thread' box that CONTINUES that work. Explicit intent, no heuristics. - Member turns are thread-scoped end to end: the round-robin drive filters the room log to the triggering thread, watermarks key on thread::member, and responder resolution reads only that thread — so parallel topics never leak into each other's deltas or eat each other's watermarks. - Stranded (timed-out) replies remember their thread and harvest back into it; pre-thread bare-number markers still parse. - Pre-thread logs hydrate through assignLegacyThreads(): a user message after a 15-min lull starts a synthetic thread, follow-ups inside the window stay together — one-time conversion only, live sends always mint real ids. - UI: threads ordered by last activity, newest open by default, older ones fold to summary rows (head text, reply count, last activity) with expand/collapse. Tests: thread minting, explicit-thread continuation + delta scoping (other thread's text never reaches the member prompt), legacy hydration split behavior, thread UI source contracts. 258/258 plugin tests pass.
… name (NousResearch#45624) `hermes profile rename default <name>` (and the Desktop/dashboard rename flows) now set a presentation-only `display_name` in profile.yaml instead of erroring. The canonical id stays "default"; resolution, comparison, and spawn paths are untouched. Named profiles keep real renames and their display_name survives the move. Surfaces: profile list/show/status, /profile (text only — data.profile stays canonical), dashboard ProfilesPage, TUI-gateway profiles.list, and Desktop (rail, switcher, Manage page, and the Bot Mode roster via a displayName fallback so a renamed default shows its name, not "default"). Slimmer redo of the direction in PR NousResearch#87760 by @yxssxn — thanks; see PR body for what changed vs that approach.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…NousResearch#89049) The group room's composer (new-thread box and reply-in-thread box) rendered a plain SDK Input. The plugin's mention provider is registered against COMPOSER_AREAS.atCompletions, which only mounts in the main chat composer — workspace tiles never see it, so typing @ or / in a group chat did nothing despite the placeholder advertising "@name to direct, @everyone for all". Fix: a member-scoped GroupMentionInput wrapper in the plugin itself — caret-aware @-token detection, popover offering @everyone/@ALL plus each seated member's handle (same botHandle the parser uses), keyboard nav (Up/Down/Enter/Tab/Escape), and insertion that produces exactly the "@handle " strings parseGroupChatMentions resolves. Both group composers (GroupChatWorkspace main box + thread reply box) mount it; the per-bot composer path is untouched. Root cause traced by the reporter of NousResearch#89049 — confirmed against source.
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fast-forwards this fork's main from 17,826 commits behind upstream up to
daca38696(2026-08-18). No local changes to main existed (0 commits ahead), so this is a clean fast-forward — merge as a regular merge, not squash, to preserve upstream history.