c1e5fa433c40c9868e91952d7f04b9f0389fe76c
4965
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c1e5fa433c |
fix(gateway,windows): reliability — supervisor task, JOB breakaway, status --deep
Three coordinated fixes for the Windows gateway reliability story:
1. CREATE_BREAKAWAY_FROM_JOB on every detached spawn
The 'hermes update' triggered from the Electron Desktop GUI ran inside
Electron's job object. Without breakaway, the post-update gateway
watcher spawned by update — already DETACHED_PROCESS — was still
reaped when Electron's job tore down, so the gateway never came back
after a GUI-initiated update. Adds CREATE_BREAKAWAY_FROM_JOB (0x01000000)
to:
- hermes_cli/_subprocess_compat.py::windows_detach_flags() — used by
every helper that calls windows_detach_popen_kwargs(), including
launch_detached_profile_gateway_restart()
- The watcher subprocess's own respawn snippet in
hermes_cli/gateway.py (inlined flags so the watcher's child
respawn also breaks away)
_spawn_detached() in gateway_windows.py already had the flag; this
change brings the rest of the codebase to parity.
2. Per-minute supervisor Scheduled Task — Windows equivalent of
systemd Restart=always
Introduces hermes_cli/gateway_supervisor.py and registers it as a
second Scheduled Task ('Hermes_Gateway_Supervisor', SC MINUTE /MO 1,
LIMITED rights) alongside the existing ONLOGON task. Every minute,
the supervisor uses the same gateway.status.get_running_pid() probe
as 'hermes gateway status' and, if no gateway is alive, calls
gateway_windows._spawn_detached() (which now includes BREAKAWAY) to
bring one back.
Covers every crash mode, not just 'machine rebooted': taskkill,
OOM, GUI update SIGTERM, parent job teardown. Cheap — one pythonw
startup per minute when down, one PID-existence check per minute
when up.
Wired into both the schtasks-success and Startup-folder-fallback
install paths via _install_supervisor_best_effort(), and removed in
uninstall(). Best-effort: a failing supervisor install logs a
warning but doesn't roll back the primary install.
3. 'hermes gateway status --deep' shows per-probe PASS/FAIL
Replaces the existing terse '--deep' output (which only printed
paths) with an actual diagnostic table:
[1] PID file present
[2] Lock file held by a live process
[3] get_running_pid() result
[4] _pid_exists(pid) — OS-level liveness
[5] gateway_state.json (state + age)
[6] Last lifecycle event from gateway-exit-diag.log
When the high-level summary disagrees with reality, the user can
see exactly which signal is lying.
Test-leak fix
-------------
tests/hermes_cli/test_gateway_wsl.py::TestGatewayCommandWSLMessages
monkey-patched is_linux/is_wsl/supports_systemd_services to simulate
WSL but did NOT stub is_windows(). On a Windows host, the dispatcher
in _gateway_command_inner takes the is_windows() branch BEFORE the
WSL guidance branch, so the test invoked gateway_windows.install()
for real. install() writes to %APPDATA%\...\Startup\Hermes_Gateway.cmd
— the REAL user Startup folder, never sandboxed by tmp_path — pointing
at the test's pytest-of-<user>/pytest-<N>/.../gateway-service/ wrapper.
When pytest tore down the tmp_path, every subsequent Windows login
flashed a cmd.exe window that failed to find the missing target.
Stubs is_windows=False on all four affected tests:
test_install_wsl_no_systemd
test_start_wsl_no_systemd
test_status_wsl_running_manual
test_status_wsl_not_running
Defense-in-depth: _build_startup_launcher() now prefixes the launcher
with 'if not exist <target> exit /b 0', so any future stale Startup
entry silently no-ops instead of flashing a console window.
Status enhancements
-------------------
- status() now reports supervisor task presence alongside the existing
schtasks/Startup info, and nudges the user to reinstall if the
supervisor isn't registered.
- Deep mode dumps both the supervisor task name + script path.
|
||
|
|
2bf0a6e760 |
feat(dashboard): full tool backend configuration in the GUI (#40418)
Replicate the `hermes tools` configurator in the dashboard Skills →
Toolsets view. Each toolset now opens a config drawer that covers the
full lifecycle the CLI offers: enable/disable, pick a provider/backend,
enter and save API keys, and run a provider's post-setup install hook
with a live log tail.
The toolset view was previously read+toggle only — the provider matrix
and key-status endpoints existed but the page never called them, and
there was no way to save a key or run a backend install (npm/pip/binary)
from the browser.
Backend:
- New CLI subcommand `hermes tools post-setup <KEY>` — non-interactive,
scriptable target that runs a provider's install hook (agent_browser,
camofox, cua_driver, kittentts, piper, ddgs, spotify, langfuse,
xai_grok). Validated against valid_post_setup_keys() so an arbitrary
key can't drive _run_post_setup.
- PUT /api/tools/toolsets/{name}/env — save API keys to ~/.hermes/.env
via save_env_value (same store the CLI writes), validated against the
toolset category's env-var allowlist; blank values skipped.
- POST /api/tools/toolsets/{name}/post-setup — spawn-action that runs
`hermes tools post-setup <key>`; frontend tails the log via the
existing /api/actions/tools-post-setup/status. Registered in
_ACTION_LOG_FILES.
Frontend:
- New ToolsetConfigDrawer component (provider radios, password key
inputs with saved-state, get-a-key links, Run-setup + live install
log). Toolset cards get a Configure button + the drawer also exposes
the enable toggle.
- api.ts: toggleToolset, getToolsetConfig, selectToolsetProvider,
saveToolsetEnv, runToolsetPostSetup + ToolsetConfig/Provider/EnvVar/
EnvResult types.
Validation: 56 admin-endpoint tests pass (10 new: env save w/ CLI
parity + allowlist reject + blank-skip, post-setup spawn validation,
auth gate); 232 web_server tests pass; web npm run build + eslint clean;
HTTP E2E exercises save-key (CLI reads it back) and spawn+poll
post-setup to exit 0.
|
||
|
|
56236b16e3 |
feat(dashboard): rehaul Skills hub browser — connected hubs, featured, preview + security scan (#40384)
The Browse-hub tab was a blank search box with sparse result cards (name + source + one Install button), no way to read a skill before installing, no visual security scan, and no indication it was even connected to any hubs. Backend (web_server.py): - GET /api/skills/hub/sources — lists the configured hubs (label + trust tier + GitHub rate-limit + index availability) and featured skills pulled from the centralized index (zero extra API calls), plus installed-skill provenance so the UI can mark already-installed results. - GET /api/skills/hub/preview — fetches a skill's SKILL.md text + file manifest WITHOUT installing (decodes byte-stored text, masks binaries). - GET /api/skills/hub/scan — runs the SAME quarantine + scan_skill + should_allow_install pipeline the CLI installer uses, then cleans up quarantine, returning verdict / per-finding detail / severity tally / install-policy decision. - search now returns per-source counts + timed-out sources + installed map. Frontend (SkillsPage HubBrowser): - Landing state: connected-hubs strip + featured skill grid (no more blank page). - Rich cards: trust-level color coding, source, tags, identifier, Details + Install (or Installed state). - Detail dialog: read the actual SKILL.md, on-demand visual security scan (verdict pill, severity tally, per-finding list, allow/block policy), GitHub repo link. - Search meta line: result count + timing + per-source breakdown (the 'feels slow / no feedback' complaint). Tests: 4 new endpoint test classes (sources/preview/scan + updated search shape) in test_dashboard_admin_endpoints.py. |
||
|
|
5af899c7ca |
feat(cli): display custom profile alias names in profile list/show (#40371)
profile list and profile show assumed the wrapper script is always named after the profile (wrapper_dir / name). When a custom alias exists — e.g. `hermes profile alias steve --name qiaobusi` creates ~/.local/bin/qiaobusi pointing at `hermes -p steve` — the display silently showed the profile name (or nothing) instead of the alias the user actually typed. The custom-alias *creation* path (create_wrapper_script(name, target)) was added later; the *display* path was never updated to match. Add find_alias_for_profile() — a reverse lookup that scans the wrapper dir for our own wrappers (alias-named file containing 'hermes -p <profile>'), prefers a custom alias over the profile-named one, strips .bat on Windows, and sorts for deterministic output. Populate ProfileInfo.alias_name and wire it into the three display sites (profile describe, list, show). Credit: salvages the intent of #11506 by wss434631143, reimplemented on current main against the post-#11506 custom-alias (--name/target) mechanism. Tests: 6 new (profile-named, custom-name, none, unrelated-file rejection, windows .bat strip, list_profiles surfacing). All 123 in test_profiles pass. E2E verified against the real CLI for both custom and profile-named aliases. |
||
|
|
fcb1944b4f |
feat(credits): usage-aware credits — in-session notices, /usage view, dev readout (#40011)
* feat(tui): HERMES_DEV_CREDITS live-spend dev readout (L0 tracer for usage-aware credits)
L0 of the usage-aware-credits feature: a dev-only, env-gated tracer that
exercises the real header -> CreditsState -> TUI pipe end-to-end behind
HERMES_DEV_CREDITS, de-risking the L1/L5 build before the notice policy exists.
- agent/credits_tracker.py: CreditsState + parse_credits_headers (headers are
strings -> paid_access via == "true", never bool(); retain-last-known; only
subscription_micros may be negative; *_usd kept verbatim).
- run_agent.py: _capture_credits / get_credits_state / get_credits_spent_micros,
session-start baseline latch, + dev-gated "credits" capture log.
- agent/chat_completion_helpers.py: capture on the streaming response.
- agent/agent_init.py: init _credits_state + _credits_session_start_micros.
- tui_gateway/server.py: _get_usage emits dev_credits_spent_micros only when flagged.
- ui-tui appChrome.tsx / types.ts: cents delta status segment + "(dev credits)" banner.
Off by default; silent for normal users. Validated live against staging
(capture log delta matches the TUI segment). Throwaway consumer (readout/log/
banner); credits_tracker + the capture plumbing are the real feature foundation.
* test(credits): lock parser under 9-state matrix + harden validation (L2)
Add tests/agent/test_credits_tracker.py with 92 tests covering the 9-state
matrix (healthy, sub_90pct, grant_exhausted, purchased_only, tool_pool_free,
depleted, debt, missing, no_org) plus validation edge cases: version strict==1
with warn-once latch for v>1, bool-string trap (paid_access/tool_pool_gated_off
== "true"/"false", never bool()), half-pair subscription limit treated as
both-absent while parse succeeds, USD regex ^-?\d+\.\d{2}$, non-int micros
→ None, negative non-subscription micros → None, as_of_ms junk → None, zero
limit ZeroDivision guard.
Harden agent/credits_tracker.py to match the spec:
- Add tool_pool_micros/tool_pool_gated_off/from_header fields to CreditsState
- Add depleted property (== not paid_access, never remaining==0)
- Change used_fraction guard to key off subscription_limit_micros (the actual
denominator) not denominator_kind (metadata)
- Replace fail-soft _safe_int with a sentinel-returning variant; full validation
now returns None on any malformed field rather than silently defaulting
- Add module-level warn-once latch for version > 1
- Add USD regex validation; add denominator_kind allow-list check
- Parse x-nous-tool-pool-* prefix headers (not x-nous-credits-tool-pool-*)
* feat(credits): notice spine — AgentNotice + notice_callback/notice_clear_callback + TUI binding (L1)
L1 of usage-aware credits: the driver-agnostic notice delivery spine that L4's
policy will fire through and L5's TUI render will consume.
- agent/credits_tracker.py: AgentNotice dataclass (text/level/kind/ttl_ms/key/id;
kind defaults "sticky", kept TTL-expressive for a future config seam).
- run_agent.py: AIAgent gains notice_callback + notice_clear_callback slots and
_emit_notice / _emit_notice_clear emitters (swallow all callback errors — a
notice must never break the agent loop; no-op when unbound).
- agent/agent_init.py: thread both callbacks through init_agent.
- tui_gateway/server.py: bind both in _agent_cbs → notification.show / notification.clear
WS events (snake_case payload, matching the existing gateway-event convention).
- ui-tui/src/gatewayTypes.ts: notification.show / notification.clear arms on GatewayEvent.
- tests/run_agent/test_notice_spine.py: 15 tests (emitter fire + fail-open + no-op,
signature threading, TUI binding payload shape).
Messaging push is out of v1 (binds neither callback). CLI binding + the TUI render/
decode land with L4 (firing) and L5 (render) so turn-end flush is wired correctly.
* feat(credits): threshold reconciliation policy + tests (L4.1)
* feat(credits): wire threshold policy into capture + latch (L4.2)
After a fresh header parse, _capture_credits runs evaluate_credits_notices against
the agent's _credits_latch and emits the result — clears first, then shows (so a
recovered depletion clears before the "restored" success lands, and depleted wins
the latest-wins slot). Gated on a bound notice_callback: messaging (no callbacks)
still caches state for /usage but runs no policy. Parse stays fail-open (miss →
keep last-known); the eval/emit path warns on failure rather than swallowing, so a
depletion-notice bug can't vanish silently.
- run_agent.py: _capture_credits split into parse (swallow→miss) + policy (warn);
latch lazy-guarded (object.__new__ safety).
- agent/agent_init.py: init agent._credits_latch = {"active": set(), "seen_below_90": False}.
* feat(tui): render credits notices in the status bar (L5, Strategy B)
The TUI now renders the notification.show / notification.clear gateway events the
agent emits — a level-colored notice overrides the status/verb slot when not busy.
- Notice state machine on turnController (pendingNotice + dedicated noticeTimer +
show/clear/applyNotice/flushPendingNotice/clearNoticeState). createGatewayEventHandler
decodes the events and delegates.
- Render priority busy > notice > status (appChrome StatusRule); notice text rendered
verbatim (its glyph comes from the policy), shrinkable so it never clips model│ctx;
dev-credits banner + Δ segment preserved. UiState.notice is snake_case (matches wire).
- Busy-wins: a notice arriving mid-turn is held and flushed at the THREE turn-end sites
(recordMessageComplete / interruptTurn / recordError) — never idle(), which reset()
also calls (would leak across sessions); reset() clears instead.
- Dedicated noticeTimer (never statusTimer); TTL starts on visibility with an id-guard;
latest-wins cancels the prior timer; clear is key-matched (no-op on mismatch); a sticky
survives a turn (flush no-ops with no pending); session reset clears (no cross-session leak).
- 20 tests (handler/turnController logic incl. R3-C2 timer isolation + render priority).
* feat(credits): cold-start seed for new Nous sessions (L3)
A genuinely-new Nous session has no inference header yet, so seed credits state from
the authoritative GET /api/oauth/account snapshot at session start (in the new-session
branch of _restore_or_build_system_prompt — inline, since the on_session_start plugin
hook gets no agent reference). The seed runs the shared notice policy, so a session that
opens already depleted warns IMMEDIATELY rather than only after the first turn.
- Maps the nested account fields (paid_service_access → paid_access; total_usable /
subscription / purchased on paid_service_access_info; rollover on subscription), each
None-guarded; float dollars → micros via round(d*1e6), *_usd left "" (render formats
from micros — never synthesize a verbatim usd from a float).
- Magnitudes-only: no monthlyCredits on the endpoint → subscription_limit_* unset →
used_fraction None → no warn90 from the seed (% only once a header lands, per D-E).
- Provider-guarded to Nous; fail-open (any error leaves _credits_state None, never
blocks startup); paid_access unknown ⇒ True (never falsely depleted).
- run_agent.py: extracted the warm-path policy/emit block into a shared
_emit_credits_notices() so capture and the seed fire notices identically.
* feat(credits): /usage Nous credits magnitudes view + recovery trigger (L6)
Add Nous credit dollar magnitudes to /usage (subscription / top-up / total
+ rollover + renewal + portal CTA), magnitudes-only per v1 (no % until the
account endpoint exposes a denominator). Reuses the existing account-usage
render machinery via a new pure build_nous_credits_snapshot() that maps a
NousPortalAccountInfo to an AccountUsageSnapshot; no nous branch is added to
fetch_account_usage (keeps the per-provider boundary intact).
CLI /usage also doubles as a depletion-recovery trigger: a force_fresh
account fetch, kept in a SEPARATE local so it never clobbers the
header-sourced agent._credits_state (which alone carries used_fraction). If
paid access recovered while credits.depleted is latched and a notice
consumer is bound, it reuses agent._emit_credits_notices() to clear it.
Gateway /usage displays magnitudes only — messaging binds no notice
consumer, so it performs no recovery emit.
Fail-open throughout: any portal hiccup leaves /usage unaffected.
* refactor(credits): dedupe HERMES_DEV_CREDITS flag parse via shared helpers
The dev-flag truthy check was inlined in three places. Replace with the shared
utils.is_truthy_value (run_agent.py, tui_gateway/server.py — also drops a
redundant inline `import os`) and a hoisted DEV_CREDITS_MODE export in
ui-tui/src/config/env.ts (consumed by appChrome, which also stops recomputing the
env check on every render). Behaviour-preserving; identical truthy set.
* fix(credits): cut dead /usage recovery trigger + bound portal fetches (L6 review)
Adversarial review found the /usage depletion-recovery trigger dead AND broken:
the CLI binds no notice_clear_callback, the TUI runs /usage in a separate
slash-worker subprocess (its own agent/latch), and the no-clobber rule made it
evaluate stale paid_access anyway. Recovery already happens on the next inference
(warm path), so the trigger was redundant — remove it and stop the depleted
notice over-promising.
- cli.py: remove the dead recovery block; bound the /usage portal fetch with a
10s wall-clock timeout (ThreadPoolExecutor) like the per-provider fetch —
urllib's per-socket timeout is not a wall-clock guarantee.
- agent/credits_tracker.py: reword the depleted CTA to "run /usage for balance"
(no false recovery promise; /usage shows fresh magnitudes, sticky clears next turn).
- agent/conversation_loop.py: same wall-clock timeout on the cold-start seed fetch
so a stalled portal can't hang session startup; tidy its time import.
* chore(credits): dev notice-state fixtures (HERMES_DEV_CREDITS_FIXTURE)
Throwaway dev scaffolding to exercise the notice pipeline without real spend or
Redis seeding. Set HERMES_DEV_CREDITS_FIXTURE to a state name (healthy / sub_90pct
/ grant_exhausted / depleted / clear) or a file path whose contents name a state
(re-read each turn → flip states live for recovery testing). _capture_credits
injects the chosen CreditsState instead of parsing real headers and runs the
shared notice policy. Deletable with the rest of the HERMES_DEV_CREDITS scaffolding.
* feat(credits): /usage monthly-grant % gauge
The portal /api/oauth/account subscription block now carries monthly_credits
(the per-period grant allowance, the % denominator). The consumer parsed
monthly_charge but dropped monthly_credits, so /usage stayed magnitudes-only.
Capture monthly_credits into NousPortalSubscriptionInfo + _subscription_from_payload.
build_nous_credits_snapshot emits a Subscription usage window (real % used, routed
through the existing render machinery) when monthly_credits is a finite positive
denominator and credits_remaining is finite and <= cap; otherwise it degrades to
magnitudes-only (older portals, rollover-over-cap, or non-finite payloads).
Guards (adversarial-review-driven): reject non-finite operands (json.loads parses
bare NaN/Infinity by default → would render $nan + a false 100% used), reject
bools, guard div-by-zero (cap>0), and suppress the gauge when remaining > cap
(rollover spanning the period makes the cap a nonsensical denominator → the
$X-of-$Y detail would read as a contradiction). Debt (remaining<0) clamps to 100%.
Money rule preserved: the ratio + magnitudes are computed from numeric float
account fields via display formatting, never by parsing a server *_usd string
(there are none on these dataclasses).
13 gauge tests added (tests/agent/test_nous_credits_gauge.py).
* fix(credits): show /usage Nous block whenever a Nous account is present
/usage runs in a slash-worker subprocess whose resolved inference provider is
often not "nous" even when the user has a Nous account, so gating the Nous
credits block on (provider == "nous") hid it entirely — the account data was
fully available but never rendered.
Gate instead on "a Nous account is logged in": a cheap local auth-state lookup
(get_provider_auth_state('nous') has an access_token) decides whether to attempt
the portal fetch, regardless of which provider inference runs on. In the gateway
the block is also lifted out of the 'if provider:' scope so a Nous-credentialled
user with another (or no) resident inference provider still sees their balance.
Fail-open and the per-fetch wall-clock timeout are preserved.
* fix(credits): show /usage Nous block when there's no live agent (TUI slash-worker)
In the TUI, /usage runs in a slash-worker subprocess that resumes the session
WITHOUT building an agent (self.agent is None), so _show_usage early-returned
"(._.) No active agent" before ever reaching the Nous credits block — which is
agent-independent (a portal fetch gated on Nous auth-state). Extract the block
into _print_nous_credits_block() and run it at the no-agent / no-calls
early-returns too (returns True if it printed, so the fallback message only
shows when there's genuinely nothing).
Verified live against staging: the block + monthly-grant gauge now render in the
slash-worker /usage path (previously hidden). The plain CLI REPL + messaging
paths are unchanged (they have a live agent).
* feat(credits): escalating 50/75/90 usage bands (single status line)
Replace the lone 90%-used warning with three escalating bands (50 info, 75 warn,
90 warn) shown as ONE status-bar line: it displays the highest band the
subscription grant has crossed, replaces the line as usage climbs, steps back
down on recovery, and clears below 50%. No stacking, no per-turn churn.
Bands live in a tunable CREDITS_USAGE_BANDS list; the policy derives everything
from it. Single notice key (credits.usage) with a usage_band latch field so the
notice only re-emits when the band actually changes. The crossing gate
(seen_below_90) is preserved so a fresh live session that opens mid-range stays
quiet until it has been observed below the lowest band (cold-start primes it when
it wants an open-high warning). Denominator math unchanged: % = subscription
grant burn (cap - grant_remaining)/cap, clamped [0,1]; top-up never moves the %.
Migrated test_credits_policy.py to the new key + added TestUsageBands (climb,
step-down, recovery-clear, idempotent, inclusive boundaries).
* feat(credits): hydrate notices at session OPEN via shared seed (TUI + first-turn)
Notices previously only fired inside a conversation turn (first message), so a
session that opened already depleted / past a usage band showed nothing at
'ready'. Extract the cold-start seed into a shared seed_credits_at_session_start()
and call it (a) in the TUI/desktop agent build right after the notice callback is
wired (fires at 'ready', before any message) and (b) as the first-turn fallback in
conversation_loop. Idempotent (skips once _credits_state exists) and fail-open.
The seed now maps monthly_credits -> subscription_limit_micros +
denominator_kind='subscription_cap', so used_fraction is computable at seed time
and usage-band warnings (not just depletion) hydrate on open. Primes the crossing
latch so a session opening already in a band warns immediately. Degrades to
depletion-only when monthly_credits is absent (older portals).
Adds test_credits_cold_start.py covering open-at-band, depletion, debt, no-cap
degradation, and the shared seed (fires/idempotent/skips-non-nous).
* feat(credits): /usage monthly-grant % gauge + fixture support + TUI surfacing
agent/account_usage.py: build_nous_credits_snapshot emits a subscription %% gauge
when the portal supplies a positive, finite monthly_credits denominator with
remaining <= cap (guards reject NaN/Infinity and rollover-over-cap, which would
render $nan or a contradictory $X-of-$Y); degrades to magnitudes-only otherwise.
Adds shared nous_credits_lines() (auth-gated, wall-clock-bounded portal fetch) so
the CLI and TUI /usage render the same block, and _snapshot_from_credits_state()
so HERMES_DEV_CREDITS_FIXTURE drives /usage offline too.
TUI: session.usage RPC carries credits_lines (agent-independent) and the /usage
panel renders them regardless of API-call count or resume state — previously the
TUI's separate /usage implementation only showed token counts.
Money rule preserved: %% and magnitudes come from numeric float account fields via
display formatting, never by parsing a server *_usd string.
* feat(credits): CLI REPL inline notices (parity with TUI)
The plain CLI agent bound no notice callbacks, so credit notices were TUI-only.
Bind notice_callback/notice_clear_callback on the CLI AIAgent; _on_notice renders
a single level-colored line above the prompt (error red / warn yellow / success
green / info dim) via _cprint, and seed credits at session open so a depletion or
usage-band warning shows before the first message — the same hydration the TUI
got. _on_notice_clear is a no-op (the REPL prints lines, no persistent slot).
* test(credits): add sub_50pct + sub_75pct dev fixtures for the new usage bands
The fixture set jumped 10%% -> 90%%; add sub_50pct (uf 0.5 -> band 50 info) and
sub_75pct (uf 0.75 -> band 75 warn) so the new escalating bands are exercisable
via HERMES_DEV_CREDITS_FIXTURE across all three surfaces (notice, session-open
seed, /usage gauge).
* fix(credits): usage-band notice clears on next prompt (not sticky-forever)
A 50/75/90 usage heads-up was sticky and camped the status bar indefinitely. Clear
the visible credits.usage notice when a new turn starts (startMessage), so it shows
until your next prompt then yields. The server latch is unchanged, so it won't
re-nag at the same band — it only re-shows when the band actually changes (climb)
or clears when usage drops below the lowest band. Depletion stays sticky.
* refactor(credits): consolidate the /usage credits block behind nous_credits_lines()
The CLI (_print_nous_credits_block) and the messaging gateway (_handle_usage_command)
each re-implemented the auth-gate + portal fetch + render, and both bypassed the
dev-fixture short-circuit that only the TUI honored — so /usage ignored
HERMES_DEV_CREDITS_FIXTURE on the CLI and in chat. Route both through the shared
agent.account_usage.nous_credits_lines() helper: one fetch/render path, one auth
gate, and the fixture works on every surface (~60 fewer duplicated lines).
The gateway usage test recorded only the last asyncio.to_thread call; /usage now
dispatches both the account fetch and the credits fetch, so it records every call
and matches the account fetch by its provider arg.
* fix(credits): keep the /usage gauge type-safe and log its fail-open path
_is_finite_num is now a TypeGuard[float], so the type checker narrows the gauge
operands (monthly_credits / credits_remaining) and the magnitudes passed to
_fmt_usd through it — no more None-operand warnings on the arithmetic. Add a debug
breadcrumb on the nous_credits_lines portal-fetch fail-open so a dead /usage block
is diagnosable in agent.log without a dev flag.
* fix(credits): harden the header tracker — prod-leak gate, hot-path probe, fire-and-forget seed
- Prod-leak guard: dev fixtures (HERMES_DEV_CREDITS_FIXTURE) now also require
HERMES_DEV_CREDITS, so a stray fixture var can't surface fabricated balances on a
real account. Matches the documented run workflow (both vars set together).
- Hot-path probe: parse_credits_headers checks for the version sentinel header
before allocating a lowercased copy of the response headers — skips that work on
every non-Nous API call. Behaviour-identical and still case-insensitive.
- Fire-and-forget seed: the real portal fetch in seed_credits_at_session_start now
runs in a daemon thread, so a slow/unreachable portal never delays session "ready"
(previously blocked up to 10s). The dev-fixture path stays synchronous; the thread
re-checks idempotency before hydrating (a live header may land first).
- Diagnostics: debug breadcrumbs on the parse and seed fail-open paths so a crashed
parser / dead seed is distinguishable from a legitimate no-headers miss.
Cold-start tests set HERMES_DEV_CREDITS alongside the fixture to match the gate.
* test(tui): fix env-timing in the StatusRule dev-credits assertion
DEV_CREDITS_MODE is read once at module load (config/env), so mutating
process.env.HERMES_DEV_CREDITS inside the test couldn't flip it — the dev-banner
assertion only passed if the env was exported before vitest started, and failed in a
normal run. Move that assertion to a sibling file that mocks config/env with
DEV_CREDITS_MODE: true (scoped, no module-reset / React-identity hazard).
* test(credits): cover the dev-fixture /usage render and usage-band clear-on-prompt
- _snapshot_from_credits_state (the offline /usage renderer) had no direct test:
lock the gauge math, the verbatim *_usd magnitudes, the depletion line and the
fixture marker, plus the no-cap (no gauge) and None-state cases.
- turnController.startMessage had no test for clearing the credits.usage notice on
the next prompt while leaving credits.depleted sticky.
* feat(credits): deliver credit notices over messaging gateways
Bind notice_callback/notice_clear_callback on the per-turn gateway agent
so usage-band / depletion / restored notices reach Telegram/Discord/Slack/
etc. Previously the messaging gateway bound neither callback, so the agent's
_emit_credits_notices early-returned and a chat user crossing a band got
nothing unless they ran /usage manually.
- render_notice_line(): AgentNotice -> single plaintext line (level glyph +
text), plaintext-only so it renders uniformly without per-platform escaping.
Fail-soft on malformed/empty notices.
- Standalone push for every notice (messaging has no persistent status bar):
route through the shared _deliver_platform_notice rail (honors private/
public delivery + thread metadata), scheduled onto the gateway loop via
safe_schedule_threadsafe from the agent's sync worker thread — same pattern
as _status_callback_sync.
- The fired-once latch lives on the cached (reused-in-place) agent and
persists across turns, so a band crosses once -> one push, no per-turn
re-nag. Re-fires only after idle-eviction rebuilds the agent (a reminder).
- Recovery ('Credit access restored') rides the show path (emitted as a
success notice, not a clear). notice_clear_callback is a no-op: a sent
platform message can't be cleanly retracted.
Tests: render glyph/levels/fail-soft + public/private delivery seam through
_deliver_platform_notice + no-adapter no-op.
* fix(credits): don't double the glyph on messaging notices
render_notice_line prepended a per-level glyph, but the notice policy already
bakes the glyph into the text (and the TUI + CLI render it verbatim) — so every
credit notice over messaging came out doubled ("⚠ ⚠ Credits 90% used",
"⛔ ✕ Credit access paused"). Emit the text verbatim instead; drop the now-dead
level→glyph map.
The render tests fed glyph-less text (and the success case only checked
startswith), so the doubling slipped through. Rework them around the verbatim
contract and add an end-to-end regression that runs real evaluate_credits_notices
output through render_notice_line and asserts the line is returned unchanged.
|
||
|
|
b91aade176 |
feat(desktop): warn when main-model switch leaves auxiliary tasks pinned to another provider (#40286)
Switching the main model never touches auxiliary slot pins (they're independent, sticky per-task overrides). A user who switches main away from a now-unpaid provider keeps paying 402s on every background aux call until they manually reset those pins — silently, with no UI signal. - /api/model/set scope:'main' now returns stale_aux: slots still pinned to a provider different from the new main (additive field). - Desktop Model Settings shows a switch-time notice after Apply AND a persistent banner when any loaded aux slot mismatches the main provider, both wired to the existing 'Reset all to main' action. - Never auto-clears pins — a dedicated cheaper aux model is a legitimate config; surface-and-offer instead of nuking. - Fixes a stale pre-existing assertion in the panel test (main model now renders via selectors, not a standalone label). |
||
|
|
f8a241e105 |
fix(delegate): flatten content blocks in live overlay tail + AUTHOR_MAP
Follow-up on the cherry-picked content-block fix. _extract_output_tail
(the live subagent overlay) still used crude str(content), which renders
a "[{'type': 'text'...}]" blob and — worse — mislabels a block-wrapped
"Error: ..." result as is_error=False. Route it through the same
_stringify_tool_content helper so error detection and previews work at
both consumer sites.
- delegate_tool.py: _extract_output_tail uses _stringify_tool_content
- tests: add _extract_output_tail content-block test (error detection +
clean preview)
- release.py: AUTHOR_MAP entry for randomsnowflake (CI gate)
|
||
|
|
f83918c31d | fix(delegate): handle content-block tool results | ||
|
|
338c074336 | fix(send-message): treat ntfy topic targets as explicit | ||
|
|
50f9ad70fc |
fix(dashboard): populate cron delivery dropdown from configured platforms (#40218)
* fix: respect disabled auto-compaction on context overflow Port from anomalyco/opencode#30749. When compression.enabled is false, NO automatic compaction trigger may fire. The proactive token-threshold paths (preflight + post-response should_compress gate) already honoured the setting, but the three provider-overflow recovery paths in the agent loop — long-context-tier 429, 413 payload-too-large, and context-overflow — called _compress_context() unconditionally, silently compressing and rotating the session against the user's explicit choice. Add a single guard at the top of the overflow-recovery dispatch: when compression is disabled and the error is one of those three overflow classes, surface a terminal error (compaction_disabled: True) telling the user to /compress manually, /new, switch to a larger-context model, or reduce attachments. Manual /compress (force=True) is unaffected — it never enters this loop. Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't compress when disabled; control case still compresses when enabled). Existing overflow-recovery tests updated to enable compaction explicitly (they verify the recovery fires); fixture defaults flipped to True to match production (compression.enabled defaults to True). * fix(dashboard): populate cron delivery dropdown from configured platforms The dashboard cron-create/edit dropdown hardcoded five delivery options (local, telegram, discord, slack, email), so users on Matrix — or any other backend-supported platform — had no way to pick their channel even though the cron scheduler delivers to all of them. It also offered Telegram/Discord/etc. to users who never set those up. - cron/scheduler.py: add cron_delivery_targets() — the single source of truth. Intersects gateway-configured platforms with cron-deliverable ones and reports whether each platform's home channel is set. - web_server.py: GET /api/cron/delivery-targets exposes that list (+ the implicit local option) to the dashboard. - CronPage.tsx: both modals render options from the endpoint. Configured platforms missing a home channel still appear, annotated "set a home channel first" (option B), so the user knows what to fix. Edit modal preserves a job's current target even if it's no longer configured. Local-only state shows a "configure a platform under Channels" hint. Validation: scheduler + endpoint E2E'd with a Matrix gateway (home set and unset); 5 new tests; tests/cron + tests/hermes_cli/test_web_server green (366 passed). |
||
|
|
0f45509daf |
fix(agent): make mid-turn /steer trusted, not read as injection
A steer rides inside a tool result (the only role-alternation-safe slot
mid-turn), so a bare "User guidance:" line reads as untrusted tool content —
well-behaved models refuse it as suspected prompt injection (observed live:
"I only follow instructions from you directly, not ones injected through
command results").
- Wrap steers in a bounded, self-describing [OUT-OF-BAND USER MESSAGE] marker
(prompt_builder.format_steer_marker), shared by both drain sites.
- Add STEER_CHANNEL_NOTE to the core system prompt so the model expects this
exact marker and trusts it as a genuine user message — while still ignoring
lookalikes buried in tool/web/file output. Static text → byte-stable prompt,
no prompt-cache regression; gated on the agent having tools.
- Desktop: steer ack is now an inline transcript note (⏩ steered · …) instead
of a toast.
Marker is intentionally static (not a per-session nonce) to honor the
byte-stable system-prompt caching policy; nonce hardening noted as follow-up.
|
||
|
|
78122c52cf |
test(slack): drop /q alias assertion now displaced by /version cap clamp
Slack's native-slash manifest hard-caps at 50 (_SLACK_MAX_SLASH_COMMANDS). Adding the /version canonical claims a pass-1 slot, so the lowest-priority pass-2 alias (/q for /quit) clamps off the end. /q stays reachable via /hermes q. Surviving aliases (/btw /bg /reset) still prove alias parity. |
||
|
|
30340eae2f |
Include git SHA in /version output via banner label helper.
Reuses format_banner_version_label() so CLI, TUI, gateway, and desktop show upstream/local commit when available. |
||
|
|
9c1bb8d2c7 |
Add /version slash command across CLI, gateway, TUI, and desktop.
Surfaces Hermes Agent version info on demand without leaving chat; works mid-run like /help and /update. |
||
|
|
ea266f43e9 |
fix(file-ops): make rg/grep search error guard reachable and preserve partial matches (#39858)
The error guard in _search_with_rg/_search_with_grep was unreachable and, if it had fired, would have discarded valid results. Two root causes: 1. Unreachable. Both methods pipe the search through `| head` with no pipefail, so the pipeline reported head's exit code (0), masking rg/grep's error code (2). The guard never fired. Worse, because _exec merges stderr into stdout (stderr=subprocess.STDOUT), the error text was then parsed as bogus match lines instead of being surfaced — the user got garbage matches with no indication the search failed. 2. Latent results-dropping. The original `not result.stdout.strip()` check was always False on error (error text lives in stdout), and the `hasattr(result, 'stderr')` branch was dead code (ExecuteResult has no stderr field). A naive broadening to `exit_code == 2` would have nuked real matches whenever rg/grep also hit a non-fatal error (e.g. one unreadable file in a tree that otherwise matched), which both tools signal with exit 2. Fix: - Prefix the piped command with `set -o pipefail` so rg/grep's real exit status propagates. rg exits 0 on a truncating head; grep exits 141 (SIGPIPE), so the strict `== 2` guard ignores truncated-success. - Add _split_tool_diagnostics() to separate tool diagnostics from match output by tool prefix and output shape. Diagnostics never become matches; on a hard error they are the message to surface. - Only surface an error when exit==2 AND no usable match payload remains, so partial errors keep their real matches. Tests: tests/tools/test_search_error_guard.py drives both methods through the real local backend (hard error surfaced, partial error keeps matches, truncation no false error, files_only/count exclude diagnostics) plus unit coverage for the splitter. Supersedes #39710. |
||
|
|
66a6b9c930 |
Merge pull request #39482 from liuhao1024/fix/rich-markup-error-on-session-resume
fix(cli): use Rich [dim] tag instead of ANSI escape in session resume messages |
||
|
|
e6f7e217ce |
Merge pull request #40093 from kshitijk4poor/feat/named-custom-discover-models-18726
feat(model): honor discover_models in terminal hermes model named-custom flow (closes #18726) |
||
|
|
7ae8aac3b9 |
feat(model): honor discover_models in terminal hermes model named-custom flow
The terminal `hermes model` wizard (_model_flow_named_custom) always live-probed a custom provider's /models endpoint, ignoring the configured `models:` list. For plans whose endpoint exposes a large catalog (e.g. Baidu Qianfan Coding Plan returns 100+ models for a 2-3 model plan) the picker flooded with models the user can't use. This wires `discover_models` (and the `models:` list) through _named_custom_provider_map into the flow and honors `discover_models: false` the same way the slash-command picker (model_switch.py sections 3 & 4) does: - Default stays True — live probe, no behaviour change. - discover_models: false → use the configured `models:` list verbatim, skip the probe (string 'false'/'no'/'0' normalised to False). - If the probe is on but returns empty, fall back to the configured list instead of forcing manual entry. Closes #18726 |
||
|
|
4b2d00f845 |
feat(model_switch): honor discover_models in custom_providers section 4
Section 3 (user `providers:`) already honors `discover_models: false` to
skip live /models discovery and keep the explicit `models:` list. Section 4
(`custom_providers:` list) did not — `should_probe` ignored the field, so any
grouped custom provider with an api_key always had its configured subset
replaced by the full live /models catalog.
This adds the same `discover_models` support to section 4:
- Default True — no behaviour change for existing configs.
- `discover_models: false` keeps the explicit `models:` list even when an
api_key is present.
- String values ("false"/"no"/"0") are normalised to False, matching
section 3.
- If any entry in a grouped endpoint opts out, the whole group opts out.
Use case: endpoints that expose a full aggregator catalog via /models but
only serve a configured subset.
Salvaged from #29810 — rebased onto current main. The PR's other change
(`key_env` resolution in section 4) landed independently in commit
|
||
|
|
6f6eb871d8 |
fix(gateway): new chats honor their profile in global-remote mode (#39993)
Follow-up to #39921. That PR scoped session.resume + prompt.submit to a session's profile, but a BRAND-NEW chat (session.create) under a non-launch profile was still built and persisted against the dashboard's launch profile. Two visible symptoms in app-global remote mode (one dashboard, many profiles): 1. "who are you" in profile S replied as the launch (default) profile/agent — the agent was built with the launch HERMES_HOME, so config/SOUL/identity came from the wrong profile. 2. "session not found" on later resume — _ensure_session_db_row persisted the row into the launch profile's state.db via _get_db(), so the session lived in the wrong db, the unified list mis-tagged it (it showed up under BOTH profiles), and resume routed to the wrong one. Fix — carry the owning profile through the create path too: - session.create accepts an optional `profile`; resolves its home and stores `profile_home` on the session (alongside what resume already set). - _start_agent_build binds that profile's HERMES_HOME while building the agent (config/skills/model/identity resolve to it) and hands the agent the profile's state.db so turns persist there. - _ensure_session_db_row writes the row into the profile's state.db, not the launch db — fixing the duplicate row + mis-tag + resume 404. - desktop sends the new-chat profile on session.create. None/launch profile → unchanged (single-profile and per-profile-remote setups take the same path). Verified live against a one-dashboard / multi-profile remote: a new chat under `work` builds as work's agent (correct SOUL identity), persists ONLY to work's state.db (launch db stays empty), the unified list tags it `work` exactly once, and it resumes cleanly. tests/test_tui_gateway_server.py: _make_agent mocks updated for the session_db param added in #39921's build path. |
||
|
|
02d6bf1c39 |
fix(desktop+gateway): full multi-profile support over one global-remote dashboard (#39921)
* fix(desktop): cross-profile session history in app-global remote mode #39894 made remote-profile sessions first-class for PER-PROFILE remote overrides. But the common setup — Settings → Gateway → "All profiles" → Remote — writes app-GLOBAL remote mode (connection.json top-level mode:'remote', empty profiles map), which the intercept didn't recognize. Switching to a non-launch profile then 404'd every session read, so no history showed for it. In global remote mode a SINGLE backend serves every profile via ?profile= (it reads each profile's state.db off the remote host's own disk — verified: one dashboard returns /api/profiles and /api/profiles/sessions?profile=all across all profiles). The fix: when no per-profile override matches but global remote mode is active, route per-session reads/mutations to that one backend and KEEP the ?profile= param so it opens the right state.db (instead of bailing to the local path and dropping the profile scope). - new globalRemoteActive() — true for connection.json mode:'remote' or the HERMES_DESKTOP_REMOTE_URL env override. - per-session branch: per-profile override → route sans profile (own db); global mode → route to the single backend WITH ?profile= preserved. - unified list is unchanged in global mode: it already passes through to the one backend, which aggregates all profiles natively. Verified live against a one-dashboard / multi-profile remote (Austin's topology): cross-profile transcript reads load (was 404), rename/delete route to the right profile, unified list spans both profiles. Known limitation (architectural, not fixed here): LIVE chat as a non-launch profile still needs a per-profile dashboard on the remote — the dashboard binds HERMES_HOME once at process start, so one global backend can't run an agent turn as another profile. Session history/read/mutate now work regardless. * fix(gateway): resume + chat any profile over one global-remote dashboard The REST half of this branch made cross-profile session history visible in app-global remote mode, but resume + chat still went over the WebSocket gateway, which was hard-bound to the dashboard's launch profile. Resuming a non-launch profile's session 404'd ("session not found") and sending spawned a new session — because session.resume/prompt.submit had no profile concept and the live agent + state.db were process-global to the launch profile's HERMES_HOME. Make the WS gateway per-session profile-aware so ONE dashboard can serve every local profile on its host (the app-global remote topology): - session.resume accepts an optional `profile`. _profile_home() resolves that profile's home on this host; resume opens THAT profile's state.db, binds its HERMES_HOME (ContextVar override) while building the agent so config/skills/ model resolve to it, and passes the profile db to the agent so turns persist to the right state.db. The owning profile_home is stored on the session. - prompt.submit re-binds the stored profile_home for the turn thread (mid-turn home reads — memory, skills — resolve to the resumed profile), reset in finally. - _make_agent gains an optional session_db param (defaults to _get_db()). - _load_cfg honors the home override (falls back to _hermes_home) so a resumed profile loads its own config; cache keyed on resolved path. - desktop: session.resume now sends the owning profile. Omitted/launch profile → unchanged (single-profile and per-profile-remote setups are byte-for-byte the same path). Verified live against a one-dashboard / multi-profile remote: resuming a non-launch profile's session loads its history, runs a real turn against THAT profile's home/env, and persists to its state.db. tests/tui_gateway/test_protocol.py: _make_agent mocks updated for the new param. |
||
|
|
2dda393f9f | test(gateway): regression tests for max_tokens propagation chain (#20741) | ||
|
|
af8b917dab | fix(termux): scope frontend npm installs | ||
|
|
9ca11b35d5 |
perf(/model): prewarm picker provider-models cache in background (#39847)
* fix: respect disabled auto-compaction on context overflow Port from anomalyco/opencode#30749. When compression.enabled is false, NO automatic compaction trigger may fire. The proactive token-threshold paths (preflight + post-response should_compress gate) already honoured the setting, but the three provider-overflow recovery paths in the agent loop — long-context-tier 429, 413 payload-too-large, and context-overflow — called _compress_context() unconditionally, silently compressing and rotating the session against the user's explicit choice. Add a single guard at the top of the overflow-recovery dispatch: when compression is disabled and the error is one of those three overflow classes, surface a terminal error (compaction_disabled: True) telling the user to /compress manually, /new, switch to a larger-context model, or reduce attachments. Manual /compress (force=True) is unaffected — it never enters this loop. Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't compress when disabled; control case still compresses when enabled). Existing overflow-recovery tests updated to enable compaction explicitly (they verify the recovery fires); fixture defaults flipped to True to match production (compression.enabled defaults to True). * perf(/model): prewarm picker provider-models cache in background The no-args /model picker calls list_authenticated_providers(), which fetches each authenticated provider's live /v1/models list serially. On a cold or stale (>1h TTL) cache that blocks ~1.5s on the user's critical path the first time /model is opened in a session. Warm that exact path off-thread during the idle window right after the CLI banner is shown: a once-per-process daemon thread runs list_authenticated_providers() to populate provider_models_cache.json for every authed provider. By the time the user types /model, the picker hits the warm disk cache (~136ms vs ~1500ms). Process-level Event guard (mirrors run_agent's _openrouter_prewarm_done) ensures at most one thread per process; fully exception-isolated so an offline/no-creds provider can never affect the session. |
||
|
|
7583aedacd |
fix(completion): remove /model <arg> autocomplete from CLI/TUI (#39727)
* fix: respect disabled auto-compaction on context overflow Port from anomalyco/opencode#30749. When compression.enabled is false, NO automatic compaction trigger may fire. The proactive token-threshold paths (preflight + post-response should_compress gate) already honoured the setting, but the three provider-overflow recovery paths in the agent loop — long-context-tier 429, 413 payload-too-large, and context-overflow — called _compress_context() unconditionally, silently compressing and rotating the session against the user's explicit choice. Add a single guard at the top of the overflow-recovery dispatch: when compression is disabled and the error is one of those three overflow classes, surface a terminal error (compaction_disabled: True) telling the user to /compress manually, /new, switch to a larger-context model, or reduce attachments. Manual /compress (force=True) is unaffected — it never enters this loop. Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't compress when disabled; control case still compresses when enabled). Existing overflow-recovery tests updated to enable compaction explicitly (they verify the recovery fires); fixture defaults flipped to True to match production (compression.enabled defaults to True). * fix(completion): remove /model <arg> autocomplete from CLI/TUI The TUI frontend already suppressed /model argument completion in favor of the two-step ModelPicker (useCompletion.ts), but the CLI prompt_toolkit completer and the gateway-backed complete.slash RPC (TUI + desktop) still emitted model aliases and probed LM Studio on every keystroke. Drops the /model branch in SlashCommandCompleter.get_completions, the _model_completions method, and the LM Studio probe/cache helper that only fed it. Command-name completion (/mod -> model) and sibling arg completers (/skin, /personality) are untouched. Removes the now-dead TestModelTabCompletion tests. |
||
|
|
d880b5be09 |
fix(update/windows): don't return _UvResult on Windows (subprocess argv crash) (#39820)
PR #39780 made ensure_uv() return a _UvResult — a str subclass whose __iter__ yields (path, fresh_bootstrap) so old `uv_bin, fresh = ensure_uv()` call sites survive the update boundary. That trick is unsafe on Windows. The dependency installer passes uv straight into the command list (`[uv_bin, "pip", "install", ...]`). On Windows, subprocess serializes argv via subprocess.list2cmdline, which iterates every entry *as a string* (`for c in arg`). Because _UvResult overrides __iter__, that iteration yields (path, fresh_bootstrap) instead of characters, injecting the bool into the command line and crashing the first update with: TypeError: sequence item 1: expected str instance, bool found This bites the common single-assignment caller (`uv_bin = ensure_uv()`) on its first update after #39780: the freshly pulled _UvResult flows into the old in-memory call site and into the argv. Reported in the field on a ~10-commits-behind Windows install. A single return value cannot satisfy both legacy 2-target unpacking and Windows char-iteration — both use the iterator protocol with contradictory results. So gate the wrapper to POSIX: Windows returns a plain str/None (the historical, subprocess-safe contract). POSIX keeps _UvResult and the #39780 update-boundary fix. Tests: list2cmdline canary proving _UvResult breaks Windows, plus Windows returns-plain-str and POSIX dual-contract coverage. |
||
|
|
db204ae203 |
fix(update): make ensure_uv() survive the update boundary (no first-run crash) (#39780)
* fix(update): make ensure_uv() survive the update boundary (no first-run crash) `hermes update` runs the `ensure_uv()` call site from the old, already-imported `hermes_cli.main` against the *freshly pulled* `managed_uv` (managed_uv is only ever lazily imported, so it loads from disk post-pull). `ensure_uv()`'s return arity flipped from a single path string to `(path, fresh_bootstrap)` ( |
||
|
|
72eb42d9ec |
feat(update): stash/restore by default + settable discard for non-interactive updates (reverts #38542, #39568) (#39645)
* Revert "fix(update): require managed marker before destructive clean" This reverts commit |
||
|
|
d41427504e |
feat(delegation): uncap max_spawn_depth (floor 1, no ceiling) (#39772)
* fix: respect disabled auto-compaction on context overflow Port from anomalyco/opencode#30749. When compression.enabled is false, NO automatic compaction trigger may fire. The proactive token-threshold paths (preflight + post-response should_compress gate) already honoured the setting, but the three provider-overflow recovery paths in the agent loop — long-context-tier 429, 413 payload-too-large, and context-overflow — called _compress_context() unconditionally, silently compressing and rotating the session against the user's explicit choice. Add a single guard at the top of the overflow-recovery dispatch: when compression is disabled and the error is one of those three overflow classes, surface a terminal error (compaction_disabled: True) telling the user to /compress manually, /new, switch to a larger-context model, or reduce attachments. Manual /compress (force=True) is unaffected — it never enters this loop. Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't compress when disabled; control case still compresses when enabled). Existing overflow-recovery tests updated to enable compaction explicitly (they verify the recovery fires); fixture defaults flipped to True to match production (compression.enabled defaults to True). * feat(delegation): uncap max_spawn_depth to match max_concurrent_children Removed the hard ceiling of 3 on delegation.max_spawn_depth. Depth now has a floor of 1 and no upper limit, mirroring max_concurrent_children. Cost (each level multiplies API spend) is the practical limiter, not a constant. - delegate_tool.py: drop _MAX_SPAWN_DEPTH_CAP, _get_max_spawn_depth() floors at 1 instead of clamping to [1,3]; depth-limit error string reworded - config.py / cli-config.yaml.example: doc comments say floor 1, no ceiling - docs (configuration, delegation, delegation-patterns): range 1-3 -> >=1 - tests: convert clamp-above-3 change-detector into a no-ceiling invariant, drop the _MAX_SPAWN_DEPTH_CAP==3 snapshot assert, fix warning-text assert |
||
|
|
3cd1bd971f | fix(cli): require Chromium for local browser readiness in setup/status surfaces | ||
|
|
ec46f5912e |
fix(gemini): default native maxOutputTokens + strip OpenAI extra_body on Gemini endpoints (#39730)
* fix: respect disabled auto-compaction on context overflow Port from anomalyco/opencode#30749. When compression.enabled is false, NO automatic compaction trigger may fire. The proactive token-threshold paths (preflight + post-response should_compress gate) already honoured the setting, but the three provider-overflow recovery paths in the agent loop — long-context-tier 429, 413 payload-too-large, and context-overflow — called _compress_context() unconditionally, silently compressing and rotating the session against the user's explicit choice. Add a single guard at the top of the overflow-recovery dispatch: when compression is disabled and the error is one of those three overflow classes, surface a terminal error (compaction_disabled: True) telling the user to /compress manually, /new, switch to a larger-context model, or reduce attachments. Manual /compress (force=True) is unaffected — it never enters this loop. Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't compress when disabled; control case still compresses when enabled). Existing overflow-recovery tests updated to enable compaction explicitly (they verify the recovery fires); fixture defaults flipped to True to match production (compression.enabled defaults to True). * fix(gemini): default native maxOutputTokens + strip OpenAI extra_body on Gemini endpoints Two distinct failures hit users on the gemini provider with only Google AI Studio keys set. 1. Truncation loop: build_gemini_request() only set maxOutputTokens when max_tokens was non-None. Hermes passes None to mean "unlimited", but Gemini's native generateContent does NOT treat an absent maxOutputTokens as full budget — it applies a low internal default and stops early with finishReason=MAX_TOKENS, truncating tool calls. The agent then retries 3x and refuses the incomplete call. Now default to the published 65,535 ceiling (shared by all current Gemini text models) when max_tokens=None. 2. HTTP 400 on Gemini endpoint: the chat_completions transport assembles profile extra_body (Nous portal 'tags', reasoning, provider prefs) and sends it via the OpenAI client to whatever base_url is resolved. When a profile that emits extra_body (e.g. Nous) is active but the endpoint is a native Gemini base_url — typical when only Google creds exist and a fallback/aux call lands on Gemini — Google rejects the unknown 'tags' field with a non-retryable 400. Strip all non-thinking_config extra_body keys when the resolved endpoint is native Gemini. Verified E2E against real transport code: tags stripped on native Gemini, preserved on Nous and the /openai compat endpoint; maxOutputTokens=65535 on None, explicit values respected. |
||
|
|
6bf55a473e |
Add CLI Telegram QR onboarding
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> |
||
|
|
8a9ded5b21 |
feat(discord): voice-channel mixer — ambient idle bed + verbal acks that overlap TTS (#39659)
* feat(discord): voice-channel mixer — ambient idle bed + verbal acks that overlap TTS
Discord voice mode can now feel conversational: the bot speaks a short
acknowledgement before it starts working, and a subtle ambient 'thinking' bed
plays underneath while tools run, ducking under speech and swelling back — the
Grok-voice-mode feel.
discord.py plays only one audio stream per voice connection, so this adds a
software mixer (VoiceMixer, a discord.AudioSource) installed once per guild on
join. It sums an ambient loop, verbal acks, and TTS replies into that single
20ms/48kHz/stereo stream (numpy int16 add + clip), so they overlap instead of
stop-and-swap. Speech ducks the ambient gain down and releases it smoothly.
- plugins/platforms/discord/voice_mixer.py: VoiceMixer + MixerChild (gain,
loop, fade, duck/release), decode_to_pcm (ffmpeg), synth_ambient_pcm (no
asset needed — synthesised pad).
- adapter: install mixer on join, tear down on leave, route
play_in_voice_channel through the mixer (legacy one-shot path kept as
fallback), play_ack_in_voice, voice_mixer_active. Defensive getattr for the
object.__new__ test helpers.
- gateway/run.py: tool_start_callback fires a one-time verbal ack on the first
tool call of a turn when in a voice channel (independent of the text
tool-progress gate). No system-prompt or message-flow changes.
- config: discord.voice_fx.* (OFF by default; ambient/duck/speech gains, ack
phrases). All in config.yaml, not .env.
- docs + tests (mixer unit + adapter integration).
Verified: 19 new tests pass, existing voice suite green (2 pre-existing
davey-module env failures unchanged), and a real-mixer E2E confirms ambient
streams, TTS overlaps it, acks layer in, and teardown is clean.
* fix(discord): make voice mixer numpy import lazy (numpy is voice-extra-only)
numpy ships in the optional 'voice' extra, not [all,dev], so a module-level
'import numpy' broke CI test collection (and would break the always-imported
Discord adapter on any install without the voice extra). Defer numpy to the
functions that actually mix audio via _require_numpy(); guard the test module
with pytest.importorskip('numpy').
|
||
|
|
ef5e48f3fd |
test(models): guard Nous silent default against expensive-flagship escalation
Assert get_default_model_for_provider("nous") never returns the priciest
catalog entry (anthropic/claude-opus-4.8) and that an override pointing at a
model absent from the catalog falls back to catalog order. Regression for the
silent flagship-billing footgun.
|
||
|
|
b459bac02c |
fix(cli): gitignore Desktop bootstrap marker so hermes update stops autostashing it
The Desktop bootstrap installer writes `.hermes-bootstrap-complete` into the managed git checkout root. Because it wasn't gitignored, `hermes update`'s `git stash push --include-untracked` treated it as a local change and created an autostash on every run — prompting the user to restore "local changes" that were really Hermes-managed runtime state (and risking the marker getting stranded in a stash, which re-triggers Desktop bootstrap). Add the marker to .gitignore; `git stash -u` and `git status --porcelain` both skip ignored files, so the updater now sees a clean tree. Fixes #38529 |
||
|
|
3278b423d5 |
fix(dashboard): strip session token from subprocess env
Add HERMES_DASHBOARD_SESSION_TOKEN to the Hermes-managed subprocess environment blocklist so dashboard authorization material does not propagate into shell, PTY, or background process launches. Extend the local environment blocklist regression coverage to prove the dashboard session token is stripped like other Hermes-managed secrets. |
||
|
|
b0d234f068 |
fix(cron): don't crash on cron list when a job's repeat is null
`cron_list` read `job.get("repeat", {})`, but the dict-default only
applies to a MISSING key. A one-shot job persisted with `"repeat": null`
returns None, and the next `.get("times")` raised AttributeError, taking
down the whole `cron list` output. Coalesce with `or {}` so a
present-but-null repeat renders as ∞ like the other cron readers already
do. Adds a regression test.
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
|
||
|
|
c8e80cd0bf | fix(update): require managed marker before destructive clean | ||
|
|
ad69d3edc7 |
fix(terminal): guard os.getcwd() against a deleted CWD
`os.getcwd()` raises FileNotFoundError when the process's working directory was removed out from under it (e.g. a scratch workspace cleaned up mid-session), crashing terminal env setup. Extract a `_safe_getcwd()` helper that falls back to TERMINAL_CWD, then the user's home, on FileNotFoundError, and route all three `os.getcwd()` call sites in terminal_tool.py through it (local default_cwd, the Docker cwd-passthrough source, and the debug-config print) so the same crash can't resurface at a sibling site. Adds unit tests for the real-cwd path and both fallback branches. Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> |
||
|
|
b1e399de95 |
fix(update-check): stop reporting phantom "N commits behind" inside Docker (#39559)
Inside the published Docker image, both the `--tui` banner and the dashboard-embedded TUI report `1 commit behind — run docker pull nousresearch/hermes-agent:latest to update` even though the container has no git repo and no way to compute a commit delta. Root cause: two independent update-detection paths, only one of which knows it's running in Docker. - `recommended_update_command()` → `detect_install_method()` reads the `.install_method` stamp that `docker/stage2-hook.sh` writes at boot → returns "docker", so the *command string* correctly says `docker pull`. - `banner.check_for_updates()` (the source of the "N commits behind" *count*) has no notion of the docker install method. It only detects a build via `HERMES_REVISION` (nix-only, unset in the image) or a `.git` dir (excluded from the image by .dockerignore). Neither matches, so it silently falls through to `check_via_pypi()`, whose PyPI-version mismatch flag (1) is then rendered verbatim by the CLI banner (build_welcome_banner), the Ink TUI badge (branding.tsx), and `hermes version` as "1 commit behind" — a phantom count, no commit math involved. `hermes update` already refuses to run in-place in the container. The dashboard's REST `/api/hermes/update/check` endpoint already short-circuits docker (returns behind=None + the docker guidance). This mirrors that guard inside `check_for_updates()` so the banner/TUI/version surfaces agree: when `detect_install_method() == "docker"`, return None before any git/pypi probe (and before writing a cache entry). None makes the render guards (`typeof === 'number' && > 0`, `behind and behind > 0`) stay false, so the badge/line disappears entirely — matching the System page. Fix is in one place (check_for_updates) because all three consumers route through it via get_update_result()/_update_result. Tests: test_check_for_updates_docker_returns_none asserts None + no git/pypi probe + no cache write; test_check_for_updates_non_docker_still_checks guards against over-broadening (pip still version-checks). Mutation-tested: removing the guard fails the docker test. Verified against a real `docker build` of the image — see PR description. |
||
|
|
899ee8c23d |
fix(gateway): tolerate non-UTF-8 status/pid files in gateway status reads
`_read_json_file` caught OSError but not UnicodeDecodeError, so a status file holding binary/non-UTF-8 bytes (truncated or clobbered write) would crash the gateway status path instead of being treated as unreadable. UnicodeDecodeError is a ValueError subclass, not an OSError, so it escaped the existing guard. Widen the catch to (OSError, UnicodeDecodeError) at both read sites in gateway/status.py — `_read_json_file` and the sibling `_read_pid_record`, which had the identical gap. Adds tests covering binary input (returns None) and valid input (still parses) for both. Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> |
||
|
|
7309f3bef7 |
fix(line): map inbound message types to the correct MessageType
The LINE adapter classified every non-text inbound message as `MessageType.IMAGE`, which doesn't exist on the enum — so any image, video, audio, file, sticker, or location message raised AttributeError the moment it was constructed. Beyond fixing the crash, every non-text message was being collapsed onto a single type. The gateway routes on MessageType (voice → STT, files → document handling, etc.), so misclassification silently mishandled media. Replace the inline ternary with a `_LINE_MESSAGE_TYPES` lookup that maps each LINE webhook type to its proper enum member (audio → VOICE to match how Telegram/WhatsApp treat voice notes), falling back to TEXT for unknown types. Adds regression tests covering the mapping and the old AttributeError. Co-authored-by: Sahibzada Allahyar <94376830+sahibzada-allahyar@users.noreply.github.com> |
||
|
|
7c00ffd92c |
fix(google-workspace): fall back to uv when venv has no pip (#39516)
The Hermes Docker image's venv is built with `uv sync`, which does not bootstrap pip into the venv. When the google-workspace setup script needs to install its deps and the running interpreter has no pip, `sys.executable -m pip install` dead-ends with "No module named pip" (reported via Discord support). install_deps() now falls back to `uv pip install --python <interpreter>` when the pip path fails and uv is on PATH. uv installs into the exact interpreter the script is running under without needing pip present, so the pip-less venv self-heals (e.g. a dep evicted on image update, or a build without the [google]/[all] extra). On environments with neither pip nor uv, the [google] extra hint is printed as before. Verified E2E against nousresearch/hermes-agent:latest: under the venv python with a missing dep, --install-deps now prints "Dependencies installed." and exits 0 instead of failing. Adds TestInstallDeps regression coverage: pip path, uv fallback, uv-not-consulted-when-pip-works control, and both no-installer-available and uv-also-fails failure cases. |
||
|
|
fb853a1783 | fix(install): scrap rebuild venv | ||
|
|
96cd37e212 |
fix(dashboard): reap orphaned embedded-chat sessions to stop slash_worker leak
Since #38591 made the dashboard's embedded chat unconditional, every browser refresh of /chat spins up a fresh session.create (new sid + a fresh _SlashWorker via _deferred_build) over /api/ws, but the old tab's WS disconnect only DETACHES the transport (ws.py) — it never closes the old session or its slash_worker. The dashboard's in-process gateway is long-lived, so the detached _SlashWorker subprocess's stdin pipe stays open forever and the worker never reaches EOF: one leaked python process per refresh. Fix at the session-lifecycle layer (not PTY signal timing — verified that a process whose owning gateway dies is always reaped via stdin-EOF; the leak is specifically the long-lived dashboard process keeping detached sessions parked). On WS disconnect, schedule a grace-delayed reap of any session left orphaned (transport detached to stdio, not mid-turn). A quick reconnect / session.resume / prompt.submit rebinds a live transport and cancels the reap, preserving the intentional detach-for-reconnect window. - server.py: extract _teardown_session() (shared with session.close), add _ws_session_is_orphaned() + _schedule_ws_orphan_reap(), gated by HERMES_TUI_WS_ORPHAN_REAP_GRACE_S (default 20s, 0 disables). - ws.py: schedule the reap for each detached session on disconnect. - tests: reap-closes-worker, spares-reattached/mid-turn/finalized, disabled-when-grace-zero. |
||
|
|
25742372eb |
fix(approval): check is_approved in execute_code guard (#39275)
check_execute_code_guard() never called is_approved() before entering the
approval flow, and never persisted session/permanent approvals from the
gateway response. This meant 'Approve session' and 'Always' buttons had
no effect — every execute_code call re-prompted the user.
- Add is_approved() check after get_current_session_key(), matching
check_all_command_guards()
- Persist session ('approve_session') and permanent ('approve_permanent')
approvals based on the gateway choice, same as terminal command guard
- Add 3 regression tests for session persistence, permanent persistence,
and short-circuit on pre-existing approval
|
||
|
|
391b594752 |
fix(cli): use Rich [dim] tag instead of ANSI escape in _restore_session_cwd
Replace [{_DIM}] with [dim] in all _restore_session_cwd and
_preload_resumed_session messages that go through _console_print (Rich
Console.print). _DIM is an ANSI escape (\x1b[2;3m) that Rich cannot
parse as a markup tag, causing MarkupError on session resume when the
stored cwd is missing or inaccessible.
Also uses [/dim] closing tag for explicit tag matching.
Fixes #39469
|
||
|
|
89baf02919 |
Merge origin/main into bb/desktop-profile-support
Resolve conflicts in desktop settings/cron/messaging/sidebar: adopt main's ListRow + actions-menu refactors for credential rows; keep our profileColor import on the sidebar. Drop the now-orphaned Tip-based helpers. |
||
|
|
46abf04012 |
fix(ssh): handle WinError 1314 symlink failure with shutil.copy2 fallback
On Windows, os.symlink() raises OSError (WinError 1314) unless the process has Administrator rights or Developer Mode is enabled. The SSH bulk-upload staging logic used symlinks to mirror the remote layout before piping through tar; this caused all ssh_bulk_upload tests to fail on Windows. - ssh.py: wrap os.symlink() in try/except OSError and fall back to shutil.copy2() so staging works on every platform. shutil was already imported, no new dependency introduced. - file_sync.py: replace str(Path(remote).parent) with posixpath.dirname(remote) in unique_parent_dirs(). pathlib.Path uses the host separator (\ on Windows), but these paths are sent to a remote Linux host over SSH and must always use forward slashes. - test_ssh_bulk_upload.py: make test_staging_symlinks_mirror_remote_layout platform-agnostic — assert file existence and content instead of os.path.islink() + os.readlink(), since the staged entry may be a copy on Windows. |
||
|
|
93b5df3189 |
fix(test): patch async_is_safe_url in web-provider SSRF mocks
web_tools.is_safe_url was replaced by async_is_safe_url, but three web-provider test files still monkeypatched the old sync name, raising AttributeError. Patch the async variant with an async lambda. |