Problem:
unlink_tasks() removes a parent→child dependency edge but does not trigger
recompute_ready(). A child whose last blocking parent is unlinked stays
stuck in 'todo' indefinitely — it only promotes to 'ready' on the next
dispatcher tick or a manual 'hermes kanban recompute'. For CLI-only users
without a dispatcher, the child is permanently stuck.
Root cause:
complete_task() and unblock_task() both call recompute_ready() after their
write transaction so downstream children are evaluated immediately.
unlink_tasks() was missing this call — removing a dependency is
semantically equivalent to completing one, so the same recompute is needed.
Fix:
Capture the rowcount result before the write_txn exits, then call
recompute_ready(conn) outside the transaction when a row was actually
deleted (so the child sees the updated task_links state).
Tests:
Added test_unlink_tasks_triggers_recompute_ready in
tests/hermes_cli/test_kanban_db.py: creates parent A (done) + parent C
(running), child B with both parents (todo), unlinks C→B, asserts B is
ready immediately. Stash-verified: FAILS without fix (child stays todo),
PASSES with fix.
62/62 tests green in tests/hermes_cli/test_kanban_db.py.
Closes#22459.
/clear, /new, /reset, and /undo now ask the user to confirm before
discarding conversation state — three-option prompt routed through the
existing tools.slash_confirm primitive.
Native yes/no buttons render on Telegram, Discord, and Slack (their
adapters already implement send_slash_confirm); other platforms get a
text-fallback prompt and reply with /approve, /always, or /cancel.
The classic prompt_toolkit CLI uses the same three-option flow via the
established _prompt_text_input pattern (see _confirm_and_reload_mcp).
TUI keeps its existing modal overlay (#12312).
Gated by new config key approvals.destructive_slash_confirm (default
true). Picking 'Always Approve' flips the gate to false so subsequent
destructive commands run silently — matches the established
mcp_reload_confirm UX.
Out of scope: /cron remove (separate domain — scheduled jobs, not
session history). Existing TUI overlay env-var (HERMES_TUI_NO_CONFIRM)
left unchanged; cosmetic unification can come later.
Closes#4069.
When a GFM table has a row-label column (first column with no header),
_render_table_block_for_telegram incorrectly included the row-label cell
in the bullet zip alongside the data cells, producing a spurious bullet
like '• 維度: 核心賣點' before the real data rows.
Detect the row-label column by comparing the first data row cell count
against the header count (has_row_label_col = len(first_data_row) ==
len(headers) + 1). When present, use cells[0] as the heading and
zip headers against cells[1:] only, correctly excluding the row-label
from the bullet list.
Fixes#22604
check_for_updates() and _resolve_repo_dir() were preferring
$HERMES_HOME/hermes-agent/ over Path(__file__).parent.parent.resolve()
when looking for a .git checkout. For profiles created with
--clone-all, $HERMES_HOME/hermes-agent/ points to a stale copy
with a frozen HEAD, causing persistent "N commits behind" banners
that never resolved.
Flip the resolution order: prefer the running code's location first,
fall back to $HERMES_HOME/hermes-agent/ only when the live checkout
doesn't have a .git (system-wide pip installs, distro packages).
The embedded-rev branch (HERMES_REVISION env var, set by nix builds)
is unaffected — it uses git ls-remote against upstream, never reads
the local checkout's HEAD.
Based on PR #21728 by @fahdad
When the source profile is the default (~/.hermes), shutil.copytree()
was copying multi-GB infrastructure alongside the ~40 MB of actual
profile data: hermes-agent/ (repo checkout + 3 GB venv), .worktrees/,
profiles/ (sibling profiles — recursive!), bin/ (installed binaries),
node_modules/ (hundreds of MB).
Add _CLONE_ALL_DEFAULT_EXCLUDE_ROOT frozenset with these five entries
and pass an ignore callback to copytree(). Exclusions are gated on
the source actually being the default profile (is_default_source) so
named-profile sources are never affected.
Also exclude at any depth: __pycache__/, *.pyc, *.pyo, *.sock, *.tmp.
Profile data (config.yaml, .env, auth.json, state.db, sessions/,
skills/, logs/) is preserved intact — clone-all means 'complete
snapshot minus infrastructure'.
Mirrors the approach already used by _default_export_ignore() and
_DEFAULT_EXPORT_EXCLUDE_ROOT (the export-side exclusion set which is
broader because it produces a portable archive, not a live clone).
Co-authored-by: MustafaKara7 <karamusti912@gmail.com>
Co-authored-by: fahdad <30740087+fahdad@users.noreply.github.com>
Fixes#5022
Based on PRs #5025, #5026, and #21728
Plugin platforms (IRC, Teams, Google Chat) currently fail with
`No live adapter for platform '<name>'` when a `deliver=<plugin>` cron
job runs in a separate process from the gateway, even though the
platforms are eligible cron targets via `cron_deliver_env_var` (added
in #21306). Built-in platforms (Telegram, Discord, Slack, etc.) use
direct REST helpers in `tools/send_message_tool.py` so cron can deliver
without holding the gateway in the same process; plugin platforms
historically depended on `_gateway_runner_ref()` which returns `None`
out of process.
This change adds an optional `standalone_sender_fn` field to
`PlatformEntry` so plugins can register an ephemeral send path that
opens its own connection, sends, and closes without needing the live
adapter. The dispatch site in `_send_via_adapter` falls through to the
hook when the gateway runner is unavailable, with a descriptive error
when neither path applies. The hook is optional, so existing plugins
are unaffected.
Reference migrations land in the same change for IRC, Teams, and
Google Chat, exercising the hook across stdlib (asyncio + IRC protocol),
Bot Framework OAuth client_credentials, and Google service-account
flows respectively.
Security hardening on the new code paths:
* IRC: control-character stripping on chat_id and message body to
block CRLF command injection; bounded nick-collision retries; JOIN
before PRIVMSG so channels with the default `+n` mode accept the
delivery.
* Teams: TEAMS_SERVICE_URL validated against an allowlist of known
Bot Framework hosts (`smba.trafficmanager.net`,
`smba.infra.gov.teams.microsoft.us`) to block SSRF; chat_id and
tenant_id constrained to the documented Bot Framework character set;
per-request timeouts so a slow STS endpoint cannot starve the
activity POST.
* Google Chat: chat_id and thread_id validated against strict
resource-name regexes; service-account refresh wrapped in
`asyncio.wait_for` so a hung token endpoint cannot stall the
scheduler.
Test coverage: 20 new tests covering happy path, missing-config errors,
network failure modes, and each defensive validation. Existing tests
unchanged. `bash scripts/run_tests.sh tests/tools/test_send_message_tool.py
tests/gateway/test_irc_adapter.py tests/gateway/test_teams.py
tests/gateway/test_google_chat.py` reports 341 passed, 0 regressions.
Documentation: new "Out-of-process cron delivery" section in
website/docs/developer-guide/adding-platform-adapters.md and an entry
in gateway/platforms/ADDING_A_PLATFORM.md naming the hook.
Three tests in tests/agent/test_auxiliary_config_bridge.py read
in-tree source files (gateway/run.py and cli.py) via
Path.read_text() with no encoding argument. The default falls
back to the system locale, which on Western Windows installs is
cp1252, and the read fails as soon as the source contains any
byte that isn't valid cp1252 (e.g. an em-dash in a comment):
UnicodeDecodeError: 'charmap' codec can't decode byte 0x8f
in position 41190: character maps to <undefined>
Linux CI doesn't catch this because the default Linux locale is
UTF-8. Windows contributors hit it on every run of the test suite.
Pin encoding="utf-8" on the three call sites that read repo
source files. This matches the existing precedent in
hermes_cli/doctor.py:363, where the same pattern (with an
explanatory comment) was applied to fix the .env read on
non-UTF-8 Windows locales.
Affected tests now pass on Windows + Python 3.12:
- TestGatewayBridgeCodeParity.test_gateway_has_auxiliary_bridge
- TestGatewayBridgeCodeParity.test_gateway_no_compression_env_bridge
- TestCLIDefaultsHaveAuxiliaryKeys.test_cli_defaults_can_merge_auxiliary
Maps obafemiferanmi1999@gmail.com (the commit-author email used on
PR #21473's branch) to GitHub login KvnGz (the PR/branch owner) so
contributor_audit.py recognizes the authored commit in the upcoming
salvage PR.
Follow-up to PR #21293 (cli.py), which fixed the same anti-pattern.
`asyncio.get_event_loop()` is documented as effectively "always returns
the running loop when called from a coroutine" and emits
DeprecationWarning/RuntimeWarning in some interpreter configurations.
The Python docs explicitly recommend get_running_loop() inside coroutines.
Replaces the remaining 9 call sites that are unconditionally inside
async def bodies:
- tools/browser_cdp_tool.py — _cdp_call() (4 sites): deadline + remaining
computations inside the async websockets.connect context manager.
- hermes_cli/web_server.py — get_status, _start_device_code_flow,
submit_oauth_code (3 sites): all FastAPI async endpoints offloading
blocking httpx / PKCE work to run_in_executor.
- environments/agent_loop.py — HermesAgentLoop (1 site): tool dispatch
inside the async rollout loop.
- environments/benchmarks/terminalbench_2/terminalbench2_env.py —
rollout_and_score_eval (1 site): test verification thread offload.
All 9 sites are unconditionally inside async def bodies, so a running
loop is guaranteed and no try/except RuntimeError fallback is needed
(unlike the cli.py case in #21293, which ran from a background thread).
Behavior is identical on supported Python versions; aligns the codebase
with the post-#21293 idiom and avoids future warnings as the deprecation
hardens.
Salvaged from PR #21930 by @Zhekinmaksim onto current main (the
original branch was 109 commits behind and carried unintended
stale-branch reverts of unrelated landed changes — _tail_lines
encoding=utf-8 and the Windows PTY bridge guard). Only the 9 swaps
from the PR's intended scope are applied here.
- Renames test_comment_custom_author -> test_comment_ignores_caller_supplied_author
and inverts its assertion: an args['author'] override is silently
ignored; the author always comes from HERMES_PROFILE.
- Adds test_comment_schema_omits_author_override to assert the
'author' property is gone from KANBAN_COMMENT_SCHEMA so the
forgery surface stays closed if someone re-adds the schema field
by accident.
- Adds test_worker_can_comment_on_foreign_task to pin the #19713
policy decision: cross-task commenting must remain unrestricted.
Without this guard, a future change accidentally adding
_enforce_worker_task_ownership to _handle_comment would close the
documented handoff channel between tasks.
Comments are injected into the next worker's system prompt by
build_worker_context() as '**{author}** (timestamp): {body}'. The
previous code accepted args['author'] as a free-form override and
exposed it on KANBAN_COMMENT_SCHEMA, which let a worker:
1. Receive a prompt-injection in a malicious task body.
2. Call kanban_comment with author='hermes-system' (or any other
authoritative-looking name) on a sibling task.
3. The next worker assigned to that sibling task sees the forged
comment in its boot context as what reads like a system-authored
directive.
Always derive author from HERMES_PROFILE (the dispatcher already sets
this per worker at hermes_cli/kanban_db.py:3718), and remove the
'author' property from the tool schema so the LLM can't see the
override surface.
Cross-task commenting itself remains unrestricted (see #19713) —
comments are the deliberate handoff channel between tasks; only the
author-override surface is closed.
Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Adds five regression tests for the Format 3 (Cloud Run relay) envelope
path:
- test_relay_flat_honors_declared_sender_type_bot: BOT sender_type
propagates to msg['sender']['type'].
- test_relay_flat_defaults_sender_type_human_when_absent: backward
compat \u2014 missing field still flows as HUMAN.
- test_relay_flat_coerces_unknown_sender_type_to_human: defensive
coercion \u2014 strip+upper normalizes whitespace/case, anything outside
{HUMAN, BOT} falls back to HUMAN.
- test_relay_flat_bot_sender_is_filtered_end_to_end: end-to-end
through _on_pubsub_message \u2014 a relay envelope with sender_type=BOT
is dropped by the BOT self-filter without dispatch.
- test_relay_flat_human_sender_dispatches: end-to-end negative
control \u2014 human relay envelopes still reach the agent loop.
Also clarifies the operator contract in the adapter comment: the
relay must forward upstream sender.type as envelope.sender_type,
otherwise bot replies forwarded as HUMAN cannot be distinguished
from genuine humans by this filter.
`ToolCall.extra_content` was annotated `Optional[Dict[str, Any]]`,
but neither `Optional` nor `Dict` are imported at the top of
`agent/transports/types.py` — only `Any` is. The rest of the file
consistently uses PEP 604 / 585 syntax (e.g. `str | None`,
`dict[str, Any] | None`).
The file has `from __future__ import annotations`, so the missing
names don't crash class definition. But the annotation IS evaluated
when anything calls `typing.get_type_hints(ToolCall)` —
introspection raises `NameError: name 'Optional' is not defined`.
ruff catches it cleanly:
F821 Undefined name `Optional` agent/transports/types.py:65:32
F821 Undefined name `Dict` agent/transports/types.py:65:41
Switch the annotation to `dict[str, Any] | None` to match the
rest of the file's style. No new imports needed.
Verified:
- ruff F-checks now pass on the file
- `typing.get_type_hints(ToolCall)` succeeds where it raised before
- 166/166 tests in tests/agent/transports/ pass on Windows + Python 3.12
WebUI sessions construct AIAgent(platform="webui") but PLATFORM_HINTS
had no "webui" entry, so the agent received no platform hint at all.
The WebUI frontend supports rich MEDIA:/absolute/path previews for
images, audio, video, PDF, HTML, CSV, diffs, and Excalidraw, but
without a hint the agent either ignores MEDIA: or falls back to
Markdown image syntax which silently fails for local files.
Add a webui hint that documents the MEDIA: render path and warns
against  for local files.
Fixes#21883
When _coerce_json fails to parse a string as JSON or parses to the wrong
type, log a clear WARNING instead of silently returning the original
value. When coerce_tool_args wraps a bare string into a single-element
list AND the string looks like a JSON array (starts with '['), warn
that the model likely emitted a JSON-encoded string instead of a
native array.
This improves diagnostics for the open-weight model output drift
described in #21933 (JSON-array-as-string), as well as any other tool
whose array-typed argument arrives stringified through
handle_function_call.
Note: delegate_task does NOT go through coerce_tool_args (it is in
_AGENT_LOOP_TOOLS and dispatched directly from run_agent.py with raw
function_args from json.loads). The actual delegate_task fix for #21933
is the previous commit. These logging changes apply to all other
array-typed arguments coerced via the shared pipeline.
Salvaged from PR #22092.
Recover delegate_task batch inputs when open-weight models emit tasks as a JSON-encoded array string, and return clear errors for malformed task lists.
Co-authored-by: Cursor <cursoragent@cursor.com>
Maps egitimviscara@gmail.com to GitHub login uzunkuyruk so that
contributor_audit.py recognizes their authored commits in upcoming
salvage PRs (e.g. #21933 fix).
SQLite's WAL mode requires shared-memory (mmap) coordination and fcntl
byte-range locks that don't reliably work on network filesystems. Upstream
documents this explicitly:
https://www.sqlite.org/wal.html#sometimes_queries_return_sqlite_busy_in_wal_mode
On NFS / SMB / some FUSE mounts / WSL1, 'PRAGMA journal_mode=WAL' raises
'sqlite3.OperationalError: locking protocol' (SQLITE_PROTOCOL). Before
this change, every feature backed by state.db or kanban.db broke silently:
- /resume, /title, /history, /branch returned 'Session database not
available.' with no cause
- gateway logged the init failure at DEBUG (invisible in errors.log)
- kanban dispatcher crashed every 60s, driving the known migration race
(duplicate column name: consecutive_failures, #21708 / #21374)
Changes:
- hermes_state.apply_wal_with_fallback(): shared helper that tries WAL
and falls back to DELETE on SQLITE_PROTOCOL-style errors with one
WARNING explaining why
- hermes_state.get_last_init_error() + format_session_db_unavailable():
capture the init failure cause and surface it in user-facing strings
(with an NFS/SMB pointer for 'locking protocol')
- hermes_cli/kanban_db.connect(): use the shared helper
- gateway/run.py: bump SessionDB init failure log DEBUG -> WARNING
(matches cli.py's existing correct behavior)
- cli.py (4 sites) + gateway/run.py (5 sites): replace bare
'Session database not available.' with format_session_db_unavailable()
Tests: 12 new tests in tests/test_hermes_state_wal_fallback.py + 1 new
test in tests/hermes_cli/test_kanban_db.py. Existing suites (state,
kanban, gateway, cli) remain green for all tests unrelated to pre-existing
failures on main.
Evidence: real-world user on NFSv3 mount (172.26.224.200:d2dfac12/home,
local_lock=none) reporting 'Session database not available.' on /resume;
'locking protocol' appears in 4 distinct log entries across backup,
kanban, TUI, and CLI paths in the same session.
closes#22032
Telegram forum supergroups address the General topic as
`message_thread_id="1"` on incoming updates, but the Bot API rejects
sends with `message_thread_id=1` ("Message thread not found"). The
gateway adapter has a `_message_thread_id_for_send` helper that maps
"1" to None for that reason; the standalone `_send_telegram` helper
used by the `send_message` tool never got the same mapping, so any
`send_message` call to a Topics-enabled group's General topic
(target shape `telegram:<chat_id>:1`) failed with "Message thread
not found."
Reuse the adapter's helper when available, with an explicit fallback
to the same mapping for environments where the adapter import path
fails (e.g. python-telegram-bot missing in this venv).
Fixes#22267
OpenViking 0.3.x requires X-OpenViking-Account and X-OpenViking-User headers for ROOT API key requests to tenant-scoped APIs. Previously the `!="default"` guard skipped these headers when account/user were the literal string "default", causing INVALID_ARGUMENT errors.
Remove the `!="default"` guard so headers are sent whenever account/user are truthy. Empty strings are still correctly skipped since `""` is falsy.
Update tests to reflect the new behavior:
- test_viking_client_headers_send_tenant_when_default: asserts "default" headers ARE present
- test_viking_client_headers_send_tenant_when_empty_falls_back_to_default: asserts "default" headers ARE present from constructor fallback
Based on #21775 by @happy5318
When an auxiliary LLM provider (or an upstream proxy) returns a non-JSON
body with `Content-Type: application/json` — e.g. an HTML 502 page from a
misconfigured gateway — the OpenAI SDK's `response.json()` raises a raw
`json.JSONDecodeError` (or wraps it in `APIResponseValidationError` whose
message contains "expecting value"). Previously this fell through to the
unknown-error branch and entered a 60s cooldown without retrying on the
main model, dropping the middle conversation turns instead.
This change folds JSON-decode detection into the existing fast-path
fallback chain: detect by `isinstance(e, JSONDecodeError)` OR substring
match for "expecting value", retry once on the main model, and use a
shorter 30s cooldown when already on main (the body shape tends to flip
back to valid quickly when the upstream proxy recovers).
The three duplicated fallback bodies (model-not-found, unknown-error,
JSON-decode) are consolidated into a single `_fallback_to_main_for_compression`
helper that handles the shared bookkeeping (record aux-model failure for
`/usage`-style callers, clear summary_model, clear cooldown).
Also adds three unit tests covering: raw `JSONDecodeError` retries on main,
substring-match for wrapped exceptions, and the 30s cooldown when already
on main.
Salvage of #22248 by @0xharryriddle. Closes#22244.
Co-authored-by: Harry Riddle <ntconguit@gmail.com>
The send path uses Hermes' reply-anchor fallback for DM topic lanes
(message_thread_id + reply_to_message_id), but send_chat_action only
accepts message_thread_id — Telegram's Bot API 10.0 rejects it for
these lanes. Without this short-circuit, every typing tick (~every 2s
during agent runs) makes a doomed API call that gets logged as a
'thread not found' debug warning. Skip the call entirely when the
metadata indicates a DM topic reply-fallback lane; the user-visible
behavior is unchanged (no typing indicator either way for these
lanes), but the logs stay clean.
Identified during salvage review of #22053.
Adds jhin.lee@unity3d.com → leehack so contributor_audit.py strict
mode passes when the salvage of #22053 (telegram DM topic reply
fallback) lands on main.
Self-review follow-up: handlePauseResume read job.state directly while
the rest of the page goes through getJobState(), which falls back to
the enabled flag when state is null/undefined. With the backend
normalizer in this PR, state is always populated on the wire, so this
has no observable effect today — but using the helper keeps the page
consistent and resilient against older Hermes backends that don't run
the normalizer.
* fix(tui): trim markdown wrap spaces
Use trim-aware wrapping for markdown prose so word-wrapped continuation lines do not keep boundary spaces.
* fix(tui): simplify markdown wrap nodes
Keep trim-aware wrapping on the rendered markdown text node while leaving nested inline segments as plain virtual text.
* fix(tui): trim definition row wrapping
Apply trim-aware wrapping to markdown definition rows so continuation lines match other prose rows.
* fix(tui): trim list and quote wrapping
Put trim-aware wrapping on the rendered list and quote rows that own markdown inline layout.
* fix(tui): preserve markdown nesting with trim wrap
Move list and quote indentation into layout padding so trim-aware wrapping does not erase nested markdown structure.
* fix(tui): trim only soft wrap spaces
Change trim-aware wrapping to remove whitespace only at soft-wrap boundaries so original leading inline spaces stay verbatim.
* fix(tui): preserve extra boundary whitespace
Trim only one soft-wrap boundary whitespace character so wrap-trim avoids leading continuations without collapsing intentional spacing.
* fix(tui): align styled wrap-trim mapping
Update styled text remapping to skip the single whitespace removed at soft-wrap boundaries without dropping preserved indentation.
* fix(tui): clean wrap trim test helpers
Clarify boundary-trim wording and strip OSC escapes from markdown render test output.
* fix(tui): strip osc before ansi in markdown tests
Remove OSC escapes from raw render output before SGR/CSI cleanup so markdown render assertions stay plain text.
Extends #19994 to the restart path. Dashboard spawns 'hermes gateway
restart' in the background; when a wedged adapter websocket pushes
drain past the 90s CLI timeout, the dashboard previously surfaced a
raw subprocess.TimeoutExpired traceback.
Mirror systemd_stop()'s TimeoutExpired catch onto both forcing-restart
sites in systemd_restart(). Adds a test that exercises the no-active-pid
branch end-to-end.
Teknium: don't need 9 tests. Keep one invariant for 'per-mode required
params are documented in both description layers' and one that pins
required=[mode] with no anyOf/oneOf (prevents re-introducing the bug).
Models that enforce required-only constraints (e.g. kimi-k2.x) were
omitting old_string/new_string for replace mode and patch for patch mode
because the schema only declared required: ["mode"].
Add explicit "REQUIRED when mode='X'" markers to each conditionally-required
property description and a top-level "REQUIRED PARAMETERS: ..." summary for
each mode. Avoids anyOf/oneOf which break Anthropic, Fireworks, and
Kimi/Moonshot providers. Add TestPatchSchemaShape to lock the shape.
Fixes#15524
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Interactive `hermes` launch drops from ~21s to ~2.5s. Three independent
fixes, each targets a distinct hot spot in the banner / tool-registration
path that fires on every CLI invocation.
1. `get_external_skills_dirs()` in-process mtime cache (~10s saved)
The function re-read + YAML-parsed the full ~/.hermes/config.yaml on
every call. Banner build invokes it once per skill to resolve the
category column, which on a 120-skill install meant ~120 reparses of
a 15 KB config (~85 ms each). Added a
`(config_path, mtime_ns) -> list[Path]` memo; stat() is ~2 us vs
~85 ms for the parse. Edits to config.yaml invalidate the cache on
the next call via mtime.
2. Feishu availability probe uses `importlib.util.find_spec` (~5.2s saved)
`tools/feishu_doc_tool.py::_check_feishu` and the identical helper in
`feishu_drive_tool.py` were calling `import lark_oapi` purely to
detect whether the SDK was installed. Executing the real import pulls
in websockets + dispatcher + every v2 API model — ~5 seconds of work
that fires at every tool-registry bootstrap. `find_spec` answers the
same question ("is lark_oapi importable?") without executing the
module. The actual tool handlers still do the real import on invoke,
so runtime behavior is unchanged.
3. `_web_requires_env` no longer triggers Nous portal refresh (~800ms saved)
`tools/web_tools.py::_web_requires_env` used
`managed_nous_tools_enabled()` to gate four gateway env-var names in
the returned list. The gate called `get_nous_auth_status()` ->
`resolve_nous_runtime_credentials()` -> live HTTP POST to the portal
on every tool-registry bootstrap. But the list is pure metadata — if
the env var is set at runtime, the tool lights up; otherwise it
doesn't. Including the four names unconditionally is harmless for
unsubscribed users (vars just aren't set) and eliminates the sync
HTTP round trip from startup.
Test:
- tests/agent/test_external_skills_dirs_cache.py (new, 6 cases):
returns config'd dir, caches on second call (yaml_load patched to
raise — never invoked), invalidates on mtime bump, empty when config
missing, returned list is a defensive copy, per-HERMES_HOME cache key
isolation.
- Existing tests/agent/test_external_skills.py and tests/tools/
continue to pass modulo pre-existing flakes on main (test_delegate,
test_send_message — unrelated, pass in isolation).
Measured: bare `hermes` (cold → REPL ready) 21,519ms -> 2,618ms on
Teknium's install (119 skills, 15 KB config.yaml, Nous auth logged in,
lark_oapi installed). 8x faster.
Windows Terminal captures Alt+Enter at the terminal layer (fullscreen
toggle), so documenting 'Alt+Enter or Ctrl+J' without qualification
leaves stock Windows Terminal users with no working newline key they
can discover from the docs alone.
- Main keybindings row: note Alt+Enter is intercepted on WT and direct
users to Ctrl+Enter / Ctrl+J instead.
- Shift+Enter compatibility table: split 'stock Windows Terminal' from
Windows Terminal Preview 1.25+ (which added Kitty protocol support
and works with the keybinding from this PR once enabled).
- Add AUTHOR_MAP entry for ra2157218@gmail.com -> Abd0r so the salvage
commit passes the email-mapping CI gate.