Cold CLI time-to-banner was ~1.8s (hermes) / ~2.8s (hermes -w). The banner
path was paying for work the session doesn't need before first input:
- aux availability probes built REAL OpenAI/httpx clients (openai import
~0.3s + SSL context) just to answer check_fns. New aux_probe_mode()
returns a cache-excluded stub; resolution policy unchanged.
- tools/mcp_tool imported the mcp SDK (~260ms, mcp.types pydantic model
construction) at module import even with zero MCP servers configured.
SDK import is now lazy behind _ensure_mcp_sdk(); _MCP_AVAILABLE is a
find_spec probe so every existing gate/test keeps its semantics.
- banner blocked 500ms on the update-check prefetch; now waits 50ms and
defers the warning line to a daemon thread (prints above the prompt).
- banner recomputed get_tool_definitions + skills scan + git state every
launch; now snapshotted to ~/.hermes/cache/banner_snapshot.json keyed on
(config.yaml, .env, checkout rev, toolsets) and replayed on warm launches
with a background refresh. Agent tool list is still computed fresh.
- _resolve_active_context_length probed the Nous portal /models (~200ms
network) per launch; the tool-search gate now prefers the on-disk
context cache when present.
- schema reconciliation re-executed SCHEMA_SQL in a scratch SQLite DB
(~85ms) per SessionDB(); the reference parse is now disk-memoized by
DDL hash (live-DB diffing still runs every startup).
- bundled-skills sync (~120-170ms rglob/hash) moved off the startup path
to a daemon thread; plugin discovery starts in the background and every
synchronous consumer joins via discover_plugins().
- hermes_cli.auth imported httpx eagerly (~30ms); now a lazy proxy that
test monkeypatching still reaches (setattr forwards to the real module).
- fast chat launch: unambiguous 'hermes'/'hermes chat' invocations skip
building all ~40 subcommand parsers (bails to full dispatch on anything
else, incl. container mode).
- -w path: git worktree add runs with checkout.workers=8 (0.6s→0.2s) and
overlaps HermesCLI construction; --skills preload runs in the background
and is folded in at agent init (finalize_preloaded_skills, same
fail-loud contract for fully-unknown skill lists); stale-worktree prune
moved off the banner path.
Warm results (PTY time-to-banner, 5-run): hermes 1.80s → 0.38-0.40s;
hermes -w -s hermes-agent-dev --yolo 2.82s → 0.57-0.69s.
Six test files still selected an OS branch with a faked host. Each one now
carries the marker for the host that owns the branch, or derives the
expectation from the real host:
- test_clipboard: macos_only on the has_clipboard_image dispatch. The fake
picked the branch, but _macos_has_image needs osascript.
- test_claw: windows_only on the tasklist/powershell scan, with return_value
in place of a side_effect list that pinned the call count.
- test_linux_desktop_entry: the parametrize over "darwin"/"win32" becomes one
marked test per host. A fake left POSIX paths and a POSIX XDG layout.
- test_graphical_browser_detection: linux_only on the display-server arm. The
$BROWSER check runs before the platform branch, so its test stays unmarked.
- test_auth_nous_provider: the fixture pinned linux so the macOS certifi
fallback could not change the result. The assertion now reads the host, so
the macOS lane covers the fallback too.
- test_tts_macos_output and test_voice_mode: the afplay policy exists because
CoreAudio init raises a TCC prompt, which no Linux runner reproduces.
tests/conftest.py refuses collection when one test carries two OS markers.
Each marker skips on all but one host, so two of them make a test that runs
nowhere while every lane reports green. tests/test_os_marker_gating.py pins
that behavior.
The docstring on TestConfirmDestructiveSlash said the Windows job runs it.
The class has no marker, so -m windows_only deselects it.
many tests patched sys.platform or a module's _IS_WINDOWS flag, then
ran on linux ci. the patch selects the branch under test, but the host
does not have the behavior the branch exists for. the test proves the
patch, not the platform. some gated assertions never ran on any host.
this commit adds three markers: linux_only, macos_only, windows_only.
a conftest hook skips a marked test on the other hosts, with a clear
reason. no test fakes a host now. two documented fakes remain
(android/termux, freebsd) because no ci runner exists for them.
each fake site got one of four treatments:
- gate it: the real host supplies the platform; mocks cover real
dependencies only, never host identity
- patch the module's own probe when the subject is the probe's consumer
- assert against the real host when the fake stood in for any non-x host
- delete the patch when it set the value the host already has
bare skipif(sys.platform != ...) guards became markers too. the lane
model skips these on linux and never imports them on windows, so they
ran on no host. platform parametrize tables are now one marked test
per os.
running on real hosts found real errors: a chrome-sandbox failure in
test_gui_command that main hides, and two windows failures fixed here.
the agents.md testing section now documents the policy.
Personality persistence used to be split per surface: the TUI/desktop wrote
the NAME to display.personality while the CLI/gateway wrote rendered TEXT
into agent.system_prompt (and their /personality none only blanked the
text, leaving the name behind). When #81946 made display.personality
authoritative everywhere, stale names written long ago resurrected
personalities users had turned off - kawaii defaulting on after updating.
- hermes_cli/personality.py: new single owner of personality state.
Built-in personality definitions, neutral-name normalization, rendering,
availability (built-ins overlaid by agent.personalities), overlay
resolution, and the ONLY sanctioned persistence path
(persist_personality -> display.personality; never agent.system_prompt).
- v34 config migration: one-time reset of display.personality to none
(announcing which personality was cleared and how to re-enable), plus a
scrub of agent.system_prompt when it verbatim-equals a known personality
render (machine-written by the old CLI/gateway). Hand-written manual
prompts are never touched.
- All surfaces rewired through the module: CLI /personality (incl. active
marker in the list), gateway /personality, TUI config.set + slash path
(which previously applied without persisting), TUI config.get (reports
the EFFECTIVE personality), completer, hermes config display, and the
tui_gateway health probe.
- cli.py/config duplicates removed: built-ins now defined once; the
desktop mirrors them from one lib module (src/lib/personalities.ts).
- Docs updated: selection lives in display.personality, built-ins always
available, one-time reset note.
The classic CLI's light-mode detection sends an OSC 11 background-color
query and blind-waits 100ms. Terminal managers that swallow OSC 11
(herdr) made every startup pay the full 100ms for nothing, and any
in-order relay that answers slower than 100ms (SSH bridges, WSL,
loaded tmux servers) delivered the reply AFTER prompt_toolkit owned
the tty — the rgb:.../escape payload leaked into the input line as
gibberish characters.
Fix: send the OSC 11 query followed by a DA1 sentinel (ESC [ c) in one
write — the same fence pattern the Ink TUI's TerminalQuerier uses.
Terminals answer queries in order and effectively all of them answer
DA1, so the DA1 reply proves the terminal has already processed (or
ignored) our OSC 11. Fast terminals and herdr-style multiplexers now
resolve in ~1ms; slow relays get their reply consumed instead of
leaked; a hypothetical DA1-mute terminal falls back at a 1s safety
net, same clean timeout path as before.
Adds real-PTY regression tests covering the herdr-style (DA1-only),
slow-relay (+300ms reply), and fully mute emulator behaviors, each
asserting zero leftover bytes in the tty buffer. Sabotage-verified:
the slow-relay test fails against the old un-fenced code with the
exact leak payload in LEFTOVER.
On slow terminals (VPS, containers under load), the OSC 11 background
color response can arrive after TCSAFLUSH completes — leaking into
prompt_toolkit's input buffer and silently consuming the first 1–3
characters of every response.
Add a 50ms post-flush drain window that reads and discards any late
bytes via select() + os.read() before prompt_toolkit grabs the tty.
Fixes#40250
get_messages() only deserializes content and tool_calls; the structured
reasoning columns (reasoning_details, codex_reasoning_items,
codex_message_items) come back as the raw TEXT they were stored as.
Feeding those rows straight back into a write, which is exactly what
the POST /api/sessions/{id}/fork handler does by piping get_messages()
into replace_messages(), hit an unguarded json.dumps() and stored the
already-serialized string encoded a second time. On replay of the fork,
json.loads() then yields the inner string instead of a list, and every
consumer's isinstance(..., list) gate silently drops it: preserved
Anthropic thinking blocks, Codex encrypted-reasoning/message-item
replay, and OpenRouter multi-turn reasoning context are all lost after
a fork, with one more encoding layer added per fork.
The /branch copy loop had the same defect from the other side: it
forwarded reasoning but none of the structured columns, and both TUI
branch writers persisted role/content alone, dropping reasoning and
reasoning_content along with them.
Route the six dumps sites in append_message and _insert_message_rows
through a shared guard that keeps already-serialized strings as-is;
structured values from the live runtime are dumped exactly as before.
Forward the reasoning fields in all three branch writers, matching the
set gateway/slash_commands.py already forwards on its own /branch path.
A session's YOLO bypass lived only in the in-memory
tools.approval._session_yolo set (or the process-frozen --yolo env
var), so resuming a session in a fresh process silently reverted the
user's /yolo ON — dangerous commands started prompting again.
Persist a yolo_mode flag in the session row's model_config JSON and
restore it on every CLI resume path:
- SessionDB.set_session_yolo() merges the flag into model_config
(same lineage-preserving merge as update_session_runtime_lock);
SessionDB.session_yolo_enabled() reads it back, false on any parse
failure.
- /yolo toggle persists ON and OFF through the new helper; the
compression/branch session-id rotation carries the flag onto the
continuation row.
- --yolo launches record the flag at session creation (agent_init),
and a /yolo toggled before the lazily-created row exists is carried
into the creation-time model_config (_ensure_db_session).
- HermesCLI._restore_session_yolo() re-enables the bypass on startup
--resume/-c, the deferred init path, and mid-chat /resume, with a
visible '⚡ YOLO mode restored from session' notice. No-op under a
frozen process-wide --yolo and never enables on absent/garbage flags.
A completely unconfigured install previously booted into a working-looking
chat (banner showed model 'unknown'), accepted a message, spun ~30s, then
failed with 'Set OPENROUTER_API_KEY' — a provider the user never chose —
and never offered setup.
- HermesCLI.run() now probes provider readiness at startup (TTY only) and
offers the shared provider picker (hermes model flow, which fronts Quick
Setup / Nous Portal OAuth) when nothing is configured. Decline is
respected; picker state re-syncs into the live CLI so the next turn works
without a restart.
- New silent probe _runtime_credentials_ready(): no printing, no state
mutation; handles keyless local endpoints and callable bearer providers.
- The empty-api-key error is provider-aware: names the actual resolved
provider and points at 'hermes model' / 'hermes setup' instead of
hardcoding OPENROUTER_API_KEY.
- Banner: unconfigured installs render 'no model configured — run /model'
in red instead of the silent 'unknown' model slug.
Consumer-onboarding audit finding #2 (sev 5), Aug 2026.
/background (/bg, /btw) exists to start independent work while the current
turn keeps running. Typed while the agent was busy it went into
_pending_input like ordinary input, and process_loop is blocked inside
self.chat() for the whole run, so the background task only started once the
foreground turn had finished. That is the one moment it was not needed.
/steer had the identical problem and was fixed the same way, by dispatching
inline on the UI thread. The command's own CommandDef already declares
busy_policy="dispatch"; the gateway honours that, the classic CLI never
consulted it.
The foreground turn is untouched: no interrupt, no steer, and ordinary
non-slash input keeps following the configured busy-input behaviour.
The /indicator command was registered in COMMAND_REGISTRY, listed in
/help, offered by tab-completion, recommended by the tips system, and
even documented in config.py — but it had no actual handler. Running
/indicator in the CLI produced "Unknown command: indicator".
Add _handle_indicator_command to CLICommandsMixin that:
- Shows the current indicator style when called with no args or "status"
- Validates the requested style against the shared INDICATOR_STYLES
allowlist (ascii | emoji | kaomoji | unicode)
- Persists the choice to display.tui_status_indicator in config.yaml
via the existing save_config_value helper
- Falls back to session-only when config save fails
The indicator-style allowlist is defined once in hermes_constants as
INDICATOR_STYLES + DEFAULT_INDICATOR_STYLE and imported by all three
consumers (CLI handler, command registry, TUI gateway config handler),
preventing drift between the TUI and CLI validation.
Also adds tests/cli/test_indicator_command.py covering dispatch,
validation, persistence, and registry integration.
Signed-off-by: dongjiang <dongjiang1989@126.com>
Two tests read cli.py's source to prove handlers exist. Root AGENTS.md bans
that outright: it passes when a handler is wired wrong and fails on a correct
rename. The #4771 rebase-loss regression is guarded by the state-machine
tests every handler delegates to.
Finishes the input stash started in the preceding commit from PR #4771.
That PR shipped only the panel renderer: its `@kb.add('c-s')` handler and
stash-state initialization were lost in a rebase, so the panel predicate
read undefined `_stash_panel_open` / `_stash_list` and the feature was
unreachable. This adds the missing half and the tests the PR never had.
Resolves the review feedback on #4771:
- Rebuilt the stash on current main's keybinding setup. The `c-s` key was
unbound repo-wide, so there is no conflict.
- Extracted the state machine into `hermes_cli/prompt_stash.py` as pure
functions (no prompt_toolkit import) so it is directly unit testable —
the PR was cli.py-only with zero tests.
- Dropped the PR's unrelated changes: delegation `supervisor_model` /
`execution_model` config aliases, and stale reverts of the banner
builder, worktree pruning, logging setup, and MCP toolset validation
that its 14k-commit-old base dragged along.
- Fixed the 📌 double-width measurement for real. Three commits in the PR
("subtract 1 from len()", "use bare len()", "subtract 1 again") were
chasing this by tweaking `len()`; all horizontal math now goes through
`_status_bar_display_width` (prompt_toolkit `get_cwidth`), which also
keeps CJK previews inside the border. Narrow terminals fall back to
compact header/footer labels instead of overflowing — caught by a
parametrized width test, not by eyeballing.
Gesture (the contributor's design, kept):
- Composer has content → push onto the stash, clear the input.
- Composer empty, one stashed → pop it straight back.
- Composer empty, 2+ stashed → open the browse panel (↑↓ / Enter / D / Esc).
- Panel open → Ctrl+S closes it.
Pushing onto a stack rather than a single slot is what makes repeated
Ctrl+S safe: a second stash never silently overwrites the first, and with
2+ parked the panel asks rather than guessing which to restore. A `📌 N`
status-bar badge and a composer placeholder advertise the parked draft so
it cannot be silently forgotten.
Deliberate departures from the PR:
- No auto-restore after the agent responds, and no `display.stash_auto_restore`
config key. The PR itself had already defaulted this to false as
"avoids surprising the user"; a keystroke the user pressed should not
cause text to reappear on its own, so the dead default is dropped
rather than carried as config surface.
- Nothing is persisted to disk. Drafts routinely contain pasted
credentials and NDA material, so the stash is session-scoped and
in-memory only. Any future persistence must route through
`get_hermes_home()`.
- Suppressed while a modal prompt owns the composer (sudo / secret /
approval / clarify / slash-confirm / model picker) so Ctrl+S can never
stash a password.
- Restoring images extends `_attached_images` instead of replacing it, so
an attachment added since the stash was taken is not silently dropped.
- `buf.reset()` on stash (not `text = ""`) clears completion state,
selection, and undo stack with the text.
Tests: 95 new tests across two files — 66 on the state machine (empty
buffer is a no-op, exact round-trips including newlines/tabs/CJK/fences,
no-clobber ordering, cap eviction, indicator states, panel cursor
clamping and deletion, the full resolve_ctrl_s decision table) and 29 on
the cli.py wiring (per-instance stash, keybinding registration guard,
layout slot, panel bounded at 8 widths, status-bar indicator lifecycle).
The keybinding-registration test asserts the `c-s` handler exists in
source specifically so the rebase loss that broke #4771 cannot recur.
Verified: 153 passed, 0 failed across the two new files plus
tests/cli/test_cli_init.py and tests/cli/test_cli_extension_hooks.py.
ruff check clean; check-windows-footguns clean.
Docs: Ctrl+S added to the CLI keybindings table.
Co-authored-by: CK iRonin.IT <cyprian@ironin.pl>
Terminals that rewrite Cmd+Backspace to Ctrl+U already reach
unix-line-discard. Kitty keyboard protocol and xterm modifyOtherKeys
terminals instead report Cmd as the super modifier bit, producing CSI
sequences prompt_toolkit has no entry for — the raw bytes fall through
the VT100 parser and land in the buffer as literal text.
Alias those to the readline kill bindings prompt_toolkit already ships.
Backspace is a CSI-u codepoint (127); ForwardDelete is a CSI tilde key,
so its modifier rides in the CSI 3 ; mod ~ form rather than CSI-u.
Ctrl+ForwardDelete keeps its own binding — that is delete-word on
Linux/Windows, not kill-line.
Salvaged from #57016 by @lEWFkRAD:
- cli.py: handle file:///C:/... drive-letter URIs on nt (strip the
leading slash urlparse leaves); join Termux example paths with literal
forward slashes so hints stay POSIX on Windows.
- gateway/status.py + hermes_cli/gateway.py: normalize backslashes to
forward slashes before the HERMES_HOME substring match so separator
style cannot defeat profile ownership detection.
- hermes_cli/banner.py: cprint degrades to plain print when
prompt_toolkit has no console (NoConsoleScreenBufferError on
redirected/absent Windows stdout).
- hermes_cli/browser_connect.py: posixpath.join for WSL /mnt/c/... bases
(os.path.join would emit backslashes on nt).
- Test hardening: symlink skip-guards, USERPROFILE alongside HOME for
ntpath.expanduser, SIGKILL absence skipif fixed via monkeypatch,
drive-letter URI / separator-normalization / banner-fallback coverage.
Dropped from the original PR: tests/cli/conftest.py fixture and the
AppSession _output monkeypatch — main's merged tests/cli/conftest.py
already handles that prompt_toolkit pollution.
Autouse fixture also resets approval_module._YOLO_MODE_FROZEN so a
HERMES_YOLO_MODE=1 host env can't poison every case (the one
startup-frozen test still patches it back explicitly). Adds the darwin
'ps -o stat=' zombie branch to _is_alive_like_dispatcher, mirroring
production hermes_cli/kanban_db.py — a no-op on Linux.
Salvaged from PR #34069 by @sunwz1115.
Co-authored-by: sunwz1115 <192549904+sunwz1115@users.noreply.github.com>
test_session_not_found_goes_to_stdout_in_full_mode passes in isolation but
fails in a full tests/cli run. Two independent leaks from the same neighbor
test conspire:
1. test_cli_init.py's _make_cli() reloads cli.py while prompt_toolkit is
stubbed with MagicMocks and never reloads it back, so sys.modules['cli']
is left with a mock _pt_print/_PT_ANSI and cli._cprint silently no-ops
for every later test. Fixed by reloading cli once more with the real
modules visible (try/finally).
2. prompt_toolkit's print_formatted_text caches its Output on the
process-global default AppSession the first time it renders without an
explicit output=. Under capsys (which swaps sys.stdout per test), the
first CLI test to emit through _cprint locks that cache onto its own
captured stdout, so later capsys tests read an empty buffer. Fixed with
an autouse fixture in a new tests/cli/conftest.py that resets the cached
output around each test.
Neither change touches production code or the flaky test's own assertion.
Related to #59358 (which addresses the same flaky test by mocking _cprint in
the assertion instead; this fixes the two underlying leaks at the source and
does not modify test_resume_quiet_stderr.py).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The #71637 prune fix cut one stage of -w startup, but the base-ref
resolution right after it still ran an uncapped-in-practice
'git fetch origin main' (timeout=30) on every launch — and on a flaky
smart-HTTP connection that fetch intermittently stalled to the full 30s,
then cascaded into step 2's SECOND 30s fetch. Measured: back-to-back
fetches of 0.9s, 1.0s, 63.5s on the same box with healthy TLS (~185ms).
_resolve_worktree_base now:
- skips the fetch entirely when FETCH_HEAD is < 5 min old and the
tracking ref exists (repeat launches pay zero network cost)
- caps the fetch at 5s and falls back to the locally-known tracking
ref (labelled 'cached') on timeout/failure instead of cascading into
a second fetch — genuine staleness stays backstopped by the pre-push
stale-base gate
- caps 'git remote show origin' the same way
Worst case drops ~60s -> ~5s; warm path is ~0.02s (was up to 30.8s).
sync_base=False and the offline HEAD fallback are unchanged.
- Remove tests/-shadowing sys.path.insert(dirname/'..') from 11 test files:
it prepended the tests/ dir itself to sys.path, so 'import agent' /
'import hermes_cli' resolved to the test packages and collection died
with ModuleNotFoundError depending on import order (2 files failed in
every full-suite run; 9 more were latent).
- Patch call_llm in 5 context-compressor tests that called compress()
unmocked: each burned ~50s attempting live LLM traffic through the
relay before falling back (572s file — the slowest in the suite, and
flaky under the 300s per-file timeout). File now runs in ~5s.
- agent/redact.py: fix two catastrophically-backtracking regexes hit by
the compressor's redaction pass on large payloads —
_STRICT_URL_USERINFO_RE anchors on the mandatory '//' (optional-scheme
prefix backtracked O(n^2): ~55s on a 320KB payload, now sub-ms;
output-equivalence fuzz-verified on 20k random strings), and the
_CFG_DOTTED_RE/_CFG_ANCHORED_RE subs gain an exact linear keyword
pre-gate so secret-free text skips the quadratic pattern entirely.
- tests/gateway/test_feishu.py: version-guard the extra_ua_tags SDK
signature check; the repo pins lark-oapi==1.6.8 but stale local
installs (1.5.3) fail the assertion — skip below the pin.
- tests/tools/test_managed_browserbase_and_modal.py: stub
agent.redact + agent.credential_persistence in the fake agent package
(empty __path__ blocks all real agent.* imports added since the fake
was written).
- tests/gateway/test_startup_restart_race.py: raise wait_for timeouts
2s -> 30s; 2s wall-clock on a loaded 40-worker box flaked in the
baseline run (passes instantly when the box is quiet).
- Remove tests/-shadowing sys.path.insert(dirname/'..') from 11 test files:
it prepended the tests/ dir itself to sys.path, so 'import agent' /
'import hermes_cli' resolved to the test packages and collection died
with ModuleNotFoundError depending on import order (2 files failed in
every full-suite run; 9 more were latent).
- Patch call_llm in 5 context-compressor tests that called compress()
unmocked: each burned ~50s attempting live LLM traffic through the
relay before falling back (572s file — the slowest in the suite, and
flaky under the 300s per-file timeout). File now runs in ~5s.
- agent/redact.py: fix two catastrophically-backtracking regexes hit by
the compressor's redaction pass on large payloads —
_STRICT_URL_USERINFO_RE anchors on the mandatory '//' (optional-scheme
prefix backtracked O(n^2): ~55s on a 320KB payload, now sub-ms;
output-equivalence fuzz-verified on 20k random strings), and the
_CFG_DOTTED_RE/_CFG_ANCHORED_RE subs gain an exact linear keyword
pre-gate so secret-free text skips the quadratic pattern entirely.
- tests/gateway/test_feishu.py: version-guard the extra_ua_tags SDK
signature check; the repo pins lark-oapi==1.6.8 but stale local
installs (1.5.3) fail the assertion — skip below the pin.
- tests/tools/test_managed_browserbase_and_modal.py: stub
agent.redact + agent.credential_persistence in the fake agent package
(empty __path__ blocks all real agent.* imports added since the fake
was written).
- tests/gateway/test_startup_restart_race.py: raise wait_for timeouts
2s -> 30s; 2s wall-clock on a loaded 40-worker box flaked in the
baseline run (passes instantly when the box is quiet).
Streamed responses no longer insert real newlines at terminal width —
logical lines are emitted whole and the terminal soft-wraps them, so
highlight-copy rejoins the full line (emulators only keep linebreaks
the app actually printed). This is the CLI equivalent of the TUI's
selection copy, which reads logical source lines from its screen
buffer. TTFT perception is preserved by mirroring the unfinished
line's tail into the spinner status text instead of chunk-printing.
/copy now prefers OSC 52 when running over SSH (SSH_CONNECTION /
SSH_TTY / SSH_CLIENT) — native tools there write the REMOTE clipboard,
which is never what the user wants. The CLI's OSC 52 writer also gains
tmux/screen DCS passthrough wrapping, mirroring the TUI's
wrapForMultiplexer. Fixes#31528 for the CLI surface.
Sabotage-verified: restoring the old chunk emitter fails 3 of the new
tests (hard-wrap detection, spinner mirror, unbreakable-run split).
Streamed response text carried a 4-space _STREAM_PAD indent and the
final-response Rich Panel used padding=(1, 4), so every line selected
out of the terminal came with leading whitespace. Both now render
flush-left (pad empty, panel padding=(1, 0)); the table-realignment
width budgets were widened to match.
/copy now writes the ORIGINAL message text through native clipboard
tools (pbcopy / PowerShell Set-Clipboard via base64 / wl-copy / xclip /
xsel — same fallback chain as the TUI's writeClipboardText), falling
back to OSC 52 only when no native backend succeeds. This is the
TUI-equivalent answer to soft-wrap mangling: the clipboard gets the raw
text, not the rendered layout.
The wake-word ear reverted to disabled after every restart even when
closed enabled. Root cause is general, not wake-specific: save_config_value
followed load_cli_config's precedence, which falls back to the repo's
checked-in cli-config.yaml when HERMES_HOME/config.yaml doesn't exist yet.
On such installs (managed/desktop first launch), the toggle's persist wrote
wake_word.enabled=true into cli-config.yaml and returned success — but
every config reader (load_config -> get_hermes_home()/config.yaml,
including load_wake_word_config) reads only HERMES_HOME/config.yaml, so the
setting was invisible on the next launch. Same silent loss hit any runtime
persist (model switch, /reasoning, /fast, skin) on a config-less install.
save_config_value now always targets get_hermes_home()/config.yaml,
creating it if absent, and never writes the shipped repo template. Also
resolves HERMES_HOME live instead of the import-time _hermes_home constant
(profile-safe).
E2E verified: persist -> fresh module reload -> load_wake_word_config sees
enabled=true and wake_surface_enabled('gui') is True, for both a fresh
(no config.yaml) and an existing-config install.
- cli.py save_config_value: target user config, create if absent
- tests: 2 regression tests (creates user config when absent; never writes
repo cli-config.yaml); fixture now sets HERMES_HOME. 11 file + 206
adjacent config/model-switch tests green
When an operator changes the global model/provider config, warn that
unpinned cron jobs with stored snapshots will fail-closed on their next
run. Adds a cron.model_drift_guard config opt-out (default true) for
fleets that should deliberately track changing global defaults.
Addresses #59031. Original PR #59177 by @doncazper.
Append a "⊙ goal 3/20" segment (turns used / turn budget) to the CLI
status bar whenever a standing /goal is active. Mirrors the desktop
composer goal indicator: active-goal-only — paused/done goals stay out
of the bar since they already print their own glyph lines in-thread.
- Snapshot: goal_active / goal_turns_used / goal_max_turns from the
cached GoalManager (in-memory attribute read, no DB hit per repaint).
- Rendered in all three width tiers of both _build_status_bar_text and
_get_status_bar_fragments, and it respects the /statusbar toggle for
free (the toggle gates _get_status_bar_fragments as a whole).
- Tests: segment composition, active-only contract, all width tiers.
Status-bar goal indicator concept from #43020.
Co-authored-by: Akshan Krithick <akshankrithick305@gmail.com>
Assisted-by: Claude Fable 5 via Hermes Agent
The default max_iterations/agent.max_turns budget was set when long
agentic runs were rare; complex tasks now routinely exceed 90 tool
calls. Raise the default to 500 across every surface that hardcodes
the fallback: AIAgent constructor, DEFAULT_CONFIG, CLI resolution
chain, gateway env bridge, cron scheduler, and TUI gateway. Explicit
user config values are unaffected (deep-merge preserves them; no
_config_version bump needed).
Docs (en + zh-Hans), CLI help text, tips, and pinned tests updated
to match.
`_prune_stale_worktrees` runs synchronously before the banner on every
`hermes -w` launch and shells out to git several times per candidate
worktree. On a repo with dozens of accumulated worktrees this dominated
startup: measured 18.5s of a 20.6s cold start, 11.6s on warm repeats.
The `git cherry` patch-equivalence probe was the single largest cost
(9.4s of 11.5s across 24 trees). It is also pure waste on repeat runs: a
tree preserved because it holds unpushed work is re-diff-hashed on every
launch, forever, always reaching the same verdict. On this repo 19 of 24
aged trees were unreapable, so ~11s per launch bought zero reaps.
Two changes, both verdict-preserving:
- Split the loop into a stat-only age filter, a parallel read-only
classification phase (thread pool, bounded to min(8, cpu_count)), and
a serial mutation phase. Only reads are concurrent; unlock/remove/
branch -D stay ordered, so log output and removal order are unchanged.
A pool failure falls back to serial rather than blocking startup.
- Memoize `git cherry` verdicts to
$HERMES_HOME/cache/worktree_merge_verdicts.json, keyed on the exact
`(base_sha, head_sha, max_ahead)` range the verdict was computed from.
Because that key is the complete input to the git call, a cache hit is
identical to recomputation by construction: if either ref moves, the
key changes and real git runs again. Bounded to 1000 entries, written
atomically, and a corrupt/hand-edited cache degrades to recomputation.
Measured on a 44-worktree repo (24 aged candidates):
_prune_stale_worktrees before 11.5s after 2.05s cold / 0.42s warm
hermes -w to banner before 13.9s after 1.79s
Work-preservation is unchanged: all 44 worktrees survived, and every
dirty/unpushed/live-locked guard still fires. Verified the 24 real-tree
verdicts are byte-identical across serial, cold-cache, and warm-cache
runs.
Tests: 75/75 in tests/cli/test_worktree.py (67 existing + 8 new). The
new cache tests were sabotage-verified — swapping the exact-sha key for a
naive path-only key makes two of them fail by deleting a worktree that
had gained unmerged work, which is the data-loss case the key prevents.
Bug-class coverage for both fixes: the full catalogue survives Save, context
lengths are preserved, the key never lands in config.yaml on either write
path, blank clears it, a pre-fix plaintext key migrates while a ${VAR}
template is left alone, two endpoints on one host keep separate credentials,
and an IP-derived name is still a valid POSIX env var.
The two delete tests asserted on the plaintext mirror; they now assert the
same invariants against the credential reference.
_setup_worktree read both files with the locale default encoding. On a
cp1251/GBK Windows machine a UTF-8 include list either decodes to
mojibake paths (non-ASCII entries silently not copied) or raises
UnicodeDecodeError, which the enclosing handler logs at DEBUG and
swallows — no include is copied at all, so the worktree starts without
.env/keys and the agent breaks invisibly. A Notepad BOM likewise glues
to the first include entry on every platform, and to the first
.gitignore line, defeating the '.worktrees/' membership check and
appending a duplicate entry on each run.
Read both files with utf-8-sig + errors=replace, matching the canonical
.env readers in hermes_cli/config.py (utf-8-sig because Notepad adds a
BOM) and the UTF-8 append this same block already performs on
.gitignore.
Regression tests exercise the real cli._setup_worktree: the two BOM
tests fail without the fix on any platform, the non-ASCII include test
additionally reproduces the Windows locale failure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The existing tests cover _normalize_moa_model() in isolation and a local
precedence expression, but not the __init__ wiring itself. Add two
init-level regression tests: constructing HermesCLI(model='moa:strategy')
strips the prefix to model='strategy' and forces requested_provider='moa',
and the moa: prefix wins over an explicit --provider. Both fail if the
override is dropped from the requested_provider resolution.
Refs #56828
hermes chat -Q -m moa:strategy failed with 'model moa:strategy is not
supported' (HTTP 401/400): the raw model string was passed straight to
the real provider. The MoA virtual provider only got wired up through the
interactive /moa command and the model picker, never through the -Q
one-shot startup path.
resolve_runtime_provider already handles requested_provider == 'moa', and
agent_init builds the MoAClient off provider == 'moa' (surface-agnostic).
The only gap was mapping the moa:<preset> model string to that provider.
Add _normalize_moa_model() and apply it in HermesCLI.__init__ before
provider resolution: a moa:<preset> model sets requested_provider='moa'
and model=<preset>, so the existing MoA path runs in non-interactive mode
too. The moa: prefix wins over an explicit --provider (previously
--provider deepseek -m moa:strategy silently dropped MoA).
Fixes#56828