Skip to content

feat(telegram): inline keyboard UI for /resume command#3

Closed
indigokarasu wants to merge 173 commits into
mainfrom
feat/telegram-resume-ui-v2
Closed

feat(telegram): inline keyboard UI for /resume command#3
indigokarasu wants to merge 173 commits into
mainfrom
feat/telegram-resume-ui-v2

Conversation

@indigokarasu

Copy link
Copy Markdown
Owner

Summary

Replaces the plain-text /resume list on Telegram with an inline keyboard showing the last 12 non-cron named sessions sorted by recency.

Changes

  • gateway/platforms/telegram.py: Intercept /resume with no args in _handle_command, send inline keyboard via new _send_resume_button_ui() method. Handle rs:<session_id> callback to dispatch synthetic /resume event.
  • gateway/run.py: New get_resume_sessions() method returns structured session data. Fetches from all platforms, excludes cron/tool, filters for titled sessions, stops at 12.

UX

  • Numbered list (1.-12.) in message body with title + preview
  • Matching numbered buttons (truncated to fit mobile ~25 chars)
  • Plain text (no parse mode) to avoid Telegram HTML/Markdown escaping issues
  • Sessions sorted by most recent activity
  • Fallback: /resume or /resume still works

wesleysimplicio and others added 30 commits May 9, 2026 08:55
…earch#21203)

detect_audio_environment() unconditionally added a hard warning when
running inside a container, blocking /voice on even when the host audio
socket was correctly forwarded (PulseAudio or PipeWire) and sounddevice
could enumerate devices.

Mirror the existing WSL/PulseAudio handling: if PULSE_SERVER or
PIPEWIRE_REMOTE is set, downgrade to a notice and let the audio backend
decide.  When neither is set, keep the block but extend the message with
the exact -v / -e flags users need.

Closes NousResearch#21203
Keep local Hermes Docker runtime data, NotebookLM auth/cache, and personal compose overrides out of Git and Docker build contexts. This protects tokens, OAuth state, sessions, logs, and caches while preserving the source tree.

Constraint: Only .gitignore and .dockerignore are in scope for this commit.

Tested: git diff --cached --name-only and git diff --cached --stat

Co-authored-by: OmX <omx@oh-my-codex.dev>
`hermes update` has always hard-coded its target to `main`. Add --branch
so callers can update against a non-default channel while preserving every
existing behavior at the default:

- `hermes update`           still pulls main (no behavior change)
- `hermes update --branch X` pulls origin/X, auto-stashing and switching
                              local HEAD to X first if needed
- `hermes update --check --branch X` reports behindness against
                              origin/X (and skips the upstream/X probe,
                              since forks don't have upstream copies of
                              their own feature branches)
- Branch absent locally   → retry as `checkout -B X origin/X` (track)
- Branch absent everywhere → exit 1 with a clear error, after restoring
                              the user's prior stash so we don't strand
                              them in a weird state

The fork-upstream sync logic was already guarded on `branch == 'main'`,
so non-main updates correctly skip the upstream trampling without
further changes.

5 new tests cover: explicit --branch, default-to-main, switch-from-other,
track-from-origin, and the fail-cleanly case. Full test_cmd_update.py
suite (15 tests) passes on main.
Three follow-up fixes — all the same shape: silently doing the wrong
thing instead of either honoring --branch or refusing.

1) --check --branch <missing> raised CalledProcessError from
   'git rev-list ... --count' (check=True) when the branch didn't
   exist on origin. 'git fetch origin' succeeds without a refspec
   (it just fetches what's there), so the bad-branch case wasn't
   caught at the fetch step. Now verify the compare ref with
   'git rev-parse --verify --quiet' before rev-list and emit a
   friendly error.

2) _update_via_zip (Windows fallback for broken git file I/O)
   hard-coded branch = 'main', so on the ZIP path --branch=foo
   silently downloaded main.zip and told the user it worked. Refuse
   in that case instead — silently lying about which branch got
   installed is exactly what --branch was added to prevent.

3) _cmd_update_check PyPI path returned before looking at branch,
   so PyPI users running 'hermes update --check --branch=x' got a
   generic PyPI version check with no indication --branch was
   dropped. Now prints a one-line warning when --branch was explicit
   and non-main.

Also pull the '(getattr(args, branch, None) or main).strip() or main'
expression into _resolve_update_branch(args) — three callsites agree
on the same parsing.

Tests: 5 new tests for the --check + --branch matrix (named branch,
missing branch, default-main upstream-first, PyPI warning) and the
ZIP refusal. test_cmd_update.py is 20/20 green, broader hermes_cli/
suite (4952 tests) unchanged.
Docker containers often run in isolated networks without access to PyPI.
The lazy-install mechanism fails silently in these environments, causing
ImportError when users try to use Anthropic, Bedrock, or Azure providers.

Add --extra anthropic, --extra bedrock, and --extra azure-identity to the
Dockerfile's uv sync command so these provider packages are pre-installed
in the published image.

Fixes NousResearch#30394
… CMD

