chore: upstream sync 2026-04-29#14
Merged
Merged
Conversation
…tle-session-sync fix(tui): keep /title session names in sync
Route /browser, /reload-mcp, /rollback, /stop, /fast, and /busy through direct TUI RPC handlers so state changes hit the live gateway session instead of slash-worker fallback. Add TUI session finalize/reset parity hooks (memory commit + plugin boundaries) and parity matrix tests to keep mutating commands off fallback.
Make Ctrl+L non-destructive by redrawing the current screen state instead of starting a new session, and stop auto-appending --global for typed /model commands so session scope remains the default unless explicitly requested.
Run ui-tui lint autofix + prettier and commit the resulting formatting-only changes for the parity PR branch.
Run ui-tui lint autofix + prettier and commit the resulting formatting-only changes for the keybinding/model parity branch.
Harden busy mode config reads against invalid display config shapes and align /fast help+usage text with accepted aliases, with regression coverage for non-dict display values.
Use explicit repaint patch semantics for Ctrl/Cmd+L and narrow the hotkey assertion to the actual +L entry so unrelated descriptions do not cause false failures.
Expose a small forceRedraw API from @hermes/ink and use it for Ctrl/Cmd+L so the hotkey performs a real terminal clear + full repaint instead of a no-op state patch.
Make `config.set fast status` read-only and keep live agent request overrides in sync with fast-mode toggles so runtime API kwargs match the selected mode.
…ding-model-parity fix(tui): align Ctrl+L and /model default scope with classic CLI
Resolve session command merge conflict and keep the branch current with main so PR NousResearch#16656 is mergeable.
Match classic CLI parity by refusing to enable fast mode when the active model cannot produce fast request overrides, avoiding a misleading fast status with no runtime effect.
Distinguish missing model from unsupported model before enabling fast mode and cover both cases so config and live agent state remain untouched on invalid fast toggles.
…rceredraw-export fix(tui): expose forceRedraw in Ink type shim
Load slash command names from the Python registry instead of regex-parsing source, and guard native rollback when no TUI session is active.
…sResearch#11955) Add GMI Cloud (api.gmi-serving.com) as a full first-class API-key provider with built-in auth, aliases, model catalog, CLI entry points, auxiliary client routing, context length resolution, doctor checks, env var tracking, and docs. - auth.py: ProviderConfig for 'gmi' (api_key, GMI_API_KEY / GMI_BASE_URL) - providers.py: HermesOverlay with extra_env_vars for models.dev detection - models.py: curated slash-form model catalog; live /v1/models fetch - main.py: 'gmi' in _named_custom_provider_map and --provider choices - model_metadata.py: _URL_TO_PROVIDER, _PROVIDER_PREFIXES, dedicated context-length probe block (GMI's /models has authoritative data) - auxiliary_client.py: alias entries; _compat_model fix for slash-form models on cached aggregator-style clients; gmi aux default model - doctor.py: GMI in provider connectivity checks - config.py: GMI_API_KEY / GMI_BASE_URL in OPTIONAL_ENV_VARS - conftest.py: explicit GMI_BASE_URL clearing (not caught by _API_KEY suffix) - docs: providers.md, environment-variables.md, fallback-providers.md, configuration.md, quickstart.md (expands provider table) Co-authored-by: Isaac Huang <isaachuang@Isaacs-MacBook-Pro.local>
- config.py: remove dead ENV_VARS_BY_VERSION[17] entry (current _config_version
is 22, so all users are past version 17 and would never be prompted for
GMI_API_KEY on upgrade — consistent with how arcee was added)
- auxiliary_client.py: use google/gemini-3.1-flash-lite-preview as GMI aux
model instead of anthropic/claude-opus-4.6 (matches cheap fast-model pattern
used by all other providers: zai→glm-4.5-flash, kimi→kimi-k2-turbo-preview,
stepfun→step-3.5-flash, kilocode→google/gemini-3-flash-preview)
- test_gmi_provider.py: fix malformed write_text() call in doctor test
(was: write_text("GMI_API_KEY=*** encoding="utf-8") → missing closing quote,
wrote literal string 'GMI_API_KEY=*** encoding=' to .env file)
- test_gmi_provider.py + test_auxiliary_client.py: update aux model assertions
to match new cheaper default
- docs/integrations/providers.md: add 'gmi' to inline 'Supported providers'
fallback list (was only in the table, not the inline list at line ~1181)
- docs/reference/cli-commands.md: add 'gmi' to --provider choices list
Keep the parity test backed by the real Python command registry while avoiding hard failures in Node-only Vitest environments that cannot import hermes_cli.commands.
…-mutating-commands fix(tui): route mutating slash commands through live gateway state
…esearch#16651) * fix: bypass FTS5 for CJK queries in session_search FTS5 default tokenizer splits CJK characters into individual tokens, so multi-character queries like "大别山项目" become AND of single chars. This produces few/no results compared to LIKE substring search. For CJK queries, skip FTS5 entirely and use LIKE for accurate phrase matching. Fixes NousResearch#15500 * fix: cache _contains_cjk, escape LIKE wildcards, add regression tests On top of the CJK FTS5 bypass from NousResearch#15509: - Cache _contains_cjk() result in a local var to avoid redundant O(n) scans on every CJK query - Escape %, _ in LIKE queries so literal wildcards in user input are not treated as SQL wildcards (consistent with other LIKE queries in hermes_state.py that use ESCAPE '\') - Fix misleading comment ('or CJK fallback' → accurate description) - Add 3 regression tests: - test_cjk_partial_fts5_results_supplemented_by_like (NousResearch#15500 / NousResearch#14829) - test_cjk_like_dedup_no_duplicates - test_cjk_like_escapes_wildcards (new wildcard escaping) * feat: trigram FTS5 index for CJK search, replace LIKE fallback Replace the LIKE '%query%' full-table-scan fallback for CJK queries with a proper trigram FTS5 index (messages_fts_trigram). The trigram tokenizer creates overlapping 3-byte sequences so substring matching works natively for any script — CJK, Thai, etc. For queries with 3+ CJK characters: uses the trigram FTS5 table with proper ranking, snippets, and indexed lookups. For shorter queries (1-2 CJK chars): falls back to LIKE since the trigram tokenizer needs ≥9 UTF-8 bytes (3 CJK chars) minimum. Schema v10 migration creates the trigram table and backfills existing messages. Triggers keep the index in sync on INSERT/UPDATE/DELETE. Builds on top of NousResearch#16276 (bypass FTS5 for CJK, escape LIKE wildcards). --------- Co-authored-by: vominh1919 <vominh1919@gmail.com>
…arch#15953) * feat(nix): parameterize dependency-groups in python.nix * refactor(nix): extract package to callPackage-able hermes-agent.nix Makes the package overridable via .override{} and adds extraPythonPackages parameter for PYTHONPATH injection. Includes build-time collision check using PEP 503 name canonicalization. * feat(nix): add overlay for external NixOS consumption External flakes can now add overlays = [ inputs.hermes-agent.overlays.default ] to get pkgs.hermes-agent with full .override support. * test(nix): add check for extraPythonPackages PYTHONPATH injection Verifies wrapper has PYTHONPATH when extras provided, and base package has no PYTHONPATH without extras. * feat(nix): add extraPlugins option for directory-based plugins Symlinks plugin packages into HERMES_HOME/plugins/ at activation time. Validates plugin.yaml presence. Asserts unique plugin names at eval time. Hermes discovers them automatically via its directory scan. * feat(nix): add extraPythonPackages option for entry-point plugins Overrides the hermes package with PYTHONPATH injection when extraPythonPackages is non-empty. Plugin .dist-info directories become visible to importlib.metadata for entry-point discovery. Works in both native systemd and container modes. * docs: add NixOS declarative plugin installation to nix-setup, plugins, and build-a-plugin guides - nix-setup.md: new Plugins section with extraPlugins/extraPythonPackages examples, overlay usage, collision checking note, options reference rows - plugins.md: Nix row in discovery table, NixOS declarative plugins section - build-a-hermes-plugin.md: Distribute for NixOS section after pip section * fix: address review feedback — remove unrelated umask, fix fetchFromGitHub naming, simplify checks - Remove accidentally introduced umask/migration changes (unrelated to plugins) - Add pluginName helper, fix fetchFromGitHub producing name='source' - Show name= in extraPlugins example docs - Simplify checks.nix: use hermes-agent.override instead of re-callPackage - Fix fragile grep shell logic in checks * refactor: address simplify feedback — lib.getName, drop unused inputs', Python list for extras - Use lib.getName instead of custom pluginName helper - Drop unused inputs' from checks.nix perSystem args - Pass extraPythonPackages as Python list literal instead of colon-split string * fix: walk propagatedBuildInputs for plugin PYTHONPATH and collision check Uses python312.pkgs.requiredPythonModules to resolve the full transitive closure of extraPythonPackages. Without this, a plugin with third-party deps (e.g. requests) would fail at runtime if those deps weren't already in the sealed uv2nix venv. The collision check now also scans the full closure, catching transitive conflicts. * cleanup: fold plugins into subdir loop, use find for symlink cleanup, inline lib.getName - Add 'plugins' to the existing cron/sessions/logs/memories subdir loop instead of a separate mkdir/chown/chmod block - Replace fragile for-glob with find -delete for stale symlink cleanup - Inline lib.getName at both call sites, remove pluginName wrapper
…NousResearch#14984) When a gateway drives Hermes (Telegram, Discord, Slack, ...), it passes the platform-native user ID as ``runtime_user_peer_name`` into the Honcho session manager. That ID wins over ``peer_name`` in ``honcho.json``, so a single user who connects over three platforms ends up as three separate Honcho peers — one per platform — with fragmented memory and no cross- platform context continuity. For multi-user bots this is correct (and must not change): each user gets their own peer scope. For the vast majority of personal Hermes deployments the configured ``peer_name`` is an unambiguous identity, though, so the reporter asked for an opt-in knob that pins the user peer to that value. Fix: new ``pinPeerName`` boolean on the host config, default ``false``. When ``true`` AND ``peerName`` is set, the configured peer_name beats the gateway's runtime identity; every other resolution case is unchanged. honcho.json: { "peerName": "Igor", "hosts": { "hermes": { "pinPeerName": true } } } session.py (resolution order, pinned case): runtime_user_peer_name → skipped (opt-in flag active) config.peer_name → WINS "Igor" session-key fallback → unreached Parsing follows the same host-block-overrides-root pattern as every other flag in HonchoClientConfig.from_global_config (``_resolve_bool`` helper). Tests (tests/honcho_plugin/test_pin_peer_name.py — 13 cases, 5 groups): - Config parsing: default, root true, host-block true, host overrides root, explicit false. - Peer resolution: runtime wins by default (regression guard for multi- user bots), config wins when pinned, pin-without-peer_name is a no-op (prevents silent peer-id collapse to session-key fallback), CLI path where runtime is absent, deepest fallback intact, assistant peer untouched by the flag. - Cross-platform unification: Telegram UID + Discord snowflake collapse to one peer when pinned; negative control confirms two distinct runtime IDs still produce two peers when unpinned. 244 honcho_plugin tests pass, 3 pre-existing skips, zero regressions. Defensive detail: session.py uses ``getattr(self._config, "pin_peer_name", False)`` so callers building partial config objects (several test fixtures across the codebase do this) don't break if they haven't updated yet. Runtime cost: one attr lookup per new session. Closes NousResearch#14984 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ck configs (NousResearch#15162) CI caught that ``test_session_manager_prefers_runtime_user_id_over_config_peer_name`` in ``tests/agent/test_memory_user_id.py`` failed after this branch: that test passes a ``MagicMock`` for ``config``, where ``mock.pin_peer_name`` silently returns another ``MagicMock`` — truthy by default. My ``getattr(..., "pin_peer_name", False)`` fallback was supposed to guard against callers that haven't added the new attr, but MagicMock *does* have the attr — it just returns a live mock for it. Tightened the gate to ``getattr(..., False) is True``. Real configs built via ``HonchoClientConfig.from_global_config`` always yield a proper boolean, so strict equality matches the pinned case and rejects both the unset-attr fallback and MagicMock stand-ins. Added a comment explaining why ``is True`` is intentional, not paranoid. Also tightened the ``peer_name`` existence check to ``getattr(..., None)`` so a MagicMock with ``peer_name`` left at its default (also truthy) doesn't spuriously enable pinning either. Verified against both the new ``test_pin_peer_name.py`` suite (13/13 pass) and the previously-failing ``TestHonchoUserIdScoping`` (3/3 pass). Zero behaviour change for real ``HonchoClientConfig`` values. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r limit (NousResearch#13868) Gateway session keys (Matrix "!room:server" + thread event IDs, Telegram supergroup reply chains, Slack thread IDs with long workspace prefixes) can exceed Honcho's 100-character session ID limit after sanitization. Every Honcho API call for those sessions then 400s with "session_id too long". Add a helper that enforces the 100-char limit after sanitization: short keys (the common case) short-circuit unchanged; over-limit keys keep a prefix and append a deterministic `-<8 hex>` SHA-256 suffix over the original key so two long keys sharing a leading segment can't collide onto the same truncated ID. Adds 7 regression tests in tests/honcho_plugin/test_client.py covering short / exact-limit / long / deterministic / collision-resistant / allowlist-preserving / hash-suffix-present cases.
Wraps _session_cache mutations in threading.RLock. Without this, concurrent gateway sessions (e.g., Telegram + Discord hitting Honcho at the same time) can race on the cache and silently lose conclusions or memory writes. Adopted from NousResearch#13510 by @hekaru-agent; the off-topic cron/jobs.py cleanup hunk from that PR is dropped here for scope isolation. Resolved a small conflict with the pinPeerName guard (kept both).
_resolve_api_key() only checks for apiKey / HONCHO_API_KEY, so all CLI subcommands (identity --show, status, migrate, etc.) bail with "No API key configured" on self-hosted instances that use baseUrl without an API key. Return "local" when baseUrl or HONCHO_BASE_URL is set, matching the client.py behavior that already handles this case for the SDK. Tested on: macOS, self-hosted Honcho (Docker, localhost:8000).
…h#17130) * feat(tui): opt-in auto-resume of the most recent session `hermes --tui` always forges a fresh session at startup unless the user sets `HERMES_TUI_RESUME=<id>`. Disconnects, terminal-window crashes, and accidental Ctrl+D therefore lose every piece of in-flight context even though `state.db` still has the full history a `/resume` away. Add an opt-in path that mirrors classic CLI's `hermes -c` muscle memory: when `display.tui_auto_resume_recent: true` is set in `~/.hermes/config.yaml`, the TUI looks up the most recent human-facing session and resumes it instead of starting fresh. Default off so existing users aren't surprised; explicit `HERMES_TUI_RESUME` always wins. Wires: * New `session.most_recent` JSON-RPC in `tui_gateway/server.py` that returns the first non-`tool` row from `list_sessions_rich`, or `{"session_id": null}` when none. Uses the same deny-list as `session.list` so sub-agent rows can't sneak in. * `createGatewayEventHandler.handleReady` re-ordered: explicit `STARTUP_RESUME_ID` first (unchanged), then conditional auto-resume via `config.get full → display.tui_auto_resume_recent`, then the legacy `newSession()` fallback. Failures of either RPC fall back to `newSession()` so the path is always finite. * Default `display.tui_auto_resume_recent: False` added to `DEFAULT_CONFIG` in `hermes_cli/config.py` (no `_config_version` bump per AGENTS.md — deep-merge handles the additive key). Tests: * 4 new vitest cases in `createGatewayEventHandler.test.ts` cover every gate-and-fallback combination (env wins, config off, config on with hit, config on with miss). * 3 new pytest cases for `session.most_recent` (denied row skip, tool-only → null, db-unavailable → null). Validation: scripts/run_tests.sh tests/test_tui_gateway_server.py — 93/93. cd ui-tui && npm run type-check — clean; npm test --run — 393/393. * review(copilot): fold session.most_recent errors into null + extend ConfigDisplayConfig * review(copilot): cover RPC-rejection fallbacks in auto-resume tests
NousResearch#17120) * fix(tui): make /browser connect actually take effect on the live agent Reports were that `/browser connect <url>` (and "changes to CDP url don't get picked up") didn't propagate to the live agent in `--tui`, forcing users to fall back to setting `browser.cdp_url` in `config.yaml` and restarting. Tracing the path on current main shows the protocol wiring is already correct — `/browser` is registered in `ui-tui/src/app/slash/commands/ops.ts` and dispatches `browser.manage` through the gateway RPC, NOT the slash worker (covered by the `browser.manage` row in `slashParity.test.ts`). But three real gaps left the experience flaky: 1. `cleanup_all_browsers()` ran AFTER `os.environ["BROWSER_CDP_URL"]` was rewritten. `_ensure_cdp_supervisor(...)` reads the env to resolve its target URL, so a tool call landing in that brief window could re-attach the supervisor to the OLD CDP endpoint just before we reaped sessions, leaving the agent talking to a dead URL. Reorder to clean first, swap env, clean again so the supervisor for the default task is definitively closed. 2. `browser.manage status` reported only the env var, ignoring `browser.cdp_url` from config.yaml. `_get_cdp_override()` (the resolver the agent itself uses) consults both — match it so `/browser status` answers the same question the next `browser_navigate` will see. Closes a stealth bug where users saw "browser not connected" while their CDP URL was perfectly set in config.yaml. 3. `/browser disconnect` only cleared `BROWSER_CDP_URL` and reaped once, leaving the same swap window as connect. Symmetrical double-cleanup here too. Frontend (`ops.ts`): * Echo "next browser tool call will use this CDP endpoint" on success so users see immediate confirmation that the gateway accepted the swap, even before any tool runs. * Mention `browser.cdp_url` in `config.yaml` in the usage hint and the not-connected status line. Persistent config is the correct fix for some terminal-multiplexer / sub-agent flows where env inheritance is unreliable; surfacing it makes that workaround discoverable. Tests (4 new, all hermetic): * `status` returns the resolved URL when only `browser.cdp_url` is set in config.yaml. * `connect` writes env AND cleans before/after, in that order. * `connect` against an unreachable endpoint does NOT mutate env or reap. * `disconnect` removes env and cleans twice. Validation: scripts/run_tests.sh tests/test_tui_gateway_server.py — 94/94 pass. cd ui-tui && npm run type-check — clean; npm test --run — 389/389. * review(copilot): always defer to _get_cdp_override; normalize bare host:port * review(copilot): collapse discovery-style CDP paths so /json/version isn't duplicated * fix(tui): /browser status must not perform CDP discovery I/O Copilot review on PR NousResearch#17120: previous version routed through `tools.browser_tool._get_cdp_override`, which calls `_resolve_cdp_override` and performs an HTTP probe to /json/version with a multi-second timeout for discovery-style URLs. That blocks the TUI on `/browser status` whenever the configured host is slow or unreachable. Status now reads env-then-config directly with no network I/O. The WS normalization still happens in `browser_navigate` for actual tool calls, so behaviour-on-call is unchanged. * fix(tui): skip /json/version probe for concrete ws://devtools/browser endpoints Round 2 Copilot review on PR NousResearch#17120: hosted CDP providers (Browserbase, browserless, etc.) return concrete `ws[s]://.../devtools/browser/<id>` URLs which are already directly connectable but don't serve the HTTP discovery path. The previous `/json/version` probe rejected these valid endpoints with 'could not reach browser CDP'. For `ws[s]://...` URLs whose path starts with `/devtools/browser/` we now do a TCP-level reachability check (`socket.create_connection`) instead of the HTTP probe. The actual CDP handshake happens on the next `browser_navigate` call, so we still surface unreachable hosts as 5031 errors — just without the false negatives. Discovery-style URLs (`http://host:port[/json[/version]]`) keep the HTTP probe path unchanged. Updated existing test + added two new ones (TCP-only success, TCP unreachable → 5031).
…lay (NousResearch#17111) `turnController.recordMessageComplete` and `recordMessageDelta` both prioritised `payload.rendered` over `payload.text`. `payload.rendered` is the Rich-Console output `tui_gateway` builds for terminals that can't render markdown themselves; the TUI already renders markdown via `<Md>`. Two real bugs follow: 1. **Final answer garbled when `display.final_response_markdown: render` is set** (NousResearch#16391). Raw ANSI escape sequences pass through into the React tree and the user sees overlapping coloured text instead of their answer. 2. **Streaming silently drops content.** Per-delta `rendered` is an *incremental* Rich fragment. The previous code did `this.bufRef = rendered ?? this.bufRef + text`, which on every tick replaced the whole accumulated buffer with the latest mid-sequence ANSI fragment. Long replies arrived truncated and looked half-painted — easy to miss as "model is being terse" instead of a client bug. Fix: * `recordMessageComplete` now prefers `payload.text`, falling back to `payload.rendered` only when the gateway elected not to send any. * `recordMessageDelta` always accumulates `text`; `rendered` is ignored on the streaming path entirely (Ink does its own markdown render via `<Md>` / `streamingMarkdown.tsx`). Tests: * `prefers raw text over Rich-rendered ANSI on message.complete` — the assistant message reflects raw markdown, not ANSI. * `falls back to payload.rendered when text is missing` — preserves the legacy "no `text`, only ANSI" path used by some adapters. * `always accumulates raw text in message.delta and ignores rendered` — pre-fix code would have made this assertion fail because each delta overwrote the buffer. Validation: `npm run type-check` clean, `npm test --run` 392/392 pass.
* fix(tui): honor display.busy_input_mode in TUI v2
The TUI v2 frontend hard-coded `composerActions.enqueue(full)` whenever
`ui.busy` was true. The classic CLI and gateway adapters honor the
`display.busy_input_mode` config key (`interrupt` | `queue` | `steer`),
but Ink ignored it — sending a message during a long-running turn always
landed in the queue regardless of config. The config default is already
`interrupt` (hermes_cli/config.py), so users who explicitly opted into
that experience were silently stuck on the legacy queue path.
This wires the value through the existing config-sync surface:
* `applyDisplay` now reads `display.busy_input_mode`, defaults to
`interrupt` (matching `_load_busy_input_mode` in tui_gateway), and
drops it into a new `UiState.busyInputMode` field.
* `dispatchSubmission` and the queue-edit fall-through call a shared
`handleBusyInput` helper that branches on the mode:
* `queue` — legacy behavior, append to the queue.
* `steer` — call `session.steer`; on rejection, fall back to
queue with a sys note.
* `interrupt` — `turnController.interruptTurn(...)` then `send()`,
so the new prompt actually moves.
* Mtime polling in `useConfigSync` already re-applies `config.full`, so
flipping `display.busy_input_mode` in `~/.hermes/config.yaml` takes
effect on the next 5s tick without restarting the TUI.
Tests:
* `applyDisplay → busy_input_mode` covers normalization + UiState fan-out.
* `normalizeBusyInputMode` mirrors the Python side's allow-list.
Validation:
* `npm run type-check` (in `ui-tui/`) — clean.
* `npm test --run` (in `ui-tui/`) — 394/394.
* review(copilot): narrow busy_input_mode type, preserve queue order on steer fallback
* review(copilot): clarify handleBusyInput comment (option, not return value)
* fix(tui): default busy_input_mode to queue in TUI (CLI keeps interrupt)
In a full-screen TUI users typically author the next prompt while the
agent is still streaming, so an unintended interrupt loses in-flight
typing. TUI fallback now defaults to `queue`; CLI / messaging
adapters keep `interrupt` as the framework default.
Override per-config via `display.busy_input_mode: interrupt` (or
`steer`) — the normalize/wire path is unchanged, only the missing-
value branch differs from the Python default.
uiStore initial value also flipped to `queue` so first-frame render
before `config.full` lands matches the eventual normalized value.
…SIGTERM races (NousResearch#17118) * fix(tui-gateway): harden stdio transport against half-closed pipes + SIGTERM races `tui_gateway` reports `tui_gateway_crash.log` traces where the main thread sits in `sys.stdin` while a worker holds `_stdout_lock` mid- flush, and SIGTERM then calls `sys.exit(0)` while the lock is still held — the interpreter shutdown stalls behind the wedged write. Two narrowly scoped hardenings: **`tui_gateway/transport.py`** * Move JSON serialisation outside the lock — long messages no longer block sibling writers while we serialise. * Treat `BrokenPipeError`, `ValueError` ("I/O on closed file") and generic `OSError` from both `write` and `flush` as "peer is gone": return `False` instead of bubbling, matching what `write_json`'s callers in `entry.py` already expect. * Split `flush` into its own try block so a stuck flush never strands a partial write or holds the lock indefinitely on its way out. * Optional `HERMES_TUI_GATEWAY_NO_FLUSH=1` env knob to skip explicit `flush()` entirely on environments where a half-closed read pipe produces an indefinite kernel-level block. Default unchanged. **`tui_gateway/entry.py`** * `_log_signal` now spawns a 1-second daemon timer that calls `os._exit(0)` if the orderly `sys.exit(0)` path is itself stuck behind a wedged worker. Atexit handlers run inside the grace window when they can; the timer is the safety net so a deadlocked flush no longer strands the gateway process. Tests: * `test_write_json_closed_stream_returns_false` — ValueError path. * `test_write_json_oserror_on_flush_returns_false` — OSError on flush must not strand the lock; the write portion still landed before the flush failure. * `test_write_json_no_flush_env_skips_flush` — env knob bypass. Validation: `scripts/run_tests.sh tests/tui_gateway/test_protocol.py` (42/42 pass; one pre-existing failure on `test_session_resume_returns_hydrated_messages` is unrelated to this change — same `include_ancestors` mock kwarg issue tracked elsewhere). `scripts/run_tests.sh tests/test_tui_gateway_server.py` 90/90 pass. * review(copilot): tighten transport hardening comments + test cleanup * review(copilot): narrow exception capture, configurable grace, simpler no-flush test * fix(tui-gateway): narrow ValueError to closed-stream; surface UnicodeEncodeError Copilot review on PR NousResearch#17118: `UnicodeEncodeError` is a ValueError subclass, so a non-UTF-8 stdout (mismatched PYTHONIOENCODING / locale) would have been silently swallowed as 'peer gone' under `except ValueError`. That hides a real environment bug. Now: - UnicodeEncodeError → log with exc_info (warning) and drop the frame - ValueError where str(e) contains 'closed file' → peer gone, return False - Any other ValueError → log loudly, drop frame (defensive, but visible) Same shape applied to flush. Adds two regression tests. * fix(tui-gateway): reserve write() False for peer-gone; re-raise programming errors Round 2 Copilot review on PR NousResearch#17118: `Transport.write()` returning `False` is documented as 'peer is gone', and `entry.py` reacts by calling `sys.exit(0)`. But the implementation also returned False for non-IO conditions (non-JSON-safe payloads, UnicodeEncodeError, unrelated ValueErrors), so a programming error or local env bug would present as a clean disconnect — exactly the diagnosis pain we wanted to eliminate. Now: - `json.dumps` failure → re-raises (TypeError/ValueError surfaces in crash log) - `BrokenPipeError` → False (peer gone) - `ValueError('...closed file...')` → False (peer gone) - `UnicodeEncodeError` and any other ValueError → re-raise - `OSError` → False (existing IO-failure semantics, debug-logged) Tests updated to assert the re-raise behaviour and added a non-serializable-payload regression test. * fix(tui-gateway): narrow OSError to peer-gone errnos; honest test naming Round 3 Copilot review on PR NousResearch#17118: - Docstring claimed False = peer gone, but generic OSError on write/flush also returned False — meaning ENOSPC/EACCES/EIO would silently exit. Added `_PEER_GONE_ERRNOS = {EPIPE, ECONNRESET, EBADF, ESHUTDOWN, +WSA}` and narrowed the OSError handlers; non-peer-gone errnos re-raise. Docstring now lists OSError as peer-gone branch with the errno set. - The `_DISABLE_FLUSH` test was named after the env var but actually patched the module constant. Renamed it to reflect the contract being tested (skips flush when constant is true) AND added a real end-to-end test that sets the env var, reloads transport.py, and asserts the constant flips. Cleanup reload restores defaults so parallel tests stay isolated. Self-review (avoid round 4): - Verified TeeTransport's secondary-swallow stays intentional. - _log_signal grace path already covered by separate tests.
…ckground hex) (NousResearch#17113) * feat(tui): expand light-terminal auto-detection (HERMES_TUI_THEME, BG hex) Modern terminals (Ghostty, Warp, iTerm2) don't set COLORFGBG, so the auto-light path was effectively COLORFGBG-only and silently broken for many users. Two pragmatic additions, both opt-in, plus a clearer priority chain: 1. **`HERMES_TUI_THEME=light|dark`** as a symmetric explicit override. The existing `HERMES_TUI_LIGHT` is fine but reads as boolean noise; a named theme env var matches `display.skin` muscle memory. 2. **`HERMES_TUI_BACKGROUND` hex/rgb hint.** Lets advanced users (or a future OSC11 query helper that caches the answer) state a ground-truth background colour. Decoded to Rec. 709 luma; ≥ 0.6 counts as light. Priority order is now fully ordered and explainable: 1. `HERMES_TUI_LIGHT` (1/0/true/false/on/off). 2. `HERMES_TUI_THEME=light|dark`. 3. `HERMES_TUI_BACKGROUND` luminance. 4. `COLORFGBG` last field — light slots 7/15 → light, 0–15 → dark (authoritative when set, so the new TERM_PROGRAM path can never stomp on a terminal that already volunteered a dark answer). 5. `TERM_PROGRAM` allow-list — empty by default. The slot is left in place because folks asked for it but populating it risks wrongly flipping users on Apple_Terminal / iTerm2 dark profiles to light. Easy to add per terminal once we have signal. Tests: 5 new cases in `theme.test.ts` covering theme env, background hex (3- and 6-char), invalid hex falling through, and COLORFGBG taking precedence over the future allow-list. Validation: `npm run type-check` clean, `npm test --run` 392/392. * review(copilot): tighten theme detection comments + drop unnecessary cast * review(copilot): strict hex regex so partial garbage doesn't slip into luminance * test(tui): make TERM_PROGRAM allow-list injectable so precedence is provable Copilot review on PR NousResearch#17113: `LIGHT_DEFAULT_TERM_PROGRAMS` is empty in production, so the prior assertion would have passed even if `detectLightMode` ignored `COLORFGBG` entirely. That defeats the test's purpose. `detectLightMode` now takes the allow-list as an optional second argument (defaults to the production set). The test injects a set containing `Apple_Terminal`, asserts the allow-list alone WOULD return light, then asserts `COLORFGBG: '15;0'` overrides it — the precedence rule is now exercised, not assumed. * fix(tui): COLORFGBG empty-trailing-field falls through; isolate DEFAULT_THEME tests Round 2 Copilot review on PR NousResearch#17113: 1. `Number(colorfgbg.split(';').at(-1))` returns 0 for an empty trailing field (e.g. `COLORFGBG='15;'` → bg===0), which would have looked like an authoritative dark slot and incorrectly blocked the TERM_PROGRAM allow-list. Added a `/^\d+$/` guard before coercion; non-numeric trailing fields now fall through. 2. Fixed the misleading '0–6 / 8–15 ranges are dark' comment — the block returns true for bg===15, so the range is actually 0–6 / 8–14. 3. `DEFAULT_THEME` is computed from `process.env` at module-load. A developer shell with `HERMES_TUI_THEME=light` (or a bright `HERMES_TUI_BACKGROUND`) would flip it and break local tests. The DEFAULT_THEME describe blocks now sterilize the relevant env vars + dynamically import theme.ts (vi.resetModules pattern from platform.test.ts). fromSkin tests compare against DARK_THEME directly to decouple them from ambient env. * test(tui): isolate ALL env-coupled theme symbols, not just DEFAULT_THEME Round 3 Copilot review on PR NousResearch#17113: the static top-level imports of `fromSkin`, `DARK_THEME`, `LIGHT_THEME` evaluated theme.ts before `importThemeWithCleanEnv` had a chance to clean the env. Because `fromSkin` closes over `DEFAULT_THEME`, an ambient `HERMES_TUI_THEME=light` or bright `HERMES_TUI_BACKGROUND` would still flip the base palette and cause local-only failures. Removed the static import entirely. Every test now obtains its theme symbols via `importThemeWithCleanEnv`, including `detectLightMode` (for consistency, even though it takes env as a parameter). `fromSkin` tests assert against the cleaned `DEFAULT_THEME` from the same dynamic import — preserves the actual contract (skins extend the ambient base palette) without coupling the test to dev-shell state. Verified by running with HERMES_TUI_THEME=light + HERMES_TUI_BACKGROUND=#ffffff: all 20 theme tests still pass. Self-review (avoid round 4): - Audited other test files importing DEFAULT_THEME (syntax.test.ts, streamingMarkdown.test.ts, constants.test.ts) — all just pass it as a parameter or assert palette property existence (works on both light + dark), so no env coupling there.
…Research#17150) * feat(tui): pluggable busy-indicator styles (kaomoji/emoji/unicode/ascii) The status-bar `FaceTicker` rotated through wide-and-variable kaomoji glyphs (`(。•́︿•̀。)`, `( ͡° ͜ʖ ͡°)`, …) every 2.5s. Real display widths range from ~5 to ~16 columns, so the rest of the bar (cwd, ctx %, voice, bg counter) shifted on every cycle. Padding the verb alone (NousResearch#17116) helped but didn't address the dominant jitter source — the glyph itself. Add four indicator styles, configurable + hot-swappable: * `kaomoji` (default — preserves the existing vibe; verb is now pad-stable so the only width churn left is the kaomoji itself). * `emoji` — single 2-col emoji frame (`⚕ 🌀 🤔 ✨ 🍵 🔮`). * `unicode` — `unicode-animations` braille spinner (1-col, smooth). * `ascii` — `| / - \` (1-col, max compat). Wires: * `display.tui_status_indicator` in `DEFAULT_CONFIG` (default `kaomoji`). * New JSON-RPC `config.set/get indicator` keys, narrow allow-list. * `applyDisplay` reads the field and patches `UiState.indicatorStyle`, so the existing `mtime` poll picks up `~/.hermes/config.yaml` edits within ~5s without a TUI restart. * `/indicator [style]` slash command (alias `/indicator-style`, subcommand completion `kaomoji|emoji|unicode|ascii`). Bare form shows the current style; setter fires `config.set` and optimistically `patchUiState({ indicatorStyle })` so the live TUI swaps immediately, matching the `/skin` UX. * `CommandDef("indicator", ..., subcommands=...)` so classic CLI autocomplete + TUI `complete.slash` both surface it. * `FaceTicker` decouples spinner cadence from verb cadence — the glyph runs at the spinner's authored interval (or `FACE_TICK_MS` for kaomoji), the verb stays on the original 2.5s cycle, and both re-arm cleanly when style changes. Tests: * `normalizeIndicatorStyle` rejects unknown / non-string input. * `applyDisplay → tui_status_indicator` covers fan-out + fallback. * `/indicator <style>` hot-swaps `UiState.indicatorStyle` after a successful `config.set`. * `/indicator sparkle` rejects with the usage hint and never hits the gateway. * Slash-parity matrix gets `'/indicator'` → `config.get`. Validation: cd ui-tui && npm run type-check — clean; npm test --run — 398/398. scripts/run_tests.sh tests/test_tui_gateway_server.py tests/hermes_cli/test_commands.py — 220/220. * chore(tui): drop /indicator-style alias to declutter autocomplete * fix(tui): drop verb-width pad — /indicator handles glyph jitter directly * fix(tui): unicode indicator style hides the verb (cleanest option) * refactor(tui): single source of truth for INDICATOR_STYLES; cleaner error format Round 1 Copilot review on PR NousResearch#17150: - Exported `INDICATOR_STYLES` const tuple from `interfaces.ts`; `IndicatorStyle` union type is derived from it. `useConfigSync` builds its validation Set from the tuple, and `session.ts` uses it for both the usage hint and the runtime allow-list — adding/removing a style now touches one line. - Backend `config.set indicator` error message: switched `sorted(allowed)` list repr to `pick one of ascii|emoji|kaomoji|unicode` (matches the TUI usage hint), and reports the normalized `raw` instead of the original `value`. Backend allowed tuple now has a comment pointing back at `INDICATOR_STYLES` so the two stay aligned. Note: kept the verb portion unpadded per design intent — fixed-width padding was the exact UX the `/indicator` command was added to remove. Stable width comes from the glyph; verbs cycling is part of the kawaii aesthetic. Reply on the verb thread will explain. * fix(tui): drop type collapse + gate verb timer + DEFAULT_INDICATOR_STYLE Round 2 Copilot review on PR NousResearch#17150: - `tui_status_indicator?: 'ascii' | ... | string` collapses to `string` in TS — consumers got no narrowing. Documented as plain `string` with a comment about runtime validation via `normalizeIndicatorStyle`. - `FaceTicker` always started a 2.5s verb interval, even for the `unicode` style which hides the verb entirely. Now gated on `showVerb` from `renderIndicator` — `unicode` stays calm. Pre-emptive self-review (avoid round 3): - Three call sites duplicated the literal `'kaomoji'` default (uiStore, normalizeIndicatorStyle, slash command). Added `DEFAULT_INDICATOR_STYLE` to interfaces.ts and threaded it through so changing the default touches one line. * fix(tui-gateway): normalize config.get indicator output to match TUI render Round 4 Copilot review on PR NousResearch#17150: `config.get` for `indicator` returned the raw `display.tui_status_indicator` value without validation, so a hand-edited config.yaml with stray casing or an unknown style would leave `/indicator` printing one thing while the TUI rendered the kaomoji default (frontend's `normalizeIndicatorStyle` does this normalization on receive). Lifted the allow-list to module scope as `_INDICATOR_STYLES` / `_INDICATOR_DEFAULT`, reused by both `config.set` and `config.get`. Comment notes the alignment with `INDICATOR_STYLES` / `DEFAULT_INDICATOR_STYLE` in interfaces.ts so adding/removing a style is a one-line change on each end. Tests cover: known value verbatim, casing/whitespace normalize, unknown→default, unset→default. * fix(tui-gateway): preserve falsy-input diagnostics in config.set indicator error Round 5 Copilot review on PR NousResearch#17150: `raw = str(value or "").strip().lower()` collapsed any falsy non-string (`0`, `False`, `[]`) to empty string, so the error message read `unknown indicator: ` with nothing after — losing the original input. Switched to `("" if value is None else str(value)).strip().lower()` so only `None` (the genuine 'no value' case) becomes blank. Used `{raw!r}` in the error so the diagnostic is unambiguous (`'0'` vs `0`). Tests: - known-value happy path (`'EMOJI'` → `'emoji'`) - falsy non-string inputs (`0` / `False` / `[]`) surface meaningfully - `None` keeps the blank-repr error
`web/package-lock.json` was updated by the design-system refactor (merged via NousResearch#17007 + follow-ups: spinner / select / badges / buttons) without bumping `nix/web.nix::npmDeps.hash`, breaking nix builds on every PR + main since 2026-04-28T18:46. Hash sourced from the actual `Check flake` failure output: specified: sha256-AahWmJ9gDQ9pMPa1FYwUjYdO2mOi6JM9Mst27E0vp68= got: sha256-+B2+Fe4djPzHHcUXRx+m0cuyaopAhW0PcHsMgYfV5VE= Standalone single-file fix so it can land fast and clear nix on every other open PR.
…iles Round 1 of NousResearch#17174 hit `nix-lockfile-check` failure. Root cause was NOT a stale hash — the primary `nix (ubuntu-latest)` and `nix (macos-latest)` builds passed. GitHub's Magic Nix Cache returned HTTP 418 (rate-limited / throttled) mid-run, so the rebuild bailed with `some outputs of '/nix/store/...-npm-deps.drv' are not valid, so checking is not possible` — no `got:` line for the script to extract. The script then incorrectly treated this as 'build failed with no hash mismatch' and exited 1, breaking the lint on every PR whenever the cache is throttled. Now we recognize the throttling/cache-disabled signature and skip that entry with a warning. A real stale hash still surfaces in the primary `.#$ATTR` build (separate CI job), so we don't lose coverage.
…sh-refresh fix(nix): refresh web/ npm-deps hash to unblock main builds
…17131) This PR groups the TUI fixes that restore macOS Terminal usability and clean up the theme/composer regressions: - copy transcript selections on macOS drag-release so Terminal.app users can copy while mouse tracking is enabled - copy composer selections on macOS drag-release; composer selection is internal to TextInput and does not use the global Ink selection bus - keep IDE Cmd+C forwarding setup macOS-only, and make keybinding conflict checks respect simple when-clause overlap/negation - force truecolor before chalk initializes (unless NO_COLOR / FORCE_COLOR / HERMES_TUI_TRUECOLOR opt-outs apply) so the default banner keeps its gold/amber/bronze gradient in Terminal.app - move TUI surfaces onto semantic theme tokens and preserve skin prompt symbols as bare tokens with renderer-owned spacing - render focused placeholders as dim hint text in TTY mode instead of inverse/selected-looking synthetic cursor text
) * fix(tui): honor documented mouse_tracking config key The TUI runtime was reading display.tui_mouse while docs and user-facing examples pointed users at display.mouse_tracking. That made persistent mouse-disable config look like a no-op for users trying to restore native terminal selection/copy behavior on Linux/SSH/tmux terminals. Use display.mouse_tracking as the canonical key, keep display.tui_mouse as a legacy fallback, and have /mouse write the documented key. Both gateway config.get and client-side config sync now share the same precedence: the canonical key wins, then the legacy key, then default on. * review(copilot): align mouse tracking config coercion - Load gateway config once before deriving display.mouse_tracking state. - Use key-presence precedence on the TUI client too, so canonical mouse_tracking wins over legacy tui_mouse even when the value is null. - Treat numeric 0 as disabled on both gateway and client, matching the existing string "0" handling. - Widen ConfigDisplayConfig mouse fields because config.get full returns raw YAML, not normalized booleans.
NousResearch#17098) Two amplifying optimizations to per-turn overhead in the gateway: 1. get_tool_definitions() memoization (model_tools.py) Keyed on (frozenset(enabled), frozenset(disabled), registry._generation, config.yaml mtime+size). Only active when quiet_mode=True (which is every hot-path caller — gateway, AIAgent.__init__); quiet_mode=False keeps the existing print side effects. Cached path returns a shallow-copy list sharing read-only schema dicts. Measured: 7.5 ms → 0.01 ms per call (~750× speedup). Gateway constructs fresh AIAgent per message, so this saves ~7 ms/turn before any LLM work. 2. check_fn() TTL cache (tools/registry.py) check_fn callables like check_terminal_requirements probe external state (Docker daemon, Modal SDK, playwright binary). For a long-lived process, hitting them on every get_definitions() pass was pure waste — external state changes on human timescales. 30 s TTL so env-var flips (hermes tools enable X) propagate within a turn or two without explicit invalidation. Measured: first call 7.5ms → 1.6ms (check_fn probes now dominate); subsequent calls ~0.01ms via the upstream memoization. Invalidation surface: - registry._generation bumps on register/deregister/register_toolset_alias, invalidating the memoized definitions automatically. - config.yaml mtime in the cache key captures user-visible config edits affecting dynamic schemas (execute_code mode, discord allowlist). - invalidate_check_fn_cache() exposed for explicit flushes (e.g. after hermes tools enable/disable). - tests/conftest.py autouse fixture clears both caches before every test so env-var monkeypatches don't see stale results. Also fixes a regression from PR NousResearch#17046 that I missed: - tools/web_tools.py — Firecrawl was removed from module scope by the lazy import, breaking 8 tests that patch 'tools.web_tools.Firecrawl'. Applied the same _FirecrawlProxy pattern used in auxiliary_client/ run_agent for OpenAI (module-level proxy that looks like the class but imports the SDK on first call/isinstance; patch() replaces the attribute as usual). Verified: - 49/49 tests/tools/test_web_tools_config.py pass (was 8 failing on main) - 68/68 tests/tools/test_homeassistant_tool.py pass (was 1 failing in the full suite due to check_fn TTL cross-test pollution; fixed by the autouse fixture) - 3887/3895 tests/tools/ (8 pre-existing fails: 2 delegate, 1 mcp dynamic discovery, 5 mcp structured content — all confirmed on main) - 2973/2976 tests/agent/ + tests/run_agent/ (3 pre-existing fails) - 868/868 tests/run_agent/ (excluding test_run_agent.py which has pre-existing suite-level issues) - Live smoke: 2 turns + /model switch + tool calls, zero errors in agent.log session window. Co-authored-by: teknium1 <teknium@users.noreply.github.com>
Validate configured providers against both Hermes runtime provider ids and catalog-normalized provider ids. This keeps providers like ai-gateway from being rejected after catalog resolution maps them to models.dev ids. Keep credential checks and vendor-slug warnings anchored to the runtime id so doctor reports actionable provider names in follow-up diagnostics.
…ch#17202) Replace the removed built-in boot-md hook (NousResearch#17093) with a how-to that shows users how to wire up the same behavior themselves via the hooks system. Uses _resolve_gateway_model() + _resolve_runtime_agent_kwargs() so the example works against custom endpoints and OAuth providers, not just the aggregator defaults that the old built-in silently assumed. Co-authored-by: teknium1 <teknium@users.noreply.github.com>
…17203) Co-authored-by: teknium1 <teknium@users.noreply.github.com>
…ousResearch#17206) detect_dangerous_command() and detect_hardline_command() were calling re.search(pattern, text, re.IGNORECASE | re.DOTALL) inline — Python's re._cache (512 patterns) amortizes compile cost on the warm path, but: 1. The first terminal() call per process pays the full compile fan-out for all 59 patterns (12 HARDLINE + 47 DANGEROUS). Measured at ~2.6 ms per detect_dangerous_command() call after re.purge(). 2. The re._cache is LRU — unrelated regex work elsewhere in the agent (response parsing, text normalization, etc.) can evict our patterns and silently re-compile them on the next terminal() call. Precompiling at module load eliminates both costs: detect_dangerous_command: cold 2.613 ms → 0.298 ms (-88%) warm 0.042 ms → 0.004 ms (-90%) detect_hardline_command: cold ~0.6 ms → 0.006 ms warm 0.011 ms → 0.002 ms Savings are per terminal() call. Agents with heavy terminal use see compound savings; the bigger value is the stability guarantee (no re._cache eviction can silently re-introduce the 2.6 ms cold cost mid-session). Implementation: - HARDLINE_PATTERNS_COMPILED and DANGEROUS_PATTERNS_COMPILED built at module load from the existing (pattern, description) tuples, using shared _RE_FLAGS = re.IGNORECASE | re.DOTALL. - detect_* functions now iterate the compiled list and call pattern_re.search(text). - Original HARDLINE_PATTERNS and DANGEROUS_PATTERNS lists kept as-is (other code in the file uses them for key derivation / _PATTERN_KEY_ALIASES). Verified: - 160/161 tests/tools/test_approval*.py pass (1 pre-existing heartbeat test flake on main). - 349/349 tests/tools/ 'approval or terminal or dangerous' pass. - Live hermes chat smoke: 3 benign terminal commands + 1 rm -rf /tmp/ (clarify prompt fired — approval path still works) + 1 sudo (sudo password prompt fired — DANGEROUS pattern match still works). 23 log lines in the smoke window, zero errors. Co-authored-by: teknium1 <teknium@users.noreply.github.com>
Prevent unterminated bracketed paste input from swallowing future keystrokes, and avoid rendering an empty Thinking panel before reasoning arrives.
Keep the latest prompt sticky while the viewport is in live assistant output beyond history, and clear stale sticky state at the real bottom using fresh scroll height.
Keep the /steer acknowledgement plain text so it reads like the rest of the TUI status copy.
Run the TUI lint autofix and formatter on the PR branch after the sticky prompt and paste recovery changes.
Match the buffered-stdin rearm cadence to IN_PASTE state so large pastes do not spin the normal escape timeout while waiting for readable data to drain.
…watchdog fix(tui): stabilize sticky prompts and paste recovery
…ft (NousResearch#17207) Three modules independently implemented the same "preserve head+tail of a secret, mask the middle" logic with slightly different behaviors that had started to drift: hermes_cli/config.py redact_key — 12-char floor, 4+4, DIM '(not set)' hermes_cli/status.py redact_key — 12-char floor, 4+4, plain '(not set)' ← drift hermes_cli/dump.py _redact — 12-char floor, 4+4, empty string The visible bug: 'hermes status' displayed the '(not set)' placeholder in plain text while 'hermes config' showed it in dim text. Same concept, inconsistent UI. Introduces mask_secret() in agent/redact.py as the canonical helper, with head/tail/floor/placeholder/empty kwargs. The three call sites become one-line wrappers that differ only in the 'empty' handling: config.redact_key → mask_secret(k, empty=color('(not set)', Colors.DIM)) status.redact_key → mask_secret(k, empty=color('(not set)', Colors.DIM)) dump._redact → mask_secret(v) # empty → '' agent.redact._mask_token (log redactor, different policy: 18-char floor, 6+4 visible, '***' on empty) also ports to mask_secret but retains its own empty-case handling to preserve the historical '***' return. Net: the three display-time redactors now agree on formatting, the canonical helper lives in one place, and future tweaks (e.g. adding bullet-point masking, changing the head/tail widths) happen once. Verified: - 3/3 tests/hermes_cli/test_web_server.py::TestRedactKey pass - 89/89 agent/tests/test_redact.py + tests/tools/test_browser_secret_exfil.py + tests/hermes_cli/test_redact_config_bridge.py pass - Live 'hermes status', 'hermes config', 'hermes dump' all render the same way they did before (verified against actual env with real keys: OpenRouter, Firecrawl, Browserbase, FAL, Tinker all show 'prefix...suffix'; Kimi shows '***' at <12 chars; unset shows '(not set)' uniformly). Co-authored-by: teknium1 <teknium@users.noreply.github.com>
…riants (NousResearch#17213) The background skill-review prompts (_SKILL_REVIEW_PROMPT and the **Skills** half of _COMBINED_REVIEW_PROMPT) steered the reviewer toward passive behavior — most passes concluded 'Nothing to save.' even when the session produced real lessons. User-preference corrections (style, format, legibility, verbosity) were especially lost: they were read as memory signals only, so skills never carried the fix. This rewrite changes the stance: - **Active-update bias.** The reviewer now treats inaction as a missed learning opportunity. 'Nothing to save.' remains an explicit escape but is no longer framed as the most-common outcome. - **User-preference corrections are first-class skill signals.** Style, tone, format, legibility, verbosity complaints — and the actual phrasings users use ('stop doing X', 'this is too verbose', 'I hate when you Y', 'remember this') — now warrant patching the skill that governs the task, not just writing to memory. - **Loaded-skill-first preference order.** When a skill was loaded via /skill-name or skill_view during the session, the reviewer patches THAT one first. It was in play; it's the right place. - **Four-step ladder: patch-loaded → patch-umbrella → support-file → create.** Support files are explicitly enumerated as three kinds: * references/<topic>.md — session-specific detail OR condensed knowledge banks (quoted research, API docs excerpts, domain notes) * templates/<name>.<ext> — starter files to copy and modify * scripts/<name>.<ext> — statically re-runnable actions - **Name-veto for CREATE.** New skill names MUST be class-level — no PR numbers, error strings, codenames, library-alone names, or session artifacts ('fix-X / debug-Y / audit-Z-today'). If the proposed name only fits today's task, fall back to one of the patch/support-file options. - **Memory scope clarified.** 'who the user is and what the current situation and state of your operations are' — MEMORY.md is situational/state, USER.md is identity/preferences. - **Curator handoff.** Reviewer flags overlap; the background curator handles consolidation at scale. Single-session reviewer doesn't attempt umbrella-rebalancing. Tests: tests/run_agent/test_review_prompt_class_first.py upgraded to assert the new behavioral contracts (active bias, user-correction signals, loaded-skill-first, support-file kinds, name-veto, memory framing, curator handoff). 17 tests, all pass. Co-authored-by: teknium1 <teknium@users.noreply.github.com>
Strategy this round: classified conflicts into 107 ghost conflicts (files dy-main has no real customizations for, only stale snapshots from prior squash syncs) and 13 real-custom files. Took --theirs for the ghosts, hand-merged the real ones. Notable resolutions: - kept fork "queue" busy_input_mode default in cli-config.yaml.example, cli.py, gateway/run.py, hermes_cli/config.py, hermes_cli/main.py, tests/gateway/test_restart_drain.py, website/docs/user-guide/cli.md - kept fork local-client-bridge stop in gateway/run.py - kept fork resolve_repo_context() in gateway/session.py - preserved fork normalize_whatsapp_identifier import alongside upstream's atomic_replace import - dropped duplicate parent_chat_id field on SessionSource (artifact from earlier bad merge) - dropped dead `_resolve_claude_code_token_from_credentials` import in hermes_cli/main.py - rewrote test_load_busy_input_mode_prefers_env_then_config_then_default with internally-consistent assertions (prior upstream-sync left it asserting that env="queue" should yield "steer") - accepted upstream changes everywhere else, including NousResearch#15281 (system-prompt duplication fix), NousResearch#15748/NousResearch#15812 (deepseek/kimi poisoned-history fix), NousResearch#15803 (api-key hygiene), NousResearch#16844/NousResearch#16884 (streaming reasoning_content fallback), NousResearch#17207 (canonical mask_secret), and the new `updates`, `tui_auto_resume_recent`, Yuanbao, indicator-style features. Documented the ghost-conflict pattern + recommended sync workflow in CLAUDE.md so future syncs don't redo this archaeology. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
🚨 CRITICAL Supply Chain Risk DetectedThis PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging. 🚨 CRITICAL: Install-hook file added or modifiedThese files can execute code during package installation or interpreter startup. Files: Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting. |
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.
Summary
Merges
upstream/main(1d4218be) intody-mainfor the 2026-04-29 sync window. Bundled with a fix for the recurring "ghost conflict" problem that has plagued every prior sync.--theirsfor those. 13 had real fork edits and were hand-merged.updates,tui_auto_resume_recent, Yuanbao platform notes, and/indicatorbusy-style command.queuedefault forbusy_input_mode, local-client-bridge stop in gateway shutdown,resolve_repo_context()for chat-scoped repo bindings,normalize_whatsapp_identifierimport.parent_chat_idfield onSessionSource(dataclass artifact from a prior bad merge), dropped dead_resolve_claude_code_token_from_credentialsimport inhermes_cli/main.py, rewrotetest_load_busy_input_mode_prefers_env_then_config_then_default(was asserting env="queue" yields "steer" — internally inconsistent after fork's queue default).CLAUDE.mdso future syncs don't redo this archaeology. Key lever: stop squashing upstream syncs into dy-main, sogit merge-basekeeps working.Test plan
python -c "import gateway.run; import hermes_cli.main; import run_agent; from gateway.session import SessionSource, resolve_repo_context"— all imports cleanpytest tests/gateway/test_restart_drain.py— 14/14 passpytest tests/gateway/— 3947 pass, 19 pre-existing unrelated failures (_build_status_thread_metadatareferenced by tests but never defined in either branch)pytest tests/gateway/test_repo_context_command.py tests/gateway/test_local_client_*.py tests/gateway/test_busy_session_ack.py tests/cli/test_cli_init.py— all dy-main custom-feature tests pass~/.hermes/hermes-agent, restart gateway, verify Discord/Slack/Telegram still route, queue mode still default, sidecar bridge still stops cleanly on shutdown.🤖 Generated with Claude Code