The earlier fix gated _resolve_use_tui, but the EARLY launcher
(_wants_tui_early) decides TUI from display.interface before cmd_chat
runs — so a `display.interface: tui` default still booted the Ink UI for
headless spawns (kanban workers), whose no-TTY bail-out exits 0 →
"protocol violation". Gate the early resolver on a real TTY: headless
stdio never boots the TUI regardless of config; explicit --tui still does.
Dragging a task done→ready did nothing: the respawn guard saw a run that
completed within the success window and deferred forever, unable to tell a
deliberate operator re-run from a status flap. Now a re-queue event
(status change, promote, unblock, reclaim) AFTER the completion bypasses
the recent_success guard, so an explicit done→ready runs again.
A retried task (→ running) kept showing "crashed Nx": the in-flight run
has no outcome yet, so the trailing crash scan skipped it and kept
counting the prior streak, and the consecutive_failures counter lingers.
Exempt `running` from both repeated_failures and repeated_crashes so a
fresh attempt clears the banner until it itself resolves (re-fires if the
new run also fails).
An inherited HERMES_TUI=1 or a `display.interface: tui` config default sent
kanban workers into the Ink TUI, whose no-TTY bail-out exits 0 without doing
the task — every attempt ended in "protocol violation". Two layers:
- _default_spawn pins `--cli` (highest-precedence interface flag) and strips
HERMES_TUI from the child env (covers older builds on PATH).
- _resolve_use_tui gates ambient TUI prefs (env/config) behind a real TTY;
an explicit --tui still wins so the informative bail-out stays reachable.
A manual done (dashboard/desktop drag) runs complete_task but ends no
run, so a trailing crashed/crashed run history never gains the
'completed' outcome that breaks the repeated_crashes streak — the card
kept flagging "needs attention" forever after being finished.
repeated_failures had the same hole via a stale counter. Terminal
statuses are now exempt from both: done means done; the history stays
on the event log for audit. Regression test included.
Close the remaining end-to-end gaps so the full gpt-5.6 family (sol/
terra/luna + their -pro high-effort modes, 6 slugs) works on every
surface a user can reach them through:
- agent/auxiliary_client.py: the Codex OAuth backend hard-caps context
at 272K for gpt-5.6 exactly as it does for 5.4/5.5, but the default
50% compaction trigger would summarize at ~136K and waste half the
usable window. Extend the existing _is_codex_gpt54_or_gpt55 chokepoint
(single enforced predicate feeding _compression_threshold_for_model)
to match gpt-5.6* on the openai-codex route so those sessions get the
same 0.85 auto-raise. Direct-API/OpenRouter routes (full 1.05M window)
are unaffected; the historical codex_gpt55_autoraise opt-out still
applies. The one-time notice banner is model-dynamic and already
renders the correct slug/cap.
- hermes_cli/config.py, agent/agent_init.py: refresh the autoraise
comments/notice to mention the 5.6 family.
- hermes_cli/codex_models.py: add the -pro variants to DEFAULT_CODEX_MODELS
+ forward-compat so ChatGPT-OAuth (openai-codex) Pro users see the full
family in /model, not just the base tiers.
Supersedes the earlier commit's note that 5.6 was intentionally kept out
of the codex catalog: the slugs are confirmed routable (OpenRouter live
+ codex backend), so they belong there like every other codex-capable
gpt-5.x slug.
E2E verified across all 6 slugs: direct-API ctx 1.05M, codex ctx 272K,
pricing reachable from openai + openai-api routes, codex compaction
override 0.85 (and None on direct-API + when opted out), present in
openai-api picker + codex catalog, /model gpt resolves to sol on both
native routes. Guard tests added for the compaction route matrix.
PR #61587 adds sol-pro/terra-pro/luna-pro to the aggregator lists.
Complete those on the native surfaces the same way this PR completes
the base tiers:
- hermes_cli/models.py: -pro variants in _PROVIDER_MODELS[openai-api].
- agent/usage_pricing.py: alias ("openai", "gpt-5.6-*-pro") onto the
base-tier PricingEntry rows — the -pro high-effort modes bill at the
SAME per-token rates (verified against OpenRouter live pricing
2026-07-09: identical prompt/completion prices for base and -pro);
they cost more per task by consuming more tokens, not a higher rate.
- Context lengths need no new entries: "gpt-5.6-sol" et al. are
substrings of their -pro variants and both lookup tables match
longest-key-first (verified: sol-pro -> 1.05M direct / 272K codex).
- model_switch sort: -pro variants parse as suffix "sol-pro" (rank 1),
so /model gpt still defaults to base sol — pinned by test.
- Not added to DEFAULT_CODEX_MODELS: only confirmed routable via API/
OpenRouter so far; codex live discovery will surface them if ChatGPT
exposes them, same policy as other unconfirmed codex slugs.
Tests: invariant tests extended (pro aliases share base entries, base
sol outranks sol-pro); 191 targeted tests pass.
Phase-2 review findings addressed:
- resolve_billing_route: normalize the "openai-api" picker slug to the
"openai" billing provider — without this the ("openai", <model>)
_OFFICIAL_DOCS_PRICING keys (incl. every pre-existing gpt-4o/gpt-4.1
entry, not just 5.6) were unreachable when the provider is openai-api.
- pricing_version: drop the "preview" tag (GA 2026-07-09 at same rates).
- model_metadata comment: dict order is cosmetic — lookups length-sort
keys at match time; the old comment implied a positional invariant.
- model_switch comment: note "sol" is a series codename, not a generic
quality word.
- tests/hermes_cli/test_gpt56_registration.py: behavior contracts (no
list snapshots) — sol > terra/luna > 5.5 sort invariant, pricing
reachability from both openai and openai-api routes, cache-write
1.25x / cache-read 0.10x input relation.
PR #61578 added the GPT-5.6 series (sol/terra/luna) to the two aggregator
surfaces (OPENROUTER_MODELS, _PROVIDER_MODELS[nous]). This completes the
registration on the remaining surfaces per the standard add-model checklist:
- agent/model_metadata.py: DEFAULT_CONTEXT_LENGTHS 1.05M (direct API, same
as gpt-5.5; more-specific keys precede gpt-5.5 for longest-substring
matching) + _CODEX_OAUTH_CONTEXT_FALLBACK 272K for all three slugs.
Without these the direct-API fallback matched generic "gpt-5" = 400K.
- hermes_cli/codex_models.py: DEFAULT_CODEX_MODELS + forward-compat
templates so ChatGPT-OAuth (openai-codex) pickers surface the series.
- hermes_cli/models.py: _PROVIDER_MODELS[openai-api] (native API picker).
- agent/usage_pricing.py: _OFFICIAL_DOCS_PRICING snapshot — sol 5/30,
terra 2.50/15, luna 1/6 per 1M in/out; cache read 0.10x input, cache
write 1.25x input (OpenAI billing change starting with the 5.6 series).
GA 2026-07-09 at preview rates. Sol Fast mode (Cerebras tier) excluded.
- hermes_cli/model_switch.py: rank "sol" as a flagship suffix so
/model gpt resolves to gpt-5.6-sol, not alphabetical-first luna.
Verified: registry E2E via real imports (both context tables, codex
forward-compat from a gpt-5.5 template, billing-route lookup for
openai/gpt-5.6-sol -> 5.00/M), alias resolution on openai-codex and
openai-api resolves to gpt-5.6-sol; 183 targeted tests pass
(model_metadata, usage_pricing, codex_models, model_catalog).
Phase-2 review finding: the validation branch expanduser()s the path but
web_server.py reads os.environ['HERMES_WEB_DIST'] raw at import — a
'~/dist' value would validate here and still 404 there. Write the
expanded path back before the web_server import. Adds a regression test
asserting the env var holds the expanded path after cmd_dashboard.
A custom HERMES_WEB_DIST without --skip-build skipped BOTH the web UI
build and any validation: cmd_dashboard fell through the build gate and
started the server against a dist that may not exist, serving 404s with
no obvious cause. This is the same failure mode issue #23817 fixed for
the --skip-build branch — the env-var branch was left unvalidated.
Add the missing else-branch: fail fast with actionable guidance when
HERMES_WEB_DIST has no index.html, proceed (still without building) when
it does.
Credit: @Caelier (#17845) originally proposed dist validation for the
dashboard startup path; the --skip-build half of that PR's scope has
since landed via the #23817 fix, this covers the remaining env-var path
on the rewritten cmd_dashboard surface.
Two async handlers still called the cron profile-walk helpers directly on
the FastAPI event loop after the 49fa04a23/346e5673d threadpool migration:
- POST /api/cron/fire called _find_cron_job_profile() inline — it walks
every profile and lists its jobs (file I/O per profile), stalling the
loop before the 202 is returned.
- POST /api/cron/blueprints/instantiate called _call_cron_for_profile()
inline for create_job.
Route both through the existing _run_cron_dashboard_io threadpool wrapper
like every other cron dashboard endpoint.
Credit: @riceharvest (#50948) originally identified the sync-I/O-in-async-
handlers bug class for the desktop boot endpoints; 49fa04a23, 346e5673d,
7d0ddbb2f, d5eee133e and 24d5bda1e have since fixed most of that PR's scope
via the managed threadpool + PID cache + alias-map surfaces. This covers
the two cron handlers those merges missed.
Follow-up to HexLab98's fix. The sync-before-regenerate invariant was
enforced by convention across 6 callsites (~3 idempotent unit-file reads
per command). Consolidate it into the one function every compare/regenerate
path funnels through — systemd_unit_is_current — and drop the now-redundant
callsite pre-syncs in refresh_systemd_unit_if_needed / systemd_start /
systemd_restart / systemd_status.
Kept the systemd_install pre-sync: the --force path bypasses the
is_current gate and calls generate_systemd_unit() directly, so it needs
its own sync to avoid baking /root/.hermes under sudo.
Reworked the two callsite-ordering tests into a chokepoint-invariant guard
(test_is_current_syncs_before_reading_unit) + a delegation test proving
start/restart no longer pre-sync. Both fail if the chokepoint sync is
removed; the pre-existing behavior test still passes.
Under sudo, start/restart refreshed the unit from /root/.hermes before
adopting the unit's pinned home, so TimeoutStopSec and env drifted and
status stayed stuck on "service definition is outdated".
grok-4.5 is GA and is now the single curated Grok entry on the
aggregator lists. grok-4.3 is NOT retired upstream — it remains fully
usable by typing the model name (validated against the live catalogs);
this only removes it from the short curated picker snapshots. The
xAI-direct list is models.dev-cache-driven and unaffected.
- derive the compact_rows projection from SCHEMA_SQL (parse once, cache)
instead of a hardcoded column list: the original #47437 list was cut
against a June schema and silently dropped session_key/chat_id/chat_type/
thread_id/display_name/origin_json/expiry_finalized/git_branch/
git_repo_root/compression_failure_* — including desktop sidebar fields.
Schema-derived means declaratively reconciled new columns are included
automatically; only system_prompt is excluded.
- guard test pinning the schema<->projection contract (mutation-verified:
dropping a column from the projection fails it)
- wire compact_rows=(not full) into /api/sessions and /api/profiles/sessions
so the SQL projection pairs with the API-level field strip (?full=1 still
returns complete rows end-to-end)
- pass compact_rows at the remaining hot list callers: /api/status active
count, _session_latest_descendant fallback, /api/sessions/stats by-source
- thread compact_rows through the compression-tip projection
(_get_session_rich_row) so projected tips can't reintroduce the blob
- add pagination tests for get_messages (#60347 shipped none): paging order,
offset-past-end, active-flag interaction; add tip-projection compact test
- AUTHOR_MAP entries for mahdiwafy + CodeForgeNet (plain emails)
list_sessions_rich and _get_session_rich_row previously used SELECT s.*,
pulling the system_prompt TEXT blob on every row even for dashboard and
picker callers that never display it. On large databases this blob routinely
runs to tens of kilobytes per session, causing unnecessary B-tree I/O.
Add compact_rows=False param to both functions. When True, an explicit
column list omitting system_prompt is substituted for s.* in both the
simple and the recursive-CTE (order_by_last_active) query paths.
Default is False so all existing callers are unaffected.
Update dashboard and session-picker callers in web_server.py and
tui_gateway/server.py to pass compact_rows=True.
Add seven regression tests covering: omission of system_prompt, presence
of all metadata fields, both query paths, _get_session_rich_row, and
backward-compat default.
(cherry picked from commit c470cbd304)
The session messages endpoint returned ALL messages in a single
response with no limit/offset. Sessions with 500+ messages produced
1.2-1.6 MB JSON payloads, causing GIL starvation and WebSocket
timeouts on the Desktop client (#60155).
Add optional limit/offset query params to both the API endpoint and
SessionDB.get_messages(). Limit clamped to 500 max per page. Response
now includes a pagination object with limit/offset/returned count.
Backward compatible: callers that omit limit get the old behavior
(all messages).
Closes#60155
(cherry picked from commit d58396b154)
Review finding: SessionDB(read_only=True) requires the DB file to exist
(its documented contract says callers guard on db_path.exists()); on a
fresh install every /api/status poll paid an OperationalError until the
first session was written. Short-circuit to 0 when state.db is absent.
Tests: fresh-install guard + existing read_only test adjusted.
The #39140 CTE used UNION ALL, which recurses forever if a corrupted
parent chain loops (a -> b -> a) — reproduced: query never returns. The
old Python walk was cycle-safe via a seen-set. UNION dedups the working
set and terminates. Regression test added and mutation-verified (UNION
ALL hangs the test, UNION passes).
Flip the handler from async def to sync def so FastAPI executes it in
its threadpool: the SessionDB open + list_sessions_rich query no longer
block the single uvicorn event loop.
Residual hunk from PR #53966 — that PR's get_profiles_sessions flip
already landed via #54523/1bb7b59c5, and its get_status offload is
superseded by #58238's read_only + timeout variant in this branch.
(cherry picked from commit 414c12a40d)
_session_latest_descendant fetched EVERY sessions row and built the
parent->children tree in Python on each call. Replace with a recursive
CTE that loads only the target session's descendant branch.
Hand-applied from PR #39140 (the schema-init cache and Rust PTY bridge
parts of that PR are intentionally NOT salvaged here); main's function
signature gained a db parameter since the PR was cut.
(cherry picked from commit 8ed5e54f65)
* feat(xai): add grok-4.5 (early access) to catalog, context lengths, and reasoning-effort allowlist
- hermes_cli/models.py: grok-4.5 in _XAI_CURATED_EXTRAS (callable but absent
from models.dev) and _XAI_STATIC_FALLBACK, so the /model picker and
validation surface it on both xai and xai-oauth.
- agent/model_metadata.py: context lengths grok-4.5 -> 500K (per model card)
and grok-build-latest -> 500K (alias); grok-4.5 added to
_GROK_EFFORT_CAPABLE_PREFIXES.
Verified live against api.x.ai /v1/responses (2026-07-08): effort
low/medium/high accepted (server default: high), "none" rejected,
function calling works, full agent turn with terminal tool succeeded.
* feat(xai): grok-4.5 GA — add aggregator catalog entries, refresh comments
grok-4.5 is now GA: models.dev lists it (500K context, effort
low/medium/high) and both OpenRouter and Nous serve x-ai/grok-4.5.
Add it to the OpenRouter fallback snapshot and the Nous static list,
and update the early-access comments.
* chore: regenerate model-catalog.json for x-ai/grok-4.5
Sessions on sub-512K-context models were spending most of their wall-clock
re-summarizing: the 50% trigger left too little post-compaction headroom
(the incompressible floor — system prompt, tool schemas, protected tail,
rolling summary — ate most of the reclaimed space), so compaction re-fired
every 1-2 turns. Three compounding defects fixed:
- Threshold floor: models with context windows below 512K now trigger at
>=75% of the window (raise-only — a higher configured value or per-model
autoraise like Codex gpt-5.5's 85% always wins). Re-derived on
update_model() in both directions.
- No max_tokens on the summary call: the summary budget is prompt guidance
only ("Target ~N tokens"). The wire cap truncated summaries mid-section
on the Anthropic Messages / NVIDIA NIM paths (thinking models burn the
cap on reasoning first), yielding truncated or thinking-only summaries
and compaction loops. Summary token ceiling lowered 12K -> 10K to keep
the guidance within the intended 1K-10K envelope.
- Reasoning traces excluded end-to-end: inline <think>/<reasoning> blocks
are now stripped from assistant content before serialization to the
summarizer, and from the summarizer's own output before the summary is
stored (previously a thinking summarizer model's trace was persisted in
_previous_summary and re-fed into every iterative update, compounding
bloat). Native reasoning fields were already excluded.
Verified E2E with real imports against a temp HERMES_HOME: threshold table
across 64K-1M windows, override interactions (user 0.85 wins, spark 0.70
raised, gpt-5.5 0.85 kept), full compress() round-trip with a thinking
summarizer, and wire-kwargs capture proving no max_tokens is sent.
- OPENROUTER_MODELS: remove openrouter/owl-alpha (free) and
tencent/hy3-preview{,:free}; add tencent/hy3 and tencent/hy3:free
- _PROVIDER_MODELS[nous]: tencent/hy3-preview -> tencent/hy3
- run_agent.py reasoning-prefix list: tencent/hy3-preview -> tencent/hy3
(prefix match still covers -preview if pinned)
- model_metadata: register hy3 context length (262144) alongside hy3-preview
- regenerate website/static/api/model-catalog.json
- update tokenhub curated-list tests to the new IDs
The tencent-tokenhub direct provider still serves hy3-preview and is
intentionally unchanged.
Review findings (hermes-pr-review Phase 2, 3-angle):
- _save_auth_store() does real filesystem I/O (mkdir, O_EXCL create, fsync,
atomic replace) and can raise on disk-full/permissions/lock-timeout. The
persist ran bare in the success path, so a persist failure aborted
_resolve_zai_base_url() after detection had already succeeded. Wrap the
persist in try/except: log a warning and still return the detected URL
(worst case: next start re-probes).
- Readability: stage the payload in a local detected_endpoint instead of
writing through the stale pre-lock 'state' dict, which is no longer what
gets persisted.
_save_provider_state() sets auth_store['active_provider'] as a side effect.
The Z.AI endpoint probe runs from credential-pool env seeding for any user
with a Z.AI key in env — persisting the probe cache must not silently make
zai the active provider. Use _store_provider_state(set_active=False).
Follow-up to PR #41201 salvage.
For air-gapped / self-hosted-IdP deploys with NO Nous Portal, let the gateway
obtain its caller-identity bearer from a generic OAuth2 client_credentials grant
against the operator's own IdP (e.g. Microsoft Entra ID) instead of only
resolve_nous_access_token(). The connector's OIDC tenant resolver reads a claim
(default tid) off that token as the tenant.
- gateway/relay: new canonical _resolve_relay_identity_token() — client_credentials
when gateway.idp.token_url (or GATEWAY_RELAY_IDP_* env) is set, else Nous Portal
(unchanged default). Wired into self_provision_relay().
- hermes_cli/gateway_enroll: _resolve_identity_token() delegates to the canonical
resolver so the enroll CLI and the runtime self-provision path share ONE impl.
Config via gateway.idp.{token_url,client_id,client_secret,scope} in config.yaml
(env override GATEWAY_RELAY_IDP_*). No behaviour change when unset.
Tests: tests/gateway/relay/test_identity_token_resolver.py (6 — mode selection,
request shape, config/env precedence, fail-closed). Relay suite 162 pass.
Validated via the cross-repo gateway<->connector live E2E (provision, managed
self-provision, inbound round-trip, /link) against a connector running the OIDC
tenant resolver with zero NAS config.
* feat(install): warn pip/Homebrew installs are unsupported (CLI, TUI, desktop)
pip and Homebrew are now Unsupported install methods per
website/docs/getting-started/platform-support.md. Surface a
warn-don't-block deprecation notice everywhere the install method is
already shown, pointing at the platform-support docs and noting these
installs will not receive further updates. NixOS (Tier 2) is untouched.
- hermes_cli/config.py: shared is_unsupported_install_method() /
format_unsupported_install_warning() helpers so the wording and docs
link stay consistent across every surface.
- hermes_cli/banner.py: generalize the existing pip-only banner
warning to also cover Homebrew.
- hermes_cli/main.py: hermes update and hermes update --check print
the warning before proceeding (still update; warn, don't block).
- tui_gateway/server.py: session.info gains install_warning.
- ui-tui: SessionPanel renders install_warning alongside the existing
'N commits behind' notice.
- apps/desktop: SessionRuntimeInfo/GatewayEventPayload gain
install_warning; applyRuntimeInfo + the live session.info event fire
a snoozable warning toast via a new reportInstallMethodWarning(),
mirroring the existing backend-contract-skew toast pattern. i18n
strings added for en/zh/zh-hant/ja.
- Tests: updated pip banner assertions for the new wording, added a
Homebrew banner test, and two tui_gateway session_info tests
(install_warning present for pip, absent for git).
* fix(nix): make `hermes` in developement environment actually work
install modules as editable overlay with uv
* feat: print install method when running --version
* fix: correct detect install method when running from a subtree
The chat PTY launch path landed on main after PR #50558 and still called
_session_latest_descendant() with the old one-arg signature. Open the
requested profile's state DB (matching the REST endpoint) so profile-scoped
resume resolves descendants in the right database.
The connector now depends on the single multiplexed gateway for per-profile
relay routing, so hosted deployments need to FORCE multiplexing on regardless
of the image's config.yaml. gateway.multiplex_profiles was config.yaml-only,
which a user could leave unset or flip off.
Add GATEWAY_MULTIPLEX_PROFILES as a standard operator override on top of the
existing config key — the same 'config.yaml is canonical, env is the operator
override' pattern the Telegram/Signal require_mention bridges use:
env (recognized token) > config.yaml (top-level or nested gateway.*) > False
- gateway/config.py: _env_multiplex_profiles_override() resolves the env var
tri-state — recognized truthy/falsy token → bool; unset/blank/unrecognized
→ None (fall through to config). Blank is deliberately None, not False, so a
provisioned-but-unpopulated Fly secret ('') can't shadow a config.yaml opt-in
(the empty-secret trap). Wired into GatewayConfig.from_dict so every consumer
(run.py, session.py via self.config) sees the resolved value.
- hermes_cli/gateway.py: the named-profile-start guard
(_guard_named_profile_under_multiplexer) reads config.yaml directly, so it
gets the SAME env precedence — otherwise env-forced multiplex would leave the
guard blind and someone could start a conflicting per-profile gateway that
double-binds a bot token. Env-forced-on trips the guard even with no
config.yaml key; env-forced-off disables it over a config opt-in.
Tests: full 3-tier precedence in test_config.py (incl. the discriminating
env-overrides-config cases + the empty/whitespace/unrecognized fall-through
trap + resolver tri-state), mutation-verified (flipping precedence fails
exactly the two env-wins tests); guard env cases in test_multiplex_lifecycle.py.
Force-on is safe on a single-profile instance: session keys stay byte-identical
(agent:main) and the _run_agent wrapper installs the per-turn secret scope, so
the fail-closed get_secret() path is satisfied.
Strict charset allowlist (alnum + - _, max 64) on the {name} path param of
the memory-provider config/setup endpoints. Prevents traversal-shaped names
from reaching find_provider_dir(), and setup now 404s when neither a
loadable provider nor a plugin manifest exists, so the command-running path
is only reachable for discoverable plugins. Adds regression tests.
The profile+gateway topology added in #60537 sits entirely behind the
loopback/--insecure auth gate. But a hosted agent (Hermes Cloud) binds
non-loopback with OAuth, so should_require_auth is True, and NAS reads
/api/status over the network (fly-provider.ts getInstanceRuntimeStatus)
with no session token. On that gated path the whole topology block was
omitted, so the Portal could never render the profile list.
Split the topology readout by sensitivity:
- profile NAMES (profiles) + gateway_mode are low-sensitivity product
surface and now ride the always-public status body, surviving the auth
gate so NAS/the Portal can enumerate profiles.
- the per-gateway detail (gateways[], carrying host ports) is deployment
recon and stays gated alongside hermes_home / config_path / env_path /
gateway_pid / gateway_health_url.
The collector now runs unconditionally (still in the executor, off the
event loop). No new fields; only the gate placement changes.