chore: sync upstream NousResearch/hermes-agent (780 commits)#4
Conversation
Adapted from PR NousResearch#20568 commit ce35185 (Eric Litovsky / @kallidean). Adds two-tier gating for the kanban tool surface so dispatcher-spawned workers see only task-lifecycle tools (show/complete/block/heartbeat/ comment/create/link) while orchestrator profiles with `toolsets: [kanban]` also see board-routing tools (kanban_list, kanban_unblock). Workers shouldn't be enumerating or unblocking the board — they should close their own task via the lifecycle tools. Hiding board-routing tools from worker schemas keeps the worker focused and the toolset-isolation contract honest. Plus inherited from the same upstream commit: - 50/200 row bound on kanban_list with `truncated` + `next_limit` metadata. - Belt-and-suspenders runtime guard `_require_orchestrator_tool()` inside the orchestrator handlers in case a stale registration ever routes a worker to one of them. - Tests for the new gate, the stricter bound, and the fact that even a worker with `toolsets: [kanban]` in config still doesn't see board routing. Co-authored-by: Eric Litovsky <elitovsky@zenproject.net>
…ch#23204) Captain Awesome's May 10 thread on hermes + Discord with GPT-5.5 / DeepSeek v4: - life-changing umbrella tweet - Google-me -> SSH-deploy landing page to VPS - cron jobs triaging tech news into Discord channels by urgency - PM paperclip agent running morning + evening standups for ADHD
…ct (NousResearch#23211) web_extract runs returned page content through the web_extract auxiliary model when pages exceed 5 000 chars (single-pass up to 500k, chunked up to 2M, refused above that). The user-guide page didn't mention this — users were surprised that long-page extracts produced summaries instead of raw markdown, and that those summaries cost main-model tokens by default. Adds: - size-driven behavior table (under 5k / 5k–500k / 500k–2M / over 2M) - which auxiliary task does the work (auxiliary.web_extract) - how to route summaries to a cheap model regardless of main - escape hatch: browser_navigate when you need raw content - troubleshooting entry for summarization timeouts
…3197) * feat(gateway): add LINE Messaging API platform plugin Adds LINE as a bundled platform plugin under `plugins/platforms/line/`, synthesized from the strongest pieces of seven open community PRs. The adapter requires zero core edits — `Platform("line")` is auto-discovered via the bundled-plugin scan in `gateway/config.py`, and all hooks (setup, env-enablement, cron delivery, standalone send) are wired through `register_platform()` kwargs the way IRC and Teams do it. Highlights merged into one plugin: - **Reply token preferred, Push fallback.** Try the free reply token first (single-use, ~60s TTL); fall back to metered Push when the token is absent, expired, or rejected. (PR NousResearch#21023) - **Slow-LLM Template Buttons postback.** When the LLM is still running past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns the original reply token to send a "Get answer" button bubble. The user taps it to fetch the cached answer via a fresh reply token — also free. State machine: PENDING → READY → DELIVERED, ERROR for cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after /stop). Set threshold to 0 to disable. (PR NousResearch#18153) - **Three-allowlist gating** — separate user / group / room allowlists with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR NousResearch#18153) - **Markdown URL preservation.** Strip bold/italic/code-fence/heading markers (LINE renders them literally) but keep `[label](url)` → `label (url)` so URLs stay tappable. (PR NousResearch#18153) - **System-message bypass** for `⚡ Interrupting`, `⏳ Queued`, etc. — busy-acks reach the user as visible bubbles instead of being swallowed into the postback cache. (PR NousResearch#18153) - **Media via public HTTPS URLs.** LINE doesn't accept binary uploads; images/audio/video must be HTTPS-reachable. The adapter serves registered tempfiles under `/line/media/<token>/<filename>` from the same aiohttp app. Allowed-roots traversal guard covers `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for setups behind tunnels/proxies. (PR NousResearch#8398) - **5-message-per-call batching.** LINE rejects >5 messages per Reply/Push; smart-chunker caps text at 4500 chars per bubble. - **Inbound dedup** via `webhookEventId` LRU. (PR NousResearch#21023) - **Self-message filter** via `/v2/bot/info` userId lookup. (PR NousResearch#21023) - **Loading-animation indicator** wired to LINE's `chat/loading/start` endpoint, DM-only (LINE rejects it for groups/rooms). (PR NousResearch#21023) - **Out-of-process cron delivery** via `_standalone_send`, so `deliver: line` cron jobs work even when cron runs detached from the gateway. - **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256 signature verification, dedup, scoped lock so two profiles can't bind the same channel. Validation ---------- - `scripts/run_tests.sh tests/gateway/test_line_plugin.py` → 73 passed in 1.05s - `scripts/run_tests.sh tests/gateway/test_line_plugin.py tests/gateway/test_irc_adapter.py tests/gateway/test_plugin_platform_interface.py tests/gateway/test_platform_registry.py tests/gateway/test_config.py` → 193 passed, 7 skipped - E2E import + register + signature roundtrip + `Platform("line")` bundled-plugin discovery verified against current `origin/main`. Closes the seven open LINE PRs (NousResearch#18153, NousResearch#16832, NousResearch#6676, NousResearch#21023, NousResearch#14942, NousResearch#14988, NousResearch#8398) by superseding them with a single plugin-form implementation that takes the best idea from each. Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com> Co-authored-by: Jetha Chan <jetha@google.com> Co-authored-by: Cattia <openclaw@liyangchen.me> Co-authored-by: perng <charles@perng.com> Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com> Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> * docs(platforms): document platform-specific slow-LLM UX pattern Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter developer guide covering the _keep_typing override pattern that LINE uses for its Template Buttons postback flow. Three subsections: - Pattern: subclass _keep_typing to layer mid-flight UX (with code) - Pattern: subclass send to route through a cache instead of sending - When this pattern is appropriate (vs. always-Push fallback) Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so tree-readers find the prose walkthrough on the docsite. Filed because the LINE plugin (PR NousResearch#23197) was the first bundled adapter to need this pattern — every prior plugin (irc, teams, google_chat) handles slow responses with the default typing-loop and a regular send_text. Documenting now while the rationale is fresh. --------- Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com> Co-authored-by: Jetha Chan <jetha@google.com> Co-authored-by: Cattia <openclaw@liyangchen.me> Co-authored-by: perng <charles@perng.com> Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com> Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com> Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
…Research#23212) Surfaces the pin command at the moment users care about it: when a consolidation just landed against their skill library and they're looking at the umbrella name in the curator output. Previously `hermes curator pin` existed but had no discovery surface — users only learned it existed by reading docs or stumbling onto `hermes curator --help`. The hint: archived 3 skill(s): • docx-extraction → document-tools • pdf-extraction → document-tools • old-stale — pruned (stale) full report: hermes curator status keep an umbrella stable: hermes curator pin document-tools Gated on having at least one consolidation that produced an umbrella. Pruned-only runs (nothing surviving to pin) skip the hint. When multiple umbrellas were produced, picks alphabetically first as a concrete example rather than listing them all. 3 new tests in tests/agent/test_curator_classification.py covering: consolidation produces hint with real umbrella name, pruned-only run omits it, multi-umbrella picks one example.
Remove the blanket /api/plugins/* exemption from auth_middleware so plugin API routes (e.g. Kanban dashboard) require the same session token as all other /api/ endpoints. Fixes NousResearch#19533
…tring
Follow-up to the previous commit's middleware fix.
- plugins/kanban/dashboard/plugin_api.py: rewrite the "Security note"
docstring. The previous text said "/api/plugins/ is unauthenticated by
design" — that's now actively wrong and dangerously misleading. New
text explains that plugin routes flow through the same session-token
middleware as core API routes and that --host 0.0.0.0 is safe to use
on a LAN as a result.
- tests/hermes_cli/test_web_server.py: extend TestPluginAPIAuth to cover
the surfaces the original PR didn't pin:
* test_plugin_route_allows_auth now exercises a real plugin path
(/api/plugins/example/hello) instead of accepting 200 OR 404 from
a maybe-loaded kanban plugin — the assertion was effectively vacuous.
* test_plugin_patch_requires_auth + test_plugin_delete_requires_auth
cover non-GET mutation methods in case a future regression
whitelists them by accident.
* test_non_kanban_plugin_route_requires_auth proves the fix is
plugin-agnostic, not kanban-specific (hits hermes-achievements +
a non-existent plugin namespace; both 401 before route resolution).
* test_plugin_websocket_unaffected_by_http_middleware locks in that
the HTTP middleware change didn't accidentally start gating WS
upgrades — kanban /events still uses its own ?token= check.
Plus a cosmetic blank-line cleanup.
…sResearch#23194) * feat(plugins): host-owned LLM access via ctx.llm Plugins can now ask the host to run a one-shot chat or structured completion against the user's active model and auth, without ever seeing an OAuth token or API key. Closes the gap where plugins that needed bounded structured inference (receipts, CRM extraction, support classification) had to either bring their own provider keys or register a tool the agent had to call. New surface on PluginContext: - ctx.llm.complete(messages, ...) - ctx.llm.complete_structured(instructions, input, json_schema, ...) - async siblings ctx.llm.acomplete / acomplete_structured Backed by the existing auxiliary_client.call_llm pipeline — every provider, fallback chain, vision routing, and timeout policy Hermes already supports applies automatically. Trust gate (fail-closed by default): - plugins.entries.<id>.llm.allow_model_override - plugins.entries.<id>.llm.allowed_models (allowlist; '*' = any) - plugins.entries.<id>.llm.allow_agent_id_override - plugins.entries.<id>.llm.allow_profile_override Embedded model@profile shorthand goes through the same gate as explicit profile=, so it can't bypass the auth-profile policy. Conflicting explicit and embedded profiles fail closed. Also lands: - plugins/plugin-llm-example/ — reference plugin that registers /receipt-extract, demonstrating image+text structured input, jsonschema validation, and the trust-gate config. - website/docs/developer-guide/plugin-llm-access.md — full API docs. - 45 unit tests covering trust gates, JSON parsing, schema validation, image encoding, async surface, and config loading. Validation: - 2628 tests pass in tests/agent/ - E2E: bundled plugin loaded with isolated HERMES_HOME, slash command produced parsed JSON via stubbed call_llm - response_format extra_body wired correctly for both json_object and json_schema modes * docs(plugin-llm): rewrite quickstart and framing The quickstart now uses a meeting-notes-to-tasks example instead of a receipt extractor, and the page leads with hook-time / gateway pre-filter / scheduled-job framing rather than the OpenClaw KB/support/CRM/finance/migration enumeration that the original upstream PR used. Receipt example moved to a separate worked example link so the docs page itself doesn't echo any of the upstream framing. Also clarifies where ctx.llm fits in the broader plugin surface (table comparing register_tool / register_platform / register_hook / etc.) and what makes this lane different from auxiliary_client internals. No code change. * docs(plugin-llm): reframe as any LLM call, not just structured output The original draft leaned heavily on complete_structured() and made the chat lane (complete() / acomplete()) feel like a footnote. Restructure so: - The page title and description say 'any LLM call.' - The lead shows BOTH a plain chat call (error rewriter) AND a structured call (triage scorer) up top. - Quick start has two complete plugin examples — /tldr (chat) and /paste-to-tasks (structured). - New 'When to use which' table for choosing complete() vs complete_structured() vs the async siblings. - Trust-gate sections explicitly note 'all four methods,' and the request-shaping list calls out chat-only fields (messages) and structured-only fields (instructions, input, json_schema) alongside each other. - The 'Where this fits' section now says 'for any reason, structured or not.' The receipt-extractor reference plugin still exists under plugins/plugin-llm-example/ — but the docs page no longer treats it as the canonical surface example. It's now described as 'a third worked example, this time with image input.' No code change. * feat(plugin-llm): split provider/model into independent explicit kwargs The first cut accepted a single 'provider/model' slug on every method and split it internally. That looked clean but broke under live test: the model-override path tried to use the slug's vendor prefix as a literal Hermes provider id, which silently switched the user off their aggregator (e.g. plugin asks for 'openai/gpt-4o-mini' on a user who routes through OpenRouter — host attempted to call the 'openai' provider directly, failed because OPENAI_API_KEY wasn't set). New shape mirrors the host's main config: ctx.llm.complete( messages=[...], provider='openrouter', # gated, optional model='openai/gpt-4o-mini', # gated, optional profile='work', # gated, optional ... ) Each is independently gated by its own allow_*_override flag. Granting model-override does NOT auto-grant provider-override. Allowlists are now per-axis (allowed_providers, allowed_models) matched literally against whatever string the plugin sends. Dropped 'model@profile' embedded-suffix shorthand entirely. Hermes doesn't use that pattern anywhere else; profile= is its own kwarg. Live E2E (against real OpenRouter via Teknium's config) confirms: - zero-config call works - default-deny blocks each override with a helpful error - model-only override stays on user's active provider (the bug) - provider+model override switches cleanly - allowlist refuses non-listed entries - structured output round-trip parses + schema-validates Tests: 49 cases (up from 45); all green. Docs updated to match the new shape, including a 'most plugins never need this section' callout on the trust-gate config block. * fix+cleanup(plugin-llm): real attribution, hook-mode coverage, move example out of core Three integration fixes for the ctx.llm surface: 1. Attribution bug — result.provider and result.model now reflect what call_llm actually used, not placeholder fallbacks ('auto', 'default'). New _resolve_attribution() helper: - explicit overrides win (what the call targeted) - response.model wins for the recorded model (provider canonicalisation: 'gpt-4o' → 'gpt-4o-2024-08-06' etc.) - falls back to _read_main_provider() / _read_main_model() when no override is set, so audit logs reflect the user's active main provider/model - 'auto' / 'default' only when EVERYTHING is empty Live verified: zero-config call now records provider='openrouter', model='anthropic/claude-4.7-opus-20260416' instead of provider='auto', model='default'. 2. Hook-mode coverage — TestHookMode confirms ctx.llm.complete works from inside a registered post_tool_call callback. The docs page promised hook integration; now there's a test that exercises the lazy-import path through the real invoke_hook machinery. Two cases: traceback-rewrite hook with conditional ctx.llm.complete, and minimal hook regression for the sync-hook + sync-llm path. 3. Reference plugin moved out of core. plugins/plugin-llm-example/ is gone from hermes-agent — it now lives in the new NousResearch/hermes-example-plugins companion repo. The docs page links there. Hermes' bundled plugins should be plugins users actually run; reference / docs-companion plugins live externally. Test count: 56 (up from 49). Wider sweep on tests/hermes_cli/ + tests/gateway/ + tests/tools/ + tests/agent/ shows 16770 passing; the 12 failures are all pre-existing on origin/main (verified by stashing this branch's changes and re-running) — kanban-boards, delegate-task, gateway-restart, tts-routing — none touch the plugin_llm surface. * chore(plugins): move all example plugins to companion repo Reference / docs-companion plugins now live exclusively in NousResearch/hermes-example-plugins, not bundled with the core repo: - example-dashboard - strike-freedom-cockpit A new fourth example, plugin-llm-async-example, was added to that repo demonstrating ctx.llm's async surface (acomplete()) with asyncio.gather() — registers /translate <lang>: <text> which fires forward translation + sentiment classifier in parallel, then a back-translation for QA. Live-tested at 2.5s for three real provider round-trips (would be ~5-6s sequential). Docs updated: - developer-guide/plugin-llm-access.md links both sync and async examples in the Reference section - user-guide/features/extending-the-dashboard.md repoints both demo sections to the companion repo with corrected install paths - user-guide/features/built-in-plugins.md drops the two demo rows - AGENTS.md notes that example plugins live in the companion repo Net: hermes-agent's plugins/ directory now contains only plugins users actually run (memory providers, dashboard tabs that ship real features, the disk-cleanup hook, platform adapters). All four demo / reference plugins live externally where they can be cloned on demand instead of inflating the core install.
In NixOS container mode, hermes is installed at a store path with no symlink on PATH (e.g. /data/current-package/bin/hermes). The kanban dispatcher spawns workers via _default_spawn() using a bare 'hermes' subprocess call, which fails with 'hermes executable not found on PATH' in container mode. Fix by calling sys.executable -m hermes instead, which is guaranteed to resolve to the same Python interpreter running the dispatcher.
Follow-up to the previous commit's contributor cherry-pick.
The cherry-picked change replaced the bare ``["hermes", ...]`` spawn with
``[sys.executable, "-m", "hermes", ...]``. The intent was right (avoid
PATH dependence — cron, systemd User= services, launchd jobs, and other
detached dispatcher invocations routinely run with a stripped $PATH that
doesn't include the venv's bin/, breaking the bare-shim spawn) but the
module name is wrong: there is no top-level ``hermes`` package. The
console-script entry point in pyproject.toml is
``hermes = "hermes_cli.main:main"``, and ``python -m hermes`` fails with
``No module named hermes``. The cherry-picked form would have replaced a
sometimes-broken spawn with an always-broken one.
This commit:
- Adds ``_resolve_hermes_argv()``, mirroring ``gateway.run._resolve_hermes_bin``.
Tries ``shutil.which("hermes")`` first (preferred — keeps existing ``ps``
output and log lines familiar in the common case) and falls back to
``[sys.executable, "-m", "hermes_cli.main"]`` when the shim is not on
PATH. The fallback goes through the running interpreter so it's
PATH-independent. Kept as a local helper rather than imported from
gateway because ``hermes_cli`` sits below ``gateway`` in the dependency
order.
- Switches the dispatcher's ``cmd`` list to use ``*_resolve_hermes_argv()``.
- Adds three regression tests:
* ``test_resolve_hermes_argv_prefers_path_shim`` — pins the PATH-first
branch so a future refactor doesn't silently flip the order.
* ``test_resolve_hermes_argv_falls_back_to_module_form_when_no_path_shim`` —
pins the correct module name (``hermes_cli.main``, NOT ``hermes``).
Direct regression guard for the form that shipped in the original PR.
* ``test_resolve_hermes_argv_module_actually_runs`` — runs the fallback
invocation as a real subprocess and asserts ``--version`` works, so
losing ``hermes_cli.main``'s ``__main__`` handling can't slip past the
string-match test.
Verified end-to-end: with the shim on PATH the resolver returns
``[/.../hermes]`` and ``--version`` works; with the shim removed the
resolver returns ``[python, -m, hermes_cli.main]`` and ``--version``
still works; the original PR's ``python -m hermes`` invocation fails as
expected (``No module named hermes``).
…locales (16 total) (NousResearch#22914) * feat(i18n): localize /model command output Reported by @tianma8888: when Chinese users run /model, the labels ("Provider:", "Context:", "_session only_", etc.) are still English. This routes the static prose through the existing i18n catalog so it follows display.language / HERMES_LANGUAGE. Changes: - locales/{en,zh,ja,de,es,fr,tr,uk}.yaml: add 17 keys under gateway.model.* covering switched/provider/context/max_output/cost/ capabilities/prompt_caching/warning/saved_global/session_only_hint/ current_label/current_tag/more_models_suffix/usage_*. - gateway/run.py _handle_model_command: replace hardcoded f-strings in the picker callback, the text-list fallback, and the direct-switch confirmation block with t("gateway.model.<key>", ...). What stays English: - model IDs, provider slugs, capability strings, cost figures, and the "[Note: model was just switched...]" prepended to the model's next prompt (LLM-facing, not user-facing). - The two slightly-different session-only hints unify on a single key with the em-dash phrasing. Validation: tests/agent/test_i18n.py 27/27 passing (parity contract holds), tests/gateway/ -k 'model or i18n' 74/74 passing. * feat(i18n): localize all gateway slash command outputs Expands the i18n catalog from 7 strings to 234 keys across 35 gateway slash command handlers, so non-English users see localized output for \`/profile\`, \`/status\`, \`/help\`, \`/personality\`, \`/voice\`, \`/reset\`, \`/agents\`, \`/restart\`, \`/commands\`, \`/goal\`, \`/retry\`, \`/undo\`, \`/sethome\`, \`/title\`, \`/yolo\`, \`/background\`, \`/approve\`, \`/deny\`, \`/insights\`, \`/debug\`, \`/rollback\`, \`/reasoning\`, \`/fast\`, \`/verbose\`, \`/footer\`, \`/compress\`, \`/topic\`, \`/kanban\`, \`/resume\`, \`/branch\`, \`/usage\`, \`/reload-mcp\`, \`/reload-skills\`, \`/update\`, \`/stop\` (plus the \`/model\` block already added in the previous commit). Reported by @tianma8888 — Chinese users want command output prose in their language, not just the labels we already had. Translations are hand-written for all 8 supported locales (en, zh, ja, de, es, fr, tr, uk), matching each catalog's existing style: full-width punctuation in zh, em-dashes in zh/ja/uk, French spaced colons, German noun capitalization, etc. What stays English (unchanged): - Identifiers/values: model IDs, file paths, profile names, session IDs, command flag names like --global, URLs, config keys. - Backtick code spans: \`/foo\`, \`config.yaml\`. - Log messages (logger.info/warning/error). - LLM-facing system notes prepended to next prompt (e.g. [Note: model was just switched...]). - Strings produced by external modules (gateway_help_lines, format_gateway, manual_compression_feedback) — those have their own surfaces. New shared keys for cross-handler boilerplate: - gateway.shared.session_db_unavailable (5 call sites: branch, title, resume, topic, _disable_telegram_topic_mode_for_chat) - gateway.shared.session_not_found (1 site) - gateway.shared.warn_passthrough (2 sites in /title's f"⚠️ {e}" pattern) YAML gotcha fixed: \`yolo.on\` and \`yolo.off\` were originally written unquoted, which YAML 1.1 parses as boolean True/False keys. Renamed to \`yolo.enabled\` / \`yolo.disabled\` for both safety and clarity. Test fix: tests/agent/test_i18n.py::test_t_missing_key_in_non_english_falls_back_to_english now resets the catalog cache on teardown, so the fake "foo: English Foo" locale doesn't poison the module-level cache for subsequent tests in the same xdist worker. (Without this, every gateway slash command test that shares a worker with the i18n suite would see the fake catalog.) Validation: - tests/agent/test_i18n.py: 27/27 (parity contract — every key in every locale, matching placeholder tokens). - tests/gateway/: 5077 passed, 0 failed (full gateway suite). - 180 t() call sites added across 35 handlers; 1872 catalog entries total (234 keys × 8 locales). * feat(i18n): add 8 new locales — af, ko, it, ga, zh-hant, pt, ru, hu Expands the static-message catalog from 8 → 16 languages, each with full 270-key parity against the English source-of-truth. Every locale now covers the same surface PR NousResearch#22914 added: approval prompts plus all 35 gateway slash command outputs. New locales: - af Afrikaans (community ask in NousResearch#21961 by @GodsBoy; PRs NousResearch#21962, NousResearch#21970) - ko Korean (PRs NousResearch#20297 by @tmdgusya, NousResearch#22285 by @project820) - it Italian (PR NousResearch#20371 by @leprincep35700) - ga Irish/Gaeilge (PR NousResearch#20962 by @ryanmcc09-dot) - zh-hant Traditional Chinese (PRs NousResearch#20523 by @jackey8616, NousResearch#13140 by @anomixer) - pt Portuguese (PRs NousResearch#20443 by @pedroborges, NousResearch#15737 by @carloshenriquecarniatto, NousResearch#22063 by @Magaav) - ru Russian (PR NousResearch#22770 by @DrMaks22) - hu Hungarian (PR NousResearch#22336 by @lunasec007) Each locale uses native-quality translations matching the existing tone and conventions of the older 8 locales: - zh-hant uses 繁體 characters with TW/HK technical vocabulary (軟體 not 软件, 連線 not 连接, 設定 not 设置, 訊息 not 消息, 工作階段 not 会话, 程式 not 程序, 預設 not 默认, 伺服器 not 服务器), full-width punctuation 「:()」. - ko uses formal 합니다체 (습니다/합니다) register throughout. - pt uses European Portuguese as baseline with neutral PT/BR vocabulary where possible. - ga uses standard An Caighdeán Oifigiúil; English loanwords retained for tech terms without good Irish equivalents (gateway, API, JSON). - All preserve {placeholder} tokens, backtick code spans, slash commands, brand names (Hermes, MCP, TTS, YOLO, OpenAI, Telegram, etc.), and emoji. Aliases added in agent/i18n.py: - af-za, Afrikaans → af - ko-kr, Korean, 한국어 → ko - it-it, italiano → it - ga-ie, Irish, Gaeilge → ga - zh-tw, zh-hk, zh-mo, traditional-chinese → zh-hant (note: zh-tw used to alias to zh; now aliases to its own zh-hant catalog) - zh-cn, zh-hans, zh-sg → zh (unchanged from before) - pt-pt, pt-br, brazilian, portuguese → pt - ru-ru, Russian, русский → ru - hu-hu, Magyar → hu The zh-tw alias re-routing is intentional: previously typing 'zh-TW' got the Simplified Chinese catalog (wrong vocabulary for Taiwan/HK users). Now those users get the proper Traditional Chinese catalog. Validation: - tests/agent/test_i18n.py: 43/43 (parity contract holds for all 16 languages × 270 keys = 4320 catalog entries, with matching placeholder tokens). - E2E alias resolution verified for all 19 alias inputs (Afrikaans, ko-KR, 한국어, italiano, Gaeilge, zh-TW, zh-HK, traditional-chinese, pt-BR, brazilian, Magyar, etc.). - tests/gateway/: 5198 passed (3 pre-existing TTS routing failures unrelated to i18n). Credit to all contributors whose PRs surfaced these language requests. Their original PRs may now be closed as superseded with credit. * feat(dashboard-i18n): add 14 web dashboard locales matching the static catalog Brings the React dashboard (web/src/) up to the same 16-language coverage the static catalog already has after the previous commits in this PR. The Translations interface is TypeScript-typed, so every new locale must provide every key — tsc -b is the parity guard. Languages added (each is a complete 429-line locale file): - af Afrikaans - ja Japanese (PR NousResearch#22513 by @snuffxxx surfaced this) - de German (PR NousResearch#21749 by @mag1art) - es Spanish (PR NousResearch#21749) - fr French (PRs NousResearch#21749, NousResearch#10310 by @foXaCe) - tr Turkish - uk Ukrainian - ko Korean (PRs NousResearch#21749, NousResearch#18894 by @ovstng, NousResearch#22285 by @project820) - it Italian - ga Irish (Gaeilge) - zh-hant Traditional Chinese (PR NousResearch#13140 by @anomixer) - pt Portuguese (PRs NousResearch#22063 by @Magaav, NousResearch#22182 by @wesleysimplicio, NousResearch#15737 by @carloshenriquecarniatto) - ru Russian (PRs NousResearch#21749, NousResearch#22770 by @DrMaks22) - hu Hungarian (PR NousResearch#22336 by @lunasec007) Each translation covers all 15 namespaces with full key parity vs en.ts, preserves every {placeholder} token verbatim, keeps identifiers untranslated (brand names, file paths, cron expressions, code spans), translates the language.switchTo tooltip into the target language, and matches existing tone conventions (zh-hant uses TW/HK vocab; ja uses formal desu/masu; ko uses formal seumnida register; ga uses An Caighdean Oifigiuil with English loanwords for tech vocab without good Irish equivalents). Plumbing: - web/src/i18n/types.ts: Locale union expanded to all 16 codes. - web/src/i18n/context.tsx: imports all 16 catalogs; exports LOCALE_META (endonym + flag per locale); isLocale() type guard. - web/src/i18n/index.ts: re-export LOCALE_META. - web/src/components/LanguageSwitcher.tsx: replaced two-state EN-ZH toggle with a click-to-open dropdown listing all 16 languages. Note: zh-hant.ts exports zhHant (camelCase) since hyphen is invalid in a JS identifier; the canonical 'zh-hant' string keys it in TRANSLATIONS. Validation: - npx tsc -b: 0 errors. Every locale satisfies Translations. - npm run build (tsc + vite production): green, 2062 modules. - Each locale file is exactly 429 lines. Out of scope: plugin dashboards (kanban/achievements ship as prebuilt bundles with no source in repo); Docusaurus docs (separate surface); TUI (no i18n yet). * feat(plugin-i18n): localize achievements + kanban plugin dashboards across all 16 locales Brings the two shipped plugin dashboards (hermes-achievements, kanban) under the same i18n umbrella as the core dashboard PR NousResearch#22914 just established. Both bundles now read user-facing strings from the host's i18n catalog via SDK.useI18n() instead of hardcoded English. ## Approach Plugin dashboards ship as prebuilt IIFE bundles in plugins/<name>/dashboard/dist/index.js — no build step, no source in repo (upstream-authored, vendored as compiled JS). Earlier contributor PRs (NousResearch#22594, NousResearch#22595, NousResearch#18747) tried direct edits but didn't actually wire the bundles to read translations. This change does the wiring properly: 1. Each bundle gets a useI18n shim at IIFE scope: const useI18n = SDK.useI18n || function () { return { t: { kanban: null }, locale: "en" }; }; Older host SDKs without useI18n still load the bundle and render English fallbacks. 2. A small tx(t, path, fallback, vars) helper resolves dotted keys under the plugin's namespace (t.kanban.* or t.achievements.*) and interpolates {placeholder} tokens. 3. Every React component starts with const { t } = useI18n() and each user-visible string is wrapped in tx(t, "key", "English fallback"). Helpers called outside React components (window.prompt callers, constants used during init) take t as a parameter. 4. Top-level constants that were English dictionaries (COLUMN_LABEL, COLUMN_HELP, DESTRUCTIVE_TRANSITIONS, DIAGNOSTIC_EVENT_LABELS in kanban) become getColumnLabel(t, status)-style functions backed by FALLBACK_* dictionaries. ## Translations added Two new top-level namespaces added to the dashboard's TypeScript-typed Translations interface: - achievements: ~70 keys covering the hero, scan banner, achievement card, share dialog, stats, filters, and empty states. - kanban: ~145 keys covering the board, columns (with nested columnLabels and columnHelp sub-dicts), card detail panel, bulk-actions toolbar, dependency editor, board switcher, and diagnostic callouts. Each key is provided across all 16 supported locales: en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu. Total new translation entries: ~3,440 (215 keys × 16 locales). ## What stays English (deliberate) - API paths, CSS class names, data-* attributes, JSON keys, regex strings, URLs, file paths (~/.hermes/kanban.db, boards/_archived/). - State identifier strings used as lookup keys (triage / todo / ready / running / blocked / done / archived) — labels translate, key strings don't. - The PNG share-card text rendered to canvas in the achievements ShareDialog (HERMES AGENT watermark, UNLOCKED stamp, tier names) — these become part of a globally-shared image and stay English. - localStorage keys (hermes.kanban.selectedBoard). - Brand names (Kanban, Hermes, WebSocket, Nous Research). ## Contributor credit PR NousResearch#22594 by @02356abc and PR NousResearch#22595 by @02356abc supplied the en + zh kanban namespace skeleton (145 keys); used as the en source- of-truth in this commit and translated to the other 14 locales. PR NousResearch#18747 by @laolaoshiren first surfaced the achievements localization request. ## Validation - npx tsc -b: 0 errors. All 16 locale .ts files satisfy the Translations type with full key parity. - npm run build (tsc + vite production build): green, 2062 modules, 1.56MB JS / 95KB CSS, ~2.5s build. - node --check on both plugin bundles: parse cleanly. - 126 tx() call sites in kanban, 46 in achievements. ## Out of scope - TUI (ui-tui/) has no i18n infrastructure yet. - Docusaurus docs (website/i18n/) — already had zh-Hans; expanding is a separate translation workstream (Thai / Korean / Hindi PRs).
task_age() crashed with ValueError when created_at contained the literal format string '%s' instead of a Unix timestamp, taking down the entire GET /board endpoint with a 500. - Add _safe_int() helper that returns None on non-numeric values - Refactor task_age() to use _safe_int instead of bare int() casts - Wrap task_age() call in _task_dict with try/except fallback so one corrupt row never kills the whole board endpoint
Follow-up to the previous commit's safe-int task_age fix. The original PR shipped without test coverage. This commit adds: - test_safe_int_accepts_int_and_int_string — sanity for the well-typed path so the helper itself can't quietly start swallowing valid values. - test_safe_int_returns_none_on_corrupt_inputs — the failure modes (None, '%s', 'abc', '', '1.5', random objects). Covers both the ValueError and TypeError catch branches. - test_task_age_handles_corrupt_created_at — the headline regression: a task with created_at='%s' used to raise ValueError and turn GET /api/plugins/kanban/board into a 500. - test_task_age_handles_corrupt_started_and_completed — confirms the safe-int treatment is consistent across all three timestamp fields. - test_task_age_well_formed_task — regression that the safe path doesn't change observable output for normal data. - test_task_dict_survives_corrupt_created_at — defense in depth. Writes a corrupt row directly via SQL, reads it back through the ORM, and confirms task_age + the surrounding plugin_api guard degrade gracefully instead of crashing. Also adds the AUTHOR_MAP entry for the contributor's GitHub-noreply email so release notes credit @baocin (the commit was authored locally as `aoi <aoi@hino.local>` — re-attributed during salvage to the github noreply form).
…+ AUTHOR_MAP Follow-up to the previous commit's casing fix. The original PR shipped the dist edits without test coverage. The contributor's reasoning (UI-only attributes in a pre-built JS bundle, nothing meaningful to unit-test) is fair, but a static-asset assertion catches the most likely regression vector — a future rebuild of the dist bundle that loses the attributes — at near-zero cost. Adds two regression tests in tests/plugins/test_kanban_dashboard_plugin.py: - test_dashboard_assignee_inputs_preserve_casing — reads dist/index.js and asserts autoCapitalize="none", autoCorrect="off", spellCheck=false, and textTransform="none" each appear at least twice (one per assignee input — inline triage/lane create + task-edit panel). - test_dashboard_lane_head_preserves_assignee_casing — reads dist/style.css and asserts the .hermes-kanban-lane-head rule body does NOT contain text-transform: uppercase. Locates the rule by marker so unrelated CSS churn nearby doesn't flake the test. Both follow the same shape as the existing test_dashboard_requests_default_board_explicitly static-asset guard from PR NousResearch#22940's salvage. Also adds the AUTHOR_MAP entry for princepal9120's GitHub-noreply email so release notes credit the right account.
…tent CDP WS (180x faster) (NousResearch#23226) Adds CDPSupervisor.evaluate_runtime() and wires it into _browser_eval as a fast path when a supervisor is alive for the current task_id. Replaces the ~180ms agent-browser subprocess fork+exec+Node-startup hop with a ~1ms Runtime.evaluate over the supervisor's already-connected WebSocket. Falls through to the existing agent-browser CLI path when no supervisor is running (e.g. backends without CDP, or before the first browser_navigate attaches one), so behaviour is unchanged where it can't apply. JS-side exceptions surface directly without falling through to the subprocess (the subprocess would just re-raise the same error, slower); supervisor-side failures (loop down, no session) fall through cleanly. Benchmark — 30 iterations of `1 + 1` against headless Chrome: supervisor WS mean= 0.96ms median= 0.91ms agent-browser subprocess mean=179.35ms median=167.73ms → 187x speedup mean Tests: 14 unit tests (mocked supervisor + response-shape coverage), 5 real-Chrome e2e tests in test_browser_supervisor.py (gated on Chrome being installed). Browser test suite: 355 passed, 1 skipped.
…search#19548) Hand-rebased onto current main from PR NousResearch#19980; the original branch was stale against main (~6 unrelated dashboard fixes had landed since), so applying the PR's dist files directly would have silently reverted them. The run-history panel in the task drawer rendered each completed run's `metadata` field as a `<code class="hermes-kanban-run-meta">` containing `JSON.stringify(r.metadata)` — a single unindented monoline. With `white-space: pre-wrap` and a monospace font, a writer task's metadata (changed_files paths, source URLs, generated-artifact details) wrapped into a tall block of code-ish text that filled the parent run row. The container's faint `--color-foreground 3%` background then made the whole thing read like a crash dump even though the run completed normally. Restyle and label, no interactivity changes: - Wrap the meta payload in a `.hermes-kanban-run-meta-block` sub-block with an explicit `Metadata` label (small, uppercase, muted) so the panel reads as auxiliary detail at a glance. - Pretty-print the JSON (`indent=2`) so the structure is scannable instead of a wall of monoline text. - Cap `.hermes-kanban-run-meta` at `max-height: 8.5rem; overflow: auto` so a verbose blob scrolls inside its own pane rather than swamping the run row. - Sub-block uses a thin `border-left` rule and `background: transparent` — distinct from the destructive-tinted treatment used by crashed / timed_out / blocked / spawn_failed runs higher in the same file. Tests: two new static-asset assertions in `tests/plugins/test_kanban_dashboard_plugin.py` lock in the rendered shape (the plugin ships built-only, no src/).
Two follow-up improvements to Tranquil-Flow's metadata-panel restyle.
Both stay within the parent PR's "tone down the panel" scope.
1. Native <details>/<summary> collapse for verbose metadata.
The parent PR consciously deferred this ("adding native expand/collapse
would be the next step but requires UX agreement"). The default they
asked for is straightforward: collapsed when the rendered JSON exceeds
300 chars (the threshold where the max-height: 8.5rem cap actually
starts mattering), expanded otherwise. <details>/<summary> is the right
primitive — zero JS, browser-handled state, accessible by default
(keyboard-navigable, screen-reader announces the disclosure state),
and survives any react-state churn for free.
The OS-default disclosure marker is suppressed (list-style: none +
::-webkit-details-marker hidden) and replaced with a CSS ::before
chevron that rotates 90deg on the [open] attribute, so the look is
consistent across Firefox/WebKit/Blink without the double-marker
that would otherwise appear on the platforms that still render the
default triangle.
2. Skip rendering when metadata is an empty object.
`r.metadata && ...` truthy-checks, but `{}` is truthy in JS — so a
completed task with no actual metadata would render a "Metadata"
labeled disclosure block containing literal `{}`. Adds an
Object.keys(r.metadata).length > 0 guard so empty payloads render
nothing instead of an empty disclosure stub.
Tests: three new static-asset assertions covering the <details> shape,
the empty-object skip, and the suppress-default-marker + animated-chevron
CSS — all in `tests/plugins/test_kanban_dashboard_plugin.py`.
Follow-up to the previous commit's toolset-vs-skill validation.
The contributor's fix raises ValueError on the first toolset name found
in the skills list. That works for one mistake, but agents that confuse
skills with toolsets usually pass several at once
(`skills=["web", "browser", "terminal"]`) — and serial-correcting one
per failure round-trip wastes tokens. Collect all toolset-shaped
entries first, then raise once with the full list.
The error message is also slightly clearer:
'web', 'browser', 'terminal' are toolset names, not skill name(s).
Put toolsets in the assignee profile's `toolsets:` config instead of
per-task skills. Skills are named skill bundles (e.g. `kanban-worker`,
`blogwatcher`); toolsets are runtime capabilities (e.g. `web`,
`browser`, `terminal`).
vs. the previous "the assignee profile's toolsets" — explicitly naming
the YAML key (`toolsets:`) and giving concrete examples in both
categories closes the conceptual gap that produced the bug to begin
with.
Adds one regression test (test_create_task_skills_lists_all_toolset_typos)
covering the multi-name aggregation path. The single-typo test from
the original PR still passes (the loose `match="toolset name"` matches
both singular and plural forms).
…ng, stale-unit warning (NousResearch#23285) When the gateway received SIGTERM, the shutdown_signal_handler ran a synchronous 'ps aux' (3s timeout) inside the asyncio event loop, then asyncio.create_task(runner.stop()). On a busy host that ate 1-3s of the teardown budget before draining could even start, and the resulting log line was a multi-line ps dump that didn't tell us who sent the signal. The shutdown path itself logged 'Stopping gateway...' and then nothing until 'Gateway stopped' — when systemd SIGKILLed mid-drain, there was no way to see which phase wedged. Changes: - New gateway/shutdown_forensics.py: * snapshot_shutdown_context(sig) — sub-millisecond /proc-only capture of signal name, parent pid+name+cmdline, INVOCATION_ID (systemd marker), loadavg_1m, TracerPid, takeover/planned-stop marker presence + whether-it-names-self. Pure stdlib, never raises. * spawn_async_diagnostic(log_path, sig) — detached subprocess with its own 'timeout 5s', start_new_session=True, writes ps auxf + pstree + dmesg to ~/.hermes/logs/gateway-shutdown-diag.log. Returns immediately, can't block the event loop or the cgroup teardown. * check_systemd_timing_alignment(drain_timeout) — reads /proc/self/cgroup for our unit, asks systemctl show for TimeoutStopUSec, returns mismatch info when the unit's stop timeout is smaller than restart_drain_timeout + 30s headroom (the case where systemd SIGKILLs mid-drain). * _parse_systemd_duration_to_us — covers '90s', '1min 30s', '500ms', '1h' style values from systemctl show. * format_context_for_log — single scannable key=value line, parent cmdline last. - gateway/run.py shutdown_signal_handler: * Replaces synchronous ps aux + ad-hoc 'hermes-related lines' filter with snapshot + detached spawn. * Always logs 'Shutdown context: signal=... parent_pid=... parent_cmdline=...' regardless of planned/unexpected so we can correlate signal source even on planned restarts. - gateway/run.py _stop_impl: * Per-phase '+X.XXs' timing for notify_active_sessions, drain (with drain_seconds, active_at_start, active_now, timed_out), post-interrupt tool kill, each adapter disconnect (Xs), all adapters disconnected, final-cleanup tool kill, SessionDB close, total teardown. - gateway/run.py start(): * Stale-unit warning at startup when the running systemd unit's TimeoutStopSec is smaller than the configured drain timeout. Points the user at 'hermes gateway service install --replace' to regenerate, or at shortening agent.restart_drain_timeout. Tests: 30 new in tests/gateway/test_shutdown_forensics.py — snapshot speed bound, signal name resolution, marker detection self-vs-other, async diag spawn doesn't block caller, systemd duration parser, and alignment check returns None outside systemd. Wider tests/gateway/ suite: 5258 passing, 3 pre-existing TTS-routing failures unchanged on main.
…k budget) Follow-up to the previous commit's behavior fix. Adds a paragraph to dispatch_once's docstring making the concurrency-cap semantic explicit, and an inline comment near the running_count query explaining why we do the count (so a future reader doesn't refactor it back to per-tick semantics thinking it's redundant). Both call out the unbounded-accumulation failure mode that motivated the fix, since nothing in the codebase or skills currently documents what max_spawn is supposed to mean. The semantic is per-board: each kanban board has its own SQLite file, so the running-count COUNT(*) is naturally scoped to the board the dispatcher tick is processing.
…search#23291) xAI is retiring grok-4, grok-4-0709, grok-4-fast{,-reasoning,-non-reasoning}, grok-4-1-fast{,-reasoning,-non-reasoning}, and grok-code-fast-1 on May 15, 2026 at 12:00 PT. Remove them from the static fallbacks so the `hermes model` picker, gateway /model picker, and setup wizard stop auto-suggesting models that will be dead in days. - _XAI_STATIC_FALLBACK in hermes_cli/models.py now lists only grok-4.20-* and grok-4.3 (the live replacements). - copilot lists in hermes_cli/models.py and hermes_cli/setup.py drop grok-code-fast-1 (Copilot proxies it through xAI, so the upstream retirement breaks it there too). Old configs that already reference retired IDs keep working until xAI flips the switch — context-length lookups in agent/model_metadata.py and the cache-affinity-header logic in provider_profiles still recognise the old names. The cleanup here is purely about not advertising them to new users. Closes NousResearch#23278. Source: https://docs.x.ai/developers/migration/may-15-retirement
…age of NousResearch#4443) (NousResearch#23373) * feat(gateway): per-platform admin/user split for slash commands Adds an opt-in two-list access control on top of the existing per-platform `allow_from` allowlists, scoped to slash commands only: - allow_admin_from — full slash command access - user_allowed_commands — what non-admins may run - group_allow_admin_from — same, group/channel scope - group_user_allowed_commands When `allow_admin_from` is unset for a scope, gating is disabled and every allowed user keeps full access (backward compat). Plain chat is unaffected. `/help` and `/whoami` are always reachable so users can see what they can run. Gate runs at the slash command dispatch site in gateway/run.py and uses `is_gateway_known_command()`, so it covers built-in AND plugin-registered commands through the live registry without per-feature wiring. Adds `/whoami` showing platform, scope, tier, and runnable commands. Salvage of PR NousResearch#4443's permission tier work, scoped down. The full tier system, tool filtering, audit log, usage tracking, rate limiting, `/promote` flow, and persistent SQLite stores are not included here — those can be re-expanded later if needed. Co-authored-by: ReqX <mike@grossmann.at> * fix(gateway): close running-agent fast-path bypass + add coverage and central docs The slash command access gate was only applied at the cold dispatch site (line ~5921). When an agent was already running, the running-agent fast-path block (line ~5574) dispatched /restart, /stop, /new, /steer, /model, /approve, /deny, /agents, /background, /kanban, /goal, /yolo, /verbose, /footer, /help, /commands, /profile, /update directly without going through the gate — letting non-admins bypass gating just because an agent happens to be busy. Refactored the gate into _check_slash_access() and called from BOTH paths. /status remains intentionally pre-gate so users can always see session state. Also added 18 more dispatch tests covering: - Running-agent fast-path: blocks non-admin, allows admin, /status always works - Alias canonicalization (gate uses canonical name, not user alias) - Unknown / unregistered commands pass through (don't false-positive) - DM admin scope-locked when group has its own admin list - Multi-platform isolation (Discord gated, Telegram unrestricted) Docs: added Slash Command Access Control section to the central messaging index page + /whoami row in the chat commands table. Co-authored-by: ReqX <mike@grossmann.at> --------- Co-authored-by: ReqX <mike@grossmann.at>
…Step-0 profile discovery The skill enumerated 8 specialist profile names (researcher, analyst, writer, reviewer, backend-eng, frontend-eng, ops, pm) as "the standard roster" and told orchestrators to "assume these exist." Almost no real Hermes setup matches that fleet — single-profile setups, Docker-worker setups, and curated-team setups all violate it — so following the skill literally produced cards assigned to non-existent profiles, which the dispatcher silently failed to spawn (no autocorrect, no fallback, just sits in `ready` forever). Changes: - Drop the standard-specialist-roster table. - Add a "Profiles are user-configured — not a fixed roster" section at the top with a Step 0 that prescribes `hermes profile list` (or asking the user) before fanning out. Cache the result in working memory. - Rewrite the worked task-graph example with placeholder names (<profile-A>, <profile-B>, <profile-C>) so the structure is still teachable but doesn't invite copy-paste of role names that may not exist. - Reframe the "If no specialist fits" anti-temptation rule: don't invent profile names; ask the user. - Add a "Inventing profile names that doesn't exist" entry to Pitfalls. - Bump skill version 2.0.0 → 3.0.0 (semantic break: previous behavior promised a roster the skill no longer enumerates). - Update website/docs/user-guide/features/kanban.md to drop the matching "(researcher, writer, analyst, backend-eng, reviewer, ops)" line and explain the discovery prompt instead. - Re-run website/scripts/generate-skill-docs.py to refresh the auto-generated skill page + catalog. Closes NousResearch#21131 in spirit — addresses the same hardcoded-names footgun @yehuosi flagged, with a different shape than their PR (delete the roster rather than replace each name with placeholder, since the roster table was the load-bearing footgun and the worked example is salvageable with placeholder profile names). Co-authored-by: yehuosi <yehuosi@users.noreply.github.com>
Adds /handoff <platform> CLI command that queues the current session for resume on the configured home channel of any messaging platform. CLI side: - /handoff telegram — marks session in shared DB, sends summary to the Telegram home channel via send_message - /handoff discord — same for Discord - Supports telegram, discord, slack, whatsapp, signal, matrix Gateway side: - On new session creation, checks for pending handoffs for the incoming message's platform - If found, loads the CLI session's full conversation history and injects it into the context prompt as a handoff transcript - Agent continues the conversation seamlessly Files: - hermes_state.py: handoff_pending, handoff_platform columns + helpers - cli.py: _handle_handoff_command dispatch + handler - hermes_cli/commands.py: CommandDef entry - gateway/run.py: handoff detection in _handle_message_with_agent - tests/hermes_cli/test_session_handoff.py: 8 tests
…-static within a session (NousResearch#24778) The long-lived prefix-cache layout split the system prompt into stable/ context/volatile blocks and re-derived them on every API call. The volatile tier (timestamp + memory snapshot + USER profile) ticks per turn, so the system message bytes mutated mid-conversation and broke upstream prompt caches (OpenRouter, Nous Portal, Anthropic). Diagnosed via live wire-format diffing: an 8-turn conversation showed OLD layout flipping system block[1] sha mid-session at the minute boundary, dropping cached_tokens to 0 on that turn (cumulative 66.6% vs 83.3% for the single-block layout). Hermes invariant: history (system + all but the last 1-2 messages) must be static. Fix: drop the long-lived layout entirely. Single layout everywhere — system_and_3 with one cached system string built once on first turn, replayed verbatim on every subsequent turn. Loses cross-session 1h prefix caching for Claude (the feature that motivated the split), but within-session caching now actually works on every provider. Removed: - run_agent.py: _use_long_lived_prefix_cache flag, _long_lived_cache_ttl, _supports_long_lived_anthropic_cache method, the long-lived branch in run_conversation, mark_tools_for_long_lived_cache call site - agent/prompt_caching.py: apply_anthropic_cache_control_long_lived, mark_tools_for_long_lived_cache, _mark_system_stable_block helper - hermes_cli/config.py: prompt_caching.long_lived_prefix and prompt_caching.long_lived_ttl config keys - tests/agent/test_prompt_caching_live.py (entire file) - tests/agent/test_prompt_caching.py: TestMarkToolsForLongLivedCache, TestApplyAnthropicCacheControlLongLived - tests/run_agent/test_anthropic_prompt_cache_policy.py: TestSupportsLongLivedAnthropicCache Targeted tests: 62/62 pass.
…al request (NousResearch#24779) * feat(nous): unified client=hermes-client-v<version> tag on every Portal request Every Hermes request to Nous Portal now carries the same client=hermes-client-v<__version__> tag (e.g. client=hermes-client-v0.13.0 on this release), sourced live from hermes_cli.__version__. The release script's regex bump auto-aligns it on every release. Centralized in agent/portal_tags.py and wired into all four call sites: - NousProfile.build_extra_body (main agent loop, every chat completion) - auxiliary_client.NOUS_EXTRA_BODY + _build_call_kwargs (aux client) - run_agent.py compression-summary fallback path - tools/web_tools.py web_extract fallback Replaces the client=aux marker added in NousResearch#24194 with the unified version tag. Tests assert against the helper output (invariant) rather than the literal string, so they don't need updating on every release. * feat(nous): cover /goal judge and kanban specify aux paths Two aux-using surfaces bypassed call_llm by invoking client.chat.completions.create() directly without extra_body, so they were missing the unified Portal client tag: - hermes_cli/goals.py — /goal standing-goal judge - hermes_cli/kanban_specify.py — kanban triage specifier Both now pass extra_body=get_auxiliary_extra_body() or None so they inherit the version tag when the aux client points at Nous Portal, and emit nothing otherwise (no tag leak to OpenRouter/Anthropic auxes).
…ousResearch#24835) - Rename 'Alibaba Cloud (DashScope)' display label to 'Qwen Cloud' in CANONICAL_PROVIDERS (model picker, /model, hermes model TUI) and PROVIDER_REGISTRY (setup wizard prompts, status output). - Move Qwen Cloud (alibaba) up to position 6 — directly below OpenAI Codex and above Xiaomi MiMo. - Move Qwen OAuth (Portal) (qwen-oauth) to the bottom of the canonical provider list. Provider slug 'alibaba' is unchanged — only the display label moved. DashScope env var (DASHSCOPE_API_KEY) and base URL are unchanged. The separate 'alibaba-coding-plan' plugin provider is not affected.
…packages (NousResearch#24841) The Dockerfile permissions section made /opt/hermes/.venv readable but not writable by the hermes runtime user. Since the 2026-05-12 policy change moved messaging packages (discord.py, telegram, slack, etc.) out of [all] and into lazy_deps.py, the Docker image no longer ships with them pre-installed. At first gateway boot, lazy_deps.ensure() tries to `uv pip install` them into the venv but fails with EACCES because site-packages is root-owned. The result: every messaging platform adapter silently fails to load inside Docker containers, producing only a cryptic "discord.py not installed" warning despite the gateway being correctly configured. Two-part fix: 1. Dockerfile: add /opt/hermes/.venv to the existing chown -R hermes:hermes line so the default (UID 10000) case works out of the box. 2. docker/entrypoint.sh: extend the needs_chown block to also re-chown the .venv when HERMES_UID is remapped. Without this, the build-time chown becomes stale when someone uses the documented HERMES_UID override in docker-compose.yml. Fixes NousResearch#21536 Related: NousResearch#17674, NousResearch#21543, NousResearch#21755
…ipped `lsp` is registered as a top-level subparser in `main()` (lines 9539-9545) via `agent.lsp.cli.register_subparser`, so it shows up in `hermes --help` output alongside the other built-ins. The `_BUILTIN_SUBCOMMANDS` set used by `_plugin_cli_discovery_needed` to short-circuit the ~500-650ms plugin import pass did not list it, so every `hermes lsp ...` invocation paid the full discovery cost despite being a fully-built-in command. This is also caught by the parity guard added in NousResearch#22120: `tests/hermes_cli/test_startup_plugin_gating.py::test_builtin_set_covers_every_registered_subcommand` has been failing on clean origin/main with: AssertionError: _BUILTIN_SUBCOMMANDS is missing these live subcommands: ['lsp']. Add them to hermes_cli/main.py::_BUILTIN_SUBCOMMANDS so plugin discovery can be skipped when the user targets them. Fix: add `"lsp"` to the frozenset (alphabetical position between `logs` and `mcp`). The accompanying `test_builtin_set_has_no_phantom_entries` guard still passes because `lsp` is genuinely live — registered via the guarded `try/except Exception` in main() since NousResearch#24168. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the duplicate _FILE_MUTATING_TOOLS frozenset in run_agent.py and
imports the canonical FILE_MUTATING_TOOL_NAMES from
agent/tool_result_classification.py (aliased as _FILE_MUTATING_TOOLS to
avoid renaming the existing call sites). Prevents future drift if
another file-mutating tool is added — only one set needs updating.
No behavior change: same frozenset({'write_file', 'patch'}), and the
117 PR-scoped tests still pass.
…hu adapters (NousResearch#25014) Only Discord and Telegram had lazy-install hooks in their check_*_requirements() functions. The remaining four platforms that were moved to lazy_deps (Slack, Matrix, DingTalk, Feishu) would just return False immediately if their packages weren't pre-installed — no attempt to install them at runtime. This means even with the .venv permissions fix (NousResearch#24841), these four platforms would still fail to load in Docker (or any fresh install) unless the user manually ran pip install. Add the same lazy_deps.ensure() pattern to all four, matching the existing Discord/Telegram implementation.
Previously :latest tracked the tip of main, which meant pulling :latest
got you whatever was last merged — fine for development, surprising for
users who expect :latest to mean 'the most recent stable release'.
Reshape the publish flow so the floating tags carry their conventional
meaning:
- :sha-<sha> every main commit (unchanged, immutable)
- :main tip of main (NEW; what :latest used to do)
- :<release_tag> every published release, e.g. :v1.2.3 (unchanged)
- :latest most recent release (CHANGED; release-only now)
Implementation:
- Rename the move-latest job to move-main; it still gates on push to
main, still ancestor-checks the existing :main label before
retagging, still uses cancel-in-progress: false so queued moves run
serially.
- Add a new move-latest job gated on release: published. Reads the
OCI revision label off the existing :latest and only advances if
the release commit is a strict descendant. This keeps backport
releases on older branches (e.g. patching v1.1.5 after v1.2.3 has
already shipped) from dragging :latest backwards.
- merge job exposes pushed_release_tag and release_tag outputs so
move-latest knows when to fire and what to retag from.
…-852727b9 ci(docker): split :latest (releases only) from :main
…sh_completion
These two functions in hermes_cli/profiles.py have no callers — the live
`hermes completion {bash,zsh}` command uses hermes_cli/completion.py's
generate_bash() / generate_zsh() instead. Multiple PRs (incl. NousResearch#6141) tried
to fix the trailing-`_hermes "$@"` zsh bug here, only to discover the
patch never reached users. Delete the dead code so future contributors
patch the right file.
The actual user-facing fix lives in the preceding cherry-picked commits
to hermes_cli/completion.py.
For PR NousResearch#11754 salvage (zsh completion compdef registration + _arguments syntax tests). CI release script blocks unmapped emails.
- memory_setup.py: use shlex.split() for plugin dep checks instead of shell=True - transcription_tools.py: avoid shell=True for auto-detected whisper commands (user-provided templates via env var still use shell=True for compatibility) - cli.py: add comment clarifying intentional shell=True for user quick_commands - Add test verifying auto-detected template is shlex-safe Addresses CONTRIBUTING.md Priority #3 (Security hardening — shell injection).
|
Important Review skippedToo many files! This PR contains 300 files, which is 150 over the limit of 150. To get a review, narrow the scope: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (300)
You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🚨 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. |
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-import |
1346 |
invalid-argument-type |
1018 |
unresolved-attribute |
935 |
invalid-assignment |
462 |
unsupported-operator |
128 |
invalid-parameter-default |
120 |
invalid-method-override |
88 |
not-subscriptable |
87 |
invalid-return-type |
38 |
no-matching-overload |
32 |
call-non-callable |
28 |
unresolved-reference |
20 |
invalid-type-form |
14 |
unused-type-ignore-comment |
11 |
not-iterable |
6 |
| +6 more rules |
First entries
tests/hermes_cli/test_aux_config.py:47: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(i: SupportsIndex, /) -> Unknown, (s: slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> list[Unknown]]` cannot be called with key of type `Literal["provider"]` on object of type `list[Unknown]`
hermes_cli/model_switch.py:976: [invalid-argument-type] invalid-argument-type: Argument is incorrect: Expected `str`, found `Any | bool | str`
tools/skills_guard.py:672: [invalid-return-type] invalid-return-type: Return type does not match returned value: expected `tuple[bool, str]`, found `tuple[None, str]`
tests/gateway/test_run_cleanup_progress.py:166: [unresolved-attribute] unresolved-attribute: Unresolved attribute `AIAgent` on type `ModuleType`
tests/gateway/test_feishu.py:1579: [unresolved-attribute] unresolved-attribute: Object of type `bound method FeishuAdapter._dispatch_inbound_event(event: MessageEvent) -> CoroutineType[Any, Any, None]` has no attribute `assert_awaited_once`
tools/skills_hub.py:261: [invalid-argument-type] invalid-argument-type: Argument to constructor `Path.__new__` is incorrect: Expected `str | PathLike[str]`, found `str | None`
gateway/platforms/telegram_network.py:18: [unresolved-import] unresolved-import: Cannot resolve imported module `httpx`
tools/send_message_tool.py:1797: [unresolved-import] unresolved-import: Cannot resolve imported module `httpx`
tests/gateway/test_discord_connect.py:65: [invalid-assignment] invalid-assignment: Object of type `<class '_FakeAllowedMentions'>` is not assignable to attribute `AllowedMentions` on type `ModuleType | MagicMock`
hermes_cli/model_switch.py:551: [invalid-parameter-default] invalid-parameter-default: Default value of type `tuple[()]` is not assignable to annotated parameter type `list[str]`
plugins/platforms/google_chat/adapter.py:3236: [unresolved-import] unresolved-import: Cannot resolve imported module `aiohttp`
tests/gateway/test_status_command.py:8: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/plugins/test_kanban_dashboard_plugin.py:18: [unresolved-import] unresolved-import: Cannot resolve imported module `fastapi.testclient`
tests/honcho_plugin/test_pin_peer_name.py:114: [invalid-assignment] invalid-assignment: Object of type `MagicMock` is not assignable to attribute `_get_or_create_honcho_session` of type `def _get_or_create_honcho_session(self, session_id: str, user_peer: Any, assistant_peer: Any) -> tuple[Any, list[Unknown]]`
tests/run_agent/test_anthropic_error_handling.py:474: [invalid-method-override] invalid-method-override: Invalid override of method `_compress_context`: Definition is incompatible with `AIAgent._compress_context`
tests/tools/test_browser_camofox_state.py:63: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(i: SupportsIndex, /) -> Unknown, (s: slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> list[Unknown]]` cannot be called with key of type `Literal["camofox"]` on object of type `list[Unknown]`
gateway/platforms/api_server.py:3138: [unresolved-attribute] unresolved-attribute: Attribute `StreamResponse` is not defined on `None` in union `Unknown | None`
tests/gateway/test_slack.py:16: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
scripts/discord-voice-doctor.py:80: [unresolved-import] unresolved-import: Cannot resolve imported module `nacl.secret`
hermes_cli/auth.py:4054: [invalid-argument-type] invalid-argument-type: Argument to bound method `dict.get` is incorrect: Expected `str`, found `(str & ~Literal["spotify"] & ~Literal["nous"] & ~Literal["openai-codex"] & ~Literal["qwen-oauth"] & ~Literal["google-gemini-cli"] & ~Literal["minimax-oauth"] & ~Literal["copilot-acp"]) | None`
tests/test_tui_gateway_server.py:2108: [unresolved-attribute] unresolved-attribute: Unresolved attribute `_split_path_input` on type `ModuleType`
plugins/memory/supermemory/__init__.py:759: [unresolved-attribute] unresolved-attribute: Attribute `get_profile` is not defined on `None` in union `_SupermemoryClient | None`
tests/run_agent/test_image_shrink_recovery.py:23: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/hermes_cli/test_opencode_go_flat_namespace.py:133: [invalid-argument-type] invalid-argument-type: Argument to function `switch_model` is incorrect: Expected `bool`, found `str | bool`
tests/honcho_plugin/test_session.py:1621: [invalid-argument-type] invalid-argument-type: Argument is incorrect: Expected `int`, found `str | bool`
... and 4318 more
✅ Fixed issues: none
Unchanged: 0 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
Resolves 2 trivial conflicts; all our customizations intact.
Conflicts resolved:
- scripts/release.py: kept our arturo6ramirez@gmail.com → Wizarck
mapping AND upstream's new discodirector + TruaShamu mappings.
- tools/cronjob_tools.py: merged the deliver-parameter description so
it carries upstream's new 'all' fan-out mode AND our
whatsapp_via_mcp_meta_business_api example. Both features preserved.
Auto-merged (no manual intervention needed):
- agent/prompt_builder.py
- gateway/config.py, gateway/run.py
- toolsets.py
Untouched by upstream (our patches survive byte-identical):
- plugins/observability/langfuse/__init__.py (application/consumer tags)
- plugins/observability/langfuse/plugin.yaml (v1.1.0)
- gateway/platforms/{whatsapp_via_mcp_meta_business_api, web_via_http_sse,
base, telegram}.py
- tools/send_message_tool.py
- hermes_cli/{status, gateway, platforms}.py
- cron/scheduler.py
Verification: tests/plugins/test_langfuse_plugin.py 14/14 pass on the
merged tree, including the 7 TestCostAttributionTags scenarios that
guard the cost-by-tag dashboard contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
68fc45b to
1adbf53
Compare
🚨 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. |
The Dockerfile.eligia-overlay still pinned the OLD upstream image (sha256:c47d282...), but the fork source tree was synced in PR #4 to commit 1979ef5 — which introduced new modules (agent.account_usage, etc.) that the older image does NOT contain. Result: the post-migration container crashed on import: ModuleNotFoundError: No module named 'agent.account_usage' Fix: pin to sha256:7df9549f19e847... (multi-arch manifest digest for nousresearch/hermes-agent:main at upstream commit 1979ef5 ↔ 2026-05-13). The base image now matches the source tree the overlay copies into it. Documented inline: bump this pin alongside every fresh upstream sync. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eam sync (#7) The 2026-05-13 sync (PR #4) merged 780 commits into the fork tree, but the overlay still pinned the OLD base image — causing post-deploy ModuleNotFoundError (PR #6 hotfix). Add a 'Upstream sync routine' section to the deploy README so future-me remembers the THREE coupled steps: source merge, image-pin bump, rebuild. Captured upstream in Wizarck/ai-playbook specs/upstream-sync.md §'Containerised forks — base-image pin discipline' (separate PR). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rch#25071) * tui: make URLs clickable + hover-highlight in any terminal Problem ------- URLs printed by `hermes --tui` were not clickable in basic macOS Terminal.app. Cmd+click did nothing, the cursor didn't change shape — like nothing was detected — even though arrow buttons and other Box onClick handlers worked fine. Root cause ---------- Two layers of dead plumbing: 1. `<Link>` only emitted the underlying `<ink-link>` (which carries the hyperlink metadata into the screen buffer) when `supportsHyperlinks()` said yes. On Apple_Terminal that's false, so the per-cell hyperlink field stayed empty, so `Ink.getHyperlinkAt()` had nothing to return on click. The visible underline was just decorative. 2. `Ink.openHyperlink()` calls `this.onHyperlinkClick?.(url)`, but `onHyperlinkClick` was never assigned anywhere in the codebase. The click pipeline (`App.tsx → onOpenHyperlink → Ink.openHyperlink`) ran but bailed silently on the optional chain. Bonus discovery: even when wired up, there was no hover affordance — terminal apps can't change the system mouse cursor, so users had no visual signal that a cell was clickable. Arrow buttons in the chrome worked because they had explicit `<Box onClick>` styling; inline link URLs didn't. Fix --- - `Link.tsx`: always emit `<ink-link>` regardless of terminal capability. The renderer's `wrapWithOsc8Link` already gates the actual OSC 8 escape on `supportsHyperlinks()` further down — so terminals that don't understand OSC 8 still don't see the escape, but the screen-buffer metadata (which the click dispatcher reads) is now populated everywhere. - `ink.tsx + root.ts`: add `onHyperlinkClick?: (url: string) => void` to `Options` / `RenderOptions`, wire it to the existing `Ink.onHyperlinkClick` field in the constructor. - `src/lib/openExternalUrl.ts`: small platform-aware opener using `child_process.spawn` with arg-array (no shell) — http(s) only, rejects `file:`, `javascript:`, `data:`, etc., so a hostile model can't trigger arbitrary local handlers via `<Link url="file:///...">`. Detached + stdio ignore so closing the TUI doesn't kill the browser and Chrome stderr doesn't leak into the alt screen. - `entry.tsx`: pass `onHyperlinkClick: openExternalUrl` to `ink.render`. - `hyperlinkHover.ts` + Ink hover wiring: track the URL under the pointer in `Ink.hoveredHyperlink`, update it from `dispatchHover`, and inverse- highlight every cell of the matching link in the render-pass overlay (same pattern as `applySearchHighlight`). This is the cursor-hover affordance for clickable links — terminals don't expose cursor shape, so we light up the link itself. - `types/hermes-ink.d.ts`: add `onHyperlinkClick` to the `RenderOptions` shim so consumers (`entry.tsx`) type-check against the new option. Tests ----- - `src/lib/openExternalUrl.test.ts` (15 cases): http(s) accepted; file/js/ data/mailto/ftp/ssh rejected; macOS open(1), Windows cmd.exe start with empty title slot, Linux xdg-open dispatch; shell-metacharacter URLs pass through unmolested as a single argv element; synchronous spawn failure returns false. Verified empirically in Apple Terminal 455.1 (macOS 15.7.3): clicking a URL opens in default browser, hovering inverts the link cells, and moving away clears the highlight. Full TUI suite: 713 passing, 0 type errors. Reverts ------- The earlier attempt that version-gated Apple_Terminal in `supports-hyperlinks.ts` was based on a wrong assumption — Terminal.app silently strips OSC 8 sequences but does not render them as clickable hyperlinks. Reverted to the original allowlist. * tui: address Copilot review — explorer.exe on win32 + comment fixes - openExternalUrl: switch win32 from `cmd.exe /c start` to `explorer.exe`. cmd.exe's `start` builtin reparses the URL through cmd's tokenizer, so `&`, `|`, `^`, `<`, `>` either split the command or get reinterpreted — breaking both the protocol-allowlist safety story AND plain http(s) URLs with `&` in query strings. `explorer.exe <url>` invokes the registered protocol handler directly with no shell. - openExternalUrl.test.ts: rename the win32 test to reflect the new contract and add two regression tests — one with `&|^<>` metachars, one with the common analytics-URL `&` query-param pattern — both pinned to single-argv-element delivery via explorer.exe. - Link.tsx: fix misleading comment. OSC 8 escapes are emitted unconditionally by the renderer (`wrapWithOsc8Link` in render-node-to-output.ts, `oscLink` in log-update.ts). Non-supporting terminals silently strip the sequence, which is why hover/click affordance has to come from the in-process overlay rather than the terminal's own link rendering. Verified: 715/715 tests pass, type-check + build clean. * tui: address Copilot review #2 — async spawn errors + hover scope + docs 1. openExternalUrl: attach a no-op `'error'` listener on the spawned child BEFORE unref(). spawn() returns a ChildProcess synchronously even when the binary is missing (ENOENT on xdg-open / explorer.exe), unreachable, or otherwise unusable; the failure surfaces later as an 'error' event. An unhandled 'error' on an EventEmitter crashes Node, which would tear down the whole TUI. The listener is a deliberate no-op — we already returned `true` synchronously and the user just doesn't see the browser pop. 2. openExternalUrl.test.ts: add a regression test using a real EventEmitter to simulate the async-error path. Pins both the listener-attached contract and the "doesn't throw on emit" behavior. Was 17/17, now 18/18. 3. ink.tsx dispatchHover: bypass `getHyperlinkAt()` and read `cellAt(...).hyperlink` directly. `getHyperlinkAt` falls back to `findPlainTextUrlAt` for cells without an OSC 8 hyperlink, but the render-pass overlay (`applyHyperlinkHoverHighlight`) only matches on `cell.hyperlink === hoveredUrl` — so plain-text URLs would burn re-renders without ever producing the highlight. Hover is now a strictly 1:1 fit for what the overlay can paint. Plain-text URLs still get the click action via the existing dispatch path. 4. root.ts + ink.tsx doc comments: replace the misleading "typically `open` / `xdg-open` / `start` shell" wording with the actual safe recipe — argv-array spawn into `open` / `xdg-open` / `explorer.exe`, with an explicit warning that `cmd.exe /c start` reparses the URL through cmd's tokenizer and is unsafe + breaks `&`-query URLs. Verified: 716/716 tests pass, type-check + build clean. * tui: address Copilot review #3 — hover damage, alt-screen cleanup, opener allowlist 1. ink.tsx onRender: stop folding steady-state hover into hlActive. hlActive forces a full-screen damage diff so previous-frame inverted cells get re-emitted when the highlight set changes. The transition IS the trigger — enter / leave / change-to-other-link. While the pointer just sits on a link the painted cells don't change and the per-cell diff handles the no-op. Folding the steady state in would burn a full-screen diff on every frame. Added a lastRenderedHoveredHyperlink tracker and gate the hlActive bump on `hovered !== lastRendered`. 2. ink.tsx setAltScreenActive: clear hoveredHyperlink (and the tracker) when toggling alt-screen state. Hover dispatch is alt-screen-gated, so once we leave there's no path to clear it. Without this, remounting <AlternateScreen> would paint a phantom hover from the previous session until the next mouse-move arrived. 3. openExternalUrl.ts openCommand: allowlist linux + the BSD family for xdg-open and return null for everything else (aix, sunos, cygwin, haiku, etc.). Previously the default-fallback always returned xdg-open, which made the caller's `if (!command) return false` dead and yielded a misleading `true` on platforms that probably don't have xdg-open. New tests cover the null path AND the openExternalUrl-returns-false-without-spawning behavior. Verified: 718/718 tests pass, type-check + build clean. * tui: address Copilot review #4 — doc comment accuracy 1. openExternalUrl return-value doc: now lists all three false paths (URL rejected / no opener for platform / synchronous spawn throw) plus a note that async 'error' events still return true because the spawn was attempted. 2. ink.tsx onHyperlinkClick field doc: clarifies the callback receives either an OSC 8 hyperlink OR a plain-text URL detected by findPlainTextUrlAt — App.tsx routes both into the same callback. 3. hyperlinkHover applyHyperlinkHoverHighlight doc: drops the misleading 'caller forces full-frame damage' promise. Caller decides; for hover the current caller only forces full damage on transitions. No behavior change. 718/718 tests pass. * tui: address Copilot review #5 — lint fixes 1. ink.tsx: reorder `./hyperlinkHover.js` import before `./screen.js` to satisfy perfectionist/sort-imports. 2. Link.tsx: drop unused `fallback` parameter destructuring + the trailing `void (null as ...)` dead-statement (would trip no-unused-expressions). Kept `fallback?: ReactNode` on the Props interface as a documented compat shim so existing call sites still compile, with a comment explaining why it's no longer wired up. 3. openExternalUrl.test.ts: replace `typeof import('node:child_process').spawn` inline annotations (forbidden by @typescript-eslint/consistent-type-imports) with a `SpawnLike` type alias backed by a real `import type { spawn as SpawnFn }`. No behavior change. 718/718 tests pass, type-check clean, lint clean on all modified files.
…ex models (NousResearch#24182) * feat(codex-runtime): scaffold optional codex app-server runtime Foundational commit for an opt-in alternate runtime that hands OpenAI/Codex turns to a 'codex app-server' subprocess instead of Hermes' tool dispatch. Default behavior is unchanged. Lands in three pieces: 1. agent/transports/codex_app_server.py — JSON-RPC 2.0 over stdio speaker for codex's app-server protocol (codex-rs/app-server). Spawn, init handshake, request/response, notification queue, server-initiated request queue (for approval round-trips), interrupt-friendly blocking reads. Tested against real codex 0.130.0 binary end-to-end during development. 2. hermes_cli/runtime_provider.py: - Adds 'codex_app_server' to _VALID_API_MODES. - Adds _maybe_apply_codex_app_server_runtime() helper, called at the end of _resolve_runtime_from_pool_entry(). Inert unless 'model.openai_runtime: codex_app_server' is set in config.yaml AND provider in {openai, openai-codex}. Other providers cannot be rerouted (anthropic, openrouter, etc. preserved). 3. tests/agent/transports/test_codex_app_server_runtime.py — 24 tests covering api_mode registration, the rewriter helper (default-off, case-insensitive, opt-in, non-eligible providers preserved), version parser, missing-binary handling, error class. Does NOT require codex CLI installed. This commit is wire-only: the api_mode is recognized but AIAgent does not yet branch on it. Followup commits add the session adapter, event projector, approval bridge, transcript projection (so memory/skill review still works), plugin migration, and slash command. Existing tests remain green: - tests/cli/test_cli_provider_resolution.py (29 passed) - tests/agent/test_credential_pool_routing.py (included above) * feat(codex-runtime): add codex item projector for memory/skill review The translator that lets Hermes' self-improvement loop keep working under the Codex runtime: converts codex 'item/*' notifications into Hermes' standard {role, content, tool_calls, tool_call_id} message shape that agent/curator.py already knows how to read. Item taxonomy (matches codex-rs/app-server-protocol/src/protocol/v2/item.rs): - userMessage → {role: user, content} - agentMessage → {role: assistant, content: text} - reasoning → stashed in next assistant's 'reasoning' field - commandExecution → assistant tool_call(name='exec_command') + tool result - fileChange → assistant tool_call(name='apply_patch') + tool result - mcpToolCall → assistant tool_call(name='mcp.<server>.<tool>') + tool result - dynamicToolCall → assistant tool_call(name=<tool>) + tool result - plan/hookPrompt/etc → opaque assistant note, no fabricated tool_calls Invariants preserved: - Message role alternation never violated: each tool item produces at most one assistant + one tool message in that order, correlated by call_id. - Streaming deltas (item/<type>/outputDelta, item/agentMessage/delta) don't materialize messages — only item/completed does. Mirrors how Hermes already only writes the assistant message after streaming ends. - Tool call ids are deterministic (codex item id-based) so replays produce identical messages and prefix caches stay valid (AGENTS.md pitfall NousResearch#16). - JSON args use sorted_keys for the same reason. Real wire formats verified against codex 0.130.0 by capturing live notifications from thread/shellCommand and including one as a fixture (COMMAND_EXEC_COMPLETED). 23 new tests, all green: - Streaming deltas don't materialize (3 paths) - Turn/thread frame events are silent - commandExecution: 5 tests including non-zero exit annotation + deterministic id stability across replays - agentMessage + reasoning attachment + reasoning consumption - fileChange: summary without inlined content - mcpToolCall: namespaced naming + error surfacing - userMessage: text fragments only (drops images/etc) - opaque items: no fabricated tool_calls - Helpers: deterministic id stability + sorted JSON args - Role alternation invariant across all four tool-shaped item types This commit is a pure addition. AIAgent integration (the wire that uses the projector) is the next commit. * feat(codex-runtime): add session adapter + approval bridge The third self-contained module: CodexAppServerSession owns one Codex thread per Hermes session, drives turn/start, consumes streaming notifications via CodexEventProjector, handles server-initiated approval requests, and translates cancellation into turn/interrupt. The adapter has a single public per-turn method: result = session.run_turn(user_input='...', turn_timeout=600) # result.final_text → assistant text for the caller # result.projected_messages → list ready to splice into AIAgent.messages # result.tool_iterations → tick count for _iters_since_skill nudge # result.interrupted → True on Ctrl+C / deadline / interrupt # result.error → error string when the turn cannot complete # result.turn_id, thread_id → for sessions DB / resume Behavior: - ensure_started() spawns codex, does the initialize handshake, and issues thread/start with cwd + permissions profile. Idempotent. - run_turn() blocks until turn/completed, drains server-initiated requests (approvals) before reading notifications so codex never deadlocks waiting for us, projects every item/completed via the projector, and increments tool_iterations for the skill nudge gate. - request_interrupt() is thread-safe (threading.Event); the next loop iteration issues turn/interrupt and unwinds. - turn_timeout deadlock guard issues turn/interrupt and records an error if the turn never completes. - close() escalates terminate → kill via the underlying client. Approval bridge: Codex emits server-initiated requests for execCommandApproval and applyPatchApproval. The adapter translates Hermes' approval choice vocabulary onto codex's decision vocabulary: Hermes 'once' → codex 'approved' Hermes 'session' or 'always' → codex 'approvedForSession' Hermes 'deny' / anything else → codex 'denied' Routing precedence: 1. _ServerRequestRouting.auto_approve_* flags (cron / non-interactive) 2. approval_callback wired by the CLI (defers to tools.approval.prompt_dangerous_approval()) 3. Fail-closed denial when neither is wired Unknown server-request methods are answered with JSON-RPC error -32601 so codex doesn't hang waiting for us. Permission profile mapping mirrors AGENTS.md: Hermes 'auto' → codex 'workspace-write' Hermes 'approval-required' → codex 'read-only-with-approval' Hermes 'unrestricted/yolo' → codex 'full-access' 20 new tests, all green. Combined with prior commits this PR now has 67 tests across three modules: - test_codex_app_server_runtime.py: 24 (api_mode + transport surface) - test_codex_event_projector.py: 23 (item taxonomy projections) - test_codex_app_server_session.py: 20 (turn loop + approvals + interrupts) Full tests/agent/transports/ directory: 249/249 pass — no regressions to existing transport tests. Still no wire into AIAgent.run_conversation(); that integration commit is small and goes next. * feat(codex-runtime): wire codex_app_server runtime into AIAgent The integration commit. AIAgent.run_conversation() now early-returns to a new helper _run_codex_app_server_turn() when self.api_mode == 'codex_app_server', bypassing the chat_completions tool loop entirely. Three small surgical edits to run_agent.py (~105 LOC total): 1. Line ~1204 (constructor api_mode validation set): Add 'codex_app_server' so an explicit api_mode='codex_app_server' passed to AIAgent() isn't silently rewritten to 'chat_completions'. 2. Line ~12048 (run_conversation, just before the while loop): Early-return to _run_codex_app_server_turn() when self.api_mode is 'codex_app_server'. Placed AFTER all standard pre-loop setup — logging context, session DB, surrogate sanitization, _user_turn_count and _turns_since_memory increments, _ext_prefetch_cache, memory manager on_turn_start — so behavior outside the model-call loop is identical between paths. Default Hermes flow is unchanged when the flag is off. 3. End-of-class (line ~15497): New method _run_codex_app_server_turn(). Lazy-instantiates one CodexAppServerSession per AIAgent (reused across turns), runs the turn, splices projected_messages into messages, increments _iters_since_skill by tool_iterations (since the chat_completions loop normally does that per iteration), fires _spawn_background_review on the same cadence as the default path. Counter accounting: _turns_since_memory ← already incremented at run_conversation:11817 (gated on memory store configured) — codex helper does NOT touch it (would double-count). _user_turn_count ← already incremented at run_conversation:11793 — codex helper does NOT touch it. _iters_since_skill ← incremented in the chat_completions loop per tool iteration. Codex helper increments by turn.tool_iterations since the loop is bypassed. User message: ALREADY appended to messages by run_conversation pre-loop (line 11823) before the early-return reaches us. Helper does NOT append again. Regression test test_user_message_not_duplicated guards this. Approval callback wiring: Lazy-fetches tools.terminal_tool._get_approval_callback at session spawn time, passes to CodexAppServerSession. CLI threads with prompt_toolkit get interactive approvals; gateway/cron contexts get the codex-side fail-closed deny. Error path: Codex session exceptions become a 'partial' result with completed=False and a final_response that explicitly tells the user how to switch back: 'Codex app-server turn failed: ... Fall back to default runtime with /codex-runtime auto.' Same return-dict shape as the chat_completions path so all callers (gateway, CLI, batch_runner, ACP) work unchanged. 9 new integration tests in tests/run_agent/test_codex_app_server_integration.py: - api_mode='codex_app_server' is accepted on AIAgent construction - run_conversation returns the expected codex shape (final_response, codex_thread_id, codex_turn_id, completed, partial) - Projected messages are spliced into messages list - _iters_since_skill ticks per tool iteration - _user_turn_count delegated to standard flow (not double-counted) - User message appears exactly once (regression guard) - _spawn_background_review IS invoked (memory/skill review keeps working) - chat.completions.create is NEVER called (loop fully bypassed) - Session exception → partial result with /codex-runtime auto hint - Interrupted turn → partial result with error preserved Adjacent test runs confirm no regressions: - tests/run_agent/test_memory_nudge_counter_hydration.py: green - tests/run_agent/test_background_review.py: green - tests/run_agent/test_fallback_model.py: green - tests/agent/transports/: 249/249 green Still missing for full feature: /codex-runtime slash command, plugin migration helper, docs page, live e2e test gated on codex binary. Those are the remaining followup commits. * feat(codex-runtime): add /codex-runtime slash command (CLI + gateway) User-facing toggle for the optional codex app-server runtime. Follows the 'Adding a Slash Command (All Platforms)' pattern from AGENTS.md exactly: single CommandDef in the central registry → CLI handler → gateway handler → running-agent guard → all surfaces (autocomplete, /help, Telegram menu, Slack subcommands) update automatically. Surface: /codex-runtime — show current state + codex CLI status /codex-runtime auto — Hermes default runtime /codex-runtime codex_app_server — codex subprocess runtime /codex-runtime on / off — synonyms Files changed: hermes_cli/codex_runtime_switch.py (new): Pure-Python state machine shared by CLI and gateway. Parse args, read/write model.openai_runtime in the config dict, gate enabling behind a codex --version check (don't let users opt in to a runtime they have no binary for; print npm install hint instead). Returns a CodexRuntimeStatus dataclass that callers render however suits their surface. hermes_cli/commands.py: Single CommandDef entry, no aliases (codex-runtime is its own thing). cli.py: Dispatch in process_command() + _handle_codex_runtime() handler that delegates to the shared module and renders results via _cprint. gateway/run.py: Dispatch in _handle_message() + _handle_codex_runtime_command() that returns a string (gateway sends as message). On a successful change that requires a new session, _evict_cached_agent() forces the next inbound message to construct a fresh AIAgent with the new api_mode — avoids prompt-cache invalidation mid-session. gateway/run.py running-agent guard: /codex-runtime joins /model in the early-intercept block so a runtime flip mid-turn can't split a turn across two transports. Tests: tests/hermes_cli/test_codex_runtime_switch.py — 25 tests covering the state machine: arg parsing (10 cases incl. case-insensitive and synonyms), reading current runtime (5 cases incl. malformed configs), writing runtime (3 cases), apply() entry point covering read-only, no-op, codex-missing-blocked, codex-present-success, disable-no-binary-check, and persist-failure paths (8 cases). All green. Adjacent test suites confirm no regressions: - tests/hermes_cli/test_commands.py + test_codex_runtime_switch.py: 167/167 green - tests/agent/transports/: 283/283 green when combined with prior commits Still missing: plugin migration helper, docs page, live e2e test gated on codex binary. Followup commits. * feat(codex-runtime): auto-migrate Hermes MCP servers to ~/.codex/config.toml Translates the user's mcp_servers config from ~/.hermes/config.yaml into the TOML format codex's MCP client expects. Wired into the /codex-runtime codex_app_server enable path so users get their MCP tool surface in the spawned subprocess automatically. The migration runs on every enable. Failures are non-fatal — the runtime change still proceeds and the user gets a warning so they can fix the codex config manually. What translates (mapping verified against codex-rs/core/src/config/edit.rs): Hermes mcp_servers.<n>.command/args/env → codex stdio transport Hermes mcp_servers.<n>.url/headers → codex streamable_http transport Hermes mcp_servers.<n>.timeout → codex tool_timeout_sec Hermes mcp_servers.<n>.connect_timeout → codex startup_timeout_sec Hermes mcp_servers.<n>.cwd → codex stdio cwd Hermes mcp_servers.<n>.enabled: false → codex enabled = false What does NOT translate (warned + skipped per server): Hermes-specific keys (sampling, etc.) — codex's MCP client has no equivalent. Listed in the per-server skipped[] field of the report. What's NOT migrated (intentional): AGENTS.md — codex respects this file natively in its cwd. Hermes' own AGENTS.md (project-level) is already in the worktree, so codex picks it up without translation. No code needed. Idempotency design: All managed content lives between a 'managed by hermes-agent' marker and the next non-mcp_servers section header. _strip_existing_managed_block removes the prior managed region cleanly, preserving any user-added codex config (model, providers.openai, sandbox profiles, etc.) above or below. Files added: hermes_cli/codex_runtime_plugin_migration.py — pure-Python migration helper. Public API: migrate(hermes_config, codex_home=None, dry_run=False) returns MigrationReport with .migrated/.errors/ .skipped_keys_per_server. No external TOML dependency — minimal formatter handles strings/numbers/booleans/lists/inline-tables. tests/hermes_cli/test_codex_runtime_plugin_migration.py — 39 tests covering: - per-server translation (12): stdio/http/sse, cwd, timeouts, enabled flag, command+url precedence, sampling drop, unknown keys - TOML formatter (8): types, escaping, inline tables, error case - existing-block stripping (4): no marker, alone, with user content above, with user content below - end-to-end migrate() (8): empty, dry-run, round-trip, idempotent re-run, preserves user config, error reporting, invalid input, summary formatting Files changed: hermes_cli/codex_runtime_switch.py — apply() now calls migrate() in the codex_app_server enable branch. Migration failure logs a warning in the result message but does NOT fail the runtime change. Disable path (auto) explicitly skips migration. tests/hermes_cli/test_codex_runtime_switch.py — 3 new tests: test_enable_triggers_mcp_migration, test_disable_does_not_trigger_migration, test_migration_failure_does_not_block_enable. All 325 feature tests green: - tests/agent/transports/: 249 (incl. 67 new) - tests/run_agent/test_codex_app_server_integration.py: 9 - tests/hermes_cli/test_codex_runtime_switch.py: 28 (3 new) - tests/hermes_cli/test_codex_runtime_plugin_migration.py: 39 (new) * perf(codex-runtime): cache codex --version check within apply() Single /codex-runtime invocation could spawn 'codex --version' up to 3 times (state report, enable gate, success message). Each spawn is ~50ms, so the cumulative cost wasn't a crisis, but it was wasteful and turned a trivial slash command into something noticeably laggy on slower systems. Refactored to lazy-once via a closure over a nonlocal cache. First call spawns; subsequent calls in the same apply() reuse the result. Behavior unchanged — same return shape, same error handling, same install hint when codex is missing. Just one subprocess per call instead of three. Two regression-guard tests added: - test_binary_check_cached_within_apply: enable path → call_count == 1 - test_binary_check_cached_on_read_only_call: state-report path → call_count == 1 Total tests for /codex-runtime now 30 (was 28); all 143 codex-runtime tests still green. * fix(codex-runtime): correct protocol field names found via live e2e test Three real bugs caught only by running a turn end-to-end against codex 0.130.0 with a real ChatGPT subscription. Unit tests passed because they asserted on our own (incorrect) wire shapes; the wire format from codex-rs/app-server-protocol/src/protocol/v2/* is the source of truth and my initial reading of the README was incomplete. Bug 1: thread/start.permissions wire format Was sending {"profileId": "workspace-write"}. Real format per PermissionProfileSelectionParams enum (tagged union): {"type": "profile", "id": "workspace-write"} AND requires the experimentalApi capability declared during initialize. AND requires a matching [permissions] table in ~/.codex/config.toml or codex fails the request with 'default_permissions requires a [permissions] table'. Fix: stop overriding permissions on thread/start. Codex picks its default profile (read-only unless user configures otherwise), which matches what codex CLI users expect — they configure their default permission profile in ~/.codex/config.toml the standard way. Trying to be clever about profile selection broke every turn we tested. Live error before fix: 'Invalid request: missing field type' on every turn/start, even though our turn/start payload was correct — the field codex was complaining about was inside the permissions sub-object we shouldn't have been sending. Bug 2: server-request method names Was matching 'execCommandApproval' and 'applyPatchApproval'. Real names per common.rs ServerRequest enum: item/commandExecution/requestApproval item/fileChange/requestApproval item/permissions/requestApproval (new third method) Fix: match the documented names. Added handler for item/permissions/requestApproval that always declines — codex sometimes asks to escalate permissions mid-turn and silent acceptance would surprise users. Live symptom before fix: agent.log showed 'Unknown codex server request: item/commandExecution/requestApproval' and codex stalled because we replied with -32601 (unsupported method) instead of an approval decision. The agent reported back 'The write command was rejected' even though Hermes never showed the user an approval prompt. Bug 3: approval decision values Was sending decision strings 'approved'/'approvedForSession'/'denied'. Real values per CommandExecutionApprovalDecision enum (camelCase): accept, acceptForSession, decline, cancel (also AcceptWithExecpolicyAmendment and ApplyNetworkPolicyAmendment variants we don't currently use). Fix: rename _approval_choice_to_codex_decision return values; update auto_approve_* fallbacks; update fail-closed default from 'denied' to 'decline'. Test mapping table updated to match. Live test verified after fixes: $ hermes (with model.openai_runtime: codex_app_server) > Run the shell command: echo hermes-codex-livetest > .../proof.txt then read it back Approval prompt fired with 'Codex requests exec in <cwd>'. User chose 'Allow once'. Codex executed the command, wrote the file, read it back. Final response: 'Read back from proof.txt: hermes-codex-livetest'. File contents on disk match. agent.log confirms: codex app-server thread started: id=019e200e profile=workspace-write cwd=/tmp/hermes-codex-livetest/workspace All 20 session tests still green after wire-format updates. * fix(codex-runtime): correct apply_patch approval params + ship docs Live e2e revealed FileChangeRequestApprovalParams doesn't carry the changeset (just itemId, threadId, turnId, reason, grantRoot) — Codex's 'reason' field describes what the patch wants to do. Test config and display logic updated to use it. The first 'apply_patch (0 change(s))' display from the live test is now 'apply_patch: <reason>'. Adds website/docs/user-guide/features/codex-app-server-runtime.md covering enable/disable, prerequisites, approval UX, MCP migration behavior, permission profile delegation to ~/.codex/config.toml, known limitations, and the architecture diagram. Wired into the Automation category in sidebars.ts. Live e2e validation across the path matrix: ✓ thread/start handshake ✓ turn/start with text input ✓ commandExecution items + projection ✓ item/commandExecution/requestApproval → Hermes UI → response ✓ Approve once → command runs ✓ Deny → command rejected, codex falls back to read-only message ✓ Multi-turn (codex remembers prior turn's results) ✓ apply_patch via Codex's fileChange path ✓ item/fileChange/requestApproval → Hermes UI ✓ MCP server migration loads inside spawned codex (verified via 'use the filesystem MCP tool' prompt) ✓ /codex-runtime auto → codex_app_server toggle cycle ✓ Disable doesn't trigger migration ✓ Enable with codex CLI present succeeds + migrates ✓ Hermes-side interrupt path (turn/interrupt request issued cleanly even if codex finishes before the interrupt lands) Known live-validated limitations now documented in the docs page: - delegate_task subagents unavailable on this runtime - permission profile selection delegated to ~/.codex/config.toml - apply_patch approval prompt has no inline changeset (codex protocol doesn't expose it) 145/145 codex-runtime tests still green. * feat(codex-runtime): native plugin migration + UX polish (quirks 2/4/5/10/11) Major: migrate native Codex plugins (#7 in OpenClaw's PR list) Discovers installed curated plugins via codex's plugin/list RPC and writes [plugins."<name>@<marketplace>"] entries to ~/.codex/config.toml so they're enabled in the spawned Codex sessions. This is the 'YouTube-video-worthy' bit Pash highlighted: when a user has google-calendar, github, etc. installed in their Codex CLI, those plugins activate automatically when they enable Hermes' codex runtime. Implementation: - hermes_cli/codex_runtime_plugin_migration.py: new _query_codex_plugins() helper spawns 'codex app-server' briefly and walks plugin/list. Returns (plugins, error) — failures are non-fatal so MCP migration still works. - render_codex_toml_section() now takes plugins + permissions args. - migrate() defaults: discover_plugins=True, default_permission_profile= 'workspace-write'. Explicit None on either disables that side. - _strip_existing_managed_block() now also strips [plugins.*] and [permissions]/[permissions.*] sections inside the managed block, so re-runs replace plugins cleanly without touching codex's own config. Quirk fixes: #2 Default permissions profile written on enable. Without this, Codex's read-only default kicks in and EVERY write triggers an approval prompt. Now writes [permissions] default = 'workspace-write' so the runtime feels normal out of the box. Set default_permission_profile=None to opt out. #4 apply_patch approval prompt now shows what's changing. Codex's FileChangeRequestApprovalParams doesn't carry the changeset. Session adapter now caches the fileChange item from item/started notifications and looks it up by itemId when codex requests approval. Prompt shows '1 add, 1 update: /tmp/new.py, /tmp/old.py' instead of 'apply_patch (0 change(s))'. Side benefit: also drains pending notifications BEFORE handling a server request, so the projector and per-turn caches are up to date when the approval decision fires. Bounded to 8 notifications per loop iter to avoid starving codex's response. #5/#10 Exec approval prompt never shows empty cwd. When codex omits cwd in CommandExecutionRequestApprovalParams, fall back to the session's cwd. If somehow neither is available, show '<unknown>' explicitly instead of an empty string. Also surfaces 'reason' from the approval params when codex provides it — gives users more context on why codex wants to run something. #11 Banner indicates the codex_app_server runtime when active. New 'Runtime: codex app-server (terminal/file ops/MCP run inside codex)' line appears in the welcome banner only when the runtime is on. Default banner is unchanged. Tests: - 7 new tests in test_codex_runtime_plugin_migration.py covering plugin discovery (mocked), failure handling, dry-run skip, opt-out flag, idempotent re-runs, and permissions writing. - 3 new tests in test_codex_app_server_session.py covering the enriched approval prompts: cwd fallback, change summary on apply_patch, fallback when no item/started cache exists. - All 26 session tests + 46 migration tests green; 153 total in PR. * feat(codex-runtime): hermes-tools MCP callback + native plugin migration The big architectural addition: when codex_app_server runtime is on, Hermes registers its own tool surface as an MCP server in ~/.codex/config.toml so the codex subprocess can call back into Hermes for tools codex doesn't ship with — web_search, browser_*, vision, image_generate, skills, TTS. Also: 'migrate native codex plugins' (Pash's YouTube-video-worthy bit) — when the user has plugins like Linear, GitHub, Gmail, Calendar, Canva installed via 'codex plugin', Hermes discovers them via plugin/list and writes [plugins.<name>@openai-curated] entries so they activate automatically. New module: agent/transports/hermes_tools_mcp_server.py FastMCP stdio server exposing 17 Hermes tools. Each call dispatches through model_tools.handle_function_call() — same code path as the Hermes default runtime. Run with: python -m agent.transports.hermes_tools_mcp_server [--verbose] Exposed: web_search, web_extract, browser_navigate / _click / _type / _press / _snapshot / _scroll / _back / _get_images / _console / _vision, vision_analyze, image_generate, skill_view, skills_list, text_to_speech. NOT exposed (deliberately): - terminal/shell/read_file/write_file/patch — codex has built-ins - delegate_task/memory/session_search/todo — _AGENT_LOOP_TOOLS in model_tools.py:493, require running AIAgent context. Documented as a limitation and surfaced in the slash command output. Migration changes (hermes_cli/codex_runtime_plugin_migration.py): - _query_codex_plugins() spawns 'codex app-server' briefly to walk plugin/list and pull installed openai-curated plugins. Failures are non-fatal — MCP migration still completes. - render_codex_toml_section() now takes plugins + permissions args AND wraps the managed block with a MIGRATION_END_MARKER comment so the stripper can reliably find both ends, even when the block contains top-level keys (default_permissions = ...). - migrate() defaults: discover_plugins=True, expose_hermes_tools=True, default_permission_profile=':workspace' (built-in codex profile name — must be prefixed with ':'). All three opt-out via explicit args. - _build_hermes_tools_mcp_entry() builds the codex stdio entry with HERMES_HOME and PYTHONPATH passthrough so a worktree-launched Hermes points the MCP subprocess at the same module layout. Live-caught wire bugs fixed during this turn: 1. Permission profile config key is top-level , NOT a [permissions] table. The [permissions] table is for *user-defined* profiles with structured fields. Built-in profile names start with ':' (':workspace', ':read-only', ':danger-no-sandbox'). Was emitting which codex rejected with 'invalid type: string "X", expected struct PermissionProfileToml'. 2. Built-in profile is , NOT . Codex rejected with 'unknown built-in profile'. 3. Codex's MCP layer sends for tool-call confirmation. We weren't handling it, so codex stalled and returned 'MCP tool call was rejected'. Now: auto-accept for our own hermes-tools server (user already opted in by enabling the runtime), decline for third-party servers. Quirk fixes shipped (from the limitations list): #2 default permissions: workspace profile written on enable. No more approval prompt on every write. #4 apply_patch approval shows what's changing: cache fileChange items from item/started, look up by itemId when codex sends item/fileChange/requestApproval. Prompt: '1 add, 1 update: /tmp/new.py, /tmp/old.py' instead of '0 change(s)'. #5/#10 exec approval cwd never empty: fall back to session cwd, then '<unknown>'. Also surfaces 'reason' from codex when present. #11 banner shows 'Runtime: codex app-server' line when active so users understand why tool counts may not match what's reachable. Tests: - 5 new tests in test_codex_runtime_plugin_migration.py covering plugin discovery, expose_hermes_tools entry generation, idempotent re-runs, opt-out flag, permissions profile. - 3 new tests in test_codex_app_server_session.py covering enriched approval prompts (cwd fallback, fileChange summary). - 2 new tests for mcpServer/elicitation/request handling (accept hermes-tools, decline others). - New test file test_hermes_tools_mcp_server.py covering module surface, EXPOSED_TOOLS safety invariants (no shell/file_ops, no agent-loop tools), and main() error paths. - 166 codex-runtime tests total, all green. Live e2e validated against codex 0.130.0 + ChatGPT subscription: ✓ /codex-runtime codex_app_server enables, migrates filesystem MCP, registers hermes-tools, writes default_permissions = ':workspace' ✓ Banner shows 'Runtime: codex app-server' line in subsequent sessions ✓ Shell command runs without approval prompt (workspace profile works) ✓ Multi-turn — codex remembers prior turn's results ✓ apply_patch path via fileChange request approval ✓ web_search via hermes-tools MCP callback returns real Firecrawl results: 'OpenAI Codex CLI – Getting Started' end-to-end in 13s ✓ Disable cycle clean Docs updated: website/docs/user-guide/features/codex-app-server-runtime.md Full re-write covering native plugin migration, the hermes-tools callback architecture, the prerequisites change ('codex login is separate from hermes auth login codex'), the trade-off table now reflecting which Hermes tools work via callback, and the limitations list updated with what's actually unavailable on this runtime. * feat(codex-runtime): pin user-config preservation invariant for quirk #6 Quirk #6 from the limitations list — user MCP servers / overrides / codex-only sections in ~/.codex/config.toml that live OUTSIDE the hermes-managed block must survive re-migration verbatim. This already worked thanks to the MIGRATION_MARKER + MIGRATION_END_MARKER pair I added when fixing the default_permissions wire format (so the strip can find both ends of the managed region even with top-level keys like default_permissions). But it was an emergent property without a test pinning it. Now explicitly tested: - User MCP server above the managed block survives migration - User MCP server below the managed block survives migration - Both above + below survive a second re-migration - User content (model, providers, sandbox, otel, etc.) outside our region is left untouched Docs added a section "Editing ~/.codex/config.toml safely" explaining the marker contract — so users know they can add their own MCP servers, override permissions, configure codex-only options, etc. without fear of Hermes overwriting their work. 167 codex-runtime tests, all green. * docs(codex-runtime): clarify the actual tool surface — shell covers terminal/read/write/find Previous docs and PR description undersold what codex's built-in toolset actually provides. apply_patch alone made it sound like the runtime could only edit files in patch format — implying you'd lose terminal use, read_file, write_file, search/find. That was wrong. Codex's 'shell' tool runs arbitrary shell commands inside the sandbox, which covers everything you'd do in bash: cat/head/tail (read), echo> or heredocs (write), find/rg/grep (search), ls/cd (navigate), build/ test/git/etc. apply_patch is for structured multi-file edits on top of that. update_plan is its in-runtime todo. view_image loads images. And codex has its own web_search built in (in addition to the Firecrawl-backed one Hermes exposes via MCP callback). Docs now have a 'What tools the model actually has' section right after Why, breaking the surface into three clearly-labeled buckets: 1. Codex's built-in toolset (always on) — shell, apply_patch, update_plan, view_image, web_search; covers everything terminal- adjacent. 2. Native Codex plugins (auto-migrated from your codex plugin install) — Linear, GitHub, Gmail, Calendar, Outlook, Canva, etc. 3. Hermes tool callback (MCP server in ~/.codex/config.toml) — web_search/web_extract via Firecrawl, browser_*, vision_analyze, image_generate, skill_view/skills_list, text_to_speech. Plus a 'What's NOT available' callout listing the four agent-loop tools (delegate_task, memory, session_search, todo) that need running AIAgent context and can't reach the codex runtime. Trade-offs table broken out: shell, apply_patch, update_plan, view_image, sandbox each get their own row with a one-line description so users can see at a glance what's available natively. Architecture diagram updated to list the codex built-ins by name instead of 'apply_patch + shell + sandbox'. No code changes — purely docs clarification. 167 codex-runtime tests still green. * fix(codex-runtime): _spawn_background_review signature + review fork api_mode downgrade Two real bugs in the self-improvement loop integration that the previous test mocked away. Bug 1: wrong call signature The codex helper was calling self._spawn_background_review() with no args after every turn. That function actually requires: messages_snapshot=list (positional or keyword) review_memory=bool (at least one trigger must be True) review_skills=bool So the call would have raised TypeError at runtime — except the only test that exercised this path mocked _spawn_background_review entirely and just asserted spawn.called, so the wrong-arg shape never surfaced. Bug 2: review fork inherits codex_app_server api_mode The review fork is constructed with: api_mode = _parent_runtime.get('api_mode') So when the parent is codex_app_server, the review fork ALSO runs as codex_app_server. But the review fork's whole job is to call agent-loop tools (memory, skill_manage) which require Hermes' own dispatch — they short-circuit with 'must be handled by the agent loop' on the codex runtime. So the review fork would have run, decided to save something, called memory or skill_manage, and silently no-op'd. Fixed in run_agent.py:_spawn_background_review() — when the parent api_mode is 'codex_app_server', the review fork is downgraded to 'codex_responses' (same OAuth credentials, same openai-codex provider, but talks to OpenAI's Responses API directly so Hermes owns the loop). Also rewrote the codex helper's review wiring to match the chat_completions path: - Computes _should_review_memory in the pre-loop block (was already being computed; now passed through to the helper as an arg). - Computes _should_review_skills AFTER the codex turn returns + counters tick (line ~15432 pattern in chat_completions). - Calls _spawn_background_review(messages_snapshot=, review_memory=, review_skills=) only when at least one trigger fires. - Adds the external memory provider sync (_sync_external_memory_for_turn) that the chat_completions path runs after every turn. Tests: Replaced the broken test_background_review_invoked (which only asserted spawn.called) with three sharper tests: - test_background_review_NOT_invoked_below_threshold: single turn at default thresholds → no review fires (would have caught the original 'every turn calls spawn with no args' bug) - test_background_review_skill_trigger_fires_above_threshold: 10 tool_iterations at threshold=10 → review fires with messages_snapshot=list, review_skills=True, counter resets - test_background_review_signature_never_breaks: regression guard asserting positional args are always empty and kwargs include messages_snapshot New TestReviewForkApiModeDowngrade class: - test_codex_app_server_parent_downgrades_review_fork: drives the real _spawn_background_review function (no mock at that level), asserts the review_agent gets api_mode='codex_responses' when the parent was codex_app_server. Live-validated against real run_conversation: - Counter ticked from 0 to 5 after a 5-tool-iteration turn - _spawn_background_review fired exactly once with kwargs-only signature - review_skills=True, review_memory=False - messages_snapshot was 12 entries (5 assistant tool_calls + 5 tool results + 1 final assistant + initial system/user) - Counter reset to 0 after fire 170 codex-runtime tests, all green. Docs: added a Self-improvement loop section to the codex runtime page explaining both how the trigger logic stays equivalent and that the review fork is auto-downgraded to codex_responses for the agent-loop tools. Also clarified that apply_patch and update_plan ARE codex's built-in tools (the previous version made it sound like they were separate from 'codex's stuff' — they're not, all five tools listed in 'What tools the model actually has' section 1 are codex built-ins). * feat(codex-runtime): expose kanban tools through Hermes MCP callback Kanban workers spawn as separate hermes chat -q subprocesses that read the user's config.yaml. If model.openai_runtime: codex_app_server is set globally (which is the whole point of opt-in), every dispatched worker ALSO comes up on the codex runtime. That mostly works — codex's built-in shell + apply_patch + update_plan do the actual task work fine — but it had one critical break: the worker handoff tools (kanban_complete, kanban_block, kanban_comment, kanban_heartbeat) are Hermes-registered tools, not codex built-ins. On the codex runtime, codex builds its own tool list and these never reach the model, so the worker would do the work but not be able to report back, hanging until the dispatcher's timeout escalates it as zombie. Fix: add all 9 kanban tools to the EXPOSED_TOOLS list in the Hermes MCP callback. They dispatch statelessly through handle_function_call() just like web_search and the others — they read HERMES_KANBAN_TASK from env (set by the dispatcher), gate correctly (worker tools require the env var, orchestrator tools require it unset), and write to ~/.hermes/kanban.db. Why kanban tools work via stateless dispatch when delegate_task/memory/ session_search/todo don't: those four are listed in _AGENT_LOOP_TOOLS (model_tools.py:493) and short-circuit in handle_function_call() with 'must be handled by the agent loop' — they need to mutate AIAgent's mid-loop state. Kanban tools have no such requirement; they're pure side-effect functions against the kanban.db plus state_meta. Tools exposed: Worker handoff (require HERMES_KANBAN_TASK): kanban_complete, kanban_block, kanban_comment, kanban_heartbeat Read-only board queries: kanban_show, kanban_list Orchestrator (require HERMES_KANBAN_TASK unset): kanban_create, kanban_unblock, kanban_link Tests: - test_kanban_worker_tools_exposed: complete/block/comment/heartbeat in EXPOSED_TOOLS (regression guard for the would-hang-worker bug) - test_kanban_orchestrator_tools_exposed: create/show/list/unblock/link Docs: - New 'Workflow features' section in the docs page covering /goal, kanban, and cron behavior on this runtime - /goal: works fully via run_conversation feedback; only caveat is approval-prompt noise on long writes-heavy goals (mitigated by the default :workspace permission profile) - Kanban: enumerated which tools are reachable via the callback and why the env var propagates correctly through the codex subprocess to the MCP server subprocess - Cron: documented as 'not specifically tested' — same rules as the CLI apply since cron runs through AIAgent.run_conversation - Trade-offs table gained rows for /goal, kanban worker, kanban orchestrator 172/172 codex-runtime tests green (+2 from kanban tests). * docs(codex-runtime): wire /codex-runtime into slash-commands ref + flag aux token cost Three docs gaps caught during a final audit: 1. /codex-runtime was only in the feature docs page, not in the slash-commands reference. Added rows to both the CLI section and the Messaging section so users discover it where they'd look for slash command syntax. 2. CODEX_HOME and HERMES_KANBAN_TASK weren't in environment-variables.md. CODEX_HOME lets users redirect Codex CLI's config dir (the migration honors it). HERMES_KANBAN_TASK is set by the kanban dispatcher and propagates to the codex subprocess + the hermes-tools MCP subprocess so kanban worker tools gate correctly — documented as 'don't set manually' since it's an internal handoff. 3. Aux client behavior on this runtime. When openai_runtime= codex_app_server is on with the openai-codex provider, every aux task (title generation, context compression, vision auto-detect, session search summarization, the background self-improvement review fork) flows through the user's ChatGPT subscription by default. This is true for the existing codex_responses path too, but it's more visible / important here because users explicitly opted in for subscription billing. Added a 'Auxiliary tasks and ChatGPT subscription token cost' section to the docs page with a YAML example showing how to override specific aux tasks to a cheaper model (typically google/gemini-3-flash-preview via OpenRouter). Also documents how the self-improvement review fork gets auto-downgraded from codex_app_server to codex_responses by the fix earlier in this PR. No code changes — pure docs. 172 codex-runtime tests still green. * docs+test(codex-runtime): pin HOME passthrough, document multi-profile + CODEX_HOME OpenClaw hit a real footgun in openclaw/openclaw#81562: when spawning codex app-server they were synthesizing a per-agent HOME alongside CODEX_HOME. That made every subprocess codex's shell tool launches (gh, git, aws, npm, gcloud, ...) see a fake $HOME and miss the user's real config files. They had to back it out in PR #81562 — keep CODEX_HOME isolation, leave HOME alone. Audit confirms Hermes' codex spawn doesn't have this problem. We do os.environ.copy() and only overlay CODEX_HOME (when provided) and RUST_LOG. HOME passes through unchanged. But it was an emergent property without a test pinning it, so adding a regression guard: test_spawn_env_preserves_HOME — confirms parent HOME survives intact in the subprocess env test_spawn_env_sets_CODEX_HOME_when_provided — confirms codex_home arg still isolates codex state correctly Docs additions: 'HOME environment variable passthrough' section — calls out the contract explicitly: CODEX_HOME isolates codex's own state, HOME stays user-real so gh/git/aws/npm/etc. find their normal config. Cites openclaw#81562 as the cautionary tale. 'Multi-profile / multi-tenant setups' section — addresses the related concern: profiles share ~/.codex/ by default. For users who want per-profile codex isolation (separate auth, separate plugins), documents the manual CODEX_HOME=<profile-scoped-dir> approach. Explains why we DON'T auto-scope CODEX_HOME per profile: doing so would silently invalidate existing codex login state for anyone upgrading to this PR with tokens already at ~/.codex/auth.json. Opt-in is safer than surprising users. 174 codex-runtime tests (+2 from HOME guards), all green. * fix(codex-runtime): TOML control-char escapes + atomic config.toml write Two footguns caught in a final audit pass before merge. Bug 1: TOML control characters not escaped The _format_toml_value() helper escaped backslashes and double quotes but passed literal control characters (\n, \t, \r, \f, \b) through unchanged. TOML basic strings don't allow literal control characters — a path or env var containing a newline would produce invalid TOML that codex refuses to load. Realistic exposure: pathological cases like a HERMES_HOME with a trailing newline (env var concatenation accident), or a PYTHONPATH with a tab from a multi-line shell heredoc. Fix: escape all five TOML basic-string control sequences (\b \t \n \f \r) in addition to \\ and \" that we already did. Order matters — backslash must come first or the other escapes get re-escaped. Bug 2: config.toml write wasn't atomic If the python process crashed between target.mkdir() and the write_text() finishing, a half-written config.toml could be left behind. On NFS / Windows / some FUSE mounts this is a real concern; on ext4/APFS small writes are usually atomic in practice but not guaranteed. Fix: write to a tempfile.mkstemp() temp file in the same directory, then Path.replace() (atomic same-dir rename on POSIX, ReplaceFile on Windows). On rename failure, clean up the temp file so repeated failed migrations don't pile up .config.toml.* files. Tests: - test_string_with_newline_escaped — \n in value → \n in output - test_string_with_tab_escaped — \t in value → \t in output - test_string_with_other_controls_escaped — \r, \f, \b - test_windows_path_escaped_correctly — backslash doubling - test_atomic_write_no_temp_leak_on_success — no .config.toml.* left over after a successful write - test_atomic_write_cleanup_on_rename_failure — temp file removed when Path.replace raises (simulated disk full) 180 codex-runtime tests, all green (+6 from this commit). Footguns audited but NOT fixed (with rationale): - Concurrent migrations race. Two Hermes processes hitting /codex-runtime codex_app_server within seconds of each other could cause one writer to lose entries. Low probability (you'd have to enable from two surfaces simultaneously) and low impact (just re-run migration). Adding fcntl/msvcrt locking is more code than it's worth here. The atomic rename above means each individual write is consistent — only the merge step is racy. - Codex protocol version drift. We pin MIN_CODEX_VERSION=0.125 and check at runtime but don't reject too-new versions. Right call — the protocol has been stable through 0.125 → 0.130. If OpenAI breaks it later we'd see the error in test_codex_app_server_runtime on CI before users hit it.
Summary
Routine upstream sync. Brings in 780 commits from
NousResearch/hermes-agent(since our last merge at 91ec395 on 2026-05-06). All our customizations intact.Conflicts (2 trivial)
scripts/release.pyarturo6ramirez@gmail.com → Wizarckmapping alongside upstream's newdiscodirector+TruaShamuadditions.tools/cronjob_tools.pydeliverparameter description so it carries BOTH upstream's new'all'fan-out mode AND ourwhatsapp_via_mcp_meta_business_apiexample.Auto-merged (no manual touch)
agent/prompt_builder.pygateway/config.py,gateway/run.pytoolsets.pyUntouched by upstream (our patches survive byte-identical)
plugins/observability/langfuse/__init__.py+plugin.yaml(the v1.1.0 cost-by-tag patch from PR feat(langfuse): inject application=hermes-bot + consumer=HERMES into traces #2)gateway/platforms/{whatsapp_via_mcp_meta_business_api, web_via_http_sse, base, telegram}.pytools/send_message_tool.pyhermes_cli/{status, gateway, platforms}.pycron/scheduler.pydeploy/eligia-vps/*(from PR feat(deploy): version /opt/hermes/* under deploy/eligia-vps/ #3)Verification
pytest tests/plugins/test_langfuse_plugin.py -q→ 14/14 passing on the merged tree, including the 7TestCostAttributionTagsscenarios that guard the cost-by-tag dashboard contract.Notable upstream changes
shell=Truereduced in subprocess calls; approval DELETE pattern DOTALL fixlazy_deps.ensure()added to slack/matrix/dingtalk/feishu adapters; WhatsApp npm install timeout configurable.venvchown to hermes so lazy_deps can install platform packages;:latestvs:maintag split in CIclient=hermes-client-v<version>tag on every Portal requestTest plan
test+nix-ubuntupre-existing failures).Generated with Claude Code.