s6-overlay's /init scrubs the environment before invoking both
/etc/cont-init.d/* scripts and the container's CMD wrapper. As a
result, ENV directives from the Dockerfile (HERMES_HOME=/opt/data,
HERMES_WEB_DIST, …) and compose-time `environment:` entries
(HERMES_UID, HERMES_GID) never reached the scripts that actually
use them. Three concrete failures observed on macOS Docker Desktop
with `~/.hermes:/opt/data`:

* stage2-hook.sh ran with HERMES_UID unset → no UID remap, hermes
  user stayed at UID 10000 instead of the host user's UID.
* skills_sync.py (invoked from stage2-hook) ran with HERMES_HOME
  unset → get_hermes_home() fell back to Path.home()/.hermes,
  populating a shadow $HERMES_HOME/.hermes/skills tree on the
  mounted volume (visible on the host as ~/.hermes/.hermes/skills).
* The main `hermes gateway run` process inherited HOME=/root from
  the /init context (s6-setuidgid doesn't update HOME), so
  libraries resolving XDG_STATE_HOME via $HOME tried to write to
  /root/.local/state/hermes/gateway-locks/ and failed with EACCES,
  preventing the Discord adapter from acquiring its bot-token lock.

Three surgical changes restore correct env flow:

1. The auto-generated /etc/cont-init.d/01-hermes-setup wrapper now
   uses `#!/command/with-contenv sh`, matching the pattern already
   used by docker/cont-init.d/02-reconcile-profiles. The container
   env (Dockerfile ENV + compose `environment:`) now reaches
   stage2-hook.sh and the skills_sync.py subprocess it spawns.

2. docker/main-wrapper.sh also switches to `#!/command/with-contenv
   sh`. The container CMD (`gateway run`, `chat`, `setup`, …) now
   sees HERMES_HOME and the other container-level env vars.

3. docker/main-wrapper.sh exports HOME=/opt/data before
   `s6-setuidgid hermes`. with-contenv populates HOME from the
   /init context (/root); s6-setuidgid drops privileges but does
   not update HOME. The hermes user's home per /etc/passwd is
   /opt/data, so the explicit override matches passwd.

No behavior change for the non-buggy paths: the s6-supervised
services already used with-contenv, and HOME=/opt/data only affects
processes that resolved $HOME-based paths to /root (silently
broken).
…earch#30870)

* feat(mcp): Nous-approved MCP catalog with interactive picker

Adds an optional-mcps/ directory mirroring optional-skills/: curated,
Nous-approved MCP servers shipped with the repo but disabled by default.
Presence in optional-mcps/ = approval. No community tier, no trust signals.
Entries are added by merging a PR.

New surface:
  hermes mcp                       Interactive catalog picker (default)
  hermes mcp catalog               Plain-text list, scriptable
  hermes mcp install <name>        Install a catalog entry

Picker behavior:
  not installed   -> install (clone/bootstrap if needed, prompt for creds)
  installed/off   -> enable
  installed/on    -> menu (disable / uninstall / reinstall)

Manifest schema (manifest_version: 1) supports:
- transport: stdio (command/args, ${INSTALL_DIR} substitution) or http (url)
- install: optional git clone + bootstrap commands (for repos that need
  local venv setup, like the n8n bridge); omit for npx/uvx servers
- auth: api_key (prompts -> ~/.hermes/.env), oauth (provider-mediated
  or native MCP), or none

Catalog entries are never auto-updated. Users re-run `hermes mcp install`
to refresh. Credentials always go to ~/.hermes/.env (the .env-is-for-secrets
rule), never to per-server env blocks.

Ships n8n as the reference manifest (https://github.com/CyberSamuraiX/hermes-n8n-mcp).

Tests: 19 catalog tests + E2E install/uninstall round-trip via the shipped
manifest.

* feat(mcp): tool-selection checklist + Linear catalog entry

Adds install-time tool selection so users only enable the MCP tools they
actually want, and ships Linear as a second reference catalog entry to
demonstrate the http+oauth path alongside n8n's stdio+api_key+git-bootstrap.

Tool selection flow:
  install (clone/auth/credentials) ->
  probe server for available tools ->
  curses checklist with pre-checked rows ->
  write mcp_servers.<name>.tools.include

Pre-check priority:
  1. user's prior tools.include  (reinstall preserves selection)
  2. manifest's tools.default_enabled  (curated subset)
  3. all probed tools  (default)

Probe-failure fallback (server unreachable, OAuth not yet complete,
backing service offline):
  - manifest declared default_enabled -> applied directly
  - no default declared -> no filter written (all-on when reachable)
  - both cases point user at hermes mcp configure <name>

Manifest schema additions:
  tools:
    default_enabled: [list, of, tool, names]   # optional

Updates:
  - optional-mcps/linear/manifest.yaml -- new reference entry (http+oauth)
  - optional-mcps/n8n/manifest.yaml -- tools.default_enabled set to the
    8 read-mostly tools; mutating tools (activate/deactivate, container_logs)
    pruned by default
  - docs: new 'Tool selection at install time' section in features/mcp.md

Tests: 7 new tests in TestToolSelection covering probe-success / probe-fail
matrix, manifest-default filtering, reinstall-preserves-selection, and
invalid-default-enabled rejection. 26 catalog tests + 32 existing
mcp_config tests passing.

* feat(mcp): polish — picker unification, include-mode convergence, hardening

Addresses review findings on PR NousResearch#30870. Lands all improvements that
belong in this PR before merge; defers separate cleanup (consolidating
two probe implementations, change-detector tests) to follow-ups.

Picker UX (mcp_picker.py)
- Unifies catalog + custom (user-added) MCPs in one view with distinct
  status badges (available / enabled / installed (disabled) /
  custom — enabled / custom — disabled)
- Adds 'Configure tools (probe server + re-pick)' action to both the
  catalog-installed and custom-row submenus — the existing
  hermes mcp configure flow was previously unreachable from the picker
- Loops until ESC/q so the user can manage several entries in one
  session instead of having to re-launch
- Uninstall message now mentions .env credentials are preserved with a
  pointer to clean them up manually if no longer needed
- Surfaces a 'requires a newer Hermes' warning per future-manifest
  entry instead of silently hiding it

Catalog (mcp_catalog.py)
- catalog_diagnostics() exposes which manifests were skipped and why
  (future_manifest vs invalid) so UIs can give actionable feedback
- _do_git_install detects SHA-shaped refs (regex /[0-9a-f]{7,40}/)
  and skips the doomed 'git clone --branch <sha>' attempt — clone --branch
  only accepts branches/tags, so SHAs always failed noisily before
  falling back to the full-clone path
- Probe-success all-tools-enabled message now mentions that new tools
  the server adds later will be auto-enabled (no-filter mode)

Convergence (tools_config.py)
- _configure_mcp_tools_interactive now writes tools.include (whitelist)
  instead of tools.exclude (blacklist), matching the catalog flow and
  hermes mcp configure. The on-disk config shape no longer depends on
  which UI the user touched last
- Two existing tests updated to assert the new include-mode contract

Discoverability
- Setup wizard final step now prints 'Browse curated MCPs: hermes mcp'
- Three tip-corpus entries pointing at the new catalog
- Docs updated with: trust model (manifests run code locally, gated by
  PR review, but read before installing), runtime ${ENV_VAR} substitution
  semantics, and the manifest_version forward-compat behavior

Tests
- 7 new tests covering future-manifest diagnostics, custom MCP picker
  rows, SHA-ref git-install path, branch-ref git-install path, and the
  tools_config include-mode write contract
- 80 MCP-related tests passing across test_mcp_catalog.py,
  test_mcp_config.py, test_mcp_tools_config.py

* fix(mcp): drop setup-wizard catalog hint to satisfy supply-chain scanner

The wizard line 'Browse curated MCPs: hermes mcp' triggered the
CI supply-chain scanner because it pattern-matches on edits to any
file named hermes_cli/setup.py — that filename matches the Python
'install-hook file' heuristic even though this setup.py is the
user-facing 'hermes setup' wizard, not a packaging install hook.

The catalog is already surfaced via three tip-corpus entries in
hermes_cli/tips.py (which the scanner doesn't flag), so dropping the
wizard mention loses no discoverability. Worth revisiting after a
scanner allowlist for this specific file lands.
…ts (NousResearch#32809)

Updates curated picker lists for both the OpenRouter fallback snapshot
(`OPENROUTER_MODELS`) and the Nous Portal list (`_PROVIDER_MODELS['nous']`).
Regenerates website/static/api/model-catalog.json via
`scripts/build_model_catalog.py` to keep the docs-hosted manifest in
sync (drift guard in `test_in_repo_lists_match_manifest`).

tests/hermes_cli/test_models.py fixtures updated — they pinned the
old model id as their live-fetch sample.
Grok models (and other LLMs) sometimes omit the schedule parameter
when calling the cronjob tool with action=create because the schema
only listed 'action' in required[] and the schedule description did
not explicitly state it was mandatory (issue NousResearch#32427).

Fix: update schema descriptions to clearly state schedule is REQUIRED
for action=create, making this explicit for models that rely on
description text for parameter compliance.

Fixes NousResearch#32427
When the gateway processes /reload-mcp, it reconnects MCP servers and
updates the global _servers registry, but cached AIAgent instances in
_agent_cache keep the tools list they were built with. The user had to
also run /new (discarding conversation history) before the agent could
see the new tools — even though /reload-mcp had succeeded.

This patch refreshes each cached agent's .tools and .valid_tool_names
in _execute_mcp_reload after discovery returns, so existing sessions
pick up new MCP tools on their next turn. The slash-confirm gate in
_handle_reload_mcp_command already obtains user consent for the
implied prompt-cache invalidation before this code runs.

Mirrors the equivalent behaviour the CLI already does in cli.py
_reload_mcp. Per-agent enabled_toolsets and disabled_toolsets are
preserved so an agent that was scoped to a subset of toolsets does
not silently gain disabled tools after the reload.

Original diagnosis + initial implementation in NousResearch#23812 from @fujinice.
The auto-reload watcher half of that PR is intentionally dropped —
users want /reload-mcp to remain explicit.

Co-authored-by: fujinice <45688690+fujinice@users.noreply.github.com>
… add'

'hermes login' was removed (the command now just prints a deprecation
message and exits). The bundled hermes-agent SKILL.md, in-code error
messages, the tip rotation, the proxy adapters, and the docs site
still pointed agents and users at the dead command — so models loading
the skill kept running 'hermes login --provider openai-codex' and
getting a dead-end print.

Replacements use the canonical 'hermes auth add <provider>' surface
(or bare 'hermes auth' for the interactive manager).

Files:
- skills/autonomous-ai-agents/hermes-agent/SKILL.md (+ regenerated docs page)
- hermes_cli/tips.py (tip rotation)
- agent/google_oauth.py (gemini-cli error message)
- agent/conversation_loop.py (nous re-auth troubleshooting line)
- agent/credential_sources.py (docstring)
- hermes_cli/proxy/cli.py + hermes_cli/proxy/adapters/nous_portal.py (proxy auth hints)
- tests/hermes_cli/test_proxy.py (updated assertions)
- website/docs/reference/faq.md, website/docs/user-guide/features/subscription-proxy.md
- zh-Hans i18n mirrors for the above

'hermes logout' is still a live command and is left untouched.
The 'hermes login' stub in hermes_cli/auth.py:login_command() and
the cli-commands.md 'Deprecated' rows are intentionally kept as
the discoverable deprecation surface.
Added AUTHOR_MAP entry for the cherry-picked fix in the preceding
commit so the release contributor audit can resolve Carlton's noreply
email.
…-local-runtime-files

Ignore local Hermes runtime files
…ousResearch#33005)

Pre-stages the AUTHOR_MAP entry so the contributor-check workflow
passes when Will Falcon's image-gen SSE fix lands.
…ocker-desktop

feat(docker): add Windows Docker Desktop compatible compose file
…opagation

fix(docker): propagate env through s6 to cont-init and main CMD
…-audio-bridge-32009

docs: add Docker audio bridge notes
…ocker-home-29108

docs: clarify xurl auth HOME in Docker
qwen3.7-max on OpenCode Go rejects the OpenAI-compatible (oa-compat)
format with HTTP 401 but works correctly via the Anthropic Messages
endpoint (/v1/messages with x-api-key auth).  Route it the same way
MiniMax models are routed: anthropic_messages api_mode.

Changes:
- hermes_cli/models.py: add qwen3.7-max routing + curated list
- hermes_cli/setup.py: add to setup wizard model list
- hermes_cli/auth.py: update provider comment
- tests: add assertions for qwen3.7-max api_mode routing
Add a first-class active-session orchestrator for the Ink TUI:

- list, activate, close, and launch live process-local TUI sessions
- hydrate committed and in-flight output when switching sessions
- dispatch a new prompt session from the +new row with session-scoped model picks
- expose a clickable live-session count in the status chrome
- preserve stable row order while initially focusing the current session
- support mouse hit-testing for floating orchestrator overlays
- add backend and frontend regression coverage for the lifecycle and UI helpers
…mode-docker-respect-pulse-pipewire

fix(voice): honor PULSE_SERVER/PIPEWIRE_REMOTE inside Docker (NousResearch#21203)
…#27507)

build-essential is a Debian metapackage (libc6-dev + gcc + g++ + make + dpkg-dev).
The Dockerfile already installs gcc + python3-dev + libffi-dev explicitly,
which covers the C-ext compile cases lazy_deps may hit at first boot.
g++/make/dpkg-dev aren't reached by the resolved [all]+[messaging] tree
on current main — verified via uv sync --dry-run on cp313-linux.

Co-authored-by: Monty Taylor <mordred@inaugust.com>
teknium1 and others added 13 commits May 27, 2026 18:26
Two pre-existing test failures on main, both pointing at code that
was hardened recently — not behaviour bugs, test expectations that
fell out of date.

1. tests/tools/test_kanban_tools.py::test_worker_complete_rejects_stale_run_id
   c002668 ("fix(kanban): add grace period to detect_crashed_workers")
   gates each running task behind a launch-window grace period so
   freshly-spawned workers whose PID isn't yet visible on /proc don't
   get reclaimed. The test creates a worker_env fixture moments before
   asserting reclamation, so the default 30s grace skips the liveness
   check and detect_crashed_workers returns []. Fix: set
   HERMES_KANBAN_CRASH_GRACE_SECONDS=0 in the test so we get the
   immediate-reclaim semantics the assertion expects.

2. tests/tools/test_windows_native_support.py::
     TestKanbanWaitpidWindowsGuard::test_source_gates_waitpid_loop
   ffdc937 ("fix(kanban): hoist zombie reaper out of dispatch_once")
   reshaped reap_worker_zombies to use an early-return Windows guard
   (\`if os.name == "nt": return []\`) instead of an inverted gate
   (\`if os.name != "nt":\`). Both correctly keep the waitpid loop off
   Windows — the early-return form is stronger because the rest of the
   function never runs. Fix: accept either gate pattern in the source
   scan.

Both failures reproduce verbatim on \`origin/main\` in a clean env;
neither relates to in-flight work on NousResearch#33564 (the FD-leak fix). Filing
this as a separate fix-it PR per green-CI-policy so the kanban CI
shard stays green for downstream PRs.
…6 image

Pre-s6, `docker run nousresearch/hermes-agent gateway run` was the
standard invocation: gateway ran as the container's main process,
tini reaped zombies, container exit code matched gateway exit code,
no supervision. With s6-overlay as PID 1, the same invocation now
auto-upgrades to supervised semantics — auto-restart on crash,
dashboard supervised alongside (when HERMES_DASHBOARD=1 is set),
multiple profile gateways under the same /init.

Users get the new behavior with zero changes to their docker run
command. A loud one-line breadcrumb on stderr explains the upgrade
and points at the opt-out for users who genuinely want pre-s6
foreground semantics.

How it works:

  1. `_gateway_command_inner` (the `gateway run` handler) checks if
     we're inside a container with s6 as PID 1.
  2. If yes, dispatches `start` to the s6 service manager (registers
     and starts gateway-default), then `exec sleep infinity` to keep
     the CMD process alive without binding container lifetime to
     gateway PID lifetime. The supervised gateway can flap freely;
     `docker stop` still tears everything down via /init stage 3.
  3. If no, falls through to the existing foreground code path
     unchanged. Host runs of `hermes gateway run` are unaffected.

Three gates make the redirect inert outside the intended scope:

  * `detect_service_manager() != "s6"` — host/non-s6-container runs.
  * `HERMES_S6_SUPERVISED_CHILD=1` env var (recursion guard) —
    exported by `S6ServiceManager._render_run_script` for the
    s6-supervised invocation itself. Without this guard, the
    supervised `gateway run --replace` would re-enter the redirect
    and recurse (run → start → run → start → ...) infinitely.
  * `--no-supervise` CLI flag OR `HERMES_GATEWAY_NO_SUPERVISE=1` env
    var — explicit user opt-out for CI smoke tests, debugging the
    foreground startup path, or any case wanting "CMD exit =
    container exit" semantics. Strict truthiness (1/true/yes,
    case-insensitive); typos like `=0` do NOT silently opt out.

Tests:

  * Unit tests in tests/hermes_cli/test_gateway_s6_dispatch.py
    cover all five paths (host no-op, supervised fire, sentinel
    recursion guard, CLI flag, env var truthy + falsy). The two
    load-bearing gates (sentinel + opt-out) were mutation-tested
    by removing each gate in isolation and confirming the dedicated
    test fails with the expected error.
  * Docker harness tests in tests/docker/test_gateway_run_supervised.py
    cover the round trips end-to-end against a built image: redirect
    fires (sleep-infinity heartbeat + supervised gateway-default
    slot + breadcrumb), --no-supervise opt-out (foreground gateway,
    no want-up on the slot), HERMES_GATEWAY_NO_SUPERVISE env var
    works identically, recursion is impossible (≤1 supervised
    python gateway-run + exactly 1 sleep-infinity parented to the
    CMD wrapper), and HERMES_DASHBOARD=1 produces both supervised
    gateway and supervised dashboard.

Docs:

  * Added a `:::tip Gateway runs supervised` admonition near the
    main docker.md example explaining the upgrade and pointing at
    the opt-out. Pre-s6 (tini-based) images still run gateway run
    as the foreground main process, so the note is scoped to the
    s6 image only.

Trade-off documented in the helper docstring: container exit code
under the redirect is sleep's exit code (always 0 on SIGTERM), not
the gateway's. That was an explicit design call — the supervised
gateway is allowed to flap without taking the container with it,
which is what "supervision" means. CI users who want exit-code
forwarding can pass --no-supervise.
…NousResearch#31213)

* fix(tui): suppress mouse-residue leaks during Python launcher startup

`hermes --tui …` spends ~100–300ms inside the Python launcher (lazy
imports, arg parsing, session resolution) before exec'ing the Node TUI
binary. During that window stdin is still in cooked + echo mode. If a
prior session left DEC mouse tracking asserted (or the user spammed
mouse movement while the previous session was opening), the terminal
keeps emitting `\\x1b[<…M` SGR motion reports that get echoed straight
back into the user's shell scrollback as literal `^[[<…M` text and
sit there above the TUI banner until the next clear.

The Node side already calls `resetTerminalModes()` in `entry.tsx`, but
by then the race is already lost — the bytes echoed during the Python
warmup window were committed to the scrollback before Node started.

Fix: write the mouse-tracking disable sequence at the very top of
`hermes_cli.main`, before every heavy import. The terminal stops
emitting motion events as soon as the bytes hit the wire (one TTY
round-trip), shrinking the race window from hundreds of milliseconds
to a few. `HERMES_TUI_NO_EARLY_DISABLE=1` opts out for diagnostics.

* test(tui): drop dead _reload_main, hoist import out of patch context

Addresses Copilot review on PR NousResearch#31213.

The tests used to import `hermes_cli.main` inside the `patch("os.write")`
context, which Copilot pointed out is order-dependent: if the module
is already loaded (e.g. imported by a prior test in the same process),
the import is a no-op and the patch only sees the explicit
`_suppress_mouse_residue_early()` call. Either way the assertion can
flake when run alongside other tests.

Move the import to module scope — every subprocess gets a fresh
`hermes_cli.main`, whose module-level invocation is a no-op under
pytest argv. Tests then exercise `_suppress_mouse_residue_early()`
directly inside their own patch context. Also drop the unused
`_reload_main` helper.

* fix(tui): skip early mouse-disable when stdout is not a TTY

Addresses Copilot review on PR NousResearch#31213.

`hermes --tui … >log` or CI capture pipes fd 1 away from the terminal.
The disable bytes can't reach the terminal in that case but would
still get written into the log file as raw CSI sequences. Guard with
`os.isatty(1)` inside the existing `try/except OSError` block so the
'never break startup' contract holds.

* docs(tui): rephrase 'raw cooked mode' as 'cooked + echo mode'

Copilot review nit on PR NousResearch#31213 — the original wording was self-
contradictory. Pre-TUI stdin state is cooked + echo (kernel TTY
discipline still owns the line buffer and echoes input back). The
TUI switches it to raw mode later when Ink mounts.
Follow-up to NousResearch#33583 (the gateway-run-supervised redirect).

Before this fix, the supervised gateway's stdout (most visibly the
"Hermes Gateway Starting…" rich-console banner) was swallowed by
`s6-log` into the rotated file at
`${HERMES_HOME}/logs/gateways/<profile>/current` and never reached
`docker logs`. Operational signal lived in two places:

  * **docker logs** — saw stderr (Python `logging` defaults to
    stderr), so warnings/errors were visible.
  * **the rotated file** — saw stdout (rich banners, `print()`
    output, third-party libs that wrote to fd 1).

This was surprising for users coming from the pre-s6 image, where
`docker run … gateway run` produced a single unified stream in
`docker logs`. They'd see partial output, conclude something was
broken, and dig around for the missing pieces.

Fix: add the `1` s6-log action directive before the file destination
so each line is forwarded to s6-log's stdout — which propagates up
the s6-supervise pipeline to /init's stdout = container stdout =
`docker logs`. The file destination is preserved as a second
destination, so the rotated log (with ISO 8601 timestamps) still
exists for `hermes logs` and for survival across container restarts.

Trade-off considered: timestamps. Putting `T` between `1` and the
file destination (not before `1`) means:

  * docker logs sees raw lines — Python's logging formatter has its
    own timestamps, and `docker logs --timestamps` adds another
    layer when desired. No double-stamping in the common reading
    path.
  * The persisted file gets s6-log's ISO 8601 timestamp so even
    output that lacked a Python-logger timestamp (rich banners,
    third-party raw prints) is correlatable in `current`.

Verification:

  * New unit-test assertion in `test_service_manager.py` locks the
    `s6-log 1` directive into the rendered run-script. Mutation-
    tested by reverting to the pre-fix script (no `1`); the assert
    catches it cleanly.
  * New docker-harness test `test_supervised_gateway_stdout_reaches_docker_logs`
    builds the image, runs `docker run … gateway run`, and asserts
    the unique `⚕` banner glyph reaches `docker logs`. Also verifies
    the rotated file still contains the banner (no regression on
    the existing file destination). Mutation-tested end-to-end: built
    a deliberately-broken image without the `1` directive and the
    test failed exactly as designed, citing the banner present in
    `current` but absent from `docker logs`.
  * `website/docs/user-guide/docker.md` gains a new `:::note Where
    gateway logs go` admonition documenting both destinations and
    the audit-log file at `${HERMES_HOME}/logs/container-boot.log`.

Existing functionality preserved: every other docker-harness test
still passes against the new image. Unit-test sweep across
`tests/hermes_cli/` (5561 tests) is green.
When operators ran `docker exec <c> hermes login` (or anything else
that wrote under $HERMES_HOME) they defaulted to root, leaving
/opt/data/auth.json root:root mode 0600. The supervised gateway
(UID 10000) then couldn't read its own credentials and returned
"Provider authentication failed: Hermes is not logged into Nous
Portal" on every Telegram/Discord/etc. message — even though
`docker exec <c> hermes chat -q ping` (also root) succeeded because
root could read its own root-owned file. _load_auth_store swallowed
PermissionError as a parse failure and copied the file aside as
auth.json.corrupt, making the diagnostic more misleading.

Fix: install a privilege-drop shim at /opt/hermes/bin/hermes,
prepended ahead of the venv on PATH. When invoked as root the shim
exec's the real venv binary via `s6-setuidgid hermes` — so any file
the docker-exec session writes is uid-aligned with the supervised
processes. Non-root callers (the supervised processes themselves,
`docker exec --user hermes`, kanban subagents, anything inside the
container that's not coming through docker-exec) hit a single exec
to the absolute venv path with no privilege change.

Recursion is impossible: the shim exec's the venv binary by
absolute path (/opt/hermes/.venv/bin/hermes), so the second hop
cannot re-enter the shim regardless of PATH state. No sentinel env
var needed (unlike NousResearch#33583's gateway-run redirect which DOES need
HERMES_S6_SUPERVISED_CHILD because there's no absolute-path
equivalent for the s6 dispatch).

Opt-out: `docker exec -e HERMES_DOCKER_EXEC_AS_ROOT=1 …` for
diagnostic sessions where the operator deliberately wants root.
Strict truthiness (1/true/yes case-insensitive); typos like `=0`
do not silently opt out, mirroring HERMES_GATEWAY_NO_SUPERVISE in
NousResearch#33583.

If `s6-setuidgid` is missing (someone stripped s6-overlay in a
downstream fork), the shim exits 126 with a remediation message
pointing at `--user hermes` and the opt-out — never silently runs
as root.

Test plan:
- tests/docker/test_docker_exec_privilege_drop.py — 11 tests
  - shim drops root to hermes uid (file ownership check)
  - shim short-circuits for non-root docker exec
  - HERMES_DOCKER_EXEC_AS_ROOT=1 keeps root
  - strict-truthiness parametrization (5 falsy values reject)
  - main CMD path unaffected (recursion guard)
  - E2E: every file written by docker-exec is readable by uid 10000
- Full tests/docker/ harness: 32/32 pass against fresh image build
- shellcheck --severity=error: clean
- hadolint: clean
- Manual: reproduced the original symptom (root-owned auth.json)
  by bypassing the shim; confirmed default docker-exec produces
  hermes-owned files; confirmed opt-out env keeps root semantics.

Known follow-up: this prevents NEW instances of the bug. Volumes
that already have root:root /opt/data/auth.json from a pre-shim
image need a one-time `chown hermes:hermes` before rebooting onto
the new image. A stage2-hook chown sweep can self-heal that, but
is deferred per scope decision.
…ontainers

Debian 13 ships only `python3` — there's no `/usr/bin/python` symlink. When
the agent emits bash commands using bare `python` (which models do frequently
from their training prior), every such call fails with:

    /usr/bin/bash: python: command not found
    Tool terminal returned error … exit_code 127

The agent then retries with different approaches, sessions take longer, and
agent.log fills with WARNING noise.

`python-is-python3` is the standard Debian package that drops a
`/usr/bin/python → python3` symlink. ~30 KB, zero behavior change for
anything calling `python3` directly; transparent fix for everything else.

Fixes NousResearch#33178.
)

* feat(web): add collapsible sidebar for the dashboard

The desktop sidebar can now be collapsed to an icon-only rail via a
toggle button in the sidebar header.  State is persisted in
localStorage so it survives page reloads.

When collapsed (lg+ only):
- Sidebar shrinks from w-64 to w-14 with a smooth width transition
- Nav items show only their icon with a native title tooltip
- Brand text, plugin headings, system actions, theme/language
  switchers, auth widget, and footer are hidden
- Mobile drawer behavior is unchanged (always full-width)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): align sidebar tooltips to sidebar edge consistently

Tooltip left position now uses the sidebar's right edge instead of the
anchor element's right edge, so narrow anchors (theme/language switchers)
align with full-width anchors (nav links, system actions).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): add tooltip animations, restore theme label, rename Sessions tab

- Sidebar tooltips now animate in with a subtle 120ms ease-out slide;
  subsequent tooltips within the same hover sequence appear instantly
  (no delay/animation) following Emil Kowalski's tooltip pattern
- Restore theme name label when sidebar is expanded
- Rename Sessions segment tab to "History" across all 16 locales

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): smooth sidebar collapse animation

- Remove icon centering on collapse; icons stay left-aligned at px-5
  so they don't jump during the width transition
- Text labels fade out with opacity transition instead of instant
  display:none, clipped naturally by overflow-hidden
- Slow collapse duration from 450ms to 600ms for a more relaxed feel
- Gateway dot always rendered with opacity toggle so it doesn't
  slide in from the right on collapse
- Pin gateway dot at fixed left offset (pl-[1.625rem]) to align
  with nav icons
- Align header toggle button with justify-center when collapsed
- Bottom switchers use items-start when collapsed to prevent reflow

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
…Research#28577)

* fix(web): allow mobile dashboard scrolling

* fix(web): combine mobile root scroll rules

---------

Co-authored-by: Wesley Simplicio <wesley.simplicio.ext@siemens-energy.com>
…search#33506)

fal announced Krea 2 day-0 as an official API partner on 2026-05-27.
Add both variants to the FAL_MODELS catalog so they appear in the
'hermes tools' model picker alongside flux-2, gpt-image, nano-banana,
etc. Users who already bill through FAL or Nous Portal subscription
can now use Krea without registering directly with Krea.

Model IDs (as listed in fal's launch announcement):
  fal-ai/krea/v2/medium/text-to-image  — $0.030 / image
  fal-ai/krea/v2/large/text-to-image   — $0.060 / image

Both share the same parameter schema:
  - aspect_ratio (1:1, 4:3, 3:2, 16:9, 2.35:1, 4:5, 2:3, 9:16)
    mapped from our 3 abstract ratios via size_style='aspect_ratio'
  - creativity (raw|low|medium|high; default medium)
  - seed (reproducibility)
  - image_style_references (up to 10 per Krea's API spec)

No num_inference_steps / guidance_scale / num_images — Krea 2 does
not expose those, and the supports-set filter strips them defensively
if the agent ever passes them.

This is the FAL-routed variant. The separate native-Krea-API plugin
shipped in PR NousResearch#33236 (plugins/image_gen/krea/) remains available for
users who want to bill directly through Krea's API with their own
key. Both routes converge on the same underlying model.

Nous Portal managed-FAL gateway: this commit makes the model IDs
known to the catalog and the picker. The Portal team will need to
allowlist these two endpoint slugs on the fal-queue origin server-side
for them to flow through the managed billing path.
…rocesses

`sqlite3.Connection.__exit__` commits/rollbacks but does NOT close the
underlying FD. `with kb.connect() as conn:` in long-lived processes
(gateway `run_slash`, dashboard `decompose_task_endpoint`) therefore
leaks one FD to `kanban.db` per call. After enough operations the
gateway dies with `[Errno 24] Too many open files` (~4 days uptime
in the production report — NousResearch#33159).

Fix: add a `connect_closing()` context manager in `hermes_cli/kanban_db`
that wraps `connect()` with a real `try/finally: conn.close()`. Switch
the 42 leak-prone call sites in `hermes_cli/kanban.py` (35),
`hermes_cli/kanban_decompose.py` (4), and `hermes_cli/kanban_specify.py`
(3) over to it.

`kanban.py` matters because `run_slash` (called from the gateway for
every `/kanban` slash command) parses argparse and dispatches to those
`_cmd_*` functions in-process — each one was leaking one FD per
invocation.

Tests inside `tests/` are untouched: short-lived processes where OS
cleanup masks the leak. Regression tests added in
`test_kanban_db.py` cover both happy-path and exception-path closure,
plus an explicit assertion that bare `with kb.connect()` still does
NOT close (documenting the upstream sqlite3 behaviour we're working
around).

Closes NousResearch#33159.
`hermes dump` and the startup banner both call `git rev-parse HEAD` to
report the running commit, but `.dockerignore` line 2 excludes `.git` —
so inside the published image `hermes dump` shows
`version: ... [(unknown)]` and the banner drops its `· upstream <sha>`
suffix entirely.  That makes support triage from container bug reports
impossible: we can't tell which commit the user is actually running.

Fix: thread the build-time SHA through as a Docker build-arg, write it
to `/opt/hermes/.hermes_build_sha` in the image, and have a new
`hermes_cli/build_info.get_build_sha()` read it as a fallback after the
existing live-git lookup fails.  Output format is unchanged in both
callsites — same 8-char short SHA whether resolved live or baked.

Wiring:
  - Dockerfile: `ARG HERMES_GIT_SHA=` + write-file step after the source
    copy.  Empty/missing arg → no file written → callers fall through to
    live git (so local `docker build` without --build-arg is unchanged).
  - docker-publish.yml: passes `HERMES_GIT_SHA=${{ github.sha }}` on all
    four build-push-action steps (amd64/arm64, smoke-test + final push).
  - dump.py:_get_git_commit() / banner.py:get_git_banner_state(): try
    live git first, fall back to baked SHA, then to legacy `(unknown)`
    / None.  Banner returns `upstream == local, ahead=0` because a built
    image is by definition pinned to one commit.

Coverage:
  - Unit tests cover build_info (file present/absent/empty/error,
    truncation, whitespace), dump (live-git wins, both fallbacks,
    identical output-format regression guard), and banner (no-repo +
    baked, no-repo + no-sha, shallow-clone fallback).
  - tests/docker/test_dump_build_sha.py is an integration regression
    guard that runs against the real image, reads
    `/opt/hermes/.hermes_build_sha`, and asserts `hermes dump` surfaces
    its content (or stays at `(unknown)` if no file).
  - Verified end-to-end: `docker build --build-arg HERMES_GIT_SHA=abc...`
    → `docker run ... dump` reports `[abc12345]`; without the build-arg
    it reports `[(unknown)]` as before.
Snapshot review_agent._session_messages before teardown so close() can
clean per-session state without dropping the user-visible
self-improvement summary. Adds two regressions:

- bg-review summarizer receives captured review-agent tool messages
  after review_agent.close() runs
- context-compressor protected-head handoff rehydration populates
  _previous_summary and keeps the old handoff out of newly summarized
  turns

Salvaged from PR NousResearch#26039 onto current main after agent/background_review.py
extraction. Original commit 63eaf60; bg-review test updated to patch
the module-level summarize_background_review_actions in
agent.background_review instead of the now-forwarder
AIAgent._summarize_background_review_actions.
@github-actions

github-actions Bot commented May 28, 2026

Copy link
Copy Markdown

🔎 Lint report: feat/telegram-resume-ui-v2 vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 9526 on HEAD, 9373 on base (🆕 +153)

🆕 New issues (95):

Rule Count
unresolved-import 53
invalid-argument-type 20
unresolved-attribute 7
invalid-assignment 7
unsupported-operator 5
not-subscriptable 1
no-matching-overload 1
invalid-return-type 1
First entries
tests/cron/test_cronjob_schema.py:27: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["REQUIRED"]` and `Unknown | str | dict[str, str] | ... omitted 3 union elements`
tests/cron/test_cronjob_schema.py:26: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> LiteralString, (key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> str]` cannot be called with key of type `Literal["schedule"]` on object of type `str`
tests/plugins/test_security_guidance_plugin.py:74: [unresolved-attribute] unresolved-attribute: Attribute `exec_module` is not defined on `None` in union `Loader | None`
tests/hermes_cli/test_dashboard_auth_401_reauth.py:33: [unresolved-import] unresolved-import: Cannot resolve imported module `fastapi.testclient`
tests/run_agent/test_switch_model_rollback.py:18: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/hermes_cli/test_dashboard_auth_prefix.py:478: [unresolved-import] unresolved-import: Cannot resolve imported module `fastapi.responses`
tests/hermes_cli/test_dashboard_auth_401_reauth.py:32: [unresolved-import] unresolved-import: Cannot resolve imported module `fastapi.responses`
tests/hermes_cli/test_dashboard_auth_plugin_hook.py:8: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
gateway/run.py:13053: [invalid-argument-type] invalid-argument-type: Argument to bound method `SessionDB.list_sessions_rich` is incorrect: Expected `str`, found `None`
tests/hermes_cli/test_dashboard_auth_gate.py:14: [unresolved-import] unresolved-import: Cannot resolve imported module `fastapi.testclient`
tests/hermes_cli/test_dashboard_auth_ws_auth.py:19: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/cron/test_cronjob_schema.py:26: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> LiteralString, (key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> str]` cannot be called with key of type `Literal["properties"]` on object of type `str`
tests/plugins/dashboard_auth/test_nous_provider.py:29: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/cron/test_cronjob_schema.py:16: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(i: SupportsIndex, /) -> str, (s: slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> list[str]]` cannot be called with key of type `Literal["action"]` on object of type `list[str]`
hermes_cli/mcp_config.py:782: [invalid-argument-type] invalid-argument-type: Argument to bound method `dict.get` is incorrect: Expected `str`, found `(Any & ~Literal["serve"] & ~Literal["picker"] & ~Literal["catalog"] & ~Literal["install"]) | None`
tests/plugins/test_security_guidance_plugin.py:70: [invalid-argument-type] invalid-argument-type: Argument to function `module_from_spec` is incorrect: Expected `ModuleSpec`, found `ModuleSpec | None`
hermes_cli/dashboard_auth/middleware.py:22: [unresolved-import] unresolved-import: Cannot resolve imported module `fastapi`
tests/hermes_cli/test_dashboard_auth_cookies.py:5: [unresolved-import] unresolved-import: Cannot resolve imported module `fastapi`
tests/hermes_cli/test_dashboard_auth_provider_base.py:10: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/plugins/test_security_guidance_plugin.py:26: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
gateway/platforms/api_server.py:1406: [invalid-argument-type] invalid-argument-type: Argument to function `APIServerAdapter._session_response` is incorrect: Expected `dict[str, Any]`, found `(Unknown & ~AlwaysFalsy) | dict[str, Any] | None`
tests/hermes_cli/test_dashboard_auth_status_endpoint.py:16: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/hermes_cli/test_kanban_db.py:3579: [invalid-argument-type] invalid-argument-type: Argument is incorrect: Expected `Connection`, found `FailingConnWrapper`
tests/hermes_cli/test_dashboard_auth_middleware.py:17: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/run_agent/test_memory_provider_init.py:92: [not-subscriptable] not-subscriptable: Cannot subscript object of type `None` with no `__getitem__` method
... and 70 more

✅ Fixed issues (33):

Rule Count
invalid-argument-type 13
unresolved-attribute 9
unsupported-operator 4
unresolved-import 3
invalid-type-form 1
call-top-callable 1
invalid-return-type 1
not-iterable 1
First entries
cli.py:12934: [invalid-argument-type] invalid-argument-type: Argument to function `len` is incorrect: Expected `Sized`, found `(str & ~AlwaysFalsy) | (list[tuple[str, str, str]] & ~AlwaysFalsy) | (int & ~AlwaysFalsy) | (Queue[Unknown] & ~AlwaysFalsy) | list[Unknown]`
tests/tools/test_vercel_sandbox_environment.py:204: [unresolved-attribute] unresolved-attribute: Unresolved attribute `Resources` on type `ModuleType`
agent/codex_runtime.py:179: [invalid-type-form] invalid-type-form: Function `callable` is not valid in a parameter annotation: Did you mean `collections.abc.Callable`?
cli.py:7317: [unresolved-attribute] unresolved-attribute: Attribute `splitlines` is not defined on `list[tuple[str, str, str]] & ~AlwaysFalsy`, `int & ~AlwaysFalsy`, `Queue[Unknown] & ~AlwaysFalsy` in union `(str & ~AlwaysFalsy) | (list[tuple[str, str, str]] & ~AlwaysFalsy) | (int & ~AlwaysFalsy) | (Queue[Unknown] & ~AlwaysFalsy) | Literal[""]`
cli.py:7320: [invalid-argument-type] invalid-argument-type: Argument to constructor `enumerate.__new__` is incorrect: Expected `Iterable[Unknown]`, found `(str & ~AlwaysFalsy) | (list[tuple[str, str, str]] & ~AlwaysFalsy) | (int & ~AlwaysFalsy) | (Queue[Unknown] & ~AlwaysFalsy) | list[Unknown]`
tests/tools/test_vercel_sandbox_environment.py:206: [unresolved-attribute] unresolved-attribute: Unresolved attribute `SandboxStatus` on type `ModuleType`
cli.py:12610: [unsupported-operator] unsupported-operator: Operator `<=` is not supported between objects of type `Literal[0]` and `str | list[tuple[str, str, str]] | int | Queue[Unknown]`
tests/tools/test_vercel_sandbox_environment.py:17: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
cli.py:12610: [unsupported-operator] unsupported-operator: Operator `<` is not supported between objects of type `str | list[tuple[str, str, str]] | int | Queue[Unknown]` and `int`
tests/tools/test_vercel_sandbox_environment.py:209: [unresolved-attribute] unresolved-attribute: Unresolved attribute `sandbox` on type `ModuleType`
cli.py:12611: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(i: SupportsIndex, /) -> tuple[str, str, str], (s: slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> list[tuple[str, str, str]]]` cannot be called with key of type `str` on object of type `list[tuple[str, str, str]]`
cli.py:12881: [unsupported-operator] unsupported-operator: Operator `-` is not supported between objects of type `str | list[tuple[str, str, str]] | int | Queue[Unknown]` and `Literal[1]`
cli.py:12607: [invalid-argument-type] invalid-argument-type: Argument to bound method `HermesCLI._normalize_slash_confirm_choice` is incorrect: Expected `list[tuple[str, str, str]]`, found `(str & ~AlwaysFalsy) | (list[tuple[str, str, str]] & ~AlwaysFalsy) | (int & ~AlwaysFalsy) | (Queue[Unknown] & ~AlwaysFalsy) | list[Unknown]`
cli.py:12611: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(i: SupportsIndex, /) -> Unknown, (s: slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> list[Unknown]]` cannot be called with key of type `list[tuple[str, str, str]]` on object of type `list[Unknown]`
cli.py:12611: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> LiteralString, (key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> str]` cannot be called with key of type `list[tuple[str, str, str]]` on object of type `str`
cli.py:12611: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(i: SupportsIndex, /) -> tuple[str, str, str], (s: slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> list[tuple[str, str, str]]]` cannot be called with key of type `list[tuple[str, str, str]]` on object of type `list[tuple[str, str, str]]`
cli.py:7337: [invalid-argument-type] invalid-argument-type: Argument to function `_append_panel_line` is incorrect: Expected `str`, found `(str & ~AlwaysFalsy) | (list[tuple[str, str, str]] & ~AlwaysFalsy) | (int & ~AlwaysFalsy) | (Queue[Unknown] & ~AlwaysFalsy)`
tests/hermes_cli/test_mcp_tools_config.py:92: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> LiteralString, (key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> str]` cannot be called with key of type `Literal["exclude"]` on object of type `str`
tests/tools/test_vercel_sandbox_environment.py:135: [call-top-callable] call-top-callable: Object of type `Top[(...) -> object]` is not safe to call; its signature is not known
cli.py:7314: [invalid-argument-type] invalid-argument-type: Argument to function `_panel_box_width` is incorrect: Expected `str`, found `(str & ~AlwaysFalsy) | (list[tuple[str, str, str]] & ~AlwaysFalsy) | (int & ~AlwaysFalsy) | (Queue[Unknown] & ~AlwaysFalsy)`
hermes_cli/mcp_config.py:764: [invalid-argument-type] invalid-argument-type: Argument to bound method `dict.get` is incorrect: Expected `str`, found `(Any & ~Literal["serve"]) | None`
tests/tools/test_vercel_sandbox_environment.py:203: [unresolved-attribute] unresolved-attribute: Unresolved attribute `Sandbox` on type `ModuleType`
tools/environments/vercel_sandbox.py:267: [unresolved-attribute] unresolved-attribute: Object of type `None` has no attribute `sync`
tools/environments/vercel_sandbox.py:23: [unresolved-import] unresolved-import: Cannot resolve imported module `httpx`
cli.py:12611: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> LiteralString, (key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> str]` cannot be called with key of type `Queue[Unknown]` on object of type `str`
... and 8 more

Unchanged: 4924 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

benbarclay and others added 10 commits May 28, 2026 15:50
… bogus git error

Inside the published Docker image, `hermes update` was hitting the
".git missing → reinstall via curl" fallback:

    ✗ Not a git repository. Please reinstall:
      curl -fsSL https://raw.githubusercontent.com/.../install.sh | bash

That message is wrong on two counts:
  1. It tells the user to run the host-side installer, which would
     install a *new* Hermes on the host — not update the running
     container.
  2. It doesn't mention `docker pull` at all, leaving Docker users
     to figure out the right action from scratch.

`hermes update --check` was worse: it bailed with "Not a git
repository — cannot check for updates." and nothing else.

Fix: detect the Docker install method (already stamped by
`docker/stage2-hook.sh` and surfaced by `detect_install_method()`)
in both update entry points and print a long-form message that
covers:

  - The right command: `docker pull nousresearch/hermes-agent:latest`
  - Restart guidance (`docker compose up -d --force-recreate` /
    re-run `docker run`)
  - How to verify the new version after restart
  - Tag-pinning caveat (`:latest` doesn't move a pinned tag)
  - Config persistence across upgrades (state under `HERMES_HOME` /
    `/opt/data` is bind-mounted and survives)
  - Fork escape hatch (build your own image with the repo's Dockerfile)

Exit code is 1 (matches `managed_error` semantic for "tried to
update but can't update this way").

Plumbing:
  - hermes_cli/config.py: new `format_docker_update_message()` helper
    sits next to the existing `_NIX_UPDATE_MSG` /
    `format_managed_message()` family so the wording lives in one
    place and both call sites (apply path + check path) consume it.
  - hermes_cli/main.py:
      * `cmd_update()`: bail right after the `is_managed()` gate, before
        any of the apply-path branches.
      * `_cmd_update_check()`: bail at the top of the function, before
        the existing `method == "pip"` branch.
    Neither path touches subprocess.run / git when method == "docker".

Coverage:
  - 7 new tests in `tests/hermes_cli/test_cmd_update_docker.py`:
      * `hermes update` in Docker → message + exit 1, no git calls
      * `hermes update --check` (via cmd_update) → same
      * `--yes` / `--force` don't bypass (intentional)
      * `_cmd_update_check` called directly → bails too
      * git/pip installs still take their normal paths (regression guards)
      * `format_docker_update_message` content-lock test pinning the
        five user-actionable bits the message must contain
  - Existing test_cmd_update.py (21 tests) + test_managed_installs.py
    (5 tests) still pass — no regression on the source-install path.
  - Verified end-to-end in a real container: `docker run ... update`
    and `docker run ... update --check` both render the message and
    exit 1.
The regression-guard test
`test_cmd_update_on_git_install_does_not_print_docker_message` mocked
`is_managed` and `detect_install_method` but not `subprocess.run`, so
once `cmd_update(check=True)` decided this was a git install it shelled
out to a real `git fetch upstream` / `git fetch origin`. On CI runners
the worktree has no `upstream` remote configured and the fetch hung
past the 30s pytest-timeout — test (4) slice failed in NousResearch#33659 CI.

Fix: stub `subprocess.run` with a successful CompletedProcess-shaped
object whose stdout is `"0\n"`, so:
  - no real git command is ever invoked
  - the rev-list parsing later in the flow (`int(stdout.strip())`)
    succeeds rather than `ValueError`-ing through the test's
    SystemExit catch
  - the flow proceeds far enough to confirm the docker banner is
    absent (the actual assertion)

Also broaden the except clause to `(SystemExit, Exception)`: the only
assertion in this test is the negative-banner check on captured stdout;
any further failure in the rest of the update flow is irrelevant to
that contract.

Verified locally: all 7 tests in
`tests/hermes_cli/test_cmd_update_docker.py` pass in 0.39s (previously
the regression-guard test alone consumed 30s+ and got SIGTERM'd).
Two unrelated transient failures on PR NousResearch#33661's initial CI run, both
pre-existing on main and recovered on rerun. Hardening:

1. tests/cron/test_scheduler.py::TestRunJobConfigLogging — added mocks for
   resolve_runtime_provider() and discover_mcp_tools(). The yaml-warning
   tests intend to exercise only the warning-log path, but
   _run_job_impl continues into provider resolution and MCP discovery
   after the warning. Both can spawn subprocesses / hit the network and
   pushed the test over its 30s budget under GHA load.

2. tests/tools/test_browser_supervisor.py — wrapped Chrome teardown
   against the stdlib subprocess._wait() race (bpo-38630). When SIGCHLD
   arrives during proc.wait(), _try_wait(WNOHANG) can return a foreign
   pid and the 'assert pid == self.pid or pid == 0' fires. Fixture now
   catches AssertionError/TimeoutExpired, force-kills, and always reaps
   so no zombie escapes. Same hardening applied to the early-skip branch.
…NousResearch#29800)

* docs(voice): use `uv pip install faster-whisper` in STT install hints

Three runtime messages told users to `pip install faster-whisper`
(reported in NousResearch#29782 for the gateway STT failure message under
Telegram-in-Docker, where the user hit `bash: pip: command not
found`). The Hermes Docker image is built on `ghcr.io/astral-sh/uv`
with a uv-managed venv that doesn't ship `pip` on PATH; users on
modern `uv tool install` / `uv venv` installs see the same problem.

The canonical install command in this repo is `uv pip install`
(see `tools/lazy_deps.py:509` `feature_install_command()`), which
works in Docker (uv image), in `uv tool install` venvs, and in
pip-based venvs that already have uv on PATH.

Changed three locations to match:

- `gateway/run.py` — Telegram/Discord/Slack/WhatsApp/etc. voice
  reply when no STT provider is configured. Suggests
  `uv pip install faster-whisper` and notes that
  `pip install faster-whisper` also works if `pip` is on PATH.
- `tools/voice_mode.py` — `/voice` status line for missing STT.
- `cli.py` — Voice-mode startup error, "Option 1".

No behavior change beyond the user-facing text. No production
code path was touched.

* docs(voice): add pip fallback to cli + voice_mode STT hints

Copilot flagged that cli.py and tools/voice_mode.py recommend
`uv pip install faster-whisper` without a fallback for environments
where uv isn't on PATH. The gateway/run.py message already lists
`pip install faster-whisper` as an alternative; this commit aligns
the two remaining call sites to match.

Addresses inline Copilot review on NousResearch#29800.

---------

Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com>
… salvage

Adds `squiddy@2rook.ai → MoonRay305` to AUTHOR_MAP so contributor_audit.py
passes for the salvaged commits in NousResearch#33482-followup PR.
…parse errors

Telegram's HTML parser chokes on common characters like '.' and '-' in
session titles (e.g. 'MCP Server', 'api-keys'). Switch from ParseMode.HTML
to plain text with no parse mode. Use numbered list with indented previews.

Also re-applies changes from feat/telegram-resume-button-ui which was lost
during a repo rebase.
- Button title truncated to ~40 chars (was 18) to use full mobile width
- disable_web_page_preview=True to suppress link previews in body text
- 13 tests covering interception, button UI, callback handling, auth, and session listing
- Tests verify: /resume with no args triggers button UI, /resume with args falls through,
  keyboard has correct buttons, labels are truncated, previews included, fallback messages,
  unauthorized users denied, callback routing, and get_resume_sessions behavior
@indigokarasu indigokarasu force-pushed the feat/telegram-resume-ui-v2 branch from 77e92a1 to 0d5914b Compare May 28, 2026 06:39
@indigokarasu

Copy link
Copy Markdown
Owner Author

Closing — this PR contains ~40 logically independent feature units (dashboard OAuth system, MCP catalog, Docker hardening, CLI flags, model catalog updates, codex fixes, API server features, security plugin, Vercel removal, and more) interleaved in a single branch. The stated scope (Telegram /resume inline keyboard) is ~5% of the diff. This appears to be a development branch pushed as a PR rather than a focused feature change.

All features need to be extracted into individual PRs. The Telegram /resume UI change specifically should be re-opened against a clean branch with only the relevant files (telegram.py, gateway/run.py, display_config.py).

CI is also blocking on 95 new type checker failures from unresolved imports in the dashboard auth system.

indigokarasu pushed a commit that referenced this pull request Jun 8, 2026
A bare `/resume` printed the recent-sessions list but armed no selection
state, so typing just `3` on the next line was sent to the agent as chat
instead of resuming session #3. `/resume 3` worked, but the natural
list-then-pick flow did not.

Arm a one-shot pending-resume prompt when bare `/resume` shows the list,
and consume the next bare numeric input as the selection (out-of-range is
reported, non-numeric/other commands disarm it). Resolves against the same
_list_recent_sessions(limit=10) list used everywhere else.

Closes NousResearch#34584.
indigokarasu pushed a commit that referenced this pull request Jun 8, 2026
…NousResearch#34192) (NousResearch#34382)

NousResearch#34192 reports Hostinger's 'Hermes WebUI' catalog crashes on startup
with:

  /usr/bin/tini: No such file or directory

The image moved from tini to s6-overlay as PID 1 (/init) earlier in
2026. Orchestration templates that still pin /usr/bin/tini as the
entrypoint \u2014 like the Hostinger Hermes WebUI catalog \u2014 have no
binary to exec and the container crashes immediately.

Hermes has no control over the Hostinger catalog template, but we can
make the image backward-compatible by symlinking /usr/bin/tini -> /init
during the s6-overlay install step. External wrappers that exec
/usr/bin/tini will land on the same s6-overlay reaper they would have
landed on if they'd used the canonical /init entrypoint.

The image's own ENTRYPOINT continues to be /init verbatim \u2014 the shim
is purely for legacy external wrappers, not for the image's own
runtime path. Once affected catalogs are updated, the symlink can be
removed.

Other issues NousResearch#34192 raises that are NOT addressed by this PR:

  * Problem #2 (UID 1024 vs 10000 mismatch): already fixed by NousResearch#33148
    (S6_KEEP_ENV=1) and NousResearch#32412 (with-contenv shebangs). The Hostinger
    template likely needs to update its env-var propagation.

  * Problem #3 (incompatible session formats): RFC for pluggable
    SessionDB is tracked in NousResearch#23717.

  * Problem NousResearch#4 (Telegram polling conflict): an operations problem on
    Hostinger's side, not in this codebase.

This PR is scoped to the one issue that can be fixed inside
Dockerfile: the missing /usr/bin/tini binary.

Tests (3 in test_dockerfile_tini_compat_shim.py):

  - test_tini_compat_symlink_present
    Guard: the symlink line must exist in Dockerfile.
  - test_tini_compat_comment_explains_why
    The NousResearch#34192 anchor comment must be present so future readers know
    why the shim is there (avoid accidental removal).
  - test_entrypoint_still_init_not_tini
    Sanity check: ENTRYPOINT remains /init (s6-overlay). The shim is
    only for external wrappers.

Refs: NousResearch#34192
Partial fix: addresses the immediate tini-binary crash. Catalog-side
fixes still needed by Hostinger for the UID and session-format
problems documented in the issue.

Co-authored-by: Cursor <cursoragent@cursor.com>
indigokarasu pushed a commit that referenced this pull request Jun 8, 2026
…bes + test-leak fix (NousResearch#40909)

* fix(gateway,windows): reliability — supervisor task, JOB breakaway, status --deep

Three coordinated fixes for the Windows gateway reliability story:

1. CREATE_BREAKAWAY_FROM_JOB on every detached spawn

   The 'hermes update' triggered from the Electron Desktop GUI ran inside
   Electron's job object. Without breakaway, the post-update gateway
   watcher spawned by update — already DETACHED_PROCESS — was still
   reaped when Electron's job tore down, so the gateway never came back
   after a GUI-initiated update. Adds CREATE_BREAKAWAY_FROM_JOB (0x01000000)
   to:
     - hermes_cli/_subprocess_compat.py::windows_detach_flags() — used by
       every helper that calls windows_detach_popen_kwargs(), including
       launch_detached_profile_gateway_restart()
     - The watcher subprocess's own respawn snippet in
       hermes_cli/gateway.py (inlined flags so the watcher's child
       respawn also breaks away)

   _spawn_detached() in gateway_windows.py already had the flag; this
   change brings the rest of the codebase to parity.

2. Per-minute supervisor Scheduled Task — Windows equivalent of
   systemd Restart=always

   Introduces hermes_cli/gateway_supervisor.py and registers it as a
   second Scheduled Task ('Hermes_Gateway_Supervisor', SC MINUTE /MO 1,
   LIMITED rights) alongside the existing ONLOGON task. Every minute,
   the supervisor uses the same gateway.status.get_running_pid() probe
   as 'hermes gateway status' and, if no gateway is alive, calls
   gateway_windows._spawn_detached() (which now includes BREAKAWAY) to
   bring one back.

   Covers every crash mode, not just 'machine rebooted': taskkill,
   OOM, GUI update SIGTERM, parent job teardown. Cheap — one pythonw
   startup per minute when down, one PID-existence check per minute
   when up.

   Wired into both the schtasks-success and Startup-folder-fallback
   install paths via _install_supervisor_best_effort(), and removed in
   uninstall(). Best-effort: a failing supervisor install logs a
   warning but doesn't roll back the primary install.

3. 'hermes gateway status --deep' shows per-probe PASS/FAIL

   Replaces the existing terse '--deep' output (which only printed
   paths) with an actual diagnostic table:
     [1] PID file present
     [2] Lock file held by a live process
     [3] get_running_pid() result
     [4] _pid_exists(pid) — OS-level liveness
     [5] gateway_state.json (state + age)
     [6] Last lifecycle event from gateway-exit-diag.log

   When the high-level summary disagrees with reality, the user can
   see exactly which signal is lying.

Test-leak fix
-------------

tests/hermes_cli/test_gateway_wsl.py::TestGatewayCommandWSLMessages
monkey-patched is_linux/is_wsl/supports_systemd_services to simulate
WSL but did NOT stub is_windows(). On a Windows host, the dispatcher
in _gateway_command_inner takes the is_windows() branch BEFORE the
WSL guidance branch, so the test invoked gateway_windows.install()
for real. install() writes to %APPDATA%\...\Startup\Hermes_Gateway.cmd
— the REAL user Startup folder, never sandboxed by tmp_path — pointing
at the test's pytest-of-<user>/pytest-<N>/.../gateway-service/ wrapper.
When pytest tore down the tmp_path, every subsequent Windows login
flashed a cmd.exe window that failed to find the missing target.

Stubs is_windows=False on all four affected tests:
  test_install_wsl_no_systemd
  test_start_wsl_no_systemd
  test_status_wsl_running_manual
  test_status_wsl_not_running

Defense-in-depth: _build_startup_launcher() now prefixes the launcher
with 'if not exist <target> exit /b 0', so any future stale Startup
entry silently no-ops instead of flashing a console window.

Status enhancements
-------------------

- status() now reports supervisor task presence alongside the existing
  schtasks/Startup info, and nudges the user to reinstall if the
  supervisor isn't registered.
- Deep mode dumps both the supervisor task name + script path.

* fix(gateway,windows): drop the per-minute supervisor task — keep breakaway + deep probes

Earlier in this branch we added a per-minute schtasks-based supervisor to
respawn the gateway after crashes / GUI-update SIGTERMs. The implementation
flashed a brief console window on every firing, which stole window focus.
We tried several variants:

  - cmd.exe wrapper invoking pythonw  -> flashes (cmd.exe is console-subsystem)
  - schtasks /TR pointing at pythonw  -> flashes (uv venv launcher pythonw is
    actually subsystem=Console, not GUI; it respawns the real pythonw)
  - schtasks /TR pointing at base uv  -> still flashes (Task Scheduler-side
    conhost preallocation; documented Windows quirk)
  - XML registration with <Hidden>true>  -> still flashes (<Hidden> only hides
    the task in the Task Scheduler UI, not the spawned window)

Researched what leading projects do:

  - Ollama: GUI-subsystem tray exe + Startup-folder shortcut. No supervisor.
  - Tailscale: real Windows Service via SCM. Session 0, no console possible.
  - Syncthing: --no-console flag inside the binary + Startup folder.
  - openclaw: VBS Run(..., 0, False) wrapper. Suppresses the *window* but
    Super User Q971162 confirms focus-steal still occurs in some cases.

None of these use a per-minute polling scheduled task. The 'auto-restart on
crash' responsibility belongs INSIDE the daemon (Tailscale's in-process
recovery / Ollama's monitor+worker pair) OR is delegated to the Windows
Service Control Manager — not Task Scheduler.

So this commit drops the supervisor entirely. The CREATE_BREAKAWAY_FROM_JOB
fix in _subprocess_compat.py (from commit c1e5fa4) survives — that is the
*real* fix for problem #2 (GUI-update kills gateway): the post-update
watcher in launch_detached_profile_gateway_restart() now breaks out of
Electron's job object, so the gateway respawn watcher survives the GUI
quit and successfully respawns the gateway.

Surviving from c1e5fa4:
  * CREATE_BREAKAWAY_FROM_JOB in hermes_cli/_subprocess_compat.py (fixes #2)
  * Inlined breakaway flag in the watcher respawn snippet in gateway.py
  * hermes gateway status --deep PASS/FAIL probes (fixes #1 — visibility)
  * 'if not exist <target> exit /b 0' guard in _build_startup_launcher
    (fixes #3 — silent no-op for stale Startup entries)
  * tests/hermes_cli/test_gateway_wsl.py is_windows=False stubs (root cause
    of #3 — pytest WSL tests no longer leak Startup entries on Win hosts)

Removed in this commit:
  * hermes_cli/gateway_supervisor.py (entire file)
  * Supervisor section in hermes_cli/gateway_windows.py (~180 lines):
      get_supervisor_task_name, get_supervisor_script_path,
      _build_supervisor_cmd_script, _write_supervisor_script,
      _install_supervisor_task, is_supervisor_task_registered,
      _install_supervisor_best_effort
  * _install_supervisor_best_effort() calls in install() (3 spots)
  * supervisor cleanup block in uninstall()
  * supervisor display lines in status() / status(deep=True)

Future direction (out of scope for this PR): the right place for Windows
'Restart=always' semantics is a real Windows Service installed via
pywin32's win32serviceutil.ServiceFramework — session-0 isolation, SCM
auto-restart, no console window possible. That's a meaningful next-PR
project, not a band-aid.

Tests: 51 pass / 2 pre-existing failures in
tests/hermes_cli/test_gateway_{windows,wsl}.py (the 2 failures are
TestSupportsSystemdServicesWSL cases that fail on origin/main too —
unrelated to this PR).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.