Compare commits

..
Author SHA1 Message Date
teknium1 0b50fe9579 docs(infographic): flaky fallback-cooldown timing fix 2026-06-27 19:51:09 -07:00
teknium1 5f91da2d1c test(fallback): fix flaky cooldown-window timing assertion
test_non_retryable_exhaustion_arms_cooldown captured `before` ahead of three
_try_activate_fallback() calls, then asserted the armed cooldown was
<= before + 5.0 + 1.0. The cooldown is set relative to the *final* call, and
the activation work (agent init, resolve_provider_client) can take >1s on a
loaded CI worker — so `before`-anchored upper bound overshot by ~0.5s and
failed reliably (slice 8/8, observed twice: 230.21 vs 229.73 bound,
905.80 vs 905.31 bound).

Anchor the upper bound to `after = time.monotonic()` captured once the
cooldown is armed. cooldown is final_call_time + 5.0 and after >= final_call_time,
so cooldown <= after + 5.0 + 1.0 holds regardless of activation latency. Still
proves the short 5s window vs the 60s rate-limit one (well under the +50s
discriminator in the sibling test). Test added in #53909.
2026-06-27 19:49:21 -07:00
teknium1 a590c5efdc docs: add infographic for provider-precedence fix (#29285) 2026-06-27 19:49:02 -07:00
2af1678bfc fix(auth): explicit provider intent beats stale OAuth active_provider (#29285)
`resolve_provider("auto")` checked `auth.json` `active_provider` BEFORE the
config.yaml `model.provider` and env-var API-key checks. So a user who was
OAuth-logged-into one provider (e.g. Anthropic) but had set an explicit
`model.provider` or exported an API key (e.g. `OPENAI_API_KEY`) was silently
routed to the stale OAuth provider — the override was invisible and surprising.

Reorder the auto-path so explicit intent wins (the order the issue asks for):

  1. explicit CLI api_key/base_url
  2. config.yaml `model.provider`            (safety net — see below)
  3. OPENAI_API_KEY / OPENROUTER_API_KEY env
  4. OpenRouter credential pool
  5. provider-specific API-key env vars
  6. auth.json `active_provider` (OAuth)      ← demoted to last-resort
  7. AWS Bedrock credential chain
  8. error

`active_provider` is still honored — it's just a last-resort fallback chosen
only when the user expressed no other preference, instead of overriding one.

The normal chat/gateway/TUI/ACP/status path already resolves config.provider
upstream in `resolve_requested_provider()` before "auto" is reached, so this
duplicate config check is the safety net for the lone direct caller
(`main.py` `resolve_provider("auto")`) and any future bypass. Because every
surface funnels through this one resolver, the fix propagates everywhere with
a single edit — no sibling path re-implements precedence.

Also add a one-shot WARN when resolution lands on `active_provider` while a
populated `model` config dict lacks a `provider` key — surfacing the silent
override the issue reported without breaking first-install.

Synthesizes the two competing PRs: #29615 (LifeJiggy — config-before-auth +
the silent-override framing) and #29809 (Minksgo — the env-before-auth
reorder). #29809 could not be merged directly (bundled unrelated, un-opt-in
cost-tagging telemetry); its reorder idea is incorporated here and credited.

Tests: tests/hermes_cli/test_provider_precedence.py — config/env beat stale
OAuth, OAuth still used as last resort, explicit request short-circuits, WARN
fires on silent fall-through. Full provider-resolution suites: 374 passed.

Fixes #29285

Co-authored-by: LifeJiggy <141562589+LifeJiggy@users.noreply.github.com>
Co-authored-by: Minksgo <153416856+Minksgo@users.noreply.github.com>
2026-06-27 19:49:02 -07:00
teknium1 2b73dd1ca6 fix(gateway): namespace --replace takeover marker by HERMES_HOME to stop cross-profile flap (#29092)
Two profile gateway services sharing the default ~/.hermes resolve the
takeover marker to the same path. A --replace from profile B could land
in profile A's marker, match on PID + start_time by coincidence of a
shared PID namespace, and make profile A exit 0 — only to be revived by
systemd Restart=always, which races the replacer again, flapping
indefinitely.

write_takeover_marker now stamps replacer_hermes_home; the shared
consume path rejects markers written under a different HERMES_HOME and
leaves them in place for the correct profile. Absent field (older
markers) is treated as same-home, so single-profile and mixed old/new
deployments are unaffected.

Salvaged from #31414 by @CryptoByz onto current main (branch was ~3962
commits behind; the consume function had since been refactored for
issue #34597). Co-authored-by: CryptoByz.
2026-06-27 19:43:02 -07:00
Teknium 28ed883959 docs: add PR infographic for config-defaults fix 2026-06-27 19:38:11 -07:00
Teknium 45b2e4dd6b fix(config): opt newer migrations out of default-stripping
The salvaged #27354 fix made save_config strip schema-default leaves by
default. Five migration sites added to main after the PR was authored
still called bare save_config(config) and intentionally materialize a
(often default-valued) key: model_catalog.ttl_hours, write_approval,
curator.consolidate, agent.verify_on_stop, and the suspicious-MCP-server
disable. Pass strip_defaults=False so those one-time deliberate writes
survive, matching the opt-out the PR applied to the other migrations.
2026-06-27 19:38:11 -07:00
郝鹏宇 98488c4be4 fix(config): prevent save_config from materialising schema defaults
Fixes #27354

Root cause:  called during init (or by any code path
that saves ) wrote injected schema defaults into
config.yaml as if the user had authored them.  Two fix layers:

1.  now only injects
    when the user actually set
    somewhere (root or agent).  A user who never set
    keeps it absent, so 's explicit-path
   detection won't treat it as user-authored.

2.  gains a  parameter and a
   new  pass that removes keys matching
    unless those paths were explicitly present in the
   **raw** (pre-normalization) config on disk.  Explicit-path detection
   uses  on  *before* any
   normalisation runs — preventing injected-in defaults from being
   mistaken for user-set values.

All migration and edit-config call sites pass
to preserve their intentional default-seeding behaviour.

New helpers:
-   — collects leaf-key paths from a raw dict
-    — removes keys matching schema defaults

Test coverage: 4 new regression tests (59 total, all passing).
2026-06-27 19:38:11 -07:00
teknium1 6dcc579bcb test(streaming): repoint anthropic stream-cleanup test to close+rebuild path
The existing test_anthropic_stream_parser_valueerror_retries_before_delivery
asserted mock_replace.call_count == 1 — i.e. it passed precisely because the
buggy OpenAI rebuild was invoked on the Anthropic path. Repoint it to assert
the corrected close+rebuild-Anthropic behavior (#28161).
2026-06-27 19:37:33 -07:00
EloquentBrush0x a0b9663c7c fix(streaming): rebuild Anthropic client on stream cleanup instead of OpenAI client
interruptible_streaming_api_call() has three connection-pool cleanup
sites that called _replace_primary_openai_client() unconditionally.
For api_mode=anthropic_messages this has two consequences:

1. _replace_primary_openai_client() fails (OPENAI_API_KEY unset on
   Anthropic-only configs), so dead connections are never purged.
2. The stale-stream detector's outer-poll site (L1977) is the only
   mechanism that can interrupt the worker thread while it blocks in
   for event in stream:. Because the Anthropic client is never closed,
   the thread stays blocked until the 900 s httpx read-timeout fires,
   producing a visible 15-minute hang for Telegram/gateway users on
   claude-opus-4-7.

Fix: mirror the existing interrupt-path pattern (L1989-1997) at all
three cleanup sites — if api_mode == "anthropic_messages", call
_anthropic_client.close() + _rebuild_anthropic_client() instead of
_replace_primary_openai_client(). _rebuild_anthropic_client() handles
both direct Anthropic and Bedrock-hosted Claude correctly, unlike the
inline build_anthropic_client() calls in open PR #14430.

PR #14430 (open) covers only the outer stale-detector site (L1977).
PR #23678 (open) covers only the inner retry sites (L1774, L1833).
This PR covers all three sites and uses _rebuild_anthropic_client()
for Bedrock parity.

Fixes #28161
2026-06-27 19:37:33 -07:00
xxxigmandHermes Agent 6f1a176b33 fix(gateway/discord): REST liveness probe to detect zombie clients (#26656)
The Discord adapter could enter a silent zombie state after a network
outage / proxy stall: the process is alive, _client looks open, but the
underlying socket is dead. discord.py's WebSocket reconnect never sees a
RST through a wedged proxy/NAT, so client.start() spins forever without
exiting — which means the bot-task done callback (which only fires on
task completion) never trips either. The bot stays "offline" in Discord
until a manual `hermes gateway restart`. Reported offline for 13-17h.

Adds an out-of-band REST liveness probe in DiscordAdapter. Every
`discord.liveness_interval_seconds` (default 60s) the adapter issues a
cheap fetch_user(bot_id) — the same REST path as message delivery, so it
fails when the proxy/NAT is wedged. After
`discord.liveness_failure_threshold` consecutive failures (default 3) the
probe closes the wedged client and surfaces a retryable fatal error,
which trips the gateway's existing _platform_reconnect_watcher and
rebuilds the adapter. Operators disable it by setting either knob to 0.

Config lives in config.yaml (discord.liveness_*) per the .env-is-secrets
policy; _apply_yaml_config bridges it to internal env vars the adapter
reads, matching the existing HERMES_DISCORD_TEXT_BATCH_* pattern.

Co-authored-by: Hermes Agent <agent@nousresearch.com>
2026-06-27 19:30:32 -07:00
teknium1 457c8a0a7c fix(file-ops): keep worktree isolation when restoring preserved cwd (#26211)
The durable _last_known_cwd anchor is keyed by the shared 'default' container,
so a non-owning worktree session could inherit the owning session's cwd through
it — breaking the wrong-worktree-routing fix (test_file_tools_cwd_resolution::
test_resolution_routes_to_resolving_sessions_worktree).

Reorder _authoritative_workspace_root so the session-specific registered cwd
override (keyed by raw session id) is checked BEFORE the shared-container
_last_known_cwd fallback. A non-owning session now resolves into its own
registered worktree; the durable anchor only fills in when there's no
session-specific override (the #26211 single-session case). Adds a regression
test covering the owner-mirrors-then-other-session-resolves interaction.
2026-06-27 19:29:06 -07:00
teknium1 b2faeba182 fix(file-ops): make preserved cwd reachable at write-time resolution (#26211)
Belt-and-suspenders on top of the cherry-picked cwd-preservation fix:

- Proactively mirror every live terminal cwd into _last_known_cwd on each
  successful read, so the durable anchor survives even when the cleanup
  thread pops both _file_ops_cache and _active_environments before
  _get_file_ops' stale-cache save branch can fire.
- Fall back to _last_known_cwd in _authoritative_workspace_root. write_file_tool
  resolves the path (via _resolve_path_for_task) BEFORE _get_file_ops rebuilds
  the env, so restoring only the rebuilt env's cwd was insufficient — the
  resolution that decides where the file lands runs first. This closes that gap.

The local env's persisted _cwd_file can't serve this role: it's keyed by a
random per-session uuid and deleted on cleanup (the same cleanup that triggers
the bug). The in-memory _last_known_cwd registry is the durable anchor instead.

Adds a real-IO E2E regression (TestSilentFileMisplacementE2E) exercising the
actual write_file_tool path after env cleanup.
2026-06-27 19:29:06 -07:00
zccyman adeba1d7a8 fix(file-ops): preserve CWD across terminal environment re-creation (#26211)
Root cause: when the terminal environment (`_active_environments` entry) is
cleaned up and re-created during a long conversation, the new environment
always starts with the default config CWD (typically `~/.hermes/hermes-agent`)
instead of preserving the user's last-known working directory. Subsequent
relative-path writes (`write_file`, `execute_code`, shell commands) silently
land in the default CWD, making files appear to be "created but absent."

Fix: add `_last_known_cwd` dict that preserves the old environment's CWD
before the stale cache entry is invalidated. When a new environment is
created for the same task_id, we check `_last_known_cwd` first and use the
preserved CWD instead of the config default.

Changes:
- tools/file_tools.py: add `_last_known_cwd` dict, save CWD before stale
  cache invalidation, restore CWD on env recreation
- tests/tools/test_file_tools.py: add `TestLastKnownCwd` with 2 tests
  verifying CWD preservation and fallback behavior

Fixes #26211
2026-06-27 19:29:06 -07:00
teknium1andStuart Horner 926a1b915d fix(tools): suppress transient check_fn flakes so subagents keep file/terminal tools
A flaky external probe in a tool's check_fn (e.g. check_terminal_requirements
running `docker version` with a 5s timeout, momentarily timing out under load)
would return False for a single get_tool_definitions() call. Because file
tools delegate their check_fn to the terminal check, that one flake silently
stripped read_file/write_file/patch/search_files AND terminal from whatever
agent was being constructed at that instant — most visibly a delegate_task
subagent, which then reported "Tool read_file does not exist". This explains
both the intermittent (~80% success) user-session failures and the
deterministic cron failures in #21658 / #5304.

The existing _check_fn TTL cache made this worse: it cached the transient
False for the full 30s window, poisoning every subagent spawned in that span.

Fix: remember the last time each check_fn returned True; when a fresh probe
fails within a short grace window of that success, treat it as a flake —
serve the last-good True and do NOT cache the failure (so the next call
re-probes). A failure with no recent success, or past the grace window, is
honored normally so a backend that genuinely went down stops advertising its
tools. Probe failures now log at WARNING regardless of quiet mode, making the
previously-silent tool loss diagnosable in subagent (quiet) sessions.

Co-authored-by: Stuart Horner <5261694+djstunami@users.noreply.github.com>
2026-06-27 19:29:00 -07:00
505bc27d8d fix(gateway): classify mixed attachments per-attachment + transcode uncommon image formats
A document attached alongside an image in the same Discord message was
swept into the vision pipeline and 400'd the whole turn ("Could not
process image"), and was simultaneously never surfaced to the agent as a
readable file. Restores the "any file type works" contract for mixed
messages and fixes the HTTP 400.

Bug 1 — mixed attachments: the inbound routing loop keyed image/audio/video
classification off the message-level type (PHOTO/VOICE/AUDIO), so a doc in
a PHOTO message landed in image_paths and poisoned the vision call. The
document context-note path was gated on message_type == DOCUMENT, so that
same doc never reached the agent at all. Now classification is
per-attachment (trust each attachment's own MIME; fall back to the
message-level type only when MIME is unknown), via shared _event_media_is_*
helpers used by both _build_media_placeholder and the main inbound loop.
The document note now fires for any non-image/audio/video attachment
regardless of message-level type.

Bug 2 — uncommon formats: AVIF/HEIC/BMP/TIFF/ICO produced the same generic
400 because providers only accept PNG/JPEG/GIF/WEBP. image_routing now
transcodes those to PNG via Pillow before declaring media_type, skipping
cleanly (logged) if Pillow/plugins are missing. SVG is vector — Pillow
can't rasterize it — so it's skipped rather than transcoded.

Closes #25935.

Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
Co-authored-by: cypres0099 <74935762+cypres0099@users.noreply.github.com>
2026-06-27 19:26:04 -07:00
teknium1 0c372274cd fix(agent): disable OpenAI SDK auto-retry that double-fires inside the rate-limit loop
Same bug class as the Anthropic fix (#26293): the OpenAI/aggregator client is
built without max_retries, so the SDK default of 2 applies. The SDK's own 1-2s
backoff ignores Retry-After and retries inside hermes's outer conversation loop,
burning request slots against a rate-limited bucket. Set max_retries=0 at the
single create_openai_client chokepoint (covers init, switch_model, recovery,
restore, request-scoped). auxiliary_client builds its own clients and is not
wrapped by the loop, so it keeps SDK retries.
2026-06-27 19:23:15 -07:00
konsisumer 1ab35ba25d fix(anthropic): stop SDK auto-retry double-firing and raise Retry-After cap to 600s
The Anthropic SDK clients were built without max_retries, so the SDK
default (max_retries=2) retried 429/5xx with its own backoff that ignores
Retry-After — double-retrying inside hermes's outer loop and burning
request slots against a bucket that won't refill for minutes. Set
max_retries=0 on all Anthropic/AnthropicBedrock client constructions so
the outer conversation loop (which already honors Retry-After) owns retry.

Also raise the Retry-After cap in the conversation loop from 120s to 600s.
Anthropic Tier 1 input-token buckets reset in ~171s, so the 120s cap made
hermes retry before the reset window and re-trip the limit.

Refs #26293
2026-06-27 19:23:15 -07:00
LeonSGP43andHermes Agent 32732a8f83 fix(agent): cap same-entry credential refreshes so fallback can activate (#26080)
A persistent upstream 401 on a single-entry OAuth pool (common for Claude
Max subscribers) made the credential-pool recovery spin forever:
try_refresh_current() re-mints a fresh token and reports success on every
401, so recover_with_credential_pool returned True and the retry loop
continue'd without ever incrementing retry_count or reaching the
auth-failover block. The configured fallback_model never activated and the
agent appeared to hang.

Cap consecutive successful same-entry refreshes (keyed by provider +
pool-entry id) at 2; once exceeded, treat the credential as unrecoverable
and return not-recovered so the loop falls through to
_try_activate_fallback. The 429/billing paths already rotate-or-fall-through
correctly (mark_exhausted_and_rotate returns None on a single entry), so
only the auth-refresh branch needed the cap.

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
2026-06-27 19:20:07 -07:00
Teknium fae920642a fix(agent): throttle cross-turn fallback-switch replay storm (#24996) (#53909)
When every provider in the fallback chain fails non-retryably back-to-back
(e.g. HTTP 400/402/429 across distinct providers), the within-turn walk is
already bounded — _fallback_index advances monotonically and the loop aborts
when the chain exhausts. The damaging mode is cross-turn: restore_primary_
runtime resets _fallback_index=0 every turn, so a client that re-submits
immediately replays the entire chain, re-marshaling the full (potentially
80k-token) context once per provider every turn with no throttle on the
non-rate-limit path. On constrained hosts this exhausts memory/swap.

Rate-limit/billing failures already arm a 60s cooldown via _rate_limited_until;
the gap was the non-rate-limit case. Now, when the chain exhausts on a non-
rate-limit failure with a non-empty chain, arm a short (5s) cooldown on the
same _rate_limited_until gate (max(), never shrinking an existing window).
The next turn's restore stays gated and does NOT reset the index, so the
chain isn't replayed until the cooldown clears. No new state, no thread sleep,
no false-trip on legitimately long chains (those walk normally within a turn).

Tests: tests/run_agent/test_24996_fallback_exhaustion_cooldown.py
2026-06-27 19:15:40 -07:00
Chaz Dinkle 1dde7e2f2a fix(anthropic): adopt Claude Code's already-refreshed token before racing refresh
Claude Code OAuth refresh tokens are single-use; Claude Code refreshes on
its own schedule, so by the time Hermes notices an expired token Claude
Code may have already rotated it. Re-read live credential sources first and
adopt a valid token rather than POSTing a possibly-stale refresh token.

Ports the _refresh_oauth_token hardening from PR #40107 (chazmaniandinkle)
on top of the keychain/file reconciliation from PR #21112 (nodejun).
Adds AUTHOR_MAP entry for nodejun.
2026-06-27 19:14:43 -07:00
jun 5a5396aecb fix(anthropic): reconcile keychain/file credentials when one is expired
read_claude_code_credentials() previously returned the macOS Keychain
entry as soon as one existed, even if its OAuth token was already
expired. Callers then ran is_claude_code_token_valid() on the result
and got False, so resolve_anthropic_token() returned None — surfacing
the misleading 'No Anthropic credentials found' error even when
~/.claude/.credentials.json held a perfectly valid token.

Now reads both sources and prefers the non-expired one. When both are
valid (or both expired), prefers the later expiresAt so any subsequent
refresh uses the freshest refresh_token.

Adds TestReadClaudeCodeCredentialsDesync covering the four reconciliation
cases. The existing 'keychain wins' priority test still passes because
both fixtures share the same expiresAt and the tiebreaker is >=.
2026-06-27 19:14:43 -07:00
Teknium db16854f34 fix(telegram): surface failed media downloads to user and agent, not a silent empty turn (#53912)
When a Telegram attachment download/cache fails (typically a transient
httpx.ConnectError to Telegram's CDN), the except handler logged a warning
and fell through to handle_message() with empty media and no text — the user
thought the file was delivered, the agent saw a content-less turn with no
signal an attachment was attempted, and the only record was a buried log line.

Adds _surface_media_cache_failure(): replies to the user in Telegram so they
know to retry, and appends an agent-visible notice to event.text via the
existing _append_observed_note channel so the agent knows an attachment was
attempted and failed. No new event fields (structured-event refactor is out
of scope per #23045). Wired into all five cache-failure sites — photo, voice,
audio, video, document — since they shared the identical silent fall-through.

Bug 1 from #23045 (unsupported types routed as fake user messages) no longer
exists on main: the document handler now accepts any file type, so there is no
rejection branch to fix.

Closes #23045
2026-06-27 19:12:57 -07:00
teknium1 4133cd9fbf docs(infographic): eager fallback on persistent transport failures 2026-06-27 19:12:21 -07:00
teknium1 6514be5a28 chore(release): add AUTHOR_MAP entry for linyubin (#50228 salvage) 2026-06-27 19:12:21 -07:00
linyubinandHermes Agent c946e6709f fix(agent): activate fallback on persistent transport failures (#22277)
Eager fallback previously fired only on rate_limit/billing. A stale-
detector-killed hung stream classifies as FailoverReason.timeout
(retryable=True) and the retry loop re-hit the same dead primary until
the budget exhausted -- 3 x ~180-300s stale kills compounding into a
15+ min silent hang while the configured fallback chain sat idle.

Extend the existing eager-fallback gate to also cover timeout and
overloaded, but only after one real retry (retry_count >= 2) so genuine
transient hiccups still recover on the primary. Reuses the same
pool-recovery guard and state-reset as the rate_limit branch -- no new
config flag, no change to the rate-limit intent.

Salvaged from PR #50228 by @linyubin. Closes #22277.

Co-authored-by: Hermes Agent <127238744+teknium1@users.noreply.github.com>
2026-06-27 19:12:21 -07:00
bykim0119 851f75d4df fix(discord): honor "*" wildcard in DISCORD_ALLOWED_USERS (#22334)
DISCORD_ALLOWED_USERS="*" now means "allow everyone", matching the
SIGNAL_ALLOWED_USERS / DISCORD_ALLOWED_CHANNELS wildcard convention and
the value `claw migrate` emits. Previously _is_allowed_user did exact
ID matching only, so "*" matched no user and blocked every non-self
sender — a P1 with no workaround.

Three sites, all required for the fix to hold at runtime:
- _is_allowed_user: short-circuit when "*" is in the allowlist.
- connect(): exclude "*" from the intents.members trigger so the
  wildcard does not request the privileged Server Members intent
  (which can block the bot from coming online).
- _resolve_allowed_usernames: preserve "*" verbatim; otherwise it lands
  in the username-resolution bucket, matches no member, and is silently
  dropped from the set and env var on the first on_ready — quietly
  undoing the fix.

Slash auth delegates to _is_allowed_user (auto-covered); component auth
already honors "*" on main.
2026-06-27 19:11:30 -07:00
Teknium 1207d81eed fix(gateway): unify outbound chat redaction onto authoritative redactor (#23810) (#53907)
The gateway banner promises 'chat responses are scrubbed before delivery',
but _redact_gateway_user_facing_secrets used a divergent 6-pattern subset that
leaked credential shapes the comprehensive agent.redact catches — notably the
GitHub fine-grained PAT (github_pat_...) and the Telegram bot-token shape
(bot<digits>:<token>), the gateway's own credential type.

_redact_gateway_user_facing_secrets now delegates to
agent.redact.redact_sensitive_text(force=True) — the same Tirith-grade redactor
already applied to logs, tool output, and approval-command prompts — so the
outbound LLM-response path (final_response -> _sanitize_gateway_final_response)
masks the full credential set. The narrow local pattern set is kept as a
fail-soft second pass. force=True honors redaction even when
security.redact_secrets is off, matching _redact_approval_command.

Test: regression guard parametrizing all 5 issue shapes x every chat surface;
asserts secret body never reaches the user and surrounding prose survives. The
existing bearer-token test's marker assertion is loosened from the literal
'[REDACTED]' to mask-agnostic (the redactor masks as '***'/partial) — it
asserts the security invariant, not the implementation's mask string.
2026-06-27 19:09:41 -07:00
LeonSGP43andambition0802 c56b39c11e fix(auxiliary): fall back to OPENROUTER_API_KEY when credential pool exhausted
_try_openrouter() returned (None, None) whenever an OpenRouter credential
pool existed but was exhausted (_select_pool_entry -> (True, None)), making
the OPENROUTER_API_KEY env-var fallback unreachable. Auxiliary tasks
(compression, vision, web_extract) silently failed even with a valid env key.

Now the pool-present branch only returns early when it successfully builds a
client; an exhausted pool falls through to the env-var path. The final
failure (pool exhausted AND no env var) still marks the provider unhealthy.

Fixes #23452.

Co-authored-by: ambition0802 <noreply@github.com>
2026-06-27 19:09:27 -07:00
qWaitCrypto 46e18804ad fix(auxiliary): fall back on 401 auth errors in auto mode (#21165)
When the primary provider returns 401 and the auth-refresh path is
unavailable or fails, both call_llm() and async_call_llm() reached the
should_fallback gate without _is_auth_error in the condition, so the
auxiliary task (e.g. compression) was dropped silently — losing message
history. Add _is_auth_error to should_fallback (NOT is_capacity_error) in
both sync and async paths, plus an 'auth error' reason branch.

Auth stays a non-capacity error: it falls back in auto mode via the
is_auto gate, but on an explicitly-configured provider it still respects
the user's choice and raises rather than silently switching providers.
2026-06-27 19:07:04 -07:00
Teknium 1a570dae00 fix(image-routing): unblock message queue on OpenRouter 'no endpoints' image 404 (#53901)
The agent's image-rejection fallback strips images and retries text-only when
a provider rejects image content, which is what lets the gateway drain its
queued messages. The fallback only fires on a hardcoded phrase list, and the
OpenRouter wording — HTTP 404 'No endpoints found that support image input' —
was missing. For OpenRouter-routed non-vision models the fallback never fired,
the retry loop re-sent the same rejected request until exhaustion, and every
subsequent message (including plain text) stayed queued behind the stuck turn.

Add the phrase to _IMAGE_REJECTION_PHRASES (the 404 already passes the 4xx
gate). Add a positive test and a guard test so the sibling OpenRouter
'no endpoints ... data policy / guardrail' 404s do NOT get their images
stripped.

Fixes #21160. Reported by @liu14goal14-ux; PR #21198 by @ygd58.
2026-06-27 19:07:02 -07:00
Teknium a94f657a50 fix(tui): route completion RPCs to the pool so they can't freeze the TUI (#53895)
complete.path and complete.slash ran inline on the tui_gateway stdin
reader thread. complete.path spawns git ls-files and fuzzy-ranks the
whole repo; complete.slash does first-call prompt_toolkit imports plus a
skill-dir scan. While either ran, prompt.submit / session.interrupt sat
unread in the stdin pipe, freezing the TUI until the 120s RPC timeout
fired — most reliably reproduced by typing @ on a large repo / WSL2 mount.

Add both to _LONG_HANDLERS so completion runs on the existing thread
pool (write_json is already _stdout_lock-guarded). Root-cause fix:
covers any slow completion, not just the bare-@ trigger.

Fixes #21123
2026-06-27 19:06:01 -07:00
teknium1andhappy5318 ccf526964a fix(gateway): bound adapter teardown awaits on the stop path (#14128)
The main stop loop in _stop_impl() awaited adapter.cancel_background_tasks()
and adapter.disconnect() with no timeout, for both the primary and the
secondary-profile (multiplex) adapter maps. A half-dead platform — a wedged
Feishu/Lark WebSocket thread blocked on network I/O is the reported case —
makes one of those awaits block forever, so the process never exits. systemd
then SIGKILLs it after TimeoutStopSec, skipping atexit PID-file cleanup, and
the next start dies with 'PID file race lost' and enters a restart loop.

The per-adapter timeout infra already existed on main
(_adapter_disconnect_timeout_secs / HERMES_GATEWAY_ADAPTER_DISCONNECT_TIMEOUT,
default 5s) but was only wired into _safe_adapter_disconnect, which the
teardown path never calls.

Add _bounded_adapter_teardown(): wraps BOTH cancel_background_tasks() and
disconnect() in the existing timeout budget, logs and forces forward progress
on timeout, and never raises. Both teardown loops now route through it, so the
stop sequence always completes regardless of any adapter's internal behavior
and PID-file cleanup runs.

Original report + fix direction by @happy5318 (#14128, #14130); this widens it
to cover cancel_background_tasks(), the multiplex loop, and the config knob.

Co-authored-by: happy5318 <happy5318@users.noreply.github.com>
2026-06-27 19:05:04 -07:00
Teknium 6717cfc805 docs(gateway): warn against custom ExecStopPost kill drop-in (restart loop) (#53903)
A user-added systemd drop-in like ExecStopPost=/bin/kill -9 $MAINPID fires
on every stop, including clean restarts — it SIGKILLs the freshly spawned
gateway before it stabilizes and Restart=always respawns it, producing an
infinite restart loop (issue #23272). The unit Hermes installs already shuts
down cleanly via KillMode=mixed + KillSignal=SIGTERM with Restart=always +
RestartForceExitStatus, so no extra kill is needed. Document this as a danger
callout in the gateway service-management section.
2026-06-27 19:04:29 -07:00
teknium1 ea8facee81 chore(release): add konsisumer to AUTHOR_MAP for PR #19608 salvage 2026-06-27 19:01:37 -07:00
konsisumer 8b4c29f0f0 fix(auth): preserve concurrently-added credentials on pool rewrite 2026-06-27 19:01:37 -07:00
Teknium 163cb24d45 feat(moa): render reference-model blocks in TUI and desktop, not just CLI (#53855)
The MoA reference-block display (each reference model's output shown as a
labelled thinking block before the aggregator responds) previously existed
only in the classic CLI. The facade already emits moa.reference / moa.aggregating
through tool_progress_callback; this wires the TUI and desktop consumers.

- tui_gateway/server.py: _on_tool_progress relays moa.reference (label / text /
  index / count) and moa.aggregating to the Ink/desktop client as their own
  events.
- ui-tui: gatewayTypes adds the two event shapes; createGatewayEventHandler
  routes them; turnController.recordMoaReference pushes a committed
  thinking-style segment tagged with the source model. Shown regardless of
  showReasoning — references ARE the mixture-of-agents process the user opted
  into, not ordinary reasoning. moa.aggregating is a status-only transition
  (no transcript entry).
- apps/desktop: use-message-stream appends each reference as a labelled
  reasoning chunk via the existing reasoning disclosure; GatewayEventPayload
  gains label/index/aggregator.

Tests: tui_gateway emit (3), Ink handler render + showReasoning-independence +
aggregating-no-segment (3). TUI typecheck/lint clean; desktop typecheck/lint
clean.
2026-06-27 18:46:20 -07:00
Teknium d3d621f7c3 revert(windows): roll back terminal-popup PRs #53791 #53810 #53829 (#53853)
* Revert "fix(windows): capture is not a no-window boundary; route flashing spawns through chokepoint (#53829)"

This reverts commit 2ecca1e7d3.

* Revert "fix(windows): stop terminal-window popups from background spawns (#53810)"

This reverts commit 5db1430af9.

* Revert "fix(windows): stop subprocess console-window popups + add CI guard (#53791)"

This reverts commit ef17cd204d.
2026-06-27 15:59:00 -07:00
Teknium 1d32e5d98c fix(gateway): relay _thinking bubbles when thinking_progress is on but tool_progress is off (#53849)
display.thinking_progress is documented as independent of tool_progress —
users can keep tool progress quiet while opting into mid-turn assistant
scratch-text bubbles. But two gates were keyed on tool_progress_enabled alone,
so with tool_progress:off the _thinking relay was silently dead even when
thinking_progress:true:

1. agent.tool_progress_callback was set to None unless tool_progress_enabled,
   so the callback that queues _thinking text never fired.
2. The send_progress_messages drain task was only started when
   tool_progress_enabled, so even queued messages had no consumer.

Both now gate on needs_progress_queue (tool_progress OR thinking_progress) —
the same condition that already decides whether to create the progress queue
at all. No effect when both are off (queue is None) or when tool_progress is
on (unchanged).

Tests: _thinking relays with thinking_progress:on/tool_progress:off, and is
suppressed when thinking_progress:off. Full progress-topics suite: 35 pass.
2026-06-27 15:48:20 -07:00
Teknium 2ecca1e7d3 fix(windows): capture is not a no-window boundary; route flashing spawns through chokepoint (#53829)
Follow-up to #53791 addressing review feedback: the footgun checker treated
capture_output=/stdout=/stderr=/check_output as proof a subprocess can't pop a
Windows console. That invariant is false — stream redirection controls where a
child's output goes, not whether a console is allocated. From a console-less
parent (Desktop/Electron, pythonw.exe, detached gateway/cron) a console-subsystem
child still flashes a window even when fully captured.

- check-windows-footguns.py: capture/redirect/check_output is no longer a blanket
  safe-pass. Added _WINDOWS_FLASHING_PROGRAMS (git/gh/npm/node/python/uv/ffmpeg/
  docker/powershell/…); calls to those are flagged even when captured. Non-flashing
  programs keep the capture exemption (no 271-site noise). _subprocess_compat.run/
  popen calls are inherently safe (wrapper injects CREATE_NO_WINDOW).
- Routed the 35 genuine flashing git/gh/npm/uv/ffmpeg/docker spawns through the
  _subprocess_compat.run/popen chokepoint (Brooklyn's wrapper from #53810) — the
  durable fix, not per-site annotations. cmd.exe /c start stays # ok (intentional).
- Updated tests + CONTRIBUTING.md rule #17 to the corrected invariant.
2026-06-27 14:49:41 -07:00
Teknium 3ac96d3308 fix(moa): resolve auxiliary tasks to the aggregator, not the preset name (#53827)
On a MoA session, auxiliary tasks (title generation, compression, vision, …)
ran through _resolve_auto with provider='moa' / model='<preset>', which sent
the preset name (e.g. 'opus-gpt') as the model id to resolve_provider_client —
producing 'HTTP 400: opus-gpt is not a valid model ID' on every turn (visible
as the title-generation warning).

MoA is a virtual provider with no real HTTP endpoint; aux tasks don't need the
reference fan-out. _resolve_auto now resolves a 'moa' main provider to the
preset's aggregator slot (its acting model) and continues Step 1 with that real
provider+model, dropping the virtual moa://local base_url + placeholder key so
the aggregator resolves via its own provider credentials. Mirrors the MoA
context-length resolution.

Verified live: a MoA turn no longer emits the 'not a valid model ID' warning.
Test: tests/agent/test_auxiliary_main_first.py (19 pass).
2026-06-27 14:21:26 -07:00
Gille e7bb67332d fix(moa): preserve Codex slot routing 2026-06-27 14:20:51 -07:00
Gille 66aeda3550 fix(moa): keep virtual provider on MoA client 2026-06-27 14:20:51 -07:00
brooklyn! 5db1430af9 fix(windows): stop terminal-window popups from background spawns (#53810)
* fix(windows): stop terminal-window popups from background spawns

Native-Windows desktop/gateway users saw cmd/conhost windows flash on
gateway restart, image paste, the dashboard Projects tree, voice notes,
and ~5 min after closing the app (detached cron). Two root causes:

- Console-subsystem exes (taskkill, schtasks, wmic, netstat, tasklist,
  agent-browser, git, ffmpeg, powershell, git-bash) spawned via raw
  subprocess allocate a fresh console when the launching process has
  none (pythonw desktop backend / detached gateway) - even with output
  captured.
- uv venv pythonw shims re-exec console python.exe, so Python children
  get a console regardless of how they're launched.

Fixes:
- Single hidden-spawn primitive (_subprocess_compat.run/.popen) that ORs
  CREATE_NO_WINDOW on Windows, no-op on POSIX. Route every Hermes-owned
  console-exe spawn through it.
- FreeConsole() catch-all in hermes_bootstrap: any Python child that
  exclusively owns an auto-allocated console detaches it at startup
  (GetConsoleProcessList()==1 gate leaves shared interactive consoles
  untouched).
- Replace PowerShell/wmic gateway PID scans with in-process psutil.
- Skip schtasks queries on non-interactive desktop restarts.
- Prefer native agent-browser .exe over .cmd shims.
- Guard test bans raw subprocess spawns of the Windows-only console
  tools repo-wide so the popup class can't regress.

* fix(windows): scope FreeConsole to background entry points; fix merge fallout

Console detach review (per #53810 feedback): GetConsoleProcessList()==1 can't
tell a uv pythonw->python phantom console apart from a user opening the
interactive CLI/TUI in its own fresh console (double-click, shortcut, ConPTY) —
both report a single attached process with a tty. Running FreeConsole() in the
import-time bootstrap therefore risked detaching a legitimately-interactive
terminal.

- Extract FreeConsole into explicit hermes_bootstrap.detach_orphan_console();
  remove it from apply_windows_utf8_bootstrap() (import side effect).
- Call it only from known background mains: gateway run, dashboard backend
  (start_server, what the desktop spawns), cron standalone, tui_gateway entry,
  slash worker. Interactive CLI/TUI never calls it.
- Behavior-contract tests: frees only when solo owner, leaves shared console,
  no-op without console / on POSIX, and asserts it's not an import side effect.

Merge fallout from origin/main (#53791):
- local.py: 3-way merge left a dangling **_popen_kwargs (NameError crashing
  every terminal init). _subprocess_compat.popen already hides the window, so
  drop it.
- discord adapter: merge stacked an undefined windows_hide_flags() onto the
  primitive call; drop the redundant arg.
- test_gateway: scan now goes psutil-first (zero spawn); rewrite the
  case-variant test to drive that production path.

* test(claw): mock _subprocess_compat.run seam for Windows process scan

claw.py's Windows tasklist/powershell scan routes through the hidden-spawn
primitive; the tests still patched claw_mod.subprocess, so on win32 the mock
was never hit and real spawns returned nothing. Patch the actual seam.
2026-06-27 14:02:24 -07:00
Teknium ef17cd204d fix(windows): stop subprocess console-window popups + add CI guard (#53791)
* fix(windows): stop subprocess console-window popups + add CI guard

The single biggest source of Windows 'terminal popup' bug reports was bare
subprocess.run/Popen calls spawning a console window. The compat helpers
(windows_hide_flags / windows_detach_popen_kwargs) already existed but the
footgun checker had no rule to stop new bare calls from reintroducing the flash.

- scripts/check-windows-footguns.py: new AST-based rule flagging subprocess
  calls that can create a new console — output-redirection-aware (capture/
  redirect/check_output exempt) and POSIX-only-program-aware (launchctl/
  systemctl/brew/etc. exempt). Comprehensive on real popups, no annotation
  burden on calls that can't flash.
- Swept all genuine window-spawning sites through windows_hide_flags()/
  windows_detach_popen_kwargs(); marked intentionally-visible launches
  (editor/terminal/foreground re-exec) with '# windows-footgun: ok'.
- tests/scripts/test_windows_footgun_subprocess_rule.py: behavior-contract
  tests + full-repo cleanliness invariant.
- CONTRIBUTING.md: documents the rule + the helper pattern.

* test: accept creationflags kwarg in psutil_android fake_subprocess_run

The Windows no-window sweep added creationflags=windows_hide_flags() to
install_psutil_android.py's subprocess.run call; the test's fake stub had a
fixed (cmd) signature and raised TypeError on the new kwarg.
2026-06-27 13:03:51 -07:00
Teknium 3b44a3c8bb feat(moa): show each reference model's output as a labelled block before the aggregator (#53793)
When a MoA preset is selected, each reference model's answer now renders in the
CLI as a thinking-style block labelled with its source model, BEFORE the
aggregator responds — so the mixture-of-agents process is visible instead of a
silent pause. The aggregator's response (and its tool actions) follow as normal.

Mechanism (shared seam, all surfaces):
- MoAChatCompletions/MoAClient take an optional reference_callback and emit
  'moa.reference' (index/count/label/text) per reference, then 'moa.aggregating'
  (aggregator label) once. agent_init wires this to the agent's
  tool_progress_callback, which every surface already consumes — so the events
  reach CLI/TUI/desktop/gateway with no new plumbing.
- CLI _on_tool_progress renders 'moa.reference' as a labelled '┊ ◇ Reference
  i/n — <model>' header + a thinking-style preview (reusing _emit_reasoning_
  preview), and 'moa.aggregating' as a spinner transition. Display-only; never
  touches message history (cache-safe).

Turn-scoped reference cache: the agent loop calls the facade once per tool-loop
iteration, but the advisory message view is identical across iterations within a
turn, so references are now run AND displayed once per user turn (keyed by the
advisory view's signature) instead of re-running/re-spamming on every iteration.
This also cuts reference API cost from O(iterations) back to O(turns).

Verified live via interactive PTY on the opus-gpt preset (gpt-5.5 + opus refs):
reference blocks render once per turn, labelled by model, before the aggregator;
fresh blocks on each new turn; aggregator tool actions still execute.

Follow-up: TUI/desktop rich rendering + gateway batched-summary already receive
the events via tool_progress_callback; their surface-specific renderers are a
separate change.
2026-06-27 12:45:23 -07:00
Dale Nguyen dbbf102b8e fix(terminal): strip VIRTUAL_ENV/CONDA_PREFIX from terminal subprocess env
The Hermes gateway runs inside its own venv, so its process environment
carries VIRTUAL_ENV (and possibly CONDA_PREFIX). The terminal tool spawned
subprocesses inheriting those markers. When the agent ran `uv sync`,
`uv pip install`, `poetry install`, etc. in ANY other project directory,
those tools honored the inherited VIRTUAL_ENV and rebuilt/synced that
project's dependencies into the Hermes venv path — wiping Hermes' own runtime
deps (and, when the other project pinned a different Python, replacing the
interpreter), bricking the gateway on the next restart (#23473).

Strip VIRTUAL_ENV/CONDA_PREFIX in both subprocess-env construction points in
tools/environments/local.py — `_sanitize_subprocess_env` and `_make_run_env`
— via a shared `_ACTIVE_VENV_MARKER_VARS` constant. The Hermes venv stays
reachable because its bin dir is already first on PATH, so removing the
active-environment markers is safe and only prevents the cross-project clobber.

Adds TestActiveVenvMarkerStripping: end-to-end (markers in os.environ don't
reach the spawned subprocess) and unit coverage for both functions, plus a
guard on the marker constant.

Also adds the AUTHOR_MAP entry for the salvaged contributor.

Closes #23473
2026-06-28 01:04:20 +05:30
Teknium d470ed0c4c fix(cli): commit tool scrollback lines in verbose mode (non-streaming/MoA) (#53785)
In the interactive CLI, the aggregator's tool calls under a MoA preset (or
any non-streaming model call, e.g. copilot-acp) appeared to overwrite each
other instead of building scrollable history. Each tool only updated the
transient spinner line; no committed scrollback line was printed.

Root cause: persistent tool lines in _on_tool_progress's tool.completed
branch were gated on tool_progress_mode in {all, new}, omitting 'verbose'.
Streaming models hid the bug because _on_tool_gen_start commits a 'preparing'
line per tool during streaming; non-streaming calls (MoA forces
_use_streaming=False) never emit that, so under 'verbose' there was no
committed line at all — only the self-overwriting spinner.

'verbose' is strictly more than 'all', so it now commits the same scrollback
line. Verified live via interactive PTY on the MoA opus-gpt preset: three
terminal calls in turn 1 and two in turn 2 each render as separate persistent
lines.
2026-06-27 12:29:55 -07:00
Teknium 227e6c0143 fix(moa): resolve context window from the aggregator, not the 256K default (#53780)
A MoA session's model is the preset name (e.g. 'opus-gpt') and its base_url is
the virtual local endpoint, so get_model_context_length() missed every probe
and fell through to the 256K fallback — even when the aggregator is a 1M-context
model. The acting model in MoA IS the aggregator, so resolve the context window
from the aggregator slot's real provider+model.

- model_metadata.get_model_context_length: when provider=='moa', resolve the
  preset's aggregator slot through resolve_runtime_provider and recurse with the
  aggregator's real provider/model/base_url. Explicit model.context_length still
  wins (checked first); falls through to the generic default if resolution fails.

Tests: opus-gpt preset now reports 1M (the aggregator window), config override
still honored.
2026-06-27 12:08:09 -07:00
ailthrim 25ec01f79f fix(desktop): don't purge Electron cache / mirror-retry after a late build failure
`hermes desktop` / `hermes update` recover from a corrupt Electron download by
purging the cached zip + re-downloading and retrying the pack, and then by
falling back to a public mirror. That recovery is only meaningful when the
packaged executable is MISSING — the signature of a partial/corrupt unpack.

A LATE failure such as macOS code signing (#40187) leaves
`Hermes.app/Contents/MacOS/Hermes` (or the platform equivalent) in place.
Re-downloading Electron can't repair a signing failure, so the purge +
slow mirror retry just grind through another identical failure before the
build finally errors out.

Gate both recovery blocks on `_desktop_packaged_executable(desktop_dir) is None`
so a build that already produced the executable fails fast instead of
triggering the destructive download recovery. The corrupt-download path
(executable missing) is unchanged.

Salvage of #42782, re-applied onto current main (the surrounding recovery was
refactored to `_electron_dist_ok` / `_redownload_electron_dist` since the PR
was opened). Adds a regression test asserting no purge / mirror retry runs when
the executable exists, and updates the existing retry/mirror tests to model the
corrupt-download case (executable absent) the recovery is actually for.

Related to #40187 (the residual cache-purge sub-issue; the signing failure
itself is fixed by #52591).
2026-06-28 00:29:34 +05:30
teknium1 1ef19bad90 fix(model): show MoA preset picker on selection and label MoA in the banner
Selecting 'Mixture of Agents' in the `hermes model` provider picker fell
through silently — select_provider_and_model had no moa branch, so it just
reprinted the current model/provider summary and exited. And the CLI session
banner rendered the bare preset name (e.g. 'opus-gpt · Nous Research'),
which is meaningless out of context.

- Add _model_flow_moa: always lists the available presets (even one), then
  prints the full reference-models + aggregator breakdown for the selection
  and persists model.provider=moa / model.default=<preset> (dropping stale
  base_url + endpoint creds, since moa is a virtual local provider).
- Wire the branch into select_provider_and_model.
- build_welcome_banner takes provider; when 'moa' it renders
  'MoA: <preset> · agg <aggregator>' instead of a bare slug. Both CLI call
  sites pass self.provider.

Tests: 2 new banner tests (moa + non-moa unchanged); E2E verified the picker
persists the preset and clears stale base_url/api_key.
2026-06-27 11:45:07 -07:00
konsisumer 1b6ebb24c0 fix(agent): validate OpenRouter provider sort before request dispatch 2026-06-27 11:43:08 -07:00
Teknium 27322612b4 fix(update): route loud build/installer output to update.log instead of the terminal (#53616)
* fix(update): route loud build/installer output to update.log instead of the terminal

hermes update flooded the terminal with the full vite asset dump,
electron-builder logs, npm deprecation warnings from the desktop build,
and the cua-driver installer's 'Next steps' wall. All of that is
low-signal noise the user doesn't need on a successful update.

- Capture the desktop --build-only subprocess (vite + electron-builder)
  into ~/.hermes/logs/update.log; print a one-line status, and on
  failure surface the last 15 lines + a pointer to the full log.
- Capture the cua-driver installer's output when verbose=False (the
  hermes update refresh path); concise upgrade line is unchanged.
- Add _log_only_write() / _run_logged_subprocess() helpers that write to
  the update.log handle without echoing to the terminal.

The repo-root npm install keeps streaming (capture_output=False) — that
is the deliberate #18840 guard so a slow postinstall download doesn't
look hung. The desktop npm install is a separate Electron process with
no such progress concern and is captured.

* fix(update): persist full cua-driver installer output to update.log

The captured cua-driver installer output was only sent to logger.debug
(agent.log) on failure, so the 'Next steps' wall was lost from
update.log entirely on success. Write the full captured output straight
to the update.log handle (sys.stdout._log) on both success and failure,
matching the desktop-build capture, so update.log keeps the complete
record of everything an update did.
2026-06-27 11:43:01 -07:00
ethernet f53b184c48 fix(ci): pass secrets down to docker workflows 2026-06-27 09:53:28 -07:00
Teknium 190e1ffac9 fix(redact): mask passwords in lowercase/dotted config keys (#53590)
The secret redactor only matched uppercase env-style keys ([A-Z0-9_]),
so config-file assignments like spring.datasource.password=secret,
app.api.key=xyz, and YAML password: secret leaked verbatim when the
agent ran cat/grep on application.properties or .env files (issue #16413).

Adds three case-insensitive config-key matchers that run only in a
config-file context, preserving the existing #4367 (lowercase code/prose)
and web-URL-passthrough carve-outs:
  - _CFG_DOTTED_RE: namespaced keys (contain a dot) — unambiguously config
  - _CFG_ANCHORED_RE: bare secret-word keys at line start (incl. export)
  - _YAML_ASSIGN_RE: unquoted colon config (password: value)
Value capture stops at whitespace and '&' so form bodies stay pair-wise;
the '://' guard keeps intentional web-URL query-param passthrough intact.

Reported-by: Murtaza1211
2026-06-27 04:43:28 -07:00
Teknium 917f6bdb00 fix(tools): let vision pick any provider+model, not just OpenRouter (#53606)
* fix(tools): let vision pick any provider+model, not just OpenRouter

hermes tools → configure → vision no longer forces an OPENROUTER_API_KEY.
It now offers the same any-provider surface as the model command: Auto
(use main model / aggregator fallback), pick any authenticated provider +
model, or a custom OpenAI-compatible endpoint. Selections persist to
auxiliary.vision.{provider,model,base_url} — the keys the vision resolver
already reads. Custom endpoint pins provider=custom so base_url routes
correctly. Reconfigure path uses the same picker instead of re-prompting
for OPENROUTER_API_KEY.

* docs: add PR infographic for vision any-provider picker
2026-06-27 04:41:42 -07:00
Brandon Zarnitz 9c81c938d3 fix(approval): honour tirith_fail_open=false on Tirith ImportError (#20733)
check_all_command_guards() swallowed ImportError from tools.tirith_security
with an unconditional pass, leaving tirith_result["action"] as "allow"
regardless of security.tirith_fail_open.  When an operator sets
tirith_fail_open: false they have explicitly opted into fail-closed
behaviour; a missing or broken Tirith module must not silently permit
command execution.

Inside the except ImportError handler, read the live security config.
When tirith_enabled is true and tirith_fail_open is false, synthesise a
"warn"-action Tirith result so the command flows through the normal
approval path (prompt the user, or block in cron/gateway contexts)
instead of bypassing it.  The default tirith_fail_open: true behaviour
is unchanged.

Adds three regression tests to tests/tools/test_approval.py:
- fail_open=true  + ImportError → silently allowed (no regression)
- fail_open=false + ImportError → approval callback invoked, command denied
- tirith_enabled=false           → always allowed regardless of fail_open

Fixes #20733

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

# Conflicts:
#	tests/tools/test_approval.py
2026-06-27 04:41:24 -07:00
Teknium fe1c1c1121 fix(session_search): demote cron below interactive sessions in discover ranking (#53597)
Cron jobs accumulate large volumes of repetitive vocabulary (recurring
project names, dates, summaries) and out-number a user's interactive
sessions. Under bare BM25 they dominate the top FTS rows, so discover's
early-exit-at-N dedup collects only cron sessions and the user's own
conversations never surface — "recall blindness" (#19434).

- _order_for_recall() stable-sorts FTS rows so interactive sources rank
  above cron before lineage dedup; within each class BM25/recency order
  is preserved. Cron is demoted, not excluded, so it still surfaces when
  it is the only match.
- raise discover scan limit 50 -> 300 so buried interactive matches are
  in hand for the demotion pass.

Fixes the cron-flooding sub-bug of #19434. The split-brain sub-bug is
covered by #52798; the child-session sub-bug is superseded by in-place
compaction.
2026-06-27 04:41:22 -07:00
Teknium cd592c105c feat(send_message): native WhatsApp media delivery via Baileys bridge (#53598)
send_message with MEDIA:/path to a WhatsApp target previously dropped the
attachment: the WhatsApp branch never passed media_files, the plugin's
_standalone_send accepted the param but only POSTed text, and WhatsApp was
absent from the media-supported platform list.

- send_message_tool: add a Platform.WHATSAPP media block (mirrors Feishu) that
  routes media_files through the whatsapp plugin's standalone_sender_fn, and
  add whatsapp to the supported-media list strings.
- whatsapp adapter: _standalone_send now sends text first (skipped when the
  chunk is media-only), then uploads each file via the bridge /send-media
  endpoint with a mediaType derived from extension/is_voice/force_document, so
  images/videos/voice arrive as native bubbles instead of documents.
- _bridge_media_type classifier maps ext -> image|video|audio|document.

Closes #19105 (remaining send_message gap). Other items in the report
(inbound video paths, image_generate auto-deliver, history dedup, native
gateway bubbles) already landed on main.
2026-06-27 04:40:05 -07:00
88c02469cc fix(mcp): never permanently wedge the circuit breaker on a dead transport (#53599)
A long-running gateway session could permanently lose an MCP server: once a
stdio subprocess died (or transient drops accumulated over the session), the
run loop exhausted its reconnect budget and returned, orphaning the task. With
no listener for _reconnect_event, the circuit breaker's half-open probe could
never revive the server — every probe hit a dead/absent session, re-armed the
60s cooldown, and looped forever until a full gateway restart (#16788).

Root cause was split ownership of transport liveness between the run loop and
the tool handler, plus a permanent give-up path. Fixed by one invariant: a
non-shutdown server task is always reconnectable.

- run loop parks (deregisters phantom tools, then awaits _reconnect_event)
  instead of returning when the reconnect budget is exhausted, so the task
  stays alive as a dormant listener
- retry budget resets on every successful (re)connect, so a healthy
  long-lived server can't accumulate lifetime drops into a death sentence
- half-open probe with no live session signals a reconnect (reviving a
  parked/dead task and respawning a dead stdio subprocess) and returns a
  clean 'reconnecting' error instead of writing into a dead pipe
- breaker resets on successful session init across all transports
  (stdio/HTTP/SSE) — fully transport-agnostic, no PID/pipe polling

Builds on the closed-PR cluster for this issue: keeps #49255's deregister-on-
exhaustion insight and #21006's signal-don't-probe insight, discards the racy
os.kill PID machinery.

Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
Co-authored-by: srojk34 <srojk34@users.noreply.github.com>
2026-06-27 04:39:54 -07:00
r266-tech dbc925b755 Guard oversized Telegram video downloads 2026-06-27 04:39:48 -07:00
Teknium 02b32e2d7c fix(moa): call reference + aggregator models through their provider's real route (#53580)
MoA was calling reference and aggregator models through a bare
call_llm(provider=slot["provider"], model=slot["model"]) with a forced
temperature and a forced max_tokens (the preset's hardcoded 4096). That left
base_url/api_key/api_mode unresolved — so the auxiliary auto-detector guessed
the API surface instead of using the provider's real runtime, and the 4096 cap
truncated long aggregator syntheses.

A MoA slot is just a model selection and must be called the same way any model
is called elsewhere. Each slot is now resolved through resolve_runtime_provider
(the canonical provider→api_mode/base_url/api_key resolver the CLI, gateway, and
delegate_task all use) via a new _slot_runtime() helper, and the resolved
endpoint is passed into call_llm. So a reference/aggregator gets its provider's
actual API surface — MiniMax → anthropic_messages, GPT-5/o-series →
max_completion_tokens, custom endpoints → their base_url — identical to how that
model is handled as the acting model.

MoA also no longer imposes its own output cap: max_tokens defaults to None
(omitted → the model's real maximum) for references and is passed through from
the caller for the aggregator. The preset's hardcoded 4096 is gone. The
max_tokens preset config field is left in place (config/web/desktop unchanged);
it is simply no longer applied as a forced cap.

Tests: slots route through resolve_runtime_provider with resolved base_url/
api_key; resolution errors fall back to bare provider/model; neither call
carries an output cap even when the preset config still contains max_tokens.
2026-06-27 04:39:42 -07:00
herbalizer404 3fe16e3cd5 fix(fallback): attach credential pool after provider switch
When automatic fallback activates a provider that differs from the
primary, try_activate_fallback() cleared the primary's pool (to avoid
cross-provider base_url contamination, #33163) but never loaded the
fallback provider's own pool. The fallback then ran with no pool, so
rate_limit/billing/auth recovery couldn't rotate its credentials.

After clearing a mismatched pool, load_pool(fb_provider) and attach it
when it has credentials, so provider-specific rotation continues to
work on the fallback target.
2026-06-27 04:39:26 -07:00
Tranquil-Flow 635841d210 fix(agent): reload credential pool on switch_model provider change (#52727)
switch_model() swapped model/provider/base_url/api_key but never
refreshed agent._credential_pool, which stays bound to the original
provider. recover_with_credential_pool() then sees a pool.provider !=
agent.provider mismatch and short-circuits — so a 429/401 on the new
provider gets no rotation and falls through to fallback instead.

Reload load_pool(new_provider) inside switch_model when the provider
changes (or the pool is missing). The reload is inside the protected
swap block and the pool is added to the rollback snapshot, so a failed
client rebuild restores the original pool.

Fixes #16678, #52727.
2026-06-27 04:39:26 -07:00
Teknium 2002bb49a7 test(telegram): make config-bridge tests immune to ambient .env pollution (#53594)
test_config_bridges_telegram_group_settings and
test_config_bridges_telegram_user_allowlists asserted the YAML→env bridge
via os.environ. A developer's real ~/.hermes/.env can repopulate TELEGRAM_*
vars during load_gateway_config(): the microsoft_teams plugin runs
load_dotenv(find_dotenv(usecwd=True)) at import time, which walks up from the
cwd (under ~/.hermes/ in worktrees) and reloads the user's .env, defeating the
env-over-YAML bridge for any key present there (e.g. TELEGRAM_GROUP_ALLOWED_CHATS).

Assert the returned PlatformConfig.extra instead — it is parsed straight from
the test's config.yaml and is immune to that ambient leak. free_response_chats
is bridged to the env var only (not extra), and TELEGRAM_FREE_RESPONSE_CHATS
doesn't appear in developer .env files, so it stays a deterministic os.environ
assertion.
2026-06-27 04:36:45 -07:00
Teknium d4c2217e87 fix(gateway): offload /model switch off the event loop (#53603)
The Telegram/Discord /model command's actual switch calls switch_model()
directly on the asyncio event loop. switch_model() can fall through to a
synchronous models.dev HTTP fetch (requests.get, 15s timeout) on a cold or
expired cache, freezing the gateway for up to 15s and dropping the Telegram
connection while a user switches models.

The picker provider-list and fallback text-list sites were already offloaded
(#41289), but the two _switch_model() calls — the picker callback and the
direct /model <name> path — were not. Wrap both in asyncio.to_thread.

Closes #20525.
2026-06-27 04:36:22 -07:00
Teknium caf4dcc7ad fix(whatsapp): resolve phone↔LID aliases in adapter DM/group allowlist (#53588)
The adapter-level intake gate (_is_dm_allowed / _is_group_allowed, reached
via _should_process_message) did a raw set-membership check against the
configured allowlist. WhatsApp now delivers inbound DM senders in LID form
(<id>@lid) while operators configure allowlists with phone numbers, so the
check never matched and every DM from an allowed contact was silently
dropped before the gateway authz layer ran.

Route both gates through the existing gateway.whatsapp_identity.
expand_whatsapp_aliases helper (already used by gateway authz and session
keys), which walks the bridge's lid-mapping-*.json session files. Phone and
LID forms now resolve to each other in both directions; exact JID matches,
wildcard, disabled/open policies, and empty-allowlist fail-closed behavior
are all preserved.

Fixes #14486
2026-06-27 04:17:12 -07:00
teknium1 38e7bd8a08 fix(agent): classify 429 'overloaded' bodies as overloaded, not rate_limit
Z.AI / Zhipu reuse HTTP 429 for server-wide overload. The 429 status
path classified these unconditionally as rate_limit with
should_rotate_credential=True, so an overloaded provider exhausted the
credential pool after two errors — fatal for a single-key user, who has
nothing to rotate to.

The credential is valid; the server is just busy. Disambiguate the 429
body against a shared _OVERLOADED_PATTERNS list and route overload
language to FailoverReason.overloaded (retryable, no rotation), matching
the existing 503/529 path and the message-only path (#52890). Genuine
rate limits (no overload language) still rotate.

Extracted the inline overloaded tuple #52890 added into the shared
_OVERLOADED_PATTERNS constant so the status-code and message paths use
one list.

Closes #14038.
2026-06-27 04:16:54 -07:00
ms-alan 16192103f4 fix(config): accept placeholder base_url in custom provider validation
_normalize_custom_provider_entry() ran urlparse() on base_url and dropped
any entry whose value was an un-expanded placeholder, so a caller reaching
the normalizer with raw config (e.g. the Dockerized gateway path) silently
skipped the provider with a 'not a valid URL' warning. Skip URL validation
when the candidate contains a placeholder token — both ${ENV_VAR} env-refs
and bare {region}-style templates — since those are expanded at runtime.

Closes #14457
2026-06-27 04:15:27 -07:00
HiddenPuppyandHermes Agent b34771fc06 fix(cli): disable prompt_toolkit CPR queries to stop escape-sequence leak (#13870)
prompt_toolkit's renderer sends ESC[6n cursor-position queries before
painting in non-fullscreen mode; the terminal replies ESC[<row>;<col>R.
Over SSH/cloudflared tunnels and slow PTYs these replies race past the
input parser and land in the display as raw '20;1R21;1R' text, and the
pending-CPR future can stall the renderer so the prompt freezes after the
agent's final answer.

Build the prompt_toolkit output with enable_cpr=False so CPR is marked
NOT_SUPPORTED up front and ESC[6n is never sent. This is the root-cause
counterpart to the existing input-side _strip_leaked_terminal_responses
scrubbing. Vt100_Output.from_pty() does not expose enable_cpr in
prompt_toolkit 3.x, so _build_cpr_disabled_output() reproduces its
get_size setup and calls the constructor directly; it returns None on any
failure so startup falls back to the default output.

Verified in a real PTY: baseline emits 1 ESC[6n query, the fix emits 0,
banner/UI render identically. Layout is unaffected — with CPR off the
renderer sizes the prompt to its preferred height (the same fallback
prompt_toolkit uses on any terminal that doesn't answer CPR).

Co-authored-by: Hermes Agent <noreply@nousresearch.com>
2026-06-27 04:15:20 -07:00
LeonSGP43 e7c013494d fix(agent): preserve nested API error bodies 2026-06-27 04:13:53 -07:00
Teknium 5ab4136631 fix(webui): switch provider when Config-page model field changes (#53583)
The dashboard Config tab's Model field is a flat string with no provider
info. _denormalize_config_from_web only updated model.default and kept the
stale provider, so picking an OpenRouter model while the default provider was
ollama-local left provider=ollama-local and every call 404'd.

When the model string actually changes, infer the serving provider — curated
catalog first, then a vendor/model-slug heuristic for non-aggregator providers
— and route the switch through the existing _normalize_main_model_assignment /
_apply_main_model_assignment chokepoints so stale base_url/api_mode/api_key are
cleared on a provider change and preserved on a same-provider re-pick. Saving
an unchanged model never re-detects, so unrelated config saves keep an explicit
provider.

Closes #14058
2026-06-27 04:13:44 -07:00
teknium1 7ee0b68973 fix(gateway,feishu): refuse executor resurrection during real shutdown
Add an explicit _closing guard to both owned executors so the
recreate-on-shutdown path only recovers from an *external* teardown of
the loop default — never resurrects a pool the gateway/adapter itself
stopped. _shutdown_*executor() sets the flag; _get_*executor() raises if
closing; feishu connect() re-arms on reconnect. Updates the gateway
recreate test to assert the refusal contract and adds feishu coverage.
2026-06-27 04:13:09 -07:00
teknium1 b296915c82 fix(feishu): route blocking SDK calls through an adapter-owned executor
Feishu SDK calls ran on asyncio's shared default executor, so a torn-down
default executor wedged every send with 'Executor shutdown has been called'
and left the gateway a zombie (#10849). The adapter now owns a
ThreadPoolExecutor recreated on demand if shut down, mirroring the
gateway-owned executor change. Routes all 17 self._client SDK calls through
_run_blocking; shuts the pool down on disconnect.
2026-06-27 04:13:09 -07:00
konsisumer 1011c07966 fix(gateway): use owned executor for agent work 2026-06-27 04:13:09 -07:00
LeonSGP43 52a09d8faf fix(byterover): honor auto extract config 2026-06-27 04:04:15 -07:00
teknium1 f062cf076b fix(agent): also treat provider=ollama as an Ollama GLM backend
Follow-up to the #13971 fix: a genuine native Ollama provider reached
through a reverse proxy carries no ollama/:11434 URL signature, so the
restricted detection would miss it. Add provider=="ollama" as an
explicit True case (idea from #14789, @Tranquil-Flow) and cover both it
and the #13971 LiteLLM-proxy-to-zai false-positive with E2E tests.
2026-06-27 04:03:07 -07:00
YuShu 266521b55f refactor(agent): trim docstring per review feedback
Remove commentary about the previous is_local_endpoint() approach
from _is_ollama_glm_backend() — git history suffices.
2026-06-27 04:03:07 -07:00
YuShuandHermes Agent 00a8252b7d fix(agent): scope Ollama/GLM stop-to-length heuristic to Ollama only
The _is_ollama_glm_backend() function was too broad: any local endpoint
running a GLM model was treated as Ollama, triggering the stop->length
misreport heuristic introduced in 8011aa3. This caused false truncation
detection on sglang, vLLM, LM Studio, and other non-Ollama servers that
correctly report finish_reason.

When a GLM model on sglang/vLLM returned finish_reason='stop', the agent
mistakenly reclassified it as 'length' if the response didn't end with
a whitelisted punctuation character (ASCII or CJK). This particularly
affected Chinese-language responses and Markdown-formatted text.

Root cause: the is_local_endpoint() fallback assumed any local GLM
endpoint = Ollama. But many non-Ollama servers also run on localhost.

Fix: remove the is_local_endpoint() catch-all. Only detect Ollama via
its distinctive signatures (port 11434, 'ollama' in URL). All other
local servers are assumed to report finish_reason correctly.

This is the correct tradeoff because:
- False negatives (Ollama at custom port, heuristic not triggered) only
  mean the user sees a truncated response — same as having no heuristic
- False positives (non-Ollama server, heuristic wrongly triggered) inject
  spurious continuation messages into the conversation — strictly worse

Adds two tests:
- sglang GLM response is NOT reclassified as truncated
- Ollama GLM on port 11434 still triggers the heuristic as before

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
2026-06-27 04:03:07 -07:00
teknium1andseason179 ab1f9b94c5 fix(telegram): accept @username chat_id in delivery paths (#13206)
TELEGRAM_HOME_CHANNEL set to an @username (not a numeric chat ID) crashed
all webhook/cron->Telegram home-channel delivery with 'ValueError: invalid
literal for int()'. The Telegram Bot API accepts both a numeric chat_id and
an @username string; Hermes was force-coercing every chat_id with int().

Add normalize_telegram_chat_id() (returns int for numeric values, passes
@username strings through) and apply it at the Bot API send/edit sites in
the Telegram adapter and the send_message tool. Username targets are now
recognized as explicit targets in _parse_target_ref.

Reapplies the approach from #13274 (season179), whose branch predated the
gateway/platforms/telegram.py -> plugins/platforms/telegram/adapter.py
relocation. Dupes: #13535 (Tranquil-Flow), #37572 (chewkaah).

Co-authored-by: season179 <season.saw@gmail.com>
2026-06-27 04:01:58 -07:00
teknium1 f2ca3e3d84 fix(gateway): hold _run_restart on _restart_task + explicit cancel-loop skip
Follow-up on the cherry-picked #13173 fix. Holds the _run_restart task in
self._restart_task (a bare asyncio.create_task keeps only a weak reference,
so a still-pending task can be GC'd mid-flight) and explicitly skips it in
the _stop_impl cancel loop alongside _stop_task. Adds AUTHOR_MAP entry for
the contributor and a regression test that fails when the task is cancellable.

Refs #12875
2026-06-27 03:57:31 -07:00
zeapsu 1ce5d6d974 fix(gateway): exclude _run_restart from _background_tasks to prevent zombie on /restart
When request_restart() adds _run_restart to _background_tasks, _stop_impl
later cancels all entries in that set.  Since _run_restart is awaiting
_stop_task at that point, the CancelledError propagates into _stop_impl,
interrupting cleanup before _shutdown_event.set() and _exit_code = 75
execute.  This leaves the gateway as a zombie (alive but disconnected) or
exiting with code 0 instead of 75, preventing systemd Restart=on-failure
from restarting the service.

Fix: don't add _run_restart to _background_tasks — it self-terminates in
~50ms and needs no lifecycle management.

Fixes #12875
2026-06-27 03:57:31 -07:00
teknium1 08e131f77c test(telegram): cover bot self-message ingestion guard (#11905)
Regression tests for the self-author guard added in the salvaged fix:
- bot-authored DM-topic watcher echo is dropped (the exact #11905 symptom)
- bot self-messages dropped in groups/supergroups too
- other bots in the same chat are still processed (self-id, not is_bot)
- observe-unmentioned sibling path also rejects self-messages
- missing from_user does not crash

Test scaffolding ported from @cola-runner's PR #12817 and adapted to the
current plugins/platforms/telegram/adapter.py and _is_own_message().
2026-06-27 03:56:52 -07:00
Sahil-SS9 6fb25f86ac fix(telegram): filter out bot's own messages from inbound processing (#52363) 2026-06-27 03:56:52 -07:00
Teknium 68a65ed7a1 fix(agent_init): correct misleading sub-64K context_length error message (#53569)
The error raised when a model's context window is below the 64K minimum
advertised "or set model.context_length in config.yaml to override" — but
the guard intentionally has no sub-64K escape hatch. Sub-64K models are
rejected by design (tool schemas + system prompt need the headroom).

The misleading clause invited a cluster of dup PRs (#11097, #11110, #8962,
#9142, #37548) all trying to wire an override that we don't want. Reword to
state the real options: pick a >=64K model, or — if your local server
under-reports its true window — declare the real value (which must itself
be >=64K). Guard behavior is unchanged.
2026-06-27 03:56:25 -07:00
Teknium d73078e7b0 fix(cron): make per-profile cron isolation intentional and tested (#4707) (#53570)
A profile's cron jobs now provably live in AND execute under that profile's
HERMES_HOME. A job authored under profile `coder` is stored at
`~/.hermes/profiles/coder/cron/jobs.json` and runs with coder's .env,
config.yaml, scripts and skills — never the default root's.

This was the de-facto behavior on main but only by accident: PR #50112 had
re-anchored cron storage at the shared default root, and a later stale-branch
squash merge (#52147) silently reverted it back to the profile home. Neither
direction was guarded by a test, so it could flip again on the next stale merge.

Changes:
- cron/jobs.py: document the per-profile storage anchor (get_hermes_home, NOT
  get_default_hermes_root) and why anchoring at the root leaks
  config/credentials/skills across profiles — the #4707 security boundary.
- cron/scheduler.py, cron/suggestions.py: same intent documented at the
  dynamic resolution helper and the suggestions store.
- tests/cron/test_cron_profile_isolation.py: pin storage, lock-path, and
  execution-home resolution to the active profile so a re-anchor can't regress.

Verified E2E: jobs created under two profiles land in separate per-profile
stores with zero cross-profile leakage and no shared-root store; scheduler
execution-home follows the active profile. Full cron suite: 576/576.
2026-06-27 03:55:01 -07:00
Bartok 864d5521ad test(curator): join straggler curator-review thread on fixture teardown
The curator_env fixture left async review threads (synchronous=False spawns
a daemon 'curator-review' thread that calls save_state() on completion)
running past test teardown. save_state() resolves the state path from
HERMES_HOME at write time, so a straggler could write into the next test's
tmp home, corrupting test_state_file_survives_corrupt_read (and others)
under CI load. Join the thread on teardown while HERMES_HOME is still
pinned to this test's home.
2026-06-27 03:52:52 -07:00
Bartok9 45ce35ed72 fix(agent): classify message-only 'overloaded' as server overload
Salvage of #14261 by @ms-alan — rebased onto current main, scoped to the
overloaded-classification fix, with a regression test that fails without it.
2026-06-27 03:52:52 -07:00
teknium1 151ae1e937 test(api-server): cover SSE failure finish_reason for both failure modes
Lock the contract that a clean stream-queue termination followed by an
agent failure never reports finish_reason: "stop". Covers the raised-
exception case (#12422 repro), the flagged failed-result case, truncation
(length), and the success happy path.

Follow-up to the salvaged #12504 fix from @flobo3.
2026-06-27 03:52:44 -07:00
flobo3 b8b695e2cd fix(api): surface agent crash in SSE chat completions stream 2026-06-27 03:52:44 -07:00
Teknium f67c0b3e60 docs(hermes-agent skill): cover v0.13–v0.17 features, fix stale claims, tighten (#53566)
Refresh the hermes-agent skill against the last 5 major releases and the
current codebase, and cut verbose prose.

Coverage added (v0.13.0–v0.17.0):
- New gateway platforms: iMessage (Photon), Teams, LINE, SimpleX, ntfy,
  Google Chat, Raft, official WhatsApp Business Cloud API (now 20+).
- New surfaces section: desktop app, web dashboard admin panel,
  hermes proxy (OpenAI-compatible OAuth proxy), Automation Blueprints.
- delegate_task(background=true) async subagents; memory-tool atomic
  batch operations; session_search three-mode shape; x_search/video_analyze
  toolsets; image_gen image-to-image; xAI Grok via SuperGrok OAuth.
- display.interface (cli/tui), curator.consolidate opt-in, PyPI install.

Accuracy fixes:
- Adding-a-Tool is two files (auto-discovery), not three.
- Testing uses scripts/run_tests.sh (canonical runner), not bare pytest.
- Dropped change-detector test count and a dangling references/ pointer.
- Refreshed overview (Windows-native, 20+ providers, many surfaces).

Conciseness: trimmed over-explained Windows keybinding/sandbox/test prose
and deep prompt-builder internals to pointers.
2026-06-27 03:51:25 -07:00
Teknium d3db73210c chore(release): map blaryx@gmail.com → Blaryxoff for PR #32602 salvage 2026-06-27 03:48:18 -07:00
blaryx 76af2456a2 fix(dashboard): merge PUT /api/config with existing on-disk config
The dashboard form is built from CONFIG_SCHEMA, which doesn't enumerate
every root-level key the YAML supports. Most visibly, `custom_providers`
is in `_KNOWN_ROOT_KEYS` but is absent from the schema — so the frontend
never sends it in the PUT body. The previous full-replace save() then
silently wiped the key from disk every time the user clicked anything
that triggered a save. Other casualties (less visible because defaults
re-mask them on load) include `agent.personalities`,
`agent.reasoning_effort`, `terminal.lifetime_seconds`, etc.

Fix: read the raw on-disk config and deep-merge the incoming PUT body
on top of it before saving. The frontend can only overwrite what it
explicitly sends; everything else is preserved verbatim.

Reuses the existing `_deep_merge` helper from `hermes_cli.config`.

Tests:
- `test_round_trip_preserves_custom_providers` exercises the exact bug:
  seed config with custom_providers, GET → drop the key → PUT,
  assert it's still on disk.
- `test_round_trip_preserves_schema_invisible_nested_keys` covers the
  shallow-vs-deep-merge case for nested dicts under `agent` etc.
Both fail on current main; both pass with this patch.
2026-06-27 03:48:18 -07:00
Teknium ec769e49d2 fix(gateway): WhatsApp/Signal hints affirm markdown instead of forbidding it (#53564)
The 'whatsapp' and 'signal' PLATFORM_HINTS told the agent 'Please do not
use markdown as it does not render' — factually wrong. Both adapters
actively convert markdown to native formatting:

- whatsapp_common.format_message(): **bold**, ~~strike~~, # headers,
  links, code blocks -> WhatsApp native syntax
- signal_format.markdown_to_signal(): same conversions via bodyRanges,
  plus '- item' / '* item' bullets -> '• ' Unicode bullets

The wrong hint made the agent strip bullets and bold the adapter would
have rendered (#12224). Rewrote both hints to mirror whatsapp_cloud:
markdown is auto-converted, bullet lists work, tables are not supported.
Added a contract test asserting markdown-converting platforms never
forbid markdown in their hint.
2026-06-27 03:46:41 -07:00
teknium1 a5d1f68c74 refactor(moa): share one virtual-provider row builder across pickers
Follow-up on the gateway-picker salvage: the cherry-picked change added a
second copy of the MoA virtual-provider row in model_switch.py, duplicating
inventory._moa_provider_row (same slug/name/preset-models, identical extra
fields). Make _moa_provider_row take a bare current_provider string and reuse
it from the gateway picker path so the row shape lives in one place and the
two surfaces can't drift.
2026-06-27 03:43:38 -07:00
dodo-reach ed54469d06 fix(gateway): show MoA presets in model picker 2026-06-27 03:43:38 -07:00
Teknium 789f8b7dc2 docs(webhook): clarify authenticated != trusted-content trust model (#53562)
HMAC validation authenticates the webhook sender, not the business
fields inside the payload (PR titles, commit messages, issue bodies),
which are authored by untrusted third parties. Expand the prompt-
injection section to make the trust boundary explicit: the agent's
capability surface, not the input channel. Document the hardening
levers (sandbox the runtime, scope the toolset, keep approvals on,
template narrowly) instead of pretending to sanitize untrusted text.

Refs #8820.
2026-06-27 03:43:33 -07:00
teknium1 4e0788783b refactor(gateway): extract MoA one-shot restore helper; restore #28686 comment; real-method tests
Follow-up on the salvaged MoA restore fix:
- Extract the finally-block restore into _restore_moa_one_shot() so the
  behavior is unit-testable without re-implementing it, and so the gateway
  /moa handler and the finally block share one implementation.
- Restore the load-bearing #28686 zombie-eviction comment above
  _release_running_agent_state that the original diff dropped.
- Rewrite the tests to call the real _restore_moa_one_shot helper (the
  originals re-implemented the restore logic inline, so they passed
  regardless of the production code).
2026-06-27 03:43:28 -07:00
srojk34 2f29e3cfc5 fix(gateway): restore MoA one-shot model override on failed turns
The MoA one-shot restore ran inside the try block after
_handle_message_with_agent returned. When that call raised an
exception (agent init failure, interpreter shutdown, OOM), the
restore was skipped and the MoA model override stayed permanently
on _session_model_overrides — silently routing all subsequent
messages through the MoA reference fan-out with no user-visible
indication.

Move the restore to the finally block so it fires on every exit
path (success, exception, interrupt). The restore data lives on
the per-turn event object and would be lost if not consumed here.
2026-06-27 03:43:28 -07:00
briandevans 17cb829991 test(moa): cover non-list/bare-dict reference_models normalization 2026-06-27 03:43:16 -07:00
briandevans 8dd4e576d0 fix(moa): tolerate non-list reference_models in hand-edited MoA preset config 2026-06-27 03:43:16 -07:00
Teknium 60f58a2b95 feat(verify-on-stop): default OFF, one-time migration, skip doc-only edits (#53552)
The verify-on-stop guard fired too eagerly — including on doc/markdown/skill
edits with nothing to verify, where it pushed a pointless /tmp verification
script. Three changes:

1. Default OFF for new installs: agent.verify_on_stop defaults to false
   (was the "auto" surface-aware sentinel). _config_version bumped 30 -> 31.
2. One-time migration (v30 -> v31): existing installs are switched off once,
   but only when the value is missing or still the "auto" sentinel — an
   explicit true/false the user set is preserved.
3. Path filter: build_verify_on_stop_nudge() now drops documentation/prose
   paths (.md/.mdx/.rst/.txt/LICENSE/CHANGELOG/...) so even when explicitly
   enabled, a doc-only turn never nudges. Mixed doc+code turns still nudge on
   the code paths.

The legacy "auto" sentinel is still honored when set explicitly (ON for
interactive coding surfaces, OFF for messaging). HERMES_VERIFY_ON_STOP env
override unchanged.
2026-06-27 03:23:22 -07:00
teknium1 29ee4bbff6 refactor(dashboard): tighten cron-job form helpers
Collapse the three near-identical optional-text helpers
(optionalText/optionalBaseUrl/listToText) into one optionalText with a
strip-trailing-slash flag, route listToText + toolsets through the
existing splitCronList, and replace the repeated
typeof x === 'string' ? x : '' ladders with a single asString helper.
Behavior-identical; all 16 vitest cases pass.
2026-06-27 03:20:32 -07:00
Versun c655cdf2c1 feat(dashboard): expose cron job execution fields 2026-06-27 03:20:32 -07:00
teknium1 50f6855217 feat(moa): make /moa one-shot only; route preset switching through the model picker
/moa no longer does a sticky model switch. It now always runs a single
prompt through the default MoA preset and restores the prior model
afterward; the whole argument is the prompt (no preset-name matching).
To switch to a MoA preset for the session, select it from the model
picker, where presets already surface under a virtual Mixture of Agents
provider on every model-selection surface.

Also fixes #53444: the TUI one-shot only set session[model_override],
which the already-built cached agent ignored, so MoA silently never ran
and the turn used the original model. The TUI now does a real in-place
agent.switch_model() via _apply_model_switch() when a live agent exists
(with a proper restore after the turn), and falls back to a model_override
for lazy/unbuilt sessions.

Removes the redundant sticky-switch branch from the CLI, gateway, and TUI
/moa handlers; updates the command description, usage string, and docs.
2026-06-27 03:09:09 -07:00
teknium1 3cd4693494 chore: add DiamondEyesFox to AUTHOR_MAP for PR #53351 salvage 2026-06-27 03:04:26 -07:00
diamondeyesfox 8df231c941 fix(agent): rebaseline in-place compression flushes 2026-06-27 03:04:26 -07:00
Mahesh Sanikommu 1b75b3fd90 feat(memory): add Supermemory setup connection summary
Add post_setup() and get_status_config() to the Supermemory memory
provider so `hermes memory setup` and `hermes memory status` print a
one-line connection summary (container, profile fact count,
auto_recall/auto_capture). Point API-key onboarding at the Hermes
connect URL (app.supermemory.ai/integrations?connect=hermes).

Salvage of #52988. Two fixes folded in:

- Test isolation: the new probe/status tests mocked _SupermemoryClient
  but not the __import__("supermemory") guard inside
  _probe_supermemory_connection, so they passed only where the optional
  supermemory package was installed and failed on a clean checkout / CI
  (the PR shipped with red CI). Added _stub_supermemory_importable()
  mirroring the existing test_is_available_false_when_import_missing
  pattern; the suite now passes with supermemory absent.

- post_setup: `if api_key and api_key not in os.environ` checked whether
  the key's *value* named an env var (always false in practice). Fixed to
  compare the value: `os.environ.get("SUPERMEMORY_API_KEY") != api_key`.

Verified: 38/38 in test_supermemory_provider.py and the full
tests/plugins/memory/ suite green with supermemory not installed.

Closes #52988
2026-06-27 15:07:34 +05:30
underthestars-zhy 8827300267 fix(photon): correlate tapbacks to bot message context
Populate `reply_to_message_id`, `reply_to_text`, and
`reply_to_is_own_message` on reaction events so the gateway injects
`[Replying to your previous message: "..."]` when the agent receives
a tapback.

The sidecar now extracts a capped text preview from the hydrated
reaction target (plain text and mixed group messages; null for
attachment/voice-only targets), emitting it as `targetText` in the
NDJSON reaction payload. The Python adapter reads this field and sets
the reply correlation fields on the `MessageEvent`.
2026-06-27 00:51:34 -07:00
underthestars-zhy 4345b3e767 fix(photon): upgrade spectrum-ts sidecar to v8.0.0
v8 made `richlink` outbound-only; inbound rich links now arrive as
plain `text`. Remove the `getBalloonBundleId`/`toRichlinkMessage`
branches from the iMessage mapper patch and update the fixture,
lockfile, and README accordingly.
2026-06-27 00:51:34 -07:00
underthestars-zhy 5636c22828 feat(photon): upgrade spectrum-ts sidecar to v7.0.0
Update the Photon platform plugin's Node.js sidecar from spectrum-ts
3.1.0 to 7.0.0, which splits the SDK into scoped `@spectrum-ts/*`
packages with `spectrum-ts` as the umbrella re-export.

- Bump exact pin in package.json/package-lock.json to 7.0.0
- Update mixed-attachments patch script to target the new
  `@spectrum-ts/imessage/dist/index.js` path and tab-indented output
- Rewrite test fixture to match v7.x mapper shape (tab-indented,
  `const ... = async` declarations, single-line builder calls) and
  point at `@spectrum-ts/imessage/dist/index.js`
- Update README upgrade guide to document the v5 package split and
  the postinstall patch validation step
- Update comments in cli.py and index.mjs to reference v5/v7 changes
2026-06-27 00:51:34 -07:00
Teknium d712a7fd73 fix(model-picker): surface the current custom/uncurated model in picker rows (#53457)
A model selected via the CLI (e.g. /model openrouter/<uncurated-name>) was
absent from every model picker — the main picker AND the MoA reference/
aggregator slot pickers — because each provider row only carried its curated
catalog. Inject the current model at the front of its provider's row so it is
selectable and shown everywhere.
2026-06-27 00:06:34 -07:00
Ben Barclay fbf748b282 fix(dashboard-auth): follow redirects on self-hosted OIDC discovery (#53399)
The self-hosted OIDC provider fetched the discovery document with a bare
httpx.get(). httpx defaults to follow_redirects=False (unlike curl -L or
the requests library), so when an IDP answers GET
/.well-known/openid-configuration with a 3xx — Authentik canonicalises the
.well-known path, and any IDP behind a reverse proxy doing an http→https
upgrade redirects too — the bare redirect (empty body) tripped the
status != 200 guard and raised 'OIDC discovery returned 302', which
routes.py maps to the provider_unreachable audit event and a 503. The
browser surfaced 'Auth provider self-hosted unreachable'.

The user's smoking gun (curl -o writing zero bytes from inside the
container) is exactly a redirect with no body — the same wall the code hit.

Add follow_redirects=True to the discovery GET only. It's safe: the
issuer-pin check and _require_https_or_loopback still validate the resolved
document and every endpoint, so a redirect can't smuggle in a bad issuer or
a cleartext endpoint. The token/revocation POSTs deliberately keep the
no-follow default (they carry an auth code / refresh token and the endpoint
is already the canonical absolute URL).

Existing discovery tests mocked httpx.get with a canned 200 and never
exercised a real 3xx. Add a regression test that runs a real loopback
server returning a 302 on the .well-known path — fails without the fix
(ProviderError: discovery returned 302), passes with it.
2026-06-27 14:14:51 +10:00
ethernet dd0e4ab81a change(ci): slice files in matrix job
avoid duplicating work, avoid file discovery on each job
2026-06-26 19:15:18 -07:00
ethernet 1a75387fa8 change(ci): log json decode error in durations 2026-06-26 19:15:18 -07:00
ethernet 707ae6e623 change(tests): don't count with pytest collect
it's way too slow. just grep files lol
2026-06-26 19:15:18 -07:00
ethernet bcc3eb3419 fix(ci): rip out some xdist legacy stuff... how did these ever work?? 2026-06-26 19:15:18 -07:00
ethernet 2fa66950e8 change(ci): upload-artifact from v4 -> v7 2026-06-26 19:15:18 -07:00
ethernet 4b0a2040e7 change(ci): use run_tests in docker 2026-06-26 19:15:18 -07:00
ethernet 18f7ad49ab change(ci): update all UV installs 2026-06-26 19:15:18 -07:00
ethernet f0cb049217 change(ci): migrate docker smoketests to real tests 2026-06-26 19:15:18 -07:00
ethernet 2bd17221b7 change(ci): pretty names 2026-06-26 19:15:18 -07:00
ethernet 9a861cd0ab change(tests): don't pass pytest args when counting tests 2026-06-26 19:15:18 -07:00
ethernet 447f9e7c89 change(nix): simpler dev setup 2026-06-26 19:15:18 -07:00
ethernet 8ae793d3de change(nix): ship fat hermes agent by default 2026-06-26 19:15:18 -07:00
ethernet fb1dd1bf91 change(ci): docker-publish.yml -> docker.yml 2026-06-26 19:15:18 -07:00
ethernet 35dfe7b58f change(ci): docker runs again on PRs 2026-06-26 19:15:18 -07:00
ethernet 4cf69f0da4 refactor(ci): more test slices 2026-06-26 19:15:18 -07:00
ethernet d4aec4e92f refactor(ci): run tests thru run_tests.sh 2026-06-26 19:15:18 -07:00
ethernet c918d07b50 refactor(ci): rewrite docker tests to check built container 2026-06-26 19:15:18 -07:00
ethernet 638243726e refactor(ci): faster docker builds via --link and chmod removal 2026-06-26 19:15:18 -07:00
brooklyn! f6e815e378 Merge pull request #53357 from helix4u/fix/desktop-titlebar-overlay 2026-06-26 20:42:11 -05:00
Gille 1bff85cf66 fix(desktop): keep titlebar overlay off session title 2026-06-26 19:18:26 -06:00
Nacho Avecilla dbe734beff fix(dashboard-auth): exclude non-interactive providers from interactive login surfaces (#53239)
* Return None instead of erroring on drain login failure

* Fix login on drain

* Remove login for drained endpoints flow and clean the code

* chore: drop unrelated credits changes from this PR

* Remove extra comments that were not really necessary
2026-06-27 10:08:13 +10:00
brooklyn! 7a38d64a85 Merge pull request #53335 from NousResearch/bb/desktop-custom-model-blank-selector
fix(desktop): show custom (non-curated) model in Settings model pickers
2026-06-26 18:59:43 -05:00
Brooklyn Nicholson a6ae179f43 fix(desktop): show custom (non-curated) model in Settings model pickers
A Radix <Select> renders a blank trigger when its `value` matches no
<SelectItem>. The Settings model pickers built their options solely from
each provider's curated `models` list, so a model added via config that
isn't in that list (e.g. anthropic/claude-opus-4.7 on nous) selected
nothing and showed an empty selector.

Union the active value into the options via a small `withActive` helper,
applied to the main, auxiliary, MoA reference, and MoA aggregator model
selects so the configured model always stays visible and selectable.
2026-06-26 18:55:44 -05:00
kshitijk4poor 7475d125d2 test(mcp): stub mcp_oauth in backgrounding test to deflake CI
The backgrounding-contract test (test_prepare_agent_startup_backgrounds_
blocking_mcp_for_chat) failed intermittently on loaded CI shards: it stubs
tools.mcp_tool.discover_mcp_tools but NOT tools.mcp_oauth, so the background
discovery thread paid the real, cold ~0.75s 'import tools.mcp_oauth' (added by
this PR's _discover_mcp_tools_without_interactive_oauth) before calling the
stubbed discovery. On a slow/loaded runner that import plus thread scheduling
exceeded the 1.0s polling deadline, leaving calls['mcp'] == 0.

Fix: stub tools.mcp_oauth with a nullcontext suppress_interactive_oauth (the
same no-op production falls back to when mcp_oauth is unavailable), so the
test exercises the backgrounding contract without paying an unrelated cold
import in its timing window. Bumped the poll deadline 1.0s -> 3.0s as
belt-and-suspenders. Production behaviour is unchanged; the import cost was
always off the main thread.

Verified: 5/5 pass repeatedly via scripts/run_tests.sh (per-file isolation,
matching CI), ruff clean.
2026-06-27 04:59:23 +05:30
zapabob e55ddc3e33 fix(mcp): suppress interactive OAuth stdin prompts during background discovery (#35927)
When an MCP server requires OAuth, the interactive `hermes` TUI froze on
startup: background MCP discovery hit the OAuth flow, which on an interactive
TTY spawns a daemon thread doing a blocking `sys.stdin.readline()` (the
"paste the redirect URL" fallback in mcp_oauth._wait_for_callback). That
thread competes with the TUI's own stdin reader for the same terminal, so
keystrokes get swallowed and the TUI appears frozen (up to the 300s OAuth
timeout). Reported symptom: "MCP OAuth: authorization required / Open this URL
... the tui is freezing, not respond to typing."

Add a thread-local `suppress_interactive_oauth()` context manager in
tools/mcp_oauth.py; `_is_interactive()` returns False while it's active, so the
stdin paste-thread and prompt are never created. Background discovery
(hermes_cli/mcp_startup.py, tui_gateway/entry.py) now runs discovery inside
that context, so OAuth-requiring servers soft-skip (raise
OAuthNonInteractiveError, already handled) instead of stealing the TUI's stdin.
A real `hermes mcp login` on the main thread is unaffected (thread-local).

Salvaged from #35945 by @zapabob (authorship preserved via cherry-pick;
resolved a conflict against main's new mcp_discovery_timeout / wait_for_mcp_
discovery refactor, keeping both). Verified E2E: with suppression the paste
prompt is NOT printed and no stdin thread spawns (raises OAuthNonInteractive
soft-skip); without it the prompt shows (the freeze). Mutation-verified
(removing the suppress check in _is_interactive fails the regression test).
76 tests pass, ruff clean.

Closes #35927.

SELF-REVIEW FIX: the original #35945 used threading.local(), which does NOT
propagate to the dedicated mcp-event-loop thread where OAuth actually runs
(discover_mcp_tools dispatches the connect via run_coroutine_threadsafe), so
the suppression was a NO-OP in production (the tests passed only by stubbing
out the cross-thread dispatch). Converted to a contextvars.ContextVar, which
asyncio copies onto the scheduled coroutine — empirically verified suppression
now holds on the mcp-event-loop thread through the real _run_on_mcp_loop path.
Added a cross-thread regression test (fails on threading.local, passes on the
ContextVar) so the no-op can't regress.
2026-06-27 04:59:23 +05:30
briandevans 2d8c44ac87 fix(hermes-home): only honour legacy dir layout when it has content
get_hermes_dir(new_subpath, old_name) returned the legacy <old_name>/
location as soon as it existed on disk — even when empty. When an empty
legacy stub is created on a profile that already has populated data at
the new consolidated <new_subpath>/ (install scaffolds, profile init, a
stray mkdir, or ensure_hermes_home() recreating legacy dirs), the
resolver silently flipped to the empty legacy dir and the real data
became invisible. No log, no error — the feature behaved as if state was
wiped. Reproduced as a Discord pairing store losing every approved user
when an empty pairing/ shadowed the populated platforms/pairing/.

Resolve the legacy path only when it has content: a populated directory
(any entry) or a non-directory file counts; an empty directory falls
through to the new layout. Inspection failures (PermissionError on
lstat/iterdir, or any OSError short of FileNotFoundError) are treated as
"occupied" so a transient error never orphans legacy data — only a
genuine FileNotFoundError counts as absent. The lstat()-based gate also
fixes the prior exists()/is_dir() path swallowing PermissionError and
mis-reading an unreadable legacy dir as absent.

This hardens all 11+ call sites that share the resolver (pairing,
image/audio/video/document caches, matrix/whatsapp session stores,
vision/credential/tts/browser dirs).

Adds TestGetHermesDir regression coverage (empty/populated/subdir/file/
unreadable/unstatable cases) and updates test_credential_files to
populate its legacy dirs so they still count as content.

Closes #27602
Closes #27715
2026-06-27 04:57:15 +05:30
briandevans c377e954fb test(gateway): isolate secret-redaction layer from provider-error rewrite
The existing test_chat_gateways_redact_secret_in_provider_error feeds a
provider-error envelope (HTTP 401), which _sanitize_gateway_final_response
rewrites wholesale to a generic category string. That rewrite strips the
secret regardless of whether the redaction layer works, so the test cannot
on its own prove _redact_gateway_user_facing_secrets is exercised.

Add test_chat_gateways_redact_secret_in_non_error_body: ordinary assistant
prose that echoes a bearer token but is NOT a provider-error envelope, so
the rewrite path does not fire and secret redaction is the only defense.
Verified fail-before (token leaks when _GATEWAY_SECRET_PATTERNS is emptied)
and pass-after across whatsapp/slack/signal/matrix, while non-secret prose
is preserved intact.
2026-06-27 04:47:10 +05:30
briandevans 57864d07ed fix(gateway): suppress operational status/error noise on all chat gateways, not just Telegram (#39293)
The Telegram noise/secret filter added in #28533 gated its work on
`_gateway_platform_value(platform) != "telegram"`, so
`_sanitize_gateway_final_response` and `_prepare_gateway_status_message`
only ran for Telegram. Every other human-facing chat surface
(WhatsApp, Discord, Slack, Signal, Matrix, plugin platforms, etc.)
received raw provider-error bodies verbatim — including any leaked
credentials the secret-redaction pass (`sk-…`, `Bearer …`, `gh[pousr]_…`,
`xox[baprs]-…`, `hf_…`, `glpat-…`) was meant to strip.

Invert the gate from a one-platform allowlist into a small
programmatic-surface denylist: only `local`, `api_server`, `webhook`,
and `msgraph_webhook` consume gateway text programmatically and keep raw
status/error text. Every other (chat) surface — including unknown/empty
platform values and on-demand plugin pseudo-members — fails closed to
the redacted, noise-filtered, sanitized path. This widens the same
root-cause fix to both call sites: status callbacks and final replies.
2026-06-27 04:47:10 +05:30
244a6f2ceb fix(desktop): broken "Open setup guide" button for plugin platforms
On the desktop Channels / Messaging page, the "Open setup guide" button was
rendered as a bare <a href={platform.docs_url} target="_blank"> with no guard.
Plugin-provided platforms (Microsoft Teams, Google Chat, Line, Raft, Yuanbao,
…) ship an empty docs_url, so the anchor's href was "".

In a packaged build, Electron resolves an empty href against the current
document — the app's own index.html inside the asar bundle — and
shell.openPath then fails with an OS "file not found" dialog. This is exactly
the Windows error reported for Messaging → Teams → Open guide.

Fix (3 changes):

1. fix(desktop) — Only render the "Open setup guide" button when docs_url is
   non-empty, and route clicks through openExternalLink so a relative/empty
   value can never be treated as a local bundle path. Fixes the whole class
   (every plugin platform), not just Teams.

2. fix(messaging) — Give the Teams platform plugin a real docs_url (Microsoft
   Teams setup guide) so its card shows a working button instead of nothing.

3. fix(messaging) — Give the Google Chat platform plugin a real docs_url
   (Google Chat setup guide) so its card shows a working button instead of
   nothing. Originally from #48940; folded in here because that PR's test
   was broken (it queried the HTTP endpoint, but google_chat is a dynamic
   enum member that only appears after the adapter module is imported).

Test plan:
- apps/desktop — new src/app/messaging/index.test.tsx: button is hidden when
  docs_url is empty; a real URL opens via the validated external opener (does
  not navigate).
- apps/desktop typecheck (tsc --noEmit) clean.
- backend — test_teams_messaging_metadata_links_setup_guide: the Teams catalog
  entry exposes the setup-guide docs_url.
- backend — test_google_chat_messaging_metadata_links_setup_guide: the Google
  Chat catalog entry exposes the setup-guide docs_url.

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Co-authored-by: p-andhika <andhika.prakasiwi@gmail.com>
2026-06-27 04:34:08 +05:30
kshitijk4poor 58919f68ab fix: also preserve provider selection on Esc-clear-filter path
The back() handler had the same filtered-index drift bug as the Enter
and Ctrl+D transitions: when the user presses Esc to clear an active
filter on the provider stage, providerIdx was reset to 0, losing the
highlighted provider. Apply the same providerIndexAfterClearingFilter
fix as the other three transition paths.

Also adds edge-case tests for the helper: undefined provider, slug not
found, empty rows, and duplicate slug first-match behavior.

Found by hermes-pr-review Phase 2 + hermes-agent-dev 3-agent review.
2026-06-27 04:33:48 +05:30
Donovan Yohan 386478211b fix(tui): preserve filtered model provider selection 2026-06-27 04:33:48 +05:30
kshitijk4poorandYosapol Jitrak b0f44d3fad fix(gateway): remove process-global HERMES_SESSION_KEY write that misroutes approval prompts across concurrent sessions
GatewayRunner._run_agent's run_sync() wrote the per-turn session key to
the process-global os.environ["HERMES_SESSION_KEY"]. Because os.environ
is shared across the whole process, concurrent gateway sessions (e.g.
two Discord threads) clobbered each other's value. A tool worker thread
whose approval contextvar was unset then fell back to os.environ via
get_current_session_key() and read whichever session ran run_sync()
last — routing "Command Approval Required" prompts to the wrong thread.

Session routing is already concurrency-safe via contextvars:
- gateway/session_context.py _SESSION_KEY (set in set_session_vars)
- tools/approval.py _approval_session_key (set via set_current_session_key
  right before the agent runs, inherited by tool worker threads)

The only non-test readers of HERMES_SESSION_KEY (tools/approval.py,
tools/terminal_tool.py, tools/kanban_tools.py) all prefer the contextvar
with os.environ as a mere fallback. CLI/cron/TUI set their own os.environ
via separate export paths (e.g. the TUI parent exporting it into the
agent subprocess), so removing this in-process write does not affect them.

Adds regression tests asserting the resolver prefers the contextvar and
does not leak a concurrent session's cleared/clobbered os.environ value.

Closes #24100

Co-authored-by: Yosapol Jitrak <yosapol@jitrak.dev>
2026-06-27 04:31:37 +05:30
kshitijk4poor cdb1dfbc49 fix: use os.pathsep, add tests, update tips for multi-root support
- Use os.pathsep instead of literal ':' so Windows paths (C:\dir) and
  the Windows separator ';' work correctly.
- Add 9 tests covering multi-root behavior: writes inside first/second
  root, writes outside all roots, trailing/leading/double separators,
  all-separators edge case, static deny priority, duplicate dedup.
- Update hermes_cli/tips.py tip string to mention multiple paths.
- Update docs to mention os.pathsep / ; on Windows.

Follow-up for salvaged PR #49557.
2026-06-27 04:01:12 +05:30
Zheng Tao d15cc9bc83 docs: update HERMES_WRITE_SAFE_ROOT docs with multi-path format
Add note about colon-separated multiple directories support.
2026-06-27 04:01:12 +05:30
Zheng Tao fa8f1517da feat(file_safety): support multiple HERMES_WRITE_SAFE_ROOT dirs
Supports multiple directories separated by ':' (Unix PATH-style).
E.g., HERMES_WRITE_SAFE_ROOT=/opt/data:/var/www/html

Fixes #49535
2026-06-27 04:01:12 +05:30
kshitijk4poor a67ddf5983 fix: drop isinstance(str) guard so client.base_url fallback works with httpx.URL
The OpenAI SDK exposes client.base_url as an httpx.URL object, not str.
The isinstance(live_raw, str) guard made this branch dead code in
production. Use _normalized_runtime_url (which coerces via str()) so
the fallback actually fires.
2026-06-27 03:59:36 +05:30
xxxigm 2608f78b93 test(delegate): cover stale parent base_url inheritance for subagents
Add regression tests ensuring delegate_task passes the parent's active
localhost endpoint to child agents instead of a leftover OpenRouter URL.
2026-06-27 03:59:36 +05:30
xxxigm 25b7348457 fix(delegate): inherit subagent endpoint from parent active client
When parent_agent.base_url still carries a stale OpenRouter URL but the
live OpenAI client already points at local Ollama, subagents were routing
API calls to OpenRouter and failing with HTTP 401. Prefer _client_kwargs
and the mounted client base_url when they disagree with the surface field.
2026-06-27 03:59:36 +05:30
kshitijk4poor 6326d5c6f6 fix: remove duplicated table renderer from Telegram adapter
The PR's original refactor commit only replaced the primitives (regex,
is_table_row, split_markdown_table_row) with shared imports but left the
verbatim-copied renderer (_render_table_block_for_telegram) and driver
(_wrap_markdown_tables) in place. Both are logic-identical to the shared
convert_table_to_bullets in gateway/platforms/helpers.py.

Replace both with a direct import alias. _TABLE_SEPARATOR_RE is still
imported separately because it's used by the rich-message routing logic
(lines 1024, 1044) to detect whether content contains tables.

Found by 3-agent parallel code-reuse review.
2026-06-27 03:57:24 +05:30
Yashiel Sookdeo 24a4df9cd1 refactor(telegram): import shared table-detection primitives from helpers.py
Replace local _TABLE_SEPARATOR_RE, _is_table_row, and
_split_markdown_table_row with imports from the shared module.
Telegram-specific rendering stays local.

Co-authored-by: Yashiel Sookdeo <yashiel@skyner.co.za>
2026-06-27 03:57:24 +05:30
Yashiel Sookdeo cf7bf5bdc9 fix(discord): auto-convert markdown tables to bullet groups
Discord does not render GFM pipe tables — raw pipe characters display
as garbage text. format_message now rewrites tables into bold-heading +
bullet groups using the shared helpers.

Fixes #21168

Co-authored-by: Yashiel Sookdeo <yashiel@skyner.co.za>
2026-06-27 03:57:24 +05:30
Yashiel Sookdeo 70c834a740 refactor: extract shared GFM table→bullet helpers into helpers.py
Move table-detection regex, row-splitting, and table-to-bullet
conversion into gateway/platforms/helpers.py so both Discord and
Telegram adapters can share them.

Co-authored-by: Yashiel Sookdeo <yashiel@skyner.co.za>
2026-06-27 03:57:24 +05:30
kshitij 9c9b28a2b3 Merge pull request #53296 from kshitijk4poor/chore/author-map-yashiels-45781
chore: AUTHOR_MAP — yashiel@skyner.co.za → yashiels
2026-06-27 03:47:07 +05:30
kshitijk4poor 5eb108f06c chore: AUTHOR_MAP — yashiel@skyner.co.za → yashiels
PR #53284 salvage (discord markdown table-to-bullet conversion; #21168)
2026-06-27 03:38:29 +05:30
Teknium 391090083c fix(desktop): persist MoA preset add/delete/set-default immediately (#53290)
The desktop MoA settings 'Add preset', 'Set default', and 'Delete' buttons
mutated local React state only and never called the save endpoint, so a newly
constructed preset vanished on refresh. Each now builds the next config and
calls saveMoa() so the change is written to config.yaml via PUT /api/model/moa.
2026-06-26 15:06:18 -07:00
Teknium 7e101e553b fix(moa): block the moa virtual provider as a reference or aggregator slot (#53281)
A MoA preset whose reference or aggregator slot points at the moa virtual
provider creates a recursive MoA tree. The runtime guards in moa_loop.py only
surface this mid-turn (references silently skipped, aggregator raises). Reject
it at the config chokepoint (_clean_slot) so it can never be saved, and hide it
from the desktop/dashboard slot pickers so it isn't offered as a dead choice.
2026-06-26 14:42:42 -07:00
liuhao1024 515192c4b9 fix(tools): use start_new_session instead of preexec_fn to prevent SIGSEGV in multi-threaded processes
preexec_fn=os.setsid runs Python code in the forked child before exec,
which is unsafe in multi-threaded processes (CPython docs). When the
Desktop gateway loads native libraries (onnxruntime, BLAS, provider SDKs)
with active thread pools, the fork can SIGSEGV before the child execs.

Replace all preexec_fn usage with start_new_session=True, which provides
the same setsid/process-group semantics without running Python in the
fork. This is already the pattern used throughout hermes_cli/gateway.py
and hermes_cli/_subprocess_compat.py.

Fixes #46789
2026-06-27 03:08:41 +05:30
srojk34 f0678b031e fix(moa): tolerate non-numeric values in hand-edited MoA preset config
_normalize_preset uses bare float() and int() to coerce
reference_temperature, aggregator_temperature, and max_tokens from
config.yaml.  When a user hand-edits a non-numeric value (e.g.
max_tokens: "8k" or reference_temperature: "hot"), the coercion raises
ValueError.  Since normalize_moa_config runs on every model-selection
and MoA turn (via resolve_moa_preset), the crash is unrecoverable and
blocks all MoA usage until the config is manually fixed.

Replace the bare casts with _coerce_float / _coerce_int helpers that
fall back to the default on TypeError/ValueError instead of raising.
2026-06-26 14:35:38 -07:00
Teknium 9b2af36d5a docs(moa): document prompt-caching behavior for references and aggregator (#53218)
* docs(moa): document prompt-caching behavior for references and aggregator

* docs(moa): clarify references preserve cache, only aggregator trades reuse

* docs(moa): correct caching prose — tail-append preserves aggregator cache too
2026-06-26 12:58:05 -07:00
Teknium 525e1e775d fix(skills): background review fork respects pinned skills (#53226)
The autonomous self-improvement review fork could still write to a pinned
skill — only external/bundled/hub-installed/protected-builtin skills were
guarded. The curator skips pinned skills from every auto-transition; the
review fork is the same kind of no-user-present actor and must too.

Adds a pin check to _background_review_write_guard so background-origin
edit/patch/delete/write_file/remove_file on a pinned skill are refused.
Stricter than the foreground _pinned_guard (delete-only) by design: with
no user in the loop there is no one to consent to an edit.

Fixes #25839
2026-06-26 12:49:33 -07:00
Nacho Avecilla f509f6e598 fix(dashboard): offload PTY spawn/close off the event loop (#53227)
* Fix blocking tasks on the dashboard

* Remove unnecessary comments
2026-06-26 12:47:23 -07:00
Teknium 217047de2d fix(agent): silence verification-stop loop status line (#53223)
The verify-on-stop guard (#52296) printed '↻ Verification required before
finishing' to the terminal on every internal nudge turn, adding noise to
CLI/gateway sessions whenever code was edited without fresh passing checks.
Demote the user-facing status emit to a logger.debug breadcrumb — the loop
still nudges the model to verify before finishing, just silently.
2026-06-26 11:52:11 -07:00
briandevans 3c8d3ecfa0 fix(approval): extend gateway-lifecycle guard to launchctl and pidof-based kills
The dangerous-command approval layer already blocks `hermes gateway
(stop|restart)`, `pkill/killall hermes|gateway`, and `kill ... $(pgrep ...)`.
A reporter noted on #33071 that the agent can still achieve the same
effect by driving launchd directly against the gateway's service label
(`launchctl stop ai.hermes.gateway`, `launchctl kickstart -k
system/ai.hermes.gateway`, etc.) or by substituting `pidof` for `pgrep`
in the kill-expansion form.

This widens the "Gateway lifecycle protection" block in
`tools/approval.py` to cover both vectors:

- `launchctl (stop|kickstart|bootout|unload|kill|disable|remove)`
  scoped to commands that target a Hermes label (`hermes`,
  `ai.hermes`). Read-only inspection (`launchctl print …`,
  `launchctl list`) and operations against unrelated labels remain
  unflagged.
- `kill ... $(pidof …)` and the backtick form, alongside the existing
  `pgrep` expansion. `pidof` is the BSD/Linux equivalent and is
  equally opaque to the `(pkill|killall) … hermes` name pattern.

Intentionally left out of scope: plain `kill -TERM <numeric_pid>` with
a PID looked up out-of-band. Catching that would require runtime PID
state and would break the existing
`TestPgrepKillExpansion::test_safe_kill_pid_not_flagged` contract,
which guarantees that a plain literal-PID `kill 12345` stays safe.
2026-06-26 11:38:28 -07:00
ethernet ba7026c376 feat(docs): clarify termux/nix as t2 platforoms 2026-06-26 11:37:56 -07:00
ethernet 772cf847b0 feat(docs): clarify platform support 2026-06-26 11:37:56 -07:00
ethernet 699adc2ca5 regen package-lock for integrity checks 2026-06-26 11:37:56 -07:00
brooklyn! ed962104c8 Merge pull request #52935 from NousResearch/bb/desktop-inline-rendering
feat(desktop): inline rich embeds, diagrams & alerts in assistant markdown
2026-06-26 13:36:43 -05:00
Brooklyn Nicholson db6ced4712 feat(desktop): consent gate for inline embeds (per-embed / per-service)
Embeds reach out to third parties on render, so default to a placeholder that
mirrors the tool-approval UX: "Load <service>" (this embed) or "Always allow
<service>" (persisted). A desktop-local store ($embedMode ask|always|off +
per-service allowlist) gates the fetch with zero gateway round-trip; an
Appearance setting controls the global default. Local renderers (mermaid, svg,
alerts) are never gated. Addresses review feedback on outbound third-party
requests.
2026-06-26 13:35:01 -05:00
Teknium 2d3071f9d4 docs(moa): clarify MoA presets are selectable on every surface (CLI, hermes model, Dashboard, Desktop, TUI) (#53211) 2026-06-26 11:16:14 -07:00
Teknium 9dd56f0dfb docs(moa): add HermesBench results to Mixture of Agents page (#53206) 2026-06-26 11:05:07 -07:00
Teknium 3d735fe156 fix(skills-hub): surface per-tap providers (NVIDIA/OpenAI/...) in runtime search (#53191)
Natural-language skill search returned a short, arbitrary list and never
surfaced NVIDIA (or OpenAI/Anthropic/HuggingFace) skills. Two causes:

1. The runtime index collapses every GitHub tap into source="github", so
   there was no way to find or filter by provider at the CLI — the per-tap
   identity only existed in the docs-site catalog.
2. HermesIndexSource.search matched only name/description/tags (not the
   identifier or provider) and broke at the first `limit` hits in raw index
   order, burying the most relevant skills. `search` also defaulted to
   --limit 10 against an 86k-entry catalog.

Changes:
- GitHubSource stamps a per-tap provider label (extra.provider) on each
  skill via github_provider_for(); source stays "github" so dedup/floor/
  index-skip logic is untouched. Flows into the built index.
- HermesIndexSource.search now matches identifier + provider too, and
  collect-then-ranks (exact > prefix > whole-word > substring) instead of
  break-at-limit.
- --source nvidia|openai|anthropic|huggingface|voltagent|gstack|minimax
  provider filters for browse/search (narrows merged results by provider).
- search --limit default 10 -> 25; table Source column shows the provider
  label for github skills.

Tested: 181 unit tests pass; E2E against the live runtime index confirms
'nvidia'/'cuda' searches now surface NVIDIA-provider skills and
--source nvidia narrows to exactly the NVIDIA catalog.
2026-06-26 11:04:41 -07:00
Teknium d430684d7c fix(gateway,windows): respawn gateway windowless after GUI update (#52239)
The post-update gateway restart path relaunched the gateway with the
venv's console `python.exe` (via `get_python_path()` in
`_gateway_run_args_for_profile`). On Windows this leaves a terminal
window open permanently: uv's `venv\Scripts\python.exe` is a launcher
shim that re-execs the *base* console interpreter, which allocates its
own conhost — and `CREATE_NO_WINDOW` cannot suppress that second window.
The clean-start path (`_spawn_detached`) already dodges this by routing
through `_resolve_detached_python` to use the windowless base
`pythonw.exe`; the restart watcher did not.

Symptom (reported on Windows 11): after an in-app GUI update, a console
window for the gateway stays open and never closes. Confirmed on the
reporter's box — the running gateway was `python.exe ... gateway run
--replace` with a live conhost child and the foreground "Press Ctrl+C to
stop" banner, born exactly at the update's "Restarting Windows gateway"
log line.

Fix:
- Add `gateway_windows.windowless_gateway_restart_spec(run_argv)` which
  rewrites a console-python gateway argv into the windowless `pythonw.exe`
  equivalent and returns the cwd + env overlay (VIRTUAL_ENV / PYTHONPATH /
  HERMES_HOME) the base interpreter needs to import `hermes_cli` without
  the venv launcher's site config. No-op on POSIX.
- `_spawn_gateway_restart_watcher` now applies that rewrite on Windows and
  threads cwd= / env= into the inlined respawn Popen. Covers both restart
  entry points (`launch_detached_profile_gateway_restart` and
  `launch_detached_gateway_restart_by_cmdline`). CREATE_NO_WINDOW |
  DETACHED_PROCESS | CREATE_BREAKAWAY_FROM_JOB and the breakaway-denied
  fallback are all preserved.

Verified E2E on a real Windows 11 box: drove the actual watcher against a
dummy old-pid; the respawned gateway came up as `pythonw.exe` (zero
console python, no conhost child) and booted fully (housekeeping + kanban
dispatcher started → imports resolved under the base interpreter).

Tests: TestWindowlessGatewayRestartSpec (behavior) +
TestGatewayDetachedWatcherWindowsFlags regression assert. Pre-existing
Linux-only failures on a Windows host (SIGKILL, systemd, docker-root)
confirmed identical on the bare base.
2026-06-26 17:39:46 +00:00
Teknium bb6a4d2a57 docs(nix): mark Nix/NixOS as no longer explicitly supported (#52975)
Add a deprecation banner to the top of the dedicated Nix & NixOS setup
guide and consistency notes at the Nix sections of installation, updating,
and the plugin-distribution guide. Nix is now best-effort only; the
supported install paths are the curl|bash installer, Docker, and Windows.
2026-06-26 10:17:43 -07:00
kyssta-exe c0568ca95f fix(config): use read_raw_config() in migrations to prevent expanding defaults (#40821) 2026-06-26 22:40:52 +05:30
brooklyn! 5cc4009deb Merge pull request #52828 from helix4u/fix/desktop-backend-update-indicator
fix(desktop): show remote backend updates without counts
2026-06-26 11:49:07 -05:00
kshitij 5038678647 Merge pull request #53110 from NousResearch/salvage/42203-find-shell-prefer-usershell
fix(terminal): prefer $SHELL over bash for background process spawning (#42203)
2026-06-26 21:11:57 +05:30
liuhao1024 d9f1f1a1de fix(terminal): prefer $SHELL over bash for background process spawning (#42203)
On macOS, terminal(background=true) silently failed: the process returned a
session_id and exit_code=0 but the command never ran (empty stdout, no side
effects). Root cause is two interacting issues:

1. _find_shell was aliased to _find_bash, which prefers `shutil.which("bash")`
   → /bin/bash (GNU bash 3.2, still shipped on macOS) over $SHELL (/bin/zsh).
2. process_registry.spawn_local runs [shell, "-lic", "set +m; <cmd>"] with
   stdin=/dev/null. bash 3.2 as a login shell sources ~/.bash_profile, which on
   many macOS setups contains `exec /bin/zsh -l`; that exec replaces bash but
   drops the -c argument, so the command is swallowed (exit 0, no output).

Decouple _find_shell from _find_bash: _find_shell now prefers the user's
configured $SHELL on POSIX (the shell they actually log in with), falling back
to _find_bash when $SHELL is unset/missing. _find_bash is unchanged, so callers
that genuinely need bash (e.g. the _run_bash login-shell snapshot) keep bash
semantics. zsh handles -lic correctly even with redirected stdin.

Salvaged from #42219 by @liuhao1024 (authorship preserved via cherry-pick).
On top of the original (8 unit tests covering $SHELL-set/unset/missing/empty,
Windows-ignores-$SHELL, _find_bash-unchanged), added an E2E regression test
that reproduces the real bash-3.2 login-shell swallow (exit 0 / no file) and
asserts the shell _find_shell selects actually executes a -lic background
command. Mutation-verified: reverting _find_shell to the bash alias fails the
$SHELL-preference test. Bug reproduced directly: /bin/bash 3.2 -lic with a
.bash_profile->exec-zsh creates no file; zsh -lic does.

Closes #42203. Supersedes #42290.
2026-06-26 20:45:32 +05:30
xxxigm 65be0061e0 fix(hermes): heal broken managed Node tree instead of PATH fallback
When a Hermes-managed node/npm/npx shim exists but fails --version, redownload
the pinned nodejs.org bundle under HERMES_HOME/node and retry. Do not fall
back to system npm on PATH when a managed tree is present.

POSIX heal probes node, npm, and npx (npm can break while node still runs).
2026-06-26 20:10:20 +05:30
xxxigm 3c5bcd3eee test(hermes): cover broken managed npm fallback in node resolution
Add POSIX runnable-probe coverage plus Windows fallback wiring that skips
a managed npm.cmd when node_tool_runnable rejects it.
2026-06-26 20:10:20 +05:30
xxxigm 9274f73e48 fix(hermes): fall back when managed node/npm fails health probe
A stale or partial Hermes-managed Node tree under the active HERMES_HOME
can leave bin/npm behind while lib/cli.js is missing. File-existence checks
alone made hermes update pick that broken npm and skip healthy system npm
on PATH. Probe managed candidates with --version before preferring them.
2026-06-26 20:10:20 +05:30
kshitij 7b2c51152a Merge pull request #52990 from NousResearch/salvage/52889-backup-projects-kanban
fix(backup): include projects.db and kanban boards in pre-update snapshot (#52889)
2026-06-26 20:09:15 +05:30
0xDevNinja 9ef49cd78f fix(backup): include projects.db, kanban boards, and sibling stores in pre-update snapshot (#52889)
projects.db (per-profile project store) and kanban.db were missing from
_QUICK_STATE_FILES, so the pre-update quick snapshot never backed them up.
On a desktop upgrade, when the update flow removes/replaces the file and the
post-update schema-init re-creates an empty one, all user-created projects,
folder mappings, the active-project pointer, kanban board bindings, and tasks
vanish silently — no error.

Add the per-profile user-created stores to the snapshot set:
- projects.db               — project store
- response_store.db         — gateway conversation history / tool payloads (WAL)
- memory_store.db           — holographic memory facts/entities (WAL)
- verification_evidence.db  — agent verification audit trail
- kanban.db                 — default board (back-compat <root>/kanban.db)
- kanban/boards             — non-default boards (<root>/kanban/boards/<slug>/kanban.db
                              + metadata); workspaces/ and attachments/ subtrees
                              are skipped as large + regenerable.

Also: the directory-branch of create_quick_snapshot now routes *.db through the
WAL-safe _safe_copy_db (SQLite backup() API), matching the top-level file path —
previously a non-default board DB with an open WAL could be copied inconsistently.

Salvaged from #52930 by @0xDevNinja (authorship preserved via cherry-pick).
On top of the original (which covered only projects.db + the default kanban.db),
this adds: non-default-board coverage, the three sibling per-profile DBs that
meet the same upgrade-wipe criteria, WAL-safe directory copies, and a
workspaces/attachments skip to avoid snapshot bloat (×20 retained). 8 tests,
all mutation-verified; E2E verified snapshot→wipe→restore preserves all six
store types on the real code path.

Closes #52889. Supersedes #52930.
2026-06-26 19:23:33 +05:30
Ben 8ab7246c45 fix(gateway): stamp drain marker with instantiation epoch so a durable-volume restart clears it (NS-570)
The external-drain marker .drain_request.json is written under HERMES_HOME,
which on Hermes Cloud is a persistent Fly volume (/opt/data). A begin-drain
marker therefore SURVIVES the post-update machine restart. But the disruptive
lifecycle actions a drain protects (auto-update / image migrate / env edit /
profile change) all restart the machine — which is exactly the signal the drain
is over. The freshly-restarted gateway re-read the orphaned marker on its
startup reconcile and parked itself back in 'draining', refusing every new turn
indefinitely (NS-570: ~52 min until manually cleared).

Fix: stamp the marker with an identity of THIS container/VM instantiation
(kernel boot_id + PID 1 start time, read from /proc) and treat a marker whose
epoch differs from the current instantiation as absent. A deliberate restart →
new PID 1 → new epoch → stale marker ignored → gateway boots 'running'. A marker
written during the current instantiation (the live drain) still matches; an s6
respawn of just the gateway (PID 1/init unchanged) keeps the same epoch, so an
in-flight drain is still honoured (D4a reversibility preserved).

The staleness check is lenient and never fail-closed: a legacy marker with no
epoch, a corrupt/contentless marker, or an environment with no /proc (epoch
unavailable) all degrade to the original presence-only behaviour. NAS is
untouched — it only ever POSTs begin/cancel-drain over HTTP; the marker file is
purely gateway-internal IPC.

The fix is entirely within gateway/drain_control.py; the watcher and the
dashboard endpoint go through the same drain_requested()/write_drain_request()
chokepoints and need no functional change.
2026-06-26 18:59:41 +05:30
Dr1985 e3db1ef92d fix(macos): clearly distinguish launchd supervision from detached fallback in gateway status
## Description

On macOS 26.x, `launchctl bootstrap` and `launchctl kickstart` return exit code 5 ("Input/output error"), which Hermes already anticipates and handles by spawning a detached fallback process. However, the gateway status reporting is ambiguous:

- `gateway status` says "Gateway service is loaded" (because `launchctl list` returns exit 0)
- But `launchctl print` shows `state = not running` — launchd isn't actually supervising anything
- The detached fallback PID running is invisible to the status command
- Users can't tell whether auto-start at login and auto-restart on crash are available

### Root Cause

Two problems in `hermes_cli/gateway.py`:

1. **`_probe_launchd_service_running()`** (line 1067): Determined launchd service liveness solely by `launchctl list <label>` exit code. On macOS 26, this returns 0 even when the service is only *registered* but not running (output lacks a `"PID"` field). This caused `GatewayRuntimeSnapshot.service_running = True` incorrectly, which suppressed the process/service mismatch warning.

2. **`launchd_status()`** (line 3569): Used the same binary "loaded/not loaded" check without inspecting whether launchd actually has a PID, whether a detached fallback is running, or whether auto-start/restart are available.

### Changes

**`hermes_cli/gateway.py`:**

1. **New `_parse_launchd_pid_from_list_output()` helper** — Extracts the PID from `launchctl list` output. When launchd is actively supervising, the output includes `"PID" = <number>;`. When only registered but not running, no PID field is present.

2. **Fixed `_probe_launchd_service_running()`** — Now requires a PID in the `launchctl list` output to confirm launchd is actually supervising. This correctly sets `service_running = False` when launchd has the service registered but `state = not running`, which triggers the existing process/service mismatch detection.

3. **Reworked `launchd_status()`** — Reports clearly separated information:
   - LaunchAgent plist currentness (stale or current)
   - Whether launchd is actively supervising (with PID)
   - Whether a detached fallback PID is running
   - Whether auto-start at login and auto-restart on crash are available
   - When launchd supervision is known to be unavailable, explains why

4. **Persistent unsupported marker** (`~/.hermes/.gateway-launchd-unsupported`) — Written when `_launchd_fallback_to_detached()` is called (launchd exit 5/125). Allows `launchd_status()` to explain *why* launchd can't supervise even when no fallback process is currently running. Cleared automatically when a future bootstrap/kickstart succeeds (e.g., after an OS update fixes the issue).

5. **Updated `_print_gateway_process_mismatch()`** — Distinguishes the managed detached fallback from a genuinely manual `nohup hermes gateway run`, providing accurate guidance for each case.

### Status Output Examples

**Before** (macOS 26, fallback active):
```
Launchd plist: ~/Library/LaunchAgents/ai.hermes.gateway.plist
✓ Service definition matches the current Hermes install
✓ Gateway service is loaded
{
    "Label" = "ai.hermes.gateway";
    "OnDemand" = true;
    ...
};
```

**After** (macOS 26, fallback active):
```
Launchd plist: ~/Library/LaunchAgents/ai.hermes.gateway.plist
✓ Service definition matches the current Hermes install
⚠ Gateway service is registered but launchd is not supervising it
  launchd cannot manage the gateway on this macOS version.
✓ Detached fallback process is running (PID 12345)
  Cron jobs will fire. Stop with: hermes gateway stop
  ⚠ Auto-start at login and auto-restart on crash are NOT available.
```

**After** (normal launchd supervision):
```
Launchd plist: ~/Library/LaunchAgents/ai.hermes.gateway.plist
✓ Service definition matches the current Hermes install
✓ Gateway is supervised by launchd (PID 12345)
  Auto-start at login and auto-restart on crash are available.
```

### Tests

Updated 5 existing tests and added 11 new tests in `tests/hermes_cli/test_gateway_service.py`:
- PID parsing from `launchctl list` output (with PID, without PID, empty, unquoted PID)
- `_probe_launchd_service_running()` requires PID presence
- Unsupport marker lifecycle (write, clear, persist across fallback)
- Marker cleared on successful bootstrap
- `launchd_status()` reporting: supervised, fallback-running, fallback-unavailable
- Existing fallback tests now verify marker creation

### Related Issues

- Issue #23387 (original macOS 26 launchd workaround)
- Issue #42524 (this issue)
2026-06-26 16:30:30 +05:30
kshitij 1c832762a8 Merge pull request #52983 from kshitijk4poor/chore/author-map-dr1985
chore: add Dr1985 to AUTHOR_MAP for launchd salvage
2026-06-26 16:29:22 +05:30
kyssta-exe 07cc567dfa fix(security): add circuit breaker for tirith crashes to prevent agent hangs (#41400) 2026-06-26 15:26:08 +05:30
brooklyn! ca82d0accc Merge pull request #52993 from NousResearch/bb/desktop-clarify-redesign
feat(desktop): redesign the clarify prompt + fix its awaiting-input states
2026-06-26 03:57:43 -05:00
Brooklyn Nicholson 54b50037e1 fix(desktop): treat a pending prompt as paused-on-you, not working
A clarify/approval/sudo/secret prompt blocks the turn on the user, but the UI
treated it as an in-flight turn: the "thinking" timer kept ticking and Esc
interrupted the run — discarding a question you might want to come back to. Add
$activeSessionAwaitingInput (the pet's awaitingInput concept, scoped to the
active session) and use it to suppress the stall indicator and disarm Esc while a
prompt waits. Clear the session's prompts (and needsInput) on Stop and on turn
end so a resolved/aborted turn can't leave a dead panel or a stuck "needs input"
dot.
2026-06-26 03:55:34 -05:00
Brooklyn Nicholson 8559246bfb feat(desktop): rebuild the clarify prompt to match the chat UI
The inline clarify panel used its own card tokens, an animated ring, and
oversized spacing — out of step with every other tool row. Rebuild it on the
shared --ui-*/--conversation-* tokens: a compact panel, letter-key badges
(A/B/C…) that double as a/b/c… shortcuts, an inline content-sizing "Other" field
(CSS field-sizing — no view swap, no layout shift on focus), and a Continue
button so picking an option selects rather than auto-sends. Selection lives on
the letter badge alone (solid primary; outlined while Other is focused-but-empty).
Also settle the panel into the standard tool block once the turn stops running,
so a stopped turn no longer strands a live, unanswerable prompt.
2026-06-26 03:55:29 -05:00
kshitij 1aa458a1e6 Merge pull request #52920 from NousResearch/salvage/38798-toolset-validation
fix(config): surface invalid platform_toolsets instead of silently dropping tools (#38798)
2026-06-26 14:14:55 +05:30
Brooklyn Nicholson da0ed979fa feat(desktop): zoomable primitive — open full, pan/zoom, copy
Add a content-agnostic Zoomable primitive (useZoomPan hook + overlay viewer):
click to open full-screen, wheel-zoom toward the cursor, drag to pan, toolbar
zoom/reset, and an optional copy action. Wire Mermaid diagrams into it with
copy-as-PNG; reusable for other inline content later.
2026-06-26 03:40:49 -05:00
kshitijk4poor 05ba5f3962 chore: add Dr1985 to AUTHOR_MAP for launchd salvage (#42567) 2026-06-26 14:09:11 +05:30
lEWFkRAD 41ede84b93 fix(config): surface invalid platform_toolsets instead of silently dropping tools (#38798)
A config migration (or hand-edit) that leaves an invalid toolset name in
`platform_toolsets` — e.g. the #38798 corruption that rewrote `hermes-cli` to
the non-existent `hermes` — silently disabled all affected tools:
resolve_toolset() returns [] for an unknown name, so the agent quietly lost its
tools with no error, warning, or log entry and degraded to text-only replies.

Surface it loudly at two points:
- After migration (migrate_config): validate platform_toolsets and record/print
  a warning per unknown name, with a `hermes-<platform>` suggestion when that
  would have been valid (the exact #38798 shape).
- At runtime (_get_platform_tools): if a platform was explicitly configured but
  every toolset name is invalid, log a warning when tools are resolved for a
  session — so an ALREADY-corrupted config is caught at startup, not only on the
  next `hermes update`.

Logic lives in a new pure, side-effect-free helper (toolset_validation.py) with
validate_toolset injected, so it is unit-testable without the tool registry.

Note: the original v25→v26 migration that caused the corruption no longer
exists (config format is now v30; no migration step rewrites toolset names).
This change is the durable defense against the silent-failure mode regardless
of cause, matching the issue's "Expected: log a warning".

Salvaged from #39207 by @lEWFkRAD (authorship preserved via cherry-pick).
Tests: 9 helper cases (incl. the #38798 corruption shape, mixed valid/invalid,
zero-tools state, non-dict/scalar/non-string) + a runtime caplog test — both the
helper warning and the runtime guard mutation-verified to fail without the fix.

Closes #38798. Supersedes #39581 (prevent-in-v25→v26 — that path is gone),
#41006 / #40208 (repair-migration for already-corrupted configs).
2026-06-26 14:07:43 +05:30
Brooklyn Nicholson e36d9862ec feat(desktop): render embeds, fences and alerts in assistant markdown
Wire the embeds module into the markdown surface: bare provider autolinks unfurl
to inline embeds, ```mermaid/```svg fences route to the rich renderers, and
`> [!NOTE]`-style blockquotes become alert callouts. Labeled links stay plain.
2026-06-26 03:22:14 -05:00
Brooklyn Nicholson 0c190083cd feat(desktop): lazy embed renderers + fenced diagrams/alerts
Per-kind renderers, each a lazy split chunk: plain-iframe video/maps (wheel
chains to the transcript; maps gate scroll behind ⌘), the in-document
blockquote-script path for X/Instagram, the dark Spotify player, and the
YouTube iframe. Adds Mermaid and DOMPurify-sanitised SVG fences and GFM alert
callouts, all sized to 33dvh and theme-matched to avoid white color-scheme
artifacts. Main-process stamps a Referer on YouTube embed requests.
2026-06-26 03:22:08 -05:00
Brooklyn Nicholson 81ac562bf0 feat(desktop): inline embed detection + module primitives
Pure, synchronous URL→descriptor matchers for YouTube, Vimeo, Instagram,
Pinterest, TikTok, X, Spotify, Google Maps and OpenStreetMap, plus the shared
embed primitives (error boundary, fail card, escape-html, dark-mode hook,
sizing token). Declares the mermaid + dompurify deps used by the fenced
renderers.
2026-06-26 03:21:59 -05:00
helix4u 063fe4f6ef fix(auxiliary): fallback on invalid provider responses 2026-06-26 13:49:46 +05:30
teknium1andrlaope fbfccbb3ee fix(security): align cron invisible-unicode set with install-time scanner
The cron runtime tripwire (_scan_cron_prompt) used a 10-char invisible-unicode
set while the install-time scanner (threat_patterns.INVISIBLE_CHARS) flags 17.
The cron-local set was missing U+2062-U+2064 (invisible math operators) and
U+2066-U+2069 (directional isolates), so a directive obfuscated with one of
those codepoints (e.g. "ig<U+2063>nore all previous instructions") slipped past
the runtime cron gate while being caught at install time.

Import the canonical set so the cron tripwire and install scanner can't drift
apart again. Emoji-ZWJ protection (_zwj_has_emoji_neighbour) is unchanged.

Fixes #35075

Co-authored-by: rlaope <piyrw9754@gmail.com>
2026-06-26 01:11:11 -07:00
Shannon Sands a0dc92450b Split dashboard PTY reconnect tests 2026-06-26 01:06:02 -07:00
Shannon Sands 41f8126148 Reconnect dashboard PTY chat after socket drops 2026-06-26 01:06:02 -07:00
Shannon Sands 6a319f570f Settle TUI resume scroll after hydration 2026-06-26 01:05:26 -07:00
Teknium 619dc4a561 fix(whatsapp_cloud): resolve reply-to text so the agent sees reply context (#52957)
Replies on WhatsApp Cloud arrived at the agent with reply_to_id set but
reply_to_text=None, so run.py never injected the "[Replying to: ...]"
disambiguation prefix (it gates on reply_to_text). Meta's webhook context
object carries only the quoted message's id, never its text.

Index (chat_id, wamid) -> text in rich_sent_store on every inbound message
and every outbound text send -- the same store that solved the identical
Telegram rich-send problem -- then look up the quoted text in
_build_message_event_from_cloud and populate reply_to_text plus
reply_to_is_own_message, derived from context.from versus the business
number.
2026-06-26 01:05:05 -07:00
Ben 19b2624404 feat(gateway): external drain trigger + accept-gating (begin/cancel + control channel)
Tasks 2.1 + 2.2 + 2.3 of the safe-shutdown plan — the reversible
quiesce-without-restart machinery NAS drives during a lifecycle action (D4a).
These ship together because the endpoint, the control channel, and the gateway
state machine are one coherent slice.

2.2 — control channel (gateway/drain_control.py, new):
The dashboard has no HTTP path into a running gateway (guardrails: "there is NO
external control channel into a running gateway"); restart/drain is driven only
by markers the gateway reacts to. So begin/cancel-drain writes/removes a
presence-based marker .drain_request.json (HERMES_HOME-scoped, atomic write,
never-raises read; a corrupt marker reads as present-contentless → fail-safe
toward quiescing). This is Q-B option A.

2.2 — gateway state machine (gateway/run.py):
- _external_drain_active flag, DISTINCT from the shutdown _draining flag: this
  one does NOT exit the process and is fully reversible.
- _enter_external_drain / _exit_external_drain: idempotent transitions that
  flip gateway_state→draining / →running via _update_runtime_status (preserving
  the live active_agents count). exit refuses to revert to running during a
  real shutdown or after the loop stops (shutdown wins).
- _drain_control_watcher: 1s background task (modelled on _handoff_watcher)
  reconciling accept-state with the marker; honours a marker that survived a
  restart on its first tick. Registered alongside the other watchers in start.
- New-turn accept gate in _handle_message, placed BEFORE the session-slot
  claim: when draining, refuse to START a new turn (so active_agents can only
  fall → no TOCTOU race), while in-flight turns finish untouched. Internal/
  system events (restart-recovery replays, bg-process completions) bypass it.

2.1 — endpoint (hermes_cli/web_server.py):
POST /api/gateway/drain {action: drain|cancel}. Authenticated by the Task-2.0a
token seam (the drain plugin registered this exact path as a token route);
attributes the request to the verified token principal. Begin writes the
marker, cancel removes it — the gateway process owns the actual transition.
Force-override (D6) is NOT here; it maps onto the existing immediate
/api/gateway/restart force path.

Tests (mocked — necessary-not-sufficient; the HARD live gate Q-B is next):
- tests/gateway/test_external_drain_control.py — marker contract (write/clear/
  read/corrupt/atomic), state machine (enter/exit/idempotency/shutdown-wins/
  loop-stopped), watcher reconcile-enter-then-exit, new-turn refusal, and
  in-flight-not-interrupted. 15 tests.
- tests/hermes_cli/test_web_server.py — /api/gateway/drain begin/default-begin/
  cancel/cancel-idempotent/bad-action-400. 6 tests.
- dashboard.drain_auth config section already added in 2.0b commit.

All touched suites green: 301 (gateway+auth) + 9 (web_server endpoints) passed.

Intentionally deferred:
- HARD live-validation gate (Q-B): real isolated `hermes gateway run`, drive a
  real begin-drain marker, prove the 5-point checklist a–e.
- Spec-doc status flip + Phase-2 PR.

Build status: external-drain, restart-drain, status, dashboard-auth, drain-plugin,
token-auth, and web_server-endpoint suites green.
2026-06-26 00:47:19 -07:00
Ben 2e322466b1 feat(dashboard-auth): drain shared-bearer-secret provider plugin
Task 2.0b: the concrete shared-bearer-secret auth provider, the FIRST consumer
of the generic token-auth capability (Task 2.0a). Implements decisions.md Q-A.

plugins/dashboard_auth/drain/ (bundled, discovered like dashboard_auth/basic):
- DrainSecretProvider: non-interactive provider, supports_token=True. Verifies
  an inbound Authorization bearer token against a per-agent shared secret with
  hmac.compare_digest (constant-time, no timing oracle) and, on a match,
  vouches for the caller as the "drain-control" principal scoped to "drain".
  The five interactive ABC methods raise NotImplementedError; verify_session
  returns None (stacks harmlessly in the cookie-verify loop).
- assess_secret_strength(): fail-closed entropy gate. Rejects secrets shorter
  than 43 url-safe-b64 chars (~256 bits), with < 16 distinct characters, or
  below 128 bits Shannon entropy — so a weak/structured/repeated secret can
  never be silently accepted. Enforced both at register() (friendly skip
  reason) and in __init__ (raises — defence in depth).
- register(ctx): no-op + skip reason when HERMES_DASHBOARD_DRAIN_SECRET is
  unset; rejects a weak secret fail-closed (drain endpoint stays gated). On a
  strong secret, registers the provider AND opts /api/gateway/drain into the
  generic token-auth seam via register_token_route().

Config: the secret is a CREDENTIAL → carried via HERMES_DASHBOARD_DRAIN_SECRET
(per-agent, provisioned by NAS at deploy). Behavioural knobs only
(dashboard.drain_auth.{scope,min_secret_chars}) live in config.yaml — added to
DEFAULT_CONFIG with the .env-is-for-secrets rationale documented inline.

Tests: tests/plugins/dashboard_auth/test_drain_provider.py — entropy gate
(strong pass; empty/short/repeated/few-distinct/custom-min reject), verify_token
(match → scoped principal, wrong/empty → None, custom scope), protocol
compliance, interactive-methods-raise, and register() (skip-no-secret,
fail-closed-weak-secret, strong-env-secret registers + route opt-in, config
scope + min_secret_chars). 21 new tests; drain + token-auth suites 44 passed.
Verified the plugin is discovered as dashboard_auth/drain alongside basic/nous.

Intentionally deferred:
- The begin/cancel-drain endpoint handler itself — Task 2.1.
- The dashboard→gateway control channel — Task 2.2.

Build status: dashboard-auth + drain-plugin suites green.
2026-06-26 00:47:19 -07:00
Ben cb9cb6ba1c feat(dashboard-auth): generic non-interactive API-token capability
Task 2.0a of the safe-shutdown drain-coordination plan. Widens the dashboard
auth framework GENERICALLY to support non-interactive (service-to-service)
bearer-token auth, mirroring the existing supports_password precedent. This is
a reusable capability — any future machine-credential provider plugs in without
core changes (decisions.md Q-C). The drain bearer-secret plugin (Task 2.0b) is
the first consumer, not the definition.

- base.py: add TokenPrincipal dataclass (the token analog of Session) +
  supports_token capability flag + verify_token() on the ABC (default raises
  NotImplementedError so a misconfigured provider fails loud). Contract mirrors
  verify_session stacking: return None for unrecognised tokens (never raise),
  raise ProviderError only on a genuine backing-store outage.
- registry.py: list_token_providers() — the supports_token subset, in
  registration order. Empty when none registered (token routes fail closed).
- token_auth.py (new): route-agnostic seam. Routes opt in via
  register_token_route(exact path); token_auth_middleware owns the auth
  decision for those routes only — authenticate via stacked providers, attach
  request.state.token_principal + token_authenticated, pass through. 401 on
  missing/unrecognised token, 503 when a provider was unreachable, untouched
  passthrough for non-token routes. Fails closed (never open).
- web_server.py: install the seam OUTERMOST (registered last → runs first).
  Both downstream gates (legacy auth_middleware + gated_auth_middleware) honour
  request.state.token_authenticated and skip enforcement, so a token-authed
  service request is never bounced to /login.
- audit.py: TOKEN_AUTH_SUCCESS / TOKEN_AUTH_FAILURE events.

Tests: tests/hermes_cli/test_dashboard_token_auth.py — ABC flag default,
verify_token NotImplementedError, registry filter, bearer extraction
(case-insensitive scheme, malformed/non-bearer → ""), provider stacking
(first-match-wins, unreachable-remembered, unreachable-then-valid, buggy
provider doesn't crash the gate), and the seam's passthrough/401/503/
fail-closed behaviour. 29 new tests; full dashboard-auth suite 169 passed.

Intentionally deferred:
- The concrete shared-bearer-secret provider plugin — Task 2.0b.
- The begin/cancel-drain endpoint that registers itself as a token route —
  Task 2.1.

Build status: dashboard-auth + plugin-hook suites green.
2026-06-26 00:47:19 -07:00
Teknium 099df3cd89 fix(security): stop blocking AGENTS.md/SOUL.md that name an agent 'Praxis' (#52925)
The known_c2_framework threat pattern included 'praxis' in its
alternation alongside genuine offensive-security tool brands (Cobalt
Strike, Sliver, Havoc, Mythic, Metasploit, Brainworm). Unlike those
distinctive brand names, 'praxis' is a common English word (Greek for
practice/action) and a legitimate agent name, so any context file that
mentioned an agent named Praxis matched at 'context' scope and the whole
AGENTS.md / SOUL.md was replaced with a [BLOCKED] placeholder before it
reached the system prompt.

Remove 'praxis' from the alternation and add a guard comment: every
token in this list must be a distinctive tool brand, not a common word.
Real C2 brands still fire.
2026-06-26 00:36:01 -07:00
teknium1 4d0dd6bd52 test(mcp): make invalid_client tests interactive under hermetic env
The new _maybe_flag_poisoned_client tests built a provider via
get_or_build_provider without an interactive stdin. Under the hermetic
test env (no TTY, no cached tokens), the non-interactive guard in
mcp_oauth_manager._make_provider raised OAuthNonInteractiveError before
the provider was built, failing 6 tests in CI parity (they passed
locally where stdin was a TTY).

Thread monkeypatch into _provider_with_token_endpoint and present an
interactive stdin, matching the sibling test_manager_builds_hermes_provider_subclass.
2026-06-26 00:35:27 -07:00
Max HsuandClaude Opus 4.8 075f93ad78 fix(mcp): auto-recover from invalid_client on stale OAuth client registration
Fixes #36767.

Two complementary recoveries for the recurring "delete three cache files and
re-auth by hand" ritual when an MCP server's dynamically-registered OAuth
client goes dead server-side (IdP redeploy / DB wipe / rebrand):

- Auto-heal (token-endpoint subset): HermesMCPOAuthProvider now sniffs
  auth-flow responses and, on a 400/401 `invalid_client` from the discovered
  token endpoint, backs up + deletes `<server>.client.json` and `.meta.json`
  and clears the in-memory client so the SDK re-runs RFC 7591 dynamic client
  registration on the next flow. Conservative by construction: only
  dynamically-registered (non config-supplied) clients, only the token
  endpoint, only on a word-boundary `invalid_client` match (so RFC 7591's
  `invalid_client_metadata` does not trip it); best-effort so a miss never
  breaks the live flow. Covers both code-exchange and refresh when the token
  endpoint was discovered. Tokens are preserved.

- `hermes mcp reauth [<name>|--all]`: the reporter's primary symptom — the
  IdP's in-browser "Redirect URI Mismatch" — produces no HTTP signal (the SDK
  only sees a callback timeout), so it cannot be auto-detected. The new
  command re-auths one or ALL `auth: oauth` servers, serially: one browser
  flow at a time, which also fixes the startup popup storm when several
  servers are stale at once. Single-server reauth is factored out of
  `mcp login` and shared.

Tests: +14 (poison helper x2; token-endpoint detection x5 incl. wrong-endpoint,
success-response, pre-registered, and invalid_client_metadata negative guards;
a bridge integration test driving the real async_auth_flow generator to prove
the detection hook preserves the bidirectional asend() forwarding contract;
reauth CLI x6). Verified against the pinned mcp==1.26.0: scripts/run_tests.sh
122/122 green for the touched suites; check-windows-footguns.py and ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 00:35:27 -07:00
Ben Barclay 6e4e5967f7 feat(relay): multi-platform-per-agent — list identity, provision-loop, N-hello, per-frame egress (Phase 1.5) (#52830)
Cut over the agent half of Shape A (D-Q1.5a/b.1/c) to front a SET of platforms on
one relay WS:

- relay_platform_identities() parses GATEWAY_RELAY_PLATFORMS (list) +
  GATEWAY_RELAY_BOT_IDS (JSON keyed map {platform:{botId,username?}}). Cut over
  from the scalar GATEWAY_RELAY_PLATFORM/_BOT_ID (no fallback, D-Q1.5c).
- self_provision_relay() loops one /relay/provision per platform under one
  gatewayId+secret, partial-failure-tolerant.
- WebSocketRelayTransport takes the identity SET, sends one hello per identity
  (connector accumulates the advertised set), and stamps the per-frame
  OutboundFrame.platform + its matching advertised botId on outbound.
- RelayAdapter remembers each chat's underlying source.platform (mirroring the
  existing guild/dm scope capture) and tags the reply's egress platform.
- send_relay_policy() declares one relevance policy per fronted platform (the
  connector keys policy by (tenant,platform,instanceId)).

Single-platform deploys are byte-identical on the wire (1-element list, no per-frame
tag -> connector session-default fallback). typecheck/ruff clean; relay unit 221 pass
(+10 new); all 15 cross-repo E2E drivers green vs connector origin/main.
2026-06-26 17:32:46 +10:00
brooklyn! a2b49e60b6 Merge pull request #52412 from GodsBoy/fix/verify-on-stop-messaging-surface-leak
fix(agent): gate verify-on-stop nudge off for messaging surfaces
2026-06-26 02:30:08 -05:00
kshitij 7d568293f9 Merge pull request #52891 from kshitijk4poor/salvage/52623-aux-host
fix(auxiliary): gate Anthropic base_url override on Anthropic-compatible host (#52608)
2026-06-26 12:24:19 +05:30
konsisumer 3cf900eb67 fix(install): discard managed lockfile churn before stashing 2026-06-25 23:49:11 -07:00
Ben Barclay cb7d1f68f8 fix(relay): accept is_reconnect kwarg in RelayAdapter.connect (#52911)
The gateway reconnect watcher (gateway/run.py) recovers a platform after a
fatal adapter error by building a fresh adapter and calling
connect(is_reconnect=True). Every BasePlatformAdapter implements
connect(*, is_reconnect: bool = False) for this — except RelayAdapter, whose
connect() was bare. So the watcher's recovery path raised:

    TypeError: connect() got an unexpected keyword argument 'is_reconnect'

Observed live on a hosted staging agent: after a fatal relay adapter error the
watcher could never re-establish relay, so the shared-bot inbound never reached
the gateway and Discord DMs stopped (dashboard surfaced the TypeError).

Relay deliberately ignores the flag: the #46621 server-side-queue-preservation
concern doesn't apply, because relay's outage buffer is the connector's durable
buffer (replayed on the transport's re-handshake), not a gateway-side queue the
adapter owns. Routine WS drops are already handled by the transport's own
reconnect supervisor (WebSocketRelayTransport, reconnect=True); the watcher path
is fatal-error recovery, and the fatal handler disconnect()s the old adapter
(cancelling its supervisor) before a fresh adapter+transport is built, so there
is no double-dial.

Adds two regression tests (both proven red without the fix): connect(is_reconnect=True)
reaches the same transport-less RuntimeError instead of TypeError, and the
signature matches BasePlatformAdapter.connect.
2026-06-26 16:46:09 +10:00
brooklyn! 0f81b0d458 Merge pull request #52901 from NousResearch/bb/desktop-tui-lint-fixes
style(desktop,tui): fix all lint/type/formatting issues
2026-06-26 01:07:14 -05:00
Brooklyn Nicholson 62fe9fd101 style(desktop,tui): fix all lint/type/formatting issues
Bring apps/desktop and ui-tui to a clean state for typecheck, eslint,
and prettier:

- Run prettier across both trees (printWidth/wrap drift; prettier is not
  CI-enforced for these JS projects, so main had accumulated drift).
- Apply eslint --fix for padding-line-between-statements and perfectionist
  import/export sorting.
- Manual fixes for non-auto-fixable rules:
  - remove unused node:net import in electron/main.cjs (uses Electron net)
  - replace inline `typeof import(...)` annotations with top-level
    `import type * as EnvModule` in two ui-tui test files
  - scoped eslint-disable no-control-regex on intentional sentinel/ANSI
    regexes (mathUnicode.ts, text.ts)
  - resolve react-hooks/exhaustive-deps per-case: correct swapped/missing
    deps, collapse redundant session.* members, and justified disables on
    settings mount-only data-load effects to preserve run-once behavior

No behavior changes; test pass/fail counts are unchanged from the main
baseline.
2026-06-26 01:04:33 -05:00
Moonsong 4e66bf1f80 fix(auxiliary): gate Anthropic base_url override on Anthropic-compatible host (#52608)
When operator config has provider=anthropic with model.base_url pointing
at a non-Anthropic host (e.g. https://openrouter.ai/api/v1 with provider=anthropic),
the auxiliary Anthropic path was unconditionally applying that override.
Main-session traffic routed correctly because the main path attaches the
right credential for the actual destination, but every side-channel call
(memory extractors, reflection, vision, title generation, janus
extractor/promise) sent ANTHROPIC_API_KEY to the foreign host and 401'd.

Gate the override on hostname == api.anthropic.com. Operators routing main
through a non-Anthropic provider must use that provider's own auxiliary
client; the Anthropic aux path now stays pointed at api.anthropic.com.

Regression tests cover openrouter, openai, anthropic-with-path, empty, and
anthropic-default-base_url cases.
2026-06-26 11:21:05 +05:30
kshitij 7aa32ec82f Merge pull request #52888 from kshitijk4poor/chore/author-map-tranquil-flow
chore: add Tranquil-Flow to AUTHOR_MAP for auxiliary base_url salvage
2026-06-26 11:20:54 +05:30
brooklyn! 2b86e9dae4 Merge pull request #52877 from NousResearch/bb/pet-scale-gesture
feat(desktop): Alt+wheel to scale the pet, never cropped
2026-06-26 00:45:07 -05:00
Brooklyn Nicholson bf60bbb6c5 refactor(desktop): collapse overlay zoom-anchor math 2026-06-26 00:42:19 -05:00
kshitijk4poor fe255ab28b chore: add Tranquil-Flow to AUTHOR_MAP for auxiliary base_url salvage (#52623) 2026-06-26 11:11:33 +05:30
Brooklyn Nicholson 7d1b72a15d feat(desktop): zoom the pet toward the cursor
Alt+wheel now scales about the pixel under the pointer instead of growing from a
corner, so the pet stays put under the cursor instead of running away. In-window
shifts its top-left; the overlay repositions its OS window (cursor-anchored on
wheel, bottom-center for slider-driven changes).
2026-06-26 00:37:45 -05:00
brooklyn! 6ba551e942 Merge pull request #52871 from NousResearch/bb/fix-tui-interrupt-queued
fix(tui-gateway): make stop interrupt queued turns
2026-06-26 00:37:13 -05:00
Brooklyn Nicholson dd980aaba1 feat(desktop): Alt+wheel to scale the pet, never cropped
Hold Alt/Option and scroll over the mascot to resize it (same on Mac and
Windows); the modifier keeps a plain scroll passing through to the page. The
gesture drives the same `display.pet.scale` path as the settings slider.

The popped-out overlay grows its OS window to fit the pet at any scale (anchored
bottom-center) so the sprite is never clipped by the window edge, and the
in-window pet re-clamps against its actual size so growing near an edge can't
crop it. Also makes the overlay click-through per-pixel: only solid sprite
pixels (plus bubble / mail button) are interactive, transparent margins pass
clicks through.
2026-06-26 00:33:22 -05:00
Brooklyn Nicholson 594380d44a fix(tui): make stop interrupt queued desktop turns
Ensure TUI/desktop stop targets the actual conversation thread and cancels any queued next prompt, including the lazy agent-start window, so a stopped session cannot keep running or restart itself.
2026-06-26 00:31:06 -05:00
kshitij a28b939092 Merge pull request #52678 from kshitijk4poor/salvage/52502-fuzzy-boundary
fix(fuzzy-match): preserve boundary space after whitespace-normalized match (#52491)
2026-06-26 10:59:14 +05:30
DavidMetcalfe 27c486e3b1 feat(agent): apply per-reasoning-model stale-timeout floor in stream + non-stream detectors
Wire get_reasoning_stale_timeout_floor() into both stale detectors so known
reasoning models (Nemotron 3 Ultra, OpenAI o1/o3, Opus 4.x thinking, DeepSeek
R1, Qwen QwQ, Grok reasoning) tolerate multi-minute thinking phases instead of
the upstream gateway idle-killing the socket (BrokenPipeError) before first
token. Applied as max(default, floor) — never overrides explicit user config,
never lowers an existing threshold.

The reasoning_timeouts.py allowlist module already landed on main via #52795,
so this salvage carries only the wiring + tests (the duplicate module and the
stale-base MoA reverts from the original PR branch are dropped).

Salvaged from #52238. Fixes #52217.
2026-06-25 22:12:06 -07:00
brooklyn! f4c656b0a0 Merge pull request #52854 from NousResearch/bb/fix-interrupt-partial-reply
fix(interrupt): keep partial streamed reply when stopped mid-response
2026-06-26 00:04:37 -05:00
teknium1 4d04c652f2 fix(curator): make external-skill write guard actually fire during curation
The salvaged #51875 added a background-review write guard in skill_manage
that refuses mutations to skills.external_dirs skills — but it only fires
when is_background_review() is true. The curator's LLM review fork ran with
the default _memory_write_origin='assistant_tool', so the guard never
triggered during the exact curation pass it exists to protect against
(GH-47688).

- Set _memory_write_origin='background_review' on the curator review fork so
  turn_context binds it onto the write-origin ContextVar and the guard fires.
- Add a regression test asserting the fork runs under the background_review
  origin (the invariant linking the fork to the guard).
- AUTHOR_MAP: map yu-xin-c for the salvaged commit.
2026-06-25 22:03:02 -07:00
yu-xin-c 96bc524a71 fix(curator): protect external skills from background curation 2026-06-25 22:03:02 -07:00
teknium1 eed9bbeb0a chore(release): add rebel0789 to AUTHOR_MAP for salvaged PR #47308 2026-06-25 22:02:22 -07:00
teknium1 6c58878e7d fix(browser): force secret-pattern redaction on browser_type display
Force redact_sensitive_text(force=True) on the browser_type text arg so
recognized credentials (API keys, tokens, JWTs) are masked in tool
progress, previews, callbacks, and return payloads even when the global
security.redact_secrets opt-out is set — a typed credential reaching chat
history is a security boundary, not log hygiene. Normal typed text matches
no pattern and stays fully readable for debuggability.

Tests assert the API-key-shaped secret is masked across every surface and
that normal text passes through unchanged.
2026-06-25 22:02:22 -07:00
rebel 8ff426e53b fix: redact browser typed text surfaces 2026-06-25 22:02:22 -07:00
brooklyn! 5add283ec8 Merge pull request #52833 from NousResearch/bb/wsl-desktop-fixes
fix(desktop): WSL2 clipboard paste, titlebar layout, HMR survival, and GPU acceleration
2026-06-25 23:55:42 -05:00
Brooklyn Nicholson 8233598e64 fix(interrupt): keep partial streamed reply when stopped mid-response
Stopping a turn while the model is streaming (stop/esc to redirect) raised
InterruptedError, set final_response to the throwaway "waiting for model
response" sentinel, and persisted messages WITHOUT the assistant text that
was already streamed to the screen. The next turn then had no record of the
half-finished reply, so the model appeared to "forget" what it just said.

Recover the on-screen text from _current_streamed_assistant_text in the
InterruptedError branch and append it as the assistant turn (and surface it
as final_response). The metadata sentinel is kept only when nothing was
streamed yet, preserving the ACP/client suppression behavior.

Completes the partial-stream recovery from 397eae5d9 (which wired the same
_current_streamed_assistant_text salvage into the connection-failure twin
but missed the user-interrupt path). The lossy handler dates to c98ee9852.
2026-06-25 23:54:20 -05:00
Brooklyn Nicholson 76074b2145 fix(desktop): transparent WCO titlebar chrome on Windows/WSLg
Use a transparent native overlay so renderer chrome shows through the min/max/close
band. Sync window pre-paint bg to the computed chrome mix.
2026-06-25 23:50:59 -05:00
Brooklyn Nicholson 3b1344c18c fix(desktop): WSL titlebar layout and WSL2 GPU acceleration
Live-measure WCO width in the renderer, drop the right rail below the titlebar
band, and re-enable GPU compositing under WSLg when /dev/dxg is present.
2026-06-25 23:50:59 -05:00
Brooklyn Nicholson da5484b61f fix(desktop): WSL2 clipboard image paste + Linux titlebar overlay
WSLg bridges clipboard text but not images — pull host screenshots via
PowerShell. Disable titleBarOverlay on plain Linux; gate overlay width per
platform in titlebar-overlay-width.cjs.
2026-06-25 23:50:59 -05:00
Teknium 5b5c79a8ef feat(kanban): typed block reasons + unblock-loop breaker (#52848)
* feat(kanban): typed block reasons + unblock-loop breaker

Stops the kanban blocked-task loop: a worker blocks a task, a cron
unblocks it, the worker re-blocks for the same reason, repeat forever.

block_task now takes a typed kind and a persistent block_recurrences
counter on the tasks table:

- kind=dependency routes to todo (parent-gated, auto-resumed), never
  the human 'blocked' bucket a cron would keep unblocking.
- needs_input/capability/transient/untyped land in blocked; each
  same-cause re-block after an unblock increments block_recurrences,
  and at BLOCK_RECURRENCE_LIMIT (default 2) the task routes to triage
  for a human instead of blocked.
- unblock_task no longer resets block_recurrences (the amnesia that
  let the loop run unbounded); complete_task clears it on success.

Wired through the worker kanban_block tool (new kind arg) and the
hermes kanban block --kind CLI flag, both reporting where the task
actually landed. Docs + 11 new tests; 536 existing kanban tests green.

* test(kanban): make second-block notify test use a distinct block cause

test_notifier_second_blocked_delivers blocked the same task twice with
the same (untyped) reason, which now trips the new unblock-loop breaker
and routes the second block to triage instead of blocked — so only one
'blocked' notification fired. The test's actual intent is that TWO
distinct block cycles each notify; give the two cycles different kinds
(needs_input then capability) so they're genuinely separate blocks. The
same-cause loop→triage path is covered by test_kanban_block_kinds.py.
2026-06-25 21:46:58 -07:00
43b8ba4181 fix(telegram): preserve Bot API update queue on watcher reconnect
After a prolonged outage the in-process network-error ladder escalates to
fatal and GatewayRunner._platform_reconnect_watcher rebuilds a fresh adapter
that reconnects through the bootstrap path. That path called
start_polling(drop_pending_updates=True), discarding every update Telegram
queued during the outage — all messages sent while the bot was down were
silently lost. The in-process ladder and 409-conflict handler already passed
drop_pending_updates=False; only bootstrap did not distinguish a cold first
boot from a reconnect.

Thread an is_reconnect signal from the watcher through
_connect_adapter_with_timeout into adapter.connect(). The base
BasePlatformAdapter.connect() gains a keyword-only is_reconnect=False so every
adapter inherits a tolerant signature (no per-platform breakage when the
runner forwards the kwarg). Telegram translates is_reconnect into
drop_pending_updates=not is_reconnect on both the polling and webhook bootstrap
calls. Cold boot still drops the stale queue; a watcher reconnect preserves it.

Fixes #46621.

Co-authored-by: annguyenNous <annguyen@nousresearch.com>
Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
Co-authored-by: Kewe63 <Kewe63@users.noreply.github.com>
2026-06-25 21:29:57 -07:00
liuhao1024 f44415e71a fix(gateway): add init-time provider fallback to _make_agent
When the primary provider raises AuthError (e.g. expired OAuth token),
_make_agent now walks the configured fallback_providers/fallback_model
chain before giving up — matching the behavior that cron/scheduler.py
and cli_agent_setup_mixin.py already have.

Fixes #47627
2026-06-25 21:21:58 -07:00
Teknium 0b7128582f fix(state): detect and repair FTS write corruption that silently drops gateway history (#52798)
A readable state.db can still reject every message write through the
messages_fts* triggers when the FTS5 index is corrupt: base-table reads and
PRAGMA integrity_check pass, but INSERT INTO messages fails with 'database
disk image is malformed'. The gateway reloads conversation_history from disk
each turn, so a silently-failed write hands the next turn stale/empty history
even though the same cached AIAgent still holds the live transcript — causing
immediate same-session amnesia. (#50502)

- hermes_state.py: _db_opens_cleanly() now drives a rolled-back message write
  through the FTS triggers, so write-only corruption (which the read-only
  probe reported healthy) is detected. repair_state_db_schema() gains an
  in-place FTS5 'rebuild' strategy (tier 0) before the dedup/drop tiers, plus
  an already_healthy short-circuit. Both 'hermes sessions repair' and
  'hermes doctor' route through these, so the fix covers the whole class.
- hermes_cli/doctor.py: the state.db check runs the write-health probe even on
  the success (readable) path and repairs in place with --fix.
- gateway/run.py: _select_cached_agent_history() prefers the cached agent's
  longer live _session_messages over a shorter persisted transcript, so an
  FTS write failure can't wipe in-session context.
- tests: regressions for write-health detection, in-place repair preserving
  rows + resuming writes, the already_healthy shortcut, and the gateway guard.

Combines the approaches from #50504 (@0-CYBERDYNE-SYSTEMS-0, issue author),
#52165 (@davidgut1982), and #50576 (@trevorgordon981).
2026-06-25 21:18:41 -07:00
teknium1 85e084d60d fix(email): reject spoofed From: header for authorization (GHSA-rxqh-5572-8m77)
The email adapter authorized senders entirely off the From: header, which is
attacker-controlled and unauthenticated by IMAP. An attacker could forge
From: an-allowlisted-address and pass both the adapter's EMAIL_ALLOWED_USERS
pre-filter and the gateway's allowlist authz (both key on the same spoofable
sender_addr), getting unauthorized commands executed by the agent.

Verify the From: domain against the trusted Authentication-Results header the
receiving mail server stamps (SPF/DKIM/DMARC) before trusting it for
authorization. Enforced only when an allowlist is in effect and allow-all is
off — fail-closed. Operators whose server does not stamp the header can opt
out via platforms.email.require_authenticated_sender: false (or
EMAIL_TRUST_FROM_HEADER=true).
2026-06-25 21:11:02 -07:00
Ben Barclay dedf5643d8 fix(gateway): scale-to-zero never armed — arm-gate counted disabled placeholder platforms (#52831)
The scale-to-zero idle watcher never started on a correctly-opted-in,
relay-only instance, so the gateway never ran its idle decision, never called
go_dormant(), and never sent going_idle to the connector. Fly's autostop still
suspended the machine on traffic-idle, but the connector never flipped the
instance to buffered-only — so an inbound DM took the live delivery path,
found no live session for the suspended machine, and was dropped fail-closed
with no wake poke. The machine slept and never woke.

Root cause: _scale_to_zero_should_arm() passed list(config.platforms.keys())
to messaging_is_relay_only_or_absent(). config.platforms is pre-seeded with a
DISABLED placeholder PlatformConfig for every known platform (telegram,
discord, slack, matrix, …), so the key set is always the full ~20-entry
catalog regardless of what the instance actually runs. The relay-only check
discarded "relay", saw the disabled placeholders as live direct-socket
platforms, and returned False — so should_arm() was False and the watcher was
never created. Verified live on a staging instance: config.platforms keys =
[telegram, discord, slack, mattermost, matrix, relay] with only relay
enabled=True; should_arm() = False.

Fix: filter config.platforms to ENABLED entries before the relay-only check,
mirroring the adapter-connect loop which already gates on
`if not platform_config.enabled: continue`. This arms off the same notion of
"active platform" the rest of start() already uses — no parallel concept.

Also add a one-line not-armed diagnostic: when an instance IS opted in (the
HERMES_SCALE_TO_ZERO stamp is set) but the watcher still doesn't arm, log why
(relay_only_or_absent, the enabled platforms, wake_url present/missing). A
non-opted instance stays silent. The arm path previously logged only on
success, so a failed arm was invisible.

Tests: the existing pure-helper tests passed bare names so they never
exercised the call site that feeds the placeholder-laden config. Add
behaviour-contract tests against the REAL _scale_to_zero_should_arm with a
realistic config.platforms (relay enabled + others disabled). The F25
regression test (relay-only + disabled placeholders must arm) and the
no-platform case are RED without this fix, GREEN with it; the
genuinely-enabled-direct-platform / not-opted-in / no-wake-url cases stay
correctly non-arming so the filter can't over-broaden.

Wake mechanism itself verified healthy independently (direct wakeUrl GET
resumed a suspended staging instance in 1.15s, clean resume signature).
2026-06-26 14:01:48 +10:00
helix4u 1c8594b634 fix(desktop): show remote backend updates without counts 2026-06-25 21:39:29 -06:00
Teknium a4091e49f1 fix(auth): write rotated Codex/xAI pool grant through to global root (#48415) (#52760)
CredentialPool._sync_device_code_entry_to_auth_store rotated single-use
OAuth refresh tokens but wrote the new chain only into the active profile
store. When a profile resolves a grant from the global-root fallback
(read_credential_pool, #18594) and the pool then refreshes it, root was
left holding a now-revoked refresh token — every other profile reading the
stale root grant subsequently died with refresh_token_reused / invalid_grant
once its access token expired.

This is the credential-pool analog of #43589 (which fixed the non-pool xAI
refresh path in _save_xai_oauth_tokens). Detect the read-from-root case
(profile lacks its own providers.<id> block) BEFORE the profile save and,
after it, write the rotated chain back to the global root via a best-effort,
seat-belted write-through. A profile that genuinely shadows root (owns the
block) is untouched; classic mode (profile == root) is a no-op; a failed root
write never breaks the profile's own save. Covers openai-codex (reported),
xai-oauth, and nous through the shared sync path.
2026-06-25 19:14:06 -07:00
Harjoth Khara 233ef98afe fix(docker): skip symlinked stage2 chown targets (#52789)
Prevents stage2-hook.sh recursive chown from following a symlinked $HERMES_HOME/home (or profiles/cron) and destroying the host user's home directory. Also guards top-level state-file chowns and refuses first-boot seeding through symlinks. Fixes #52781.

Co-authored-by: harjoth <harjoth.khara@gmail.com>
2026-06-26 12:09:52 +10:00
teknium1 1abfa66ba6 chore(release): add DavidMetcalfe to AUTHOR_MAP for PR #52272 salvage 2026-06-25 19:00:48 -07:00
DavidMetcalfe 865a09a610 fix(agent): detect thinking-timeout for reasoning models and surface actionable guidance instead of misleading file-write advice
Two-part fix:

Part 1 (classifier override at agent/error_classifier.py:720-738):
A transport disconnect on a reasoning model — even on a large session —
now routes to FailoverReason.timeout instead of context_overflow. Without
this, large-session reasoning-model disconnects route to the compression
branch and silently delete conversation history on a phantom
context-length error. The override is strictly targeted: non-reasoning
models (gpt-4o, claude-3-5-sonnet, llama-3.3-70b, etc.) still route to
context_overflow on large sessions — the existing intentional behavior
for chat models whose proxy doesn't idle-kill during prefill/generation.

Part 2 (new agent/thinking_timeout_guidance.py + integration at
agent/conversation_loop.py:3488-3567):
New is_thinking_timeout() and build_thinking_timeout_guidance() helpers.
When a known reasoning model (NVIDIA Nemotron 3 Ultra, OpenAI o1/o3,
Anthropic Opus 4.x thinking, DeepSeek R1, Qwen QwQ, xAI Grok reasoning)
hits a transport-kill on a small session (classifier says timeout
directly) or after Part 1 routes correctly (large session), the user
now sees reasoning-specific guidance with three actionable workarounds
in priority order:

  1. Set providers.<provider>.models.<model>.stale_timeout_seconds: 900
     in ~/.hermes/config.yaml (Hermes's built-in floor is already 600s
     for known reasoning models; raise further if upstream is even
     tighter).
  2. Lower reasoning_budget or set reasoning_effort: medium on this
     model if the provider supports it.
  3. Use a smaller / faster reasoning model if the task doesn't
     require deep thinking.

The new guidance takes precedence via if/elif over the existing
_is_stream_drop block, so a reasoning-model user with a transport-kill
message sees actionable advice instead of the misleading "try
execute_code with Python's open() for large files" advice (which is
correct for the unrelated large-file-write stream-drop case but
actively wrong for the thinking-timeout case).

Verified:
- 478 tests passing across 9 directly-relevant files (49 new + 429
  existing, zero regressions).
- Ruff lint clean on all 4 modified/new files.
- Negative test: 6 parametrized regression guards confirm non-reasoning
  models still route to context_overflow on large sessions; 4
  parametrized gates confirm non-timeout classifier reasons never
  trigger the guidance; 5 parametrized cases confirm non-transport
  messages never trigger it.
- Regression guard: new guidance message does NOT contain
  "execute_code" or "open()" — the misleading advice is fully
  replaced, not appended alongside.
- Cross-vendor dual review via agy -p:
  - Gemini 3.5 Flash (Medium) — passed: true, zero blockers, one
    SHOULD-FIX (vprint block duplication — fixed by extracting
    detection into a helper module).
  - GPT-OSS 120B (Medium) — passed: true, zero blockers, two nits
    (test placement — adopted at tests/agent/test_thinking_timeout_guidance.py;
    primary-model capture — accepted as non-issue per Flash's nit).

Dependency note for maintainers:
This PR includes agent/reasoning_timeouts.py (the reasoning-model
allowlist module from PR #52238) because the Layer 1 override is
load-bearing on get_reasoning_stale_timeout_floor(). After PR #52238
lands on main, this PR's duplicate agent/reasoning_timeouts.py should
be rebased away. Either PR can land first; the other rebase is
mechanical.

Fixes #52271.
2026-06-25 19:00:48 -07:00
Teknium 811df74a10 fix(gateway): defer cross-process cache cleanup off the cache lock (#52197) (#52761)
The #45966 cross-process coherence guard popped the stale cached agent
and then called the blocking _cleanup_agent_resources (memory-provider
shutdown, tool-resource teardown, async-client teardown) while still
holding _agent_cache_lock, on the gateway event-loop thread. While that
ran, _sweep_idle_cached_agents (driven by _session_expiry_watcher)
blocked acquiring the same lock and the asyncio loop stalled for minutes,
tripping repeated Discord 'heartbeat blocked' warnings.

Fix mirrors the cap-enforcer / idle-sweep paths: pop the stale entry
under the lock, release it, then schedule the SOFT release on a daemon
thread. The soft path (_release_evicted_agent_soft) is also more correct
here than the hard teardown the regression used — the same session
rebuilds a fresh agent immediately after invalidation, so its terminal
sandbox / browser / bg processes (keyed on task_id) must be preserved
for the rebuilt agent to inherit, not torn down.

Verified the cross-process site was the only cleanup-under-lock instance;
the other _cleanup_agent_resources call sites run outside the lock.
2026-06-25 18:58:47 -07:00
teknium1 e29823f1e8 chore(release): map agt-user noreply email for #48496 salvage 2026-06-25 18:50:11 -07:00
Teknium ce802e932c fix(telegram): heartbeat loop exits cleanly when bot has no get_me
CI shard test_telegram_conflict.py timed out (140s) because the new
_polling_heartbeat_loop, started by connect(), busy-spun under those
tests: they monkeypatch asyncio.sleep to instant and pass a bot double
with no get_me(), so the probe raised AttributeError (swallowed) and the
loop re-entered immediately with no real pacing, starving the event loop.

Guard the loop to return when bot.get_me is not callable — a real PTB Bot
always exposes it, so this only triggers on a torn-down app or a test
double, where there is nothing to probe. Also cancel the heartbeat task in
the conflict tests that call connect() without disconnect(), matching the
production disconnect() teardown.

Verified: test_telegram_conflict.py now runs in ~4.5s; the 22
heartbeat/reconnect tests still pass; E2E confirms a hanging get_me still
fires the reconnect ladder while a missing get_me exits without spinning.
2026-06-25 18:50:11 -07:00
agt-user 8501caf51f fix(telegram): persistent heartbeat loop to detect CLOSE-WAIT polling sockets
When a Telegram long-poll TCP socket enters CLOSE-WAIT (remote sent FIN
but httpx hasn't noticed), epoll still reports it readable so no
exception is raised. PTB's error_callback never fires, the reconnect
ladder never engages, and the gateway silently stops receiving messages
while the process stays alive — until a manual systemctl restart.

The existing recovery only covers two cases: error_callback-driven
reconnects (which require an exception PTB never gets) and a one-shot
_verify_polling_after_reconnect probe (which runs only right after an
explicit reconnect). A socket that wedges during steady-state operation
is never detected.

Add _polling_heartbeat_loop: a background asyncio.Task started in
connect() (polling mode only) that probes get_me() every 90s on the
general request pool (not the getUpdates pool, so healthy long-polls are
never interrupted). On asyncio.TimeoutError/OSError it hands off to the
existing _handle_polling_network_error ladder; other errors are
swallowed. disconnect() cancels and awaits the task. Worst-case
detection window ~105s.

Complementary to #51541 (general-pool keepalive limits / fd leak) — that
recycles idle pooled connections; this detects a wedged active read.

Fixes #48495

Co-authored-by: agt-user <267614622+agt-user@users.noreply.github.com>
2026-06-25 18:50:11 -07:00
liuhao1024 56cf517ccd fix(cron): detect partial job loss in restore_cron_jobs_if_emptied (#52144)
The desktop scheduler can overwrite cron/jobs.json with its own small
set of internally-tracked crons after an update/restart, causing
partial loss of tool-created cron jobs. The previous guard only
checked for total loss (live_count == 0), missing the case where
live_count > 0 but less than the pre-update snapshot count.

Compare live_count against snap_count instead of checking for zero,
so both total loss (0 vs N) and partial loss (1 vs 19) trigger
restoration.

Salvaged from #52161 by @liuhao1024.

Closes #52144
2026-06-25 18:49:18 -07:00
brooklyn! 6b639bc2b9 Merge pull request #52772 from NousResearch/bb/editor
feat(desktop): in-app spot editor for the file preview pane
2026-06-25 20:25:06 -05:00
brooklyn! 41f4dce828 Merge pull request #52756 from NousResearch/bb/delegate-bg-resume-ux
feat(delegation): calm "will resume" affordance for background delegate_task
2026-06-25 20:08:06 -05:00
Brooklyn Nicholson 985350dd85 feat(cli): note background delegate_task dispatch in _on_tool_complete
A top-level delegate_task dispatches in the background and re-enters as a
fresh turn when done. Print a one-line dispatch-time note — no spinner,
nothing to poll — so the idle prompt doesn't read as "nothing happened."
2026-06-25 19:57:58 -05:00
Brooklyn Nicholson 7f02f30b76 feat(tui): add width-budgeted "resumes when subagent finishes" status segment
When idle with a background subagent still in flight, append a tail status
segment spelling out that the agent resumes on its own. Width-budgeted like
every tail segment, so it drops first on a tight terminal where the ⛓ count
already carries the signal.
2026-06-25 19:57:58 -05:00
Brooklyn Nicholson 563d347e4d feat(desktop): show a calm "will resume" notice for background delegate_task
When idle with a top-level delegate_task still in flight, render a static,
shimmering system-note at the transcript tail instead of a spinner (which
reads as "stuck"). Reuses the shared steer / slash-status chrome (centered,
0.6875rem, muted, Codicon) so it sits in the thread like every other meta
line, and mirrors the primary child's latest stream line, falling back to
generic copy. i18n across en/ja/zh/zh-hant; markdown prose/heading rhythm
tuned so a re-entered turn breathes.
2026-06-25 19:57:51 -05:00
Brooklyn Nicholson 6e096a850a feat(desktop): add $backgroundResume store for parked delegate_task
Track top-level delegate_task work that dispatches in the background and
re-enters as a fresh turn. $backgroundResume returns {count, activity} for
the active session while idle — count of parked tasks plus the primary
child's latest stream line (tool/progress/thinking) when readable.
2026-06-25 19:57:45 -05:00
Brooklyn Nicholson 09623b4527 fix(desktop): make the tab modified dot amber with a separating ring
Use the app's amber warn color for the unsaved-edits tab dot (was inheriting
the label text color) and add a tab-bg ring + soft drop shadow so it stays
legible where it overlaps the filename.
2026-06-25 19:55:31 -05:00
Brooklyn Nicholson c456029b4e Merge remote-tracking branch 'origin/main' into bb/editor 2026-06-25 19:53:31 -05:00
Brooklyn Nicholson 1f950e189c feat(desktop): vertical resize for the bottom-row terminal pane
Extends the pane store with heightOverride (alongside widthOverride) and a
get/set/clear API, and wires the pane shell + desktop controller so the
bottom-row terminal pane can be resized on the Y axis with its size persisted.
2026-06-25 19:50:29 -05:00
Brooklyn Nicholson ff81365988 feat(desktop): in-app spot editor for the file preview pane
Adds a CodeMirror 6 spot editor to the right-rail file preview so users can
make quick edits in-app without leaving for an IDE. Entering edit mode is a
pure in-place swap of the read view — same fixed-height header, same gutter
geometry/typography (mirrors SourceView 1:1) so nothing shifts — toggled via
the Edit button, a bare `e` when the pane is hovered/focused, or the tab.

- Save path is transport-agnostic (writeDesktopFileText): local Electron IPC
  or a new hardened POST /api/fs/write-text on the dashboard server (path
  validation, parent-must-exist, regular-files-only, size cap, atomic
  temp-file + os.replace), behind the existing auth middleware.
- Stale-on-disk guard re-reads before writing and offers overwrite vs
  discard-and-reload instead of clobbering external/agent edits.
- VS Code-style modified dot on the tab; ⌘/Ctrl+S and ⌘/Ctrl+Enter save,
  Esc cancels; GitHub highlight style matched to the read view's Shiki theme.
- Typing stays render-free (draft in a ref; dirty flips once at the boundary).
2026-06-25 19:50:25 -05:00
Que0x b8fc8c908b fix(approval): fold Windows absolute home paths in dangerous-command detection
The detector folds absolute home / Hermes-home prefixes into their canonical
~/ and ~/.hermes/ forms so static patterns catch /home/alice/.bashrc the same
way they catch ~/.bashrc (abd69b81). On native Windows this fold never fired,
so terminal commands writing to shell startup files, ~/.ssh/authorized_keys,
or ~/.hermes/config.yaml / .env returned "safe" and skipped the approval
prompt — and config.yaml carries the approval policy itself.

Two compounding causes:

1. The fold ran after the backslash-escape strip (r\m -> rm), which dissolves
   the backslash separators in a Windows path (C:\Users\alice\.bashrc ->
   C:Usersalice...) before the fold could match. It now runs before the strip.
2. The fold only recognized POSIX absolute paths and only the home prefix,
   leaving multi-segment backslash suffixes (\.ssh\authorized_keys) to be
   mangled by the strip.

Consolidated into _home_prefix_fold_regex / _fold_home_prefixes: match a home
prefix with either separator, capture the rest of the path token, and
normalize its separators to / so multi-segment patterns match. The
degenerate-path guard generalizes count("/") >= 2 to "at least two components
below the root" (also rejecting a bare drive root C:\). HOME is consulted
directly because Windows' expanduser ignores it; the more specific Hermes home
is folded first, longest candidate first, so neither fold clobbers the other.

POSIX behavior unchanged; the r\m -> rm anti-obfuscation strip still runs.
Adds TestWindowsAbsolutePathFolding, which monkeypatches a Windows-style
HOME/HERMES_HOME so the behavior is also exercised on the CI runner.
2026-06-25 17:49:39 -07:00
brooklyn! 7cd5eaa646 Merge pull request #52745 from NousResearch/desktop/bundle-main
desktop: bundle main.cjs for electron
2026-06-25 19:06:59 -05:00
ethernet df514654ba desktop: bundle main.cjs for electron
fixes simple-git not found
2026-06-25 20:05:20 -04:00
brooklyn! 55af6c447a Merge pull request #52206 from NousResearch/bb/desktop-tools-curation
fix(desktop): hide platform/internal toolsets from the Skills & Tools list
2026-06-25 18:56:04 -05:00
teknium1 6dfb8326f5 fix(state): exclude delegate/branch/tool children from resume walk + reconcile salvaged fixes
Follow-up to the salvage of #45035 + #48682. The two PRs touched different
functions (resolve_resume_session_id vs get_compression_tip) but #45035's
descendant walk followed ANY parent_session_id child, so a delegate/subagent
child could hijack the resume target. Apply the same _branched_from /
_delegate_from / source!='tool' exclusion the rest of hermes_state.py uses,
so the resume walk only follows genuine compression continuations.

Also updates the unrealistic delegation test fixture to carry the real
_delegate_from marker, and updates 3 list_sessions_rich test mocks for the
order_by_last_active kwarg #48682 added.

AUTHOR_MAP: map PINKIIILQWQ + ailang323 salvage authors.
2026-06-25 16:29:09 -07:00
longer 6d9ca04574 fix(desktop): resume latest compression continuation 2026-06-25 16:29:09 -07:00
Pink 263f6b03eb chore: rename test to reflect new semantics of resolve_resume_session_id 2026-06-25 16:29:09 -07:00
PINKIIILQWQ abd6b85200 fix(state): resolve compression chain tip in resolve_resume_session_id
After context compression, the parent session holds pre-compression messages
and a child (or deeper descendant) holds the continuation.
resolve_resume_session_id() short-circuited when the input session already
had messages (row is not None -> return session_id), causing REST API
endpoints, gateway resume, and CLI resume to serve stale parent messages.

Remove the early-return. Walk the full descendant chain, record the
deepest node that has messages (best), and return best if not None
else the original session_id (preserving the empty-chain fallback).

Callers (api_server.py, web_server.py, cli_agent_setup_mixin.py,
cli_commands_mixin.py) all use the resolved != input -> redirect pattern
and are transparent to this change.
2026-06-25 16:29:09 -07:00
Teknium 208f0d7c3b fix(update): default pre-update backup to off (#52729)
The pre-update HERMES_HOME zip shipped on by default (DEFAULT_CONFIG +
runtime fallback both True), so every `hermes update` zipped the entire
~/.hermes — sessions DB, caches, skills — adding minutes to each update.
The shipped cli-config.yaml.example, the --backup help, and the example
config all already said "off by default," so the live default
contradicted its own documentation.

Flip the default to off everywhere: DEFAULT_CONFIG, the runtime
`.get(..., False)` fallback in _run_pre_update_backup, and the stale
--backup help string. Users who want the #48200 safety net opt in via
updates.pre_update_backup: true or --backup for a single run.

Updated test_default_enabled_creates_backup -> test_default_disabled_is_silent
to assert the new default (silent no-op, no zip).
2026-06-25 16:01:09 -07:00
kshitijand0xDevNinja e4ff494860 fix(cron): add default retention to per-run job output (#52383) (#52646)
* fix(cron): add default retention to per-run job output to bound disk usage (#52383)

Per-run cron output (cron/output/<job>/<timestamp>.md) is written once
per execution and was never pruned, so a frequently-scheduled job on
a long-running deploy accumulates one file per run indefinitely and
can fill the volume ('no space left on device').

save_job_output() now keeps the most recent N output files per job and
removes older ones. N defaults to 50 and is configurable via
cron.output_retention; a non-positive value disables pruning for
operators who manage cleanup externally.

Salvaged from #52402 by @0xDevNinja.

Closes #52383

* fix(config): add cron.output_retention to DEFAULT_CONFIG

Follow-up to #52383: the retention config key was functional via
get()-with-default but missing from DEFAULT_CONFIG, so the deep-merge
wouldn't auto-populate it for new installs. Add it explicitly.

---------

Co-authored-by: 0xDevNinja <manmit0x@gmail.com>
2026-06-25 16:00:13 -07:00
brooklyn! ffa3d3c811 Merge pull request #49037 from NousResearch/bb/projects-paradigm
feat(desktop): first-class projects — sidebar, coding rail, review pane, and agent project tools
2026-06-25 17:49:05 -05:00
Teknium fd2a35b169 fix: stop reporting cache-hit rate and cost across all UI surfaces (#52717)
* fix: stop reporting cache-hit rate and cost across all UI surfaces

Cost estimates and cache read/write token reporting are unreliable on
providers that don't surface cached_tokens (e.g. ollama-cloud, which doesn't
implement prompt_tokens_details.cached_tokens), producing misleading
near-zero 'cache hit' readouts and cost figures. Remove cost + cache-hit
reporting from every user-facing surface; keep input/output/total token
counts (provider-agnostic and accurate) and the Nous account billing UI
(real account money, separate from per-conversation estimates).

Surfaces:
- CLI /usage + model-info: drop cost lines + cache read/write token lines
- Gateway /usage + /model: drop cost + cache lines
- tui_gateway/server.py: stop emitting cost_usd / cache_read in usage and
  subagent.complete payloads
- TUI (Ink): drop cost from status bar (+ showCost plumbing), /usage panel,
  thinking rollup, agents overlay (incl. compare view); keep token counts
- Desktop Command Center: drop cost stat, per-model cost, actual-cost hint

Underlying estimate_usage_cost / format_cost / insights cost columns are
left intact but no longer surfaced (display-only change, reversible).

* test: update TUI + gateway + CLI tests for removed cost/cache-hit reporting

- CLI /usage test asserts cost/cache lines are absent, tokens present
- gateway /usage test drops cost + cache asserts; removes cost-included test
- TUI subagentTree summary expectation drops the cost segment
- useConfigSync + appChrome status-rule tests drop showCost prop/state
2026-06-25 15:21:22 -07:00
Brooklyn Nicholson 19ca295a84 fix(desktop): clarify branch convert actions
Open checked-out branches, switch the primary checkout for the default branch, and create linked worktrees only for non-trunk free branches.
2026-06-25 17:19:36 -05:00
teknium1 3e99ec0ff9 test(hermes_state): cover update_session_billing_route overwrite + prompt null
Regression for the salvaged #48254 fix: billing route is first-writer-wins
via update_token_counts (COALESCE), so a mid-session provider switch left
the dashboard attributing cost to the original provider. Asserts the new
update_session_billing_route() overwrites unconditionally, nulls system_prompt
so the next turn rebuilds Model:/Provider:, and preserves billing_mode when
omitted (COALESCE on None).
2026-06-25 14:44:00 -07:00
x7peeps c7e934a5b4 fix(hermes_state): persist billing provider/base_url after mid-session /model switch
The session database records billing_provider and billing_base_url using
COALESCE(column, ?) in update_token_counts(), making them write-once.
When a user switches models mid-session via /model, the runtime (agent.provider,
agent.base_url) updates correctly, but the session row never reflects the new
provider. This causes the dashboard Models page to display a stale provider
badge and misattributes token usage / cost analytics.

Fix: add update_session_billing_route() that unconditionally sets
billing_provider, billing_base_url, and billing_mode (no COALESCE), and call
it from switch_model() in agent_runtime_helpers.py after the swap succeeds.

This follows the same pattern as update_session_model() which already
unconditionally updates the model column (added for the identical COALESCE
problem on the model field).

Closes #48248
2026-06-25 14:44:00 -07:00
Gille bf0513bca0 test(windows): align gateway restart CI coverage 2026-06-25 14:42:38 -07:00
Gille e7d2f0b93c fix(windows): suppress console flashes and harden gateway restarts 2026-06-25 14:42:38 -07:00
Brooklyn Nicholson 9f3aa1685c fix(cli): register project command beside MoA 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson 890e890281 chore(desktop): update package lock 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson a391523bcc i18n(desktop): add project and worktree strings 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson b8d220f268 feat(desktop): wire project settings and shell chrome 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson 62af32efe7 feat(desktop): keep active sessions aligned with cwd 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson 68680db10d feat(desktop): add Codex-style review pane 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson 7a7f9a5b3d feat(desktop): add composer coding rail and worktree flow 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson 488ae376db feat(desktop): render backend-authoritative projects sidebar 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson 74352a1e61 feat(desktop): add project and coding stores 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson 344415892f feat(desktop): add shared project UI primitives 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson e2b8018729 feat(desktop): add git worktree and review IPC 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson 86e748df13 fix(agent): require code for coding posture 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson cb3f8ec03d fix(tools): isolate per-session worktree cwd 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson 4ffdedd369 feat(tools): add project workspace tools 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson 4e023f5bc9 feat(gateway): build authoritative project tree 2026-06-25 16:40:27 -05:00
Brooklyn Nicholson e7811345c1 feat(kanban): link tasks to project worktrees 2026-06-25 16:40:26 -05:00
Brooklyn Nicholson 8a45ce2dd4 feat(projects): add per-profile project store 2026-06-25 16:40:26 -05:00
Brooklyn Nicholson 4cdd1a3230 feat(sessions): record git workspace metadata 2026-06-25 16:40:26 -05:00
brooklyn! c4ba4770eb Merge pull request #52704 from NousResearch/bb/desktop-root-boundary-recover
fix(desktop): recover root error boundary from transient render races (salvage #41787)
2026-06-25 16:17:18 -05:00
brooklyn! 43f9d24513 Merge pull request #52703 from NousResearch/bb/desktop-resume-cross-wired-cache
fix(desktop): reject cross-wired runtime-id cache on session resume (salvage #50464)
2026-06-25 16:17:14 -05:00
Brooklyn Nicholsonandizumi0uu 2e3efce66e fix(desktop): recover the root error boundary from transient render races
A stale-index render race in assistant-ui (a just-shrunk thread rendered
at an old message index during a session switch / teardown) throws
errors like "tapClientLookup: Index N out of bounds", "Cannot read
properties of undefined (reading 'type')", or "Tried to unmount a fiber
that is already unmounted". These bubble to the root ErrorBoundary and
latch the WHOLE desktop app on the "Reload window" fallback even though
the next render against fresh state would be fine.

Teach the root boundary to treat that small set of known-transient
renderer errors as recoverable: log them and schedule a next-tick
reset() so React re-renders against current state instead of stranding
the user on the fallback.

Auto-recovery is BOUNDED -- at most MAX_RECOVERIES (3) attempts within a
5s window -- so a genuinely persistent error can't spin the boundary in
a reset -> throw -> reset loop; after the budget is spent the fallback
is left up for the user. Manual retry (the button) resets the budget.
Only the root boundary auto-recovers; scoped boundaries keep their own
fallbacks, and unrecognized errors are never swallowed.

Tests: transient race recovers (fallback never sticks), a persistent
recoverable error stops at the cap and surfaces the fallback (proving
the loop is bounded), and neither a non-root boundary nor an
unrecognized root error auto-recovers.

Closes #41693. Supersedes #41787 by @izumi0uu, reimplemented with a
bounded recovery budget so a non-transient error can't loop forever.

Co-authored-by: izumi0uu <izumi0uu@gmail.com>
2026-06-25 16:15:20 -05:00
Brooklyn Nicholsonandprofessorpalmer f7bf740640 fix(desktop): reject cross-wired runtime-id cache on session resume
resumeSession's warm-cache fast-path trusted the
storedSessionId -> runtimeId -> ClientSessionState mapping without
checking the cached state still BELONGS to the session being resumed.
A pooled profile backend that gets idle-reaped and respawned
(pruneSecondaryGateways) re-mints runtime ids, so a recycled id can
resolve to a live-but-DIFFERENT session's cache entry. The only
existing guard was a session.usage 404 -- that catches a fully-dead
runtime id, but a recycled id still 200s, so the fast-path happily
painted the wrong transcript under the current route (open chat A,
chat B loads).

Fold the belongs-to check into a single takeWarmCache() helper used at
BOTH cache reads -- the early transcript-keep decision and the fast-path
itself -- so a cross-wired entry can't even briefly flash a stale
transcript before the full resume repaints. On a mismatch the helper
purges both stale map entries and reports a miss, falling through to a
full resume that rebinds a correct runtime id. The full-resume path
already guards its final paint with isCurrentResume(), so only the
cached fast-path was missing the belongs-to check.

Pre-existing bug from the initial desktop app (#20059); not introduced
by the session-switch perf work (#49807), which left these lines
untouched.

Tests: two cases in use-session-actions.test.tsx driven through a
harness that owns the two cache maps -- a cross-wired mapping is
rejected + purged (the bug), and a correctly-wired cache still serves
from memory with no needless refetch (no perf regression).

Supersedes #50464 by @professorpalmer, reimplemented to also guard the
early transcript-keep read (whole-class fix, not just the fast-path).

Co-authored-by: professorpalmer <professorpalmer@users.noreply.github.com>
2026-06-25 16:11:18 -05:00
Teknium c6575df927 feat(moa): expose MoA presets as selectable virtual models (#46081)
* feat(moa): expose MoA presets as selectable virtual models

Reconstructed onto current main (PR #46081's base had diverged with no common
ancestor, marking the PR dirty so CI never dispatched). MoA is now a virtual
provider: each named preset is a selectable model under provider 'moa', and the
preset's aggregator is the acting model that answers and calls tools.

Reference models fan out in parallel via a bounded ThreadPoolExecutor (the same
batch pattern delegate_task uses) — all references dispatched at once, collected
when every one finishes, then handed to the aggregator. Output order is
preserved, failures and the MoA-recursion guard stay isolated per reference.

- Removed the old mixture_of_agents model tool and moa toolset.
- Added moa as a virtual provider in the provider/model inventory.
- /moa is shortcut behavior over model selection (default preset / named preset
  / one-shot prompt).
- Dashboard + Desktop manage named presets; presets appear in model pickers.
- Parallel reference fan-out in agent/moa_loop.py with regression test.

* fix(moa): thread moa_config through _run_agent to _run_agent_inner

The reconstructed gateway MoA wiring declared moa_config on _run_agent (the
profile-scoping wrapper) and used it inside _run_agent_inner, but the wrapper
never forwarded it — _run_agent_inner had no such parameter, so the runtime hit
NameError: name 'moa_config' is not defined on the compression-failure session
sync path. Add moa_config to _run_agent_inner's signature and forward it from
both wrapper call sites (multiplex and non-multiplex). Caught by
tests/gateway/test_compression_failure_session_sync.py on CI shard test(4).

* fix(moa): classify moa as a virtual provider in the catalog

The moa virtual provider has no PROVIDER_REGISTRY/ProviderProfile entry, so
provider_catalog() fell through to the default auth_type="api_key" with no
env vars — tripping two catalog invariants:
  - test_provider_catalog: api_key providers must expose a credential env var
  - test_provider_parity: every hermes-model provider must be desktop-configurable

moa already declares auth_type="virtual" in HERMES_OVERLAYS; consult that
overlay as an auth_type fallback so the catalog reports moa as virtual (no real
credential, no network endpoint). Exempt virtual providers from the desktop
parity union check the same way 'custom' is exempt — derived from the catalog,
not a hardcoded slug, so future virtual providers are covered too.
2026-06-25 13:52:06 -07:00
teknium1 f284d85efa fix(cron): restore [SILENT] silence + suppress empty-turn explainer on Telegram
Scheduled jobs delivering to Telegram/etc. started posting a literal
'⚠️ No reply: the model returned empty content…' message instead of
staying silent. Two interacting causes:

1. The turn-completion explainer (#34452) replaces an empty model turn
   with a user-facing '⚠️ No reply…' string. In a cron context that is
   not a silence marker, so the scheduler delivered it — a regression
   from the previously-silent empty turn. run_job now detects the
   explainer text deterministically (via the same formatter that
   produced it) for abnormal-empty turn_exit_reasons and strips it to
   empty, so the existing empty-response suppression + soft-fail guard
   apply. The explainer is unchanged on CLI/gateway.

2. The cron suppression used a loose 'SILENT_MARKER in ...upper()'
   substring check. It leaked bracketless near-markers the model emits
   ('SILENT', 'NO_REPLY', 'NO REPLY' — #51438, #46917) and wrongly
   swallowed a real report that merely quoted '[SILENT]' mid-sentence.
   Replaced with _is_cron_silence_response(): suppresses a canonical
   token as the whole response, its own first/last line, or the
   documented bracketed '[SILENT] <note>' prefix — while a token buried
   mid-sentence in a genuine report is delivered. Preserves the
   intentional cron trailing/prefix tolerance (existing tests unchanged).

Tests: bracketless-variant suppression, mid-sentence-quote delivery,
direct matcher contract, and explainer-strip + defensive real-report
delivery.
2026-06-25 13:45:09 -07:00
liuhao1024 f23d077b5f fix(fuzzy-match): preserve boundary space after whitespace-normalized match
The trailing-whitespace expansion in _map_normalized_positions
unconditionally consumed whitespace after the matched region — including
the word-boundary space that separates the match from the next token.
This caused silent file corruption when the fuzzy matcher fell back to
the whitespace_normalized strategy.

Guard the expansion on the normalized match actually ending with
whitespace (i.e. the original had a run of spaces that were collapsed).
When the match ends with a non-space character, the first whitespace in
the original is a boundary and must not be consumed.

Fixes #52491
2026-06-26 01:55:27 +05:30
GodsBoy f168631be0 fix(agent): gate verify-on-stop nudge off for messaging surfaces
The verify-on-stop guard (PRs #52296, #52297) defaulted ON for every
session, so on gateway messaging surfaces (Telegram, Discord, etc.) the
model complied with the nudge by writing a hermes-verify temp script and
emitting an ad-hoc verification summary, which the gateway delivered to
the end user as chat noise.

Resolve a surface-aware default instead. The DEFAULT_CONFIG value becomes
the sentinel "auto", which verify_on_stop_enabled() resolves to ON for
interactive coding surfaces (CLI, TUI, desktop) and programmatic callers,
and OFF for conversational messaging surfaces. The surface is read from
HERMES_SESSION_PLATFORM (what the gateway actually binds), with
HERMES_SESSION_SOURCE and HERMES_PLATFORM as fallbacks, matching the
sibling resolution in skill_commands.py and prompt_builder.py. An explicit
HERMES_VERIFY_ON_STOP env var or a boolean agent.verify_on_stop config
still overrides in either direction.

The passive evidence ledger and the call site are untouched.
2026-06-25 10:05:04 +02:00
Brooklyn Nicholson 00779800f6 fix(desktop): hide platform/internal toolsets from the Skills & Tools list
GET /api/tools/toolsets returns the full CONFIGURABLE_TOOLSETS set with no
desktop curation, so the Skills & Tools → Toolsets list shows entries that
don't belong in a flat per-user toggle: platform-coupled toolsets (discord,
discord_admin, yuanbao — which `hermes tools` already platform-restricts off
the CLI) and internal plumbing (context_engine, moa). `hermes tools` curates
these out; the desktop didn't.

Add a small documented block-list + predicate (mirroring
desktop-slash-commands.ts) and apply it in the toolset list filter. Hiding a
row is cosmetic — enabled state and runtime gating are untouched.
2026-06-24 19:18:29 -05:00
882 changed files with 58987 additions and 10606 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
watch_file pyproject.toml uv.lock
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix nix/hermes-agent.nix nix/desktop.nix
use flake
@@ -1,50 +0,0 @@
name: Hermes smoke test
description: >
Run the image's built-in entrypoint against `--help` and `dashboard --help`
to catch basic runtime regressions before publishing. Requires the image
to already be loaded into the local Docker daemon under `image`.
Works identically on amd64 and arm64 runners.
inputs:
image:
description: Fully-qualified image tag (e.g. nousresearch/hermes-agent:test)
required: true
runs:
using: composite
steps:
- name: Ensure /tmp/hermes-test is hermes-writable
shell: bash
run: |
# The image runs as the hermes user (UID 10000). GitHub Actions
# creates /tmp/hermes-test root-owned by default, which hermes
# can't write to — chown it to match the in-container UID before
# bind-mounting. Real users doing `docker run -v ~/.hermes:...`
# with their own UID hit the same issue and have their own
# remediations (HERMES_UID env var, or chown locally).
mkdir -p /tmp/hermes-test
sudo chown -R 10000:10000 /tmp/hermes-test
- name: hermes --help
shell: bash
run: |
# Use the image's real ENTRYPOINT (/init + main-wrapper.sh) so
# this exercises the actual production startup path. PR #30136
# review caught that an --entrypoint override here had been
# silently neutered by the s6-overlay migration — stage2-hook
# ignores its CMD args, so the smoke test was a no-op.
docker run --rm \
-v /tmp/hermes-test:/opt/data \
"${{ inputs.image }}" --help
- name: hermes dashboard --help
shell: bash
run: |
# Regression guard for #9153: dashboard was present in source but
# missing from the published image. If this fails, something in
# the Dockerfile is excluding the dashboard subcommand from the
# installed package.
docker run --rm \
-v /tmp/hermes-test:/opt/data \
"${{ inputs.image }}" dashboard --help
+23 -1
View File
@@ -20,6 +20,7 @@ permissions:
pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment)
actions: read # needed by osv-scanner (SARIF upload)
security-events: write # needed by osv-scanner (SARIF upload)
packages: write # needed by docker build
concurrency:
group: ci-${{ github.ref }}
@@ -32,6 +33,7 @@ jobs:
# (all lanes true) so post-merge validation is never weakened.
# ─────────────────────────────────────────────────────────────────────
detect:
name: Detect affected areas
runs-on: ubuntu-latest
outputs:
python: ${{ steps.classify.outputs.python }}
@@ -53,11 +55,15 @@ jobs:
# Skipped workflows (if condition is false) don't spin up runners.
# ─────────────────────────────────────────────────────────────────────
tests:
name: Python tests
needs: detect
if: needs.detect.outputs.python == 'true'
uses: ./.github/workflows/tests.yml
with:
slice_count: 8
lint:
name: Python lints
needs: detect
if: needs.detect.outputs.python == 'true'
uses: ./.github/workflows/lint.yml
@@ -65,35 +71,49 @@ jobs:
event_name: ${{ needs.detect.outputs.event_name }}
typecheck:
name: TypeScript
needs: detect
if: needs.detect.outputs.frontend == 'true'
uses: ./.github/workflows/typecheck.yml
docs-site:
name: Docs Site
needs: detect
if: needs.detect.outputs.site == 'true'
uses: ./.github/workflows/docs-site-checks.yml
history-check:
name: Deny unrelated histories
needs: detect
if: needs.detect.outputs.event_name == 'pull_request'
uses: ./.github/workflows/history-check.yml
contributor-check:
name: Check contributors
needs: detect
if: needs.detect.outputs.python == 'true'
uses: ./.github/workflows/contributor-check.yml
uv-lockfile:
name: Check uv.lock
needs: detect
uses: ./.github/workflows/uv-lockfile-check.yml
docker-lint:
name: Lint Docker scripts
needs: detect
if: needs.detect.outputs.docker_meta == 'true'
uses: ./.github/workflows/docker-lint.yml
docker:
name: Build&Test Docker image
needs: detect
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' || needs.detect.outputs.docker_meta == 'true'
uses: ./.github/workflows/docker.yml
secrets: inherit
supply-chain:
name: Supply-chain scan
needs: detect
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true' || needs.detect.outputs.mcp_catalog == 'true')
uses: ./.github/workflows/supply-chain-audit.yml
@@ -104,7 +124,7 @@ jobs:
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
osv-scanner:
needs: detect
name: OSV scan
uses: ./.github/workflows/osv-scanner.yml
# ─────────────────────────────────────────────────────────────────────
@@ -127,6 +147,8 @@ jobs:
- docker-lint
- supply-chain
- osv-scanner
# we don't require docker to pass rn because it's so slow lol
# - docker
if: always()
runs-on: ubuntu-latest
steps:
+1 -1
View File
@@ -2,7 +2,7 @@ name: Docker / shell lint
# Lints the container build inputs: Dockerfile (via hadolint) and any shell
# scripts under docker/ (via shellcheck). These catch the class of regression
# the behavioral docker-publish smoke test can't — unquoted variable
# the behavioral docker smoke test can't — unquoted variable
# expansions, silently-failing RUN commands, etc.
#
# Rules and ignores are documented in .hadolint.yaml at the repo root.
@@ -1,24 +1,9 @@
name: Docker Build and Publish
name: Docker Build, Test, and Publish
on:
push:
branches: [main]
paths:
- '**/*.py'
- 'pyproject.toml'
- 'uv.lock'
- 'Dockerfile'
- 'docker/**'
- '.github/workflows/docker-publish.yml'
- '.github/actions/hermes-smoke-test/**'
# No paths filter — the job must always run so the required check
# reports a status (path-gated workflows leave checks "pending" forever
# when no matching files change, which blocks merge).
pull_request:
release:
types: [published]
workflow_call:
permissions:
contents: read
@@ -39,11 +24,7 @@ env:
IMAGE_NAME: nousresearch/hermes-agent
jobs:
# ---------------------------------------------------------------------------
# Build amd64 natively. This job also runs the smoke tests (basic --help
# and the dashboard subcommand regression guard from #9153), because amd64
# is the only arch we can `load` into the local daemon on an amd64 runner.
# ---------------------------------------------------------------------------
# Build, test, and optionally push the amd64 image.
build-amd64:
# Only run on the upstream repository, not on forks
if: github.repository == 'NousResearch/hermes-agent'
@@ -53,24 +34,19 @@ jobs:
digest: ${{ steps.push.outputs.digest }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# The image build + smoke test + integration tests run ONLY on
# push-to-main and release — never on PRs. They are the heaviest jobs
# in CI (~15-45 min) and a broken build surfaces on the main push (and
# is gated pre-merge by docker-lint + uv-lockfile-check). Every step
# below is skipped on PRs, so the job still reports green and the
# required check never hangs.
# The image build + integration tests run on every event
# (PRs, push-to-main, release). Publish steps below are gated to
# push-to-main / release only.
- name: Set up Docker Buildx
if: github.event_name != 'pull_request'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
# Build once, load into the local daemon for smoke testing. Cached
# Build once, load into the local daemon for testing. Cached
# to gha with a per-arch scope; the push step below reuses every
# layer from this build.
- name: Build image (amd64, smoke test)
if: github.event_name != 'pull_request'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- name: Build image (amd64)
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
@@ -82,25 +58,12 @@ jobs:
cache-from: type=gha,scope=docker-amd64
cache-to: type=gha,mode=max,scope=docker-amd64
- name: Smoke test image
if: github.event_name != 'pull_request'
uses: ./.github/actions/hermes-smoke-test
with:
image: ${{ env.IMAGE_NAME }}:test
# ---------------------------------------------------------------------
# Run the docker-integration test suite against the freshly-built
# image already loaded into the local daemon (`:test`). These tests
# are excluded from the sharded `tests.yml :: test` matrix on purpose
# (see `_SKIP_PARTS` in scripts/run_tests_parallel.py) because each
# shard would otherwise reach the session-scoped ``built_image``
# fixture in ``tests/docker/conftest.py`` and start a 3-7min
# ``docker build`` — guaranteed to
# die in fixture setup.
# image already loaded into the local daemon (`:test`).
#
# Piggybacking here avoids a second image build: the smoke test
# already proved the image loads + runs, so the daemon has it under
# `${IMAGE_NAME}:test` and we just point ``HERMES_TEST_IMAGE`` at
# Piggybacking here avoids a second image build: the build step
# already loaded the image into the daemon under
# `${IMAGE_NAME}:test`, so we just point ``HERMES_TEST_IMAGE`` at
# that. The fixture's ``HERMES_TEST_IMAGE`` branch (see
# tests/docker/conftest.py:62-63) short-circuits the rebuild.
#
@@ -110,26 +73,20 @@ jobs:
# cheapest path to coverage on every PR that touches docker code.
# ---------------------------------------------------------------------
- name: Install uv (for docker tests)
if: github.event_name != 'pull_request'
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
- name: Set up Python 3.11 (for docker tests)
if: github.event_name != 'pull_request'
run: uv python install 3.11
- name: Install Python dependencies (for docker tests)
if: github.event_name != 'pull_request'
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
# ``dev`` extra pulls in pytest, pytest-asyncio —
# everything tests/docker/ needs. We deliberately avoid ``all``
# here because the docker tests only drive the container via
# subprocess and don't import hermes_agent's optional deps.
uv pip install -e ".[dev]"
uv sync --locked --python 3.11 --extra dev
- name: Run docker integration tests
if: github.event_name != 'pull_request'
env:
# Skip rebuild; use the image already loaded by the build step.
HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
@@ -139,12 +96,11 @@ jobs:
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
run: |
source .venv/bin/activate
python -m pytest tests/docker/ -v --tb=short
scripts/run_tests.sh tests/docker/ --file-timeout 600
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -155,7 +111,7 @@ jobs:
- name: Push amd64 by digest
id: push
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
@@ -179,7 +135,7 @@ jobs:
- name: Upload digest artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digest-amd64
path: /tmp/digests/*
@@ -187,10 +143,7 @@ jobs:
retention-days: 1
# ---------------------------------------------------------------------------
# Build arm64 natively on GitHub's free arm64 runner. This replaces the
# previous QEMU-emulated arm64 build, which was ~5-10x slower and shared
# a cache scope with amd64. Matches the amd64 job's shape: build+load,
# smoke test, then on push/release push by digest.
# Build, test, and optionally push the arm64 image.
# ---------------------------------------------------------------------------
build-arm64:
if: github.repository == 'NousResearch/hermes-agent'
@@ -200,29 +153,26 @@ jobs:
digest: ${{ steps.push.outputs.digest }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# arm64 build runs only on push-to-main and release (see build-amd64).
- name: Set up Docker Buildx
if: github.event_name != 'pull_request'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
# Log in to ghcr.io so the registry-backed build cache below can be
# read (cache-from) on every event and written (cache-to) on
# push/release. Uses the workflow's GITHUB_TOKEN, which is valid for
# the whole job — unlike the gha cache backend's short-lived Azure SAS
# token, which expired mid-build on slow cold-cache arm64 runs and
# crashed the build before the smoke test (the reason the gha cache
# crashed the build before the tests ran (the reason the gha cache
# was removed from arm64 PRs in the first place).
- name: Log in to ghcr.io (build cache)
if: github.event_name != 'pull_request'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Build once, load into the local daemon for smoke testing, then push
# Build once, load into the local daemon for testing, then push
# by digest below. Reads AND writes the registry-backed cache so the
# push reuses layers from this build and the next build starts warm.
#
@@ -230,9 +180,8 @@ jobs:
# cache that previously broke here: its credential is the job-lifetime
# GITHUB_TOKEN, not a short-lived SAS token, so the cold-build-outlives-
# token failure mode cannot recur.
- name: Build image (arm64, smoke test, cached publish)
if: github.event_name != 'pull_request'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- name: Build image (arm64, cached publish)
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
@@ -244,15 +193,29 @@ jobs:
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
- name: Smoke test image
if: github.event_name != 'pull_request'
uses: ./.github/actions/hermes-smoke-test
with:
image: ${{ env.IMAGE_NAME }}:test
- name: Install uv for docker tests
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
- name: Set up Python 3.11 for docker tests
run: uv python install 3.11
- name: Install Python dependencies for docker tests
run: |
uv sync --locked --python 3.11 --extra dev
- name: Run docker tests
env:
# Skip rebuild; use the image already loaded by the build step.
HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
run: |
scripts/run_tests.sh tests/docker/ --file-timeout 600
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -260,7 +223,7 @@ jobs:
- name: Push arm64 by digest
id: push
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
@@ -282,7 +245,7 @@ jobs:
- name: Upload digest artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digest-arm64
path: /tmp/digests/*
@@ -304,17 +267,17 @@ jobs:
timeout-minutes: 10
steps:
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+3 -3
View File
@@ -37,7 +37,7 @@ jobs:
fetch-depth: 0 # need full history for merge-base + worktree
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
- name: Install ruff + ty
uses: ./.github/actions/retry
@@ -110,7 +110,7 @@ jobs:
cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY"
- name: Upload reports as artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: lint-reports
path: .lint-reports/
@@ -164,7 +164,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
- name: Install ruff
uses: ./.github/actions/retry
+9 -9
View File
@@ -3,17 +3,17 @@ name: Build Skills Index
on:
schedule:
# Run twice daily: 6 AM and 6 PM UTC
- cron: '0 6,18 * * *'
workflow_dispatch: # Manual trigger
- cron: "0 6,18 * * *"
workflow_dispatch: # Manual trigger
push:
branches: [main]
paths:
- 'scripts/build_skills_index.py'
- '.github/workflows/skills-index.yml'
- "scripts/build_skills_index.py"
- ".github/workflows/skills-index.yml"
permissions:
contents: read
actions: write # to trigger deploy-site.yml on schedule
actions: write # to trigger deploy-site.yml on schedule
jobs:
build-index:
@@ -21,11 +21,11 @@ jobs:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.11'
python-version: "3.11"
- name: Install dependencies
run: pip install httpx==0.28.1 pyyaml==6.0.2
@@ -36,7 +36,7 @@ jobs:
run: python scripts/build_skills_index.py
- name: Upload index artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: skills-index
path: website/static/api/skills-index.json
+38 -36
View File
@@ -2,6 +2,11 @@ name: Tests
on:
workflow_call:
inputs:
slice_count:
description: Number of parallel test slices
type: number
default: 8
permissions:
contents: read
@@ -12,13 +17,11 @@ concurrency:
cancel-in-progress: true
jobs:
test:
generate:
name: "Generate slices"
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
slice: [1, 2, 3, 4, 5, 6]
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -27,13 +30,26 @@ jobs:
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: test_durations.json
# main always writes a new suffix, but jobs pick the latest one with the same prefix
# quote from https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#cache-hits-and-misses
# If you provide restore-keys, the cache action sequentially searches for any caches that match the list of restore-keys.
# If there are no exact matches, the action searches for partial matches of the restore keys.
# When the action finds a partial match, the most recent cache is restored to the path directory.
key: test-durations
- name: Generate test slices
id: matrix
run: |
MATRIX=$(python3 scripts/run_tests_parallel.py --generate-slices ${{ inputs.slice_count }})
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
test:
name: Run tests slice ${{ matrix.slice.index }}/${{ inputs.slice_count }}
needs: generate
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.generate.outputs.matrix) }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install ripgrep (prebuilt binary)
run: |
set -euo pipefail
@@ -49,7 +65,7 @@ jobs:
rg --version
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
@@ -78,33 +94,19 @@ jobs:
# re-download, keeping the persisted cache small and fast to restore.
run: uv cache prune --ci
- name: Run tests (slice ${{ matrix.slice }}/6)
# Per-file isolation via scripts/run_tests_parallel.py: discovers
# every test_*.py file under tests/ (excluding integration/ + e2e/),
# then runs `python -m pytest <file>` in a freshly-spawned subprocess
- name: Run tests (slice ${{ matrix.slice.index }}/${{ inputs.slice_count }})
# Per-file isolation via scripts/run_tests.sh: each test file runs
# in its own freshly-spawned `python -m pytest <file>` subprocess
# with bounded parallelism. No xdist, no shared workers, no
# module-level state leakage between files.
#
# Why per-file (not per-test): per-test spawn cost (~250ms × 17k
# tests = 70min CPU minimum) blew the wall-clock budget. Per-file
# spawn (~250ms × ~850 files = ~3.5min) fits while still giving
# every file a fresh interpreter — the only isolation boundary
# that matters in practice (cross-file leakage was the original
# flake source; intra-file is the test author's responsibility).
#
# Why drop xdist entirely: xdist's persistent workers accumulate
# state across files, which is exactly the leakage we wanted to
# fix. ThreadPoolExecutor + subprocess.run is ~60 lines and does
# the job with cleaner semantics.
#
# Matrix slicing (--slice I/N): files are distributed across 6
# jobs by cached duration (LPT algorithm) so each job gets
# roughly equal wall time. Without a cache, files default to 2s
# estimate and get split roughly evenly by count — still correct,
# just not perfectly balanced.
# File list is pre-computed by the generate job (--generate-slices)
# which runs LPT distribution once and passes the file list to each
# matrix job via --files. Previously each job re-discovered files and
# re-ran LPT independently — redundant N times.
run: |
source .venv/bin/activate
python scripts/run_tests_parallel.py --slice ${{ matrix.slice }}/6
scripts/run_tests.sh --files '${{ matrix.slice.files }}'
env:
# Ensure tests don't accidentally call real APIs
OPENROUTER_API_KEY: ""
@@ -114,7 +116,7 @@ jobs:
- name: Upload per-slice durations
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-durations-slice-${{ matrix.slice }}
name: test-durations-slice-${{ matrix.slice.index }}
path: test_durations.json
retention-days: 1
@@ -173,7 +175,7 @@ jobs:
rg --version
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
+4 -4
View File
@@ -6,6 +6,7 @@ on:
jobs:
typecheck:
name: Check TypeScript
runs-on: ubuntu-latest
strategy:
matrix:
@@ -22,8 +23,7 @@ jobs:
# native builds. Skipping install scripts drops node-pty's node-gyp
# header fetch — the transient flake that killed this job pre-`tsc` — and
# is faster. retry covers the remaining registry blips.
-
uses: ./.github/actions/retry
- uses: ./.github/actions/retry
with:
command: npm ci --ignore-scripts
- run: npm run --prefix ${{ matrix.package }} typecheck
@@ -35,6 +35,7 @@ jobs:
# users build apps/desktop from source on install/update. Run the real
# `vite build` here so that class of break fails in CI instead.
desktop-build:
name: Build desktop app
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -44,8 +45,7 @@ jobs:
cache: npm
# Keep install scripts here: the production build may need node-pty's
# native binary. retry handles the transient install-time fetch flakes.
-
uses: ./.github/actions/retry
- uses: ./.github/actions/retry
with:
command: npm ci
- run: npm run --prefix apps/desktop build
+16 -16
View File
@@ -5,11 +5,11 @@ name: Publish to PyPI
on:
push:
tags:
- 'v20*' # CalVer tags: v2026.5.15, v2026.5.15.2, etc.
- "v20*" # CalVer tags: v2026.5.15, v2026.5.15.2, etc.
workflow_dispatch:
inputs:
confirm_tag:
description: 'Tag to publish (e.g. v2026.5.15). Must already exist.'
description: "Tag to publish (e.g. v2026.5.15). Must already exist."
required: true
type: string
@@ -27,7 +27,7 @@ jobs:
name: Build distribution 📦
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# On workflow_dispatch, check out the confirmed tag.
@@ -43,17 +43,17 @@ jobs:
fi
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.13'
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
node-version: "22"
- name: Build web dashboard
run: cd web && npm ci && npm run build
@@ -81,7 +81,7 @@ jobs:
run: uv build --sdist --wheel
- name: Upload distribution artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: python-package-distributions
path: dist/
@@ -94,17 +94,17 @@ jobs:
name: pypi
url: https://pypi.org/p/hermes-agent
permissions:
id-token: write # OIDC trusted publishing
id-token: write # OIDC trusted publishing
steps:
- name: Download distribution artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-package-distributions
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
skip-existing: true
@@ -116,12 +116,12 @@ jobs:
needs: publish
runs-on: ubuntu-latest
permissions:
contents: write # attach assets to the existing release
id-token: write # sigstore signing
contents: write # attach assets to the existing release
id-token: write # sigstore signing
steps:
- name: Download distribution artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-package-distributions
path: dist/
@@ -145,7 +145,7 @@ jobs:
- name: Sign with Sigstore
if: env.skip_sign != 'true'
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
with:
inputs: >-
./dist/*.tar.gz
+3 -3
View File
@@ -4,7 +4,7 @@ name: uv.lock check
# that modify pyproject.toml without regenerating uv.lock (or vice versa)
# must not merge, because the Docker build's `uv sync --frozen` step will
# fail on a stale lockfile and we'd rather catch it here than in the
# docker-publish workflow on main.
# docker workflow on main.
#
# ─────────────────────────────────────────────────────────────────────────
# IMPORTANT: this check runs against the MERGED state, not just your branch
@@ -63,7 +63,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
# `uv lock --check` re-resolves the project from pyproject.toml and
# compares the result to uv.lock, exiting non-zero if they disagree.
@@ -100,7 +100,7 @@ jobs:
This check is blocking because the Docker image build uses
`uv sync --frozen --extra all`, which rejects stale lockfiles
— catching it here avoids a ~15 min failed docker-publish run
— catching it here avoids a ~15 min failed docker run
on `main` post-merge.
EOF
echo "::error title=uv.lock out of sync::Run \`uv lock\` locally and commit the result. If on a PR, sync with main first."
+14 -14
View File
@@ -189,7 +189,13 @@ RUN cd web && npm run build && \
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
COPY . .
# --link decouples this layer from parents for cache purposes; --chmod bakes
# the final read-only permissions at copy time so we skip the separate
# `chmod -R` pass that previously walked ~30k files across the venv +
# node_modules + source (21s amd64 / 222s arm64 — #49113). `a+rX,go-w`
# gives the non-root hermes user read + traverse but no write; root retains
# write so the build steps below don't need chmod u+w dances.
COPY --link --chmod=a+rX,go-w . .
# ---------- Permissions ----------
# Link hermes-agent itself (editable). Deps are already installed in the
@@ -197,19 +203,15 @@ COPY . .
# resolution or downloads.
RUN uv pip install --no-cache-dir --no-deps -e "."
# Keep /opt/hermes immutable for the runtime hermes user. Hosted/container
# instances must not be able to self-edit the installed source or venv; user
# data, skills, plugins, config, logs, and dashboard uploads live under
# /opt/data instead. Root can still repair the image during build/boot, but
# supervised Hermes processes drop to the non-root hermes user.
# Wire the exec shim and install-method stamp. Files under /opt/hermes are
# already root-owned (COPY, uv sync, npm install all run as root) and
# read-only for the hermes user (go-w from the --chmod above).
USER root
RUN mkdir -p /opt/hermes/bin && \
cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \
chmod 0755 /opt/hermes/bin/hermes && \
printf 'docker\n' > /opt/hermes/.install_method && \
chown -R root:root /opt/hermes && \
chmod -R a+rX /opt/hermes && \
chmod -R a-w /opt/hermes
printf 'docker\n' > /opt/hermes/.install_method
# The ``.install_method`` stamp is baked next to the running code (the install
# tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data
# volume that is commonly bind-mounted from the host and even shared with a
@@ -236,13 +238,11 @@ RUN mkdir -p /opt/hermes/bin && \
#
# The arg is optional — local `docker build` without --build-arg simply
# omits the file, and the runtime falls back to live-git lookup. CI
# (.github/workflows/docker-publish.yml) passes ${{ github.sha }} so
# (.github/workflows/docker.yml) passes ${{ github.sha }} so
# every published image has it.
ARG HERMES_GIT_SHA=
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
chmod u+w /opt/hermes && \
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
chmod a-w /opt/hermes /opt/hermes/.hermes_build_sha; \
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha; \
fi
# ---------- s6-overlay service wiring ----------
+1 -1
View File
@@ -74,7 +74,7 @@ _POLISHED_TOOLS = {
"kanban_create", "kanban_show", "kanban_comment", "kanban_complete",
"kanban_block", "kanban_link", "kanban_heartbeat",
"yb_query_group_info", "yb_query_group_members", "yb_search_sticker",
"yb_send_dm", "yb_send_sticker", "mixture_of_agents",
"yb_send_dm", "yb_send_sticker",
}
+53 -2
View File
@@ -719,6 +719,55 @@ def init_agent(
print("🔑 Using credentials: Microsoft Entra ID")
elif isinstance(effective_key, str) and len(effective_key) > 12:
print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}")
elif agent.provider == "moa":
from agent.moa_loop import MoAClient
agent.api_mode = "chat_completions"
# Route reference-model outputs to the agent's tool_progress_callback so
# every surface that already consumes it (CLI spinner/scrollback, TUI,
# desktop, gateway) can show each reference's answer as a labelled block
# before the aggregator acts. The facade emits "moa.reference" and
# "moa.aggregating" events; we forward them through the same callback
# the tool lifecycle uses. Best-effort and cache-safe — these are
# display-only events, they never touch the message history.
def _moa_reference_relay(event: str, **kwargs: Any) -> None:
cb = getattr(agent, "tool_progress_callback", None)
if cb is None:
return
try:
if event == "moa.reference":
label = str(kwargs.get("label") or "")
text = str(kwargs.get("text") or "")
idx = kwargs.get("index")
count = kwargs.get("count")
cb(
"moa.reference",
label,
text,
None,
moa_index=idx,
moa_count=count,
)
elif event == "moa.aggregating":
cb(
"moa.aggregating",
str(kwargs.get("aggregator") or ""),
None,
None,
moa_ref_count=kwargs.get("ref_count"),
)
except Exception:
pass
agent.client = MoAClient(
agent.model or "default",
reference_callback=_moa_reference_relay,
)
agent._client_kwargs = {}
agent.api_key = api_key or "moa-virtual-provider"
agent.base_url = "moa://local"
if not agent.quiet_mode:
print(f"🤖 AI Agent initialized with MoA preset: {agent.model}")
elif agent.api_mode == "bedrock_converse":
# AWS Bedrock — uses boto3 directly, no OpenAI client needed.
# Region is extracted from the base_url or defaults to us-east-1.
@@ -1621,8 +1670,10 @@ def init_agent(
f"Model {agent.model} has a context window of {_ctx:,} tokens, "
f"which is below the minimum {MINIMUM_CONTEXT_LENGTH:,} required "
f"by Hermes Agent. Choose a model with at least "
f"{MINIMUM_CONTEXT_LENGTH // 1000}K context, or set "
f"model.context_length in config.yaml to override."
f"{MINIMUM_CONTEXT_LENGTH // 1000}K context. If your server "
f"reports a window smaller than the model's true window, set "
f"model.context_length in config.yaml to the real value "
f"(this must be at least {MINIMUM_CONTEXT_LENGTH // 1000}K)."
)
# Inject context engine tool schemas (e.g. lcm_grep, lcm_describe, lcm_expand).
+95 -1
View File
@@ -42,6 +42,14 @@ from utils import base_url_host_matches, base_url_hostname, env_var_enabled, ato
logger = logging.getLogger(__name__)
# Max consecutive successful credential-pool token refreshes of the SAME entry
# on a persistent auth failure before we give up and let the fallback chain
# activate. A single-entry OAuth pool can re-mint a fresh token indefinitely
# even when the upstream keeps rejecting it, so without this cap the retry loop
# spins forever and never reaches ``_try_activate_fallback``. See #26080.
_MAX_AUTH_REFRESH_ATTEMPTS = 2
def _ra():
"""Lazy ``run_agent`` reference for test-patch routing."""
import run_agent
@@ -775,6 +783,30 @@ def recover_with_credential_pool(
return False, has_retried_429
refreshed = pool.try_refresh_current()
if refreshed is not None:
# ``try_refresh_current()`` re-mints a fresh OAuth token and reports
# success even when the upstream keeps rejecting it — a single-entry
# pool (common for OAuth/Max subscribers) has nothing to rotate to,
# so a bare "refreshed → retry" loop spins forever on the same dead
# token and the configured fallback never activates. Cap consecutive
# same-entry refreshes and fall through to fallback once exceeded.
# See #26080.
refreshed_id = getattr(refreshed, "id", None)
if refreshed_id is not None:
refresh_counts = getattr(agent, "_auth_pool_refresh_counts", None)
if refresh_counts is None:
refresh_counts = {}
agent._auth_pool_refresh_counts = refresh_counts
refresh_key = (agent.provider, refreshed_id)
refresh_counts[refresh_key] = refresh_counts.get(refresh_key, 0) + 1
if refresh_counts[refresh_key] > _MAX_AUTH_REFRESH_ATTEMPTS:
_ra().logger.warning(
"Credential auth failure persists after %s refreshes for "
"pool entry %s — treating as unrecoverable and allowing "
"fallback to activate.",
refresh_counts[refresh_key] - 1,
refreshed_id,
)
return False, has_retried_429
_ra().logger.info(f"Credential auth failure — refreshed pool entry {getattr(refreshed, 'id', '?')}")
agent._swap_credential(refreshed)
return True, has_retried_429
@@ -1420,6 +1452,15 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
keepalive_http = agent._build_keepalive_http_client(client_kwargs.get("base_url", ""))
if keepalive_http is not None:
client_kwargs["http_client"] = keepalive_http
# Delegate all rate-limit / 5xx retry to hermes's outer conversation loop,
# which honors Retry-After and applies adaptive/jittered backoff. The OpenAI
# SDK default (max_retries=2) uses its own 1-2s backoff that ignores
# Retry-After and double-retries inside our loop — the same deadlock the
# Anthropic clients hit (#26293). This is the single chokepoint every primary
# OpenAI/aggregator client passes through (init, switch_model, recovery,
# restore, request-scoped); auxiliary_client builds its own clients and keeps
# SDK retries because it is NOT wrapped by the conversation loop.
client_kwargs.setdefault("max_retries", 0)
# Uses the module-level `OpenAI` name, resolved lazily on first
# access via __getattr__ below. Tests patch via `run_agent.OpenAI`.
client = _ra().OpenAI(**client_kwargs)
@@ -1499,6 +1540,10 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# _client_kwargs is a dict — snapshot a shallow copy so mutating the
# live dict doesn't poison the rollback target.
_snapshot["_client_kwargs"] = dict(getattr(agent, "_client_kwargs", {}) or {})
# Snapshot the credential pool reference so a failed client rebuild can
# restore the original pool (issue #52727: pool reload is part of this
# switch and must be reversible on rollback).
_snapshot["_credential_pool"] = getattr(agent, "_credential_pool", _MISSING)
try:
# Clear the per-config context_length override so the new model's
@@ -1523,8 +1568,36 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
if api_key:
agent.api_key = api_key
# ── Reload credential pool for the new provider (issue #52727) ──
# Without this, ``recover_with_credential_pool`` sees a
# ``pool.provider != agent.provider`` mismatch and short-circuits,
# leaving the new provider with no rotation/recovery on 401/429 and
# burning the original pool's entries. Only reload when the provider
# actually changed (or the pool was missing) — re-selecting the same
# provider must not churn the pool reference. A reload failure is
# logged + swallowed: the switch itself must still complete.
old_norm = (old_provider or "").strip().lower()
new_norm = (new_provider or "").strip().lower()
if old_norm != new_norm or getattr(agent, "_credential_pool", None) is None:
try:
from agent.credential_pool import load_pool
agent._credential_pool = load_pool(new_provider)
except Exception as _pool_exc: # noqa: BLE001
logger.warning(
"switch_model: credential pool reload failed for %s (%s); "
"continuing without pool rotation this turn",
new_provider, _pool_exc,
)
# ── Build new client ──
if api_mode == "anthropic_messages":
if (new_provider or "").strip().lower() == "moa":
from agent.moa_loop import MoAClient
agent.api_key = api_key or "moa-virtual-provider"
agent.base_url = "moa://local"
agent._client_kwargs = {}
agent.client = MoAClient(agent.model or "default")
elif api_mode == "anthropic_messages":
from agent.anthropic_adapter import (
build_anthropic_client,
resolve_anthropic_token,
@@ -1697,6 +1770,27 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
old_model, old_provider, new_model, new_provider,
)
# ── Persist billing route to session DB ──
# The agent's _session_db / session_id may not be set in all contexts
# (tests, bare agents without a session DB, etc.). This ensures the
# dashboard Model cards show the actual provider after a mid-session
# /model switch instead of the stale session-creation provider.
# See #48248 for the full bug description.
_session_db = getattr(agent, "_session_db", None)
_session_id = getattr(agent, "session_id", None)
if _session_db is not None and _session_id:
try:
_session_db.update_session_billing_route(
_session_id,
provider=agent.provider,
base_url=agent.base_url,
billing_mode=getattr(agent, "api_mode", None),
)
except Exception:
logger.warning(
"Failed to persist billing route after model switch",
exc_info=True,
)
def invoke_tool(agent, function_name: str, function_args: dict, effective_task_id: str,
+97 -25
View File
@@ -673,6 +673,9 @@ def _build_anthropic_client_with_bearer_hook(
kwargs = {
"timeout": timeout_obj,
"http_client": http_client,
# Delegate retry to hermes's outer loop (honors Retry-After); the SDK
# default max_retries=2 ignores it and double-retries. (#26293)
"max_retries": 0,
# The SDK requires *something* for api_key/auth_token. Our
# event hook overrides Authorization per request so this value
# is never sent. The sentinel string makes accidental leaks
@@ -757,6 +760,12 @@ def build_anthropic_client(
_read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0
kwargs = {
"timeout": Timeout(timeout=float(_read_timeout), connect=10.0),
# Delegate all rate-limit / 5xx retry to hermes's outer conversation
# loop, which honors Retry-After. The SDK default (max_retries=2) uses
# its own 1-2s backoff that ignores Retry-After and double-retries
# inside our loop — burning request slots against a bucket that won't
# refill for minutes. (#26293)
"max_retries": 0,
}
if normalized_base_url:
# Azure Anthropic endpoints require an ``api-version`` query parameter.
@@ -852,6 +861,9 @@ def build_anthropic_bedrock_client(region: str):
return _anthropic_sdk.AnthropicBedrock(
aws_region=region,
timeout=Timeout(timeout=900.0, connect=10.0),
# Delegate retry to hermes's outer loop (honors Retry-After); the SDK
# default max_retries=2 ignores it and double-retries. (#26293)
max_retries=0,
default_headers={"anthropic-beta": ",".join([*_COMMON_BETAS, _CONTEXT_1M_BETA])},
)
@@ -914,44 +926,72 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
return None
def _read_claude_code_credentials_from_file() -> Optional[Dict[str, Any]]:
"""Read Claude Code OAuth credentials from ~/.claude/.credentials.json.
Returns dict with {accessToken, refreshToken?, expiresAt?, source} or None.
"""
cred_path = Path.home() / ".claude" / ".credentials.json"
if not cred_path.exists():
return None
try:
data = json.loads(cred_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, IOError) as e:
logger.debug("Failed to read ~/.claude/.credentials.json: %s", e)
return None
oauth_data = data.get("claudeAiOauth")
if not (oauth_data and isinstance(oauth_data, dict)):
return None
access_token = oauth_data.get("accessToken", "")
if not access_token:
return None
return {
"accessToken": access_token,
"refreshToken": oauth_data.get("refreshToken", ""),
"expiresAt": oauth_data.get("expiresAt", 0),
"source": "claude_code_credentials_file",
}
def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
"""Read refreshable Claude Code OAuth credentials.
Checks two sources in order:
Reads from two possible sources and reconciles them:
1. macOS Keychain (Darwin only) — "Claude Code-credentials" entry
2. ~/.claude/.credentials.json file
Selection rules when both are present:
- If exactly one is non-expired, prefer that one. (Handles the case
where Claude Code refreshes one source but not the other — observed
in the wild on Claude Code 2.1.x.)
- Otherwise, prefer the source with the later ``expiresAt`` so that
any subsequent refresh uses the most recent ``refreshToken``.
This intentionally excludes ~/.claude.json primaryApiKey. Opencode's
subscription flow is OAuth/setup-token based with refreshable credentials,
and native direct Anthropic provider usage should follow that path rather
than auto-detecting Claude's first-party managed key.
Returns dict with {accessToken, refreshToken?, expiresAt?} or None.
Returns dict with {accessToken, refreshToken?, expiresAt?, source} or None.
"""
# Try macOS Keychain first (covers Claude Code >=2.1.114)
kc_creds = _read_claude_code_credentials_from_keychain()
if kc_creds:
return kc_creds
file_creds = _read_claude_code_credentials_from_file()
# Fall back to JSON file
cred_path = Path.home() / ".claude" / ".credentials.json"
if cred_path.exists():
try:
data = json.loads(cred_path.read_text(encoding="utf-8"))
oauth_data = data.get("claudeAiOauth")
if oauth_data and isinstance(oauth_data, dict):
access_token = oauth_data.get("accessToken", "")
if access_token:
return {
"accessToken": access_token,
"refreshToken": oauth_data.get("refreshToken", ""),
"expiresAt": oauth_data.get("expiresAt", 0),
"source": "claude_code_credentials_file",
}
except (json.JSONDecodeError, OSError, IOError) as e:
logger.debug("Failed to read ~/.claude/.credentials.json: %s", e)
if kc_creds and file_creds:
kc_valid = is_claude_code_token_valid(kc_creds)
file_valid = is_claude_code_token_valid(file_creds)
if kc_valid and not file_valid:
return kc_creds
if file_valid and not kc_valid:
return file_creds
# Both valid or both expired: prefer the later expiresAt so the
# downstream refresh path uses the freshest refresh_token.
kc_exp = kc_creds.get("expiresAt", 0) or 0
file_exp = file_creds.get("expiresAt", 0) or 0
return kc_creds if kc_exp >= file_exp else file_creds
return None
return kc_creds or file_creds
def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool:
@@ -1034,8 +1074,40 @@ def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False)
def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
"""Attempt to refresh an expired Claude Code OAuth token."""
refresh_token = creds.get("refreshToken", "")
"""Attempt to refresh an expired Claude Code OAuth token.
Claude Code's OAuth refresh tokens are single-use: a successful refresh
rotates the pair and invalidates the old refresh token. Claude Code itself
also refreshes on its own schedule (IDE/CLI activity), so by the time
Hermes notices an expired token, Claude Code may have already rotated it.
POSTing our now-stale refresh token in that window races Claude Code and
fails with ``invalid_grant``.
So before refreshing, re-read the live credential sources. If Claude Code
has already produced a valid token, adopt it and skip the POST entirely.
Only fall back to refreshing ourselves when no fresh credential is found.
"""
# Claude Code may have already refreshed — adopt its token rather than
# racing it with our (possibly already-rotated) refresh token. Only adopt
# when the live re-read produced a DIFFERENT token with a real future
# expiry: re-adopting the same credential we were just handed would be a
# no-op, and a 0/absent ``expiresAt`` means "managed key / unknown expiry"
# (see is_claude_code_token_valid) which must NOT be treated as a fresh
# refresh here.
current = read_claude_code_credentials()
if current:
current_token = current.get("accessToken", "")
current_exp = current.get("expiresAt", 0) or 0
if (
current_token
and current_token != creds.get("accessToken", "")
and current_exp > 0
and is_claude_code_token_valid(current)
):
logger.debug("Adopted Claude Code's already-refreshed OAuth token")
return current_token
refresh_token = (current or {}).get("refreshToken", "") or creds.get("refreshToken", "")
if not refresh_token:
logger.debug("No refresh token available — cannot refresh")
return None
+182 -15
View File
@@ -666,6 +666,28 @@ def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str:
return str(url or "").strip().rstrip("/")
# Hostnames (lowercase, exact) that the auxiliary Anthropic path is allowed to
# be pointed at via config.yaml model.base_url. Anything else falls back to the
# Anthropic default — operators routing main-session traffic through a
# non-Anthropic host (e.g. OpenRouter, OpenAI) with provider=anthropic in config
# must NOT have that foreign host leak into the auxiliary client. See #52608.
_ANTHROPIC_COMPATIBLE_HOSTS = frozenset({
"api.anthropic.com",
})
def _is_anthropic_compatible_host(url: str) -> bool:
"""Return True if ``url``'s hostname is an Anthropic endpoint we trust for aux calls."""
if not url:
return False
try:
from urllib.parse import urlparse
host = (urlparse(url).hostname or "").strip().lower().rstrip(".")
return host in _ANTHROPIC_COMPATIBLE_HOSTS
except Exception:
return False
def _nous_min_key_ttl_seconds() -> int:
try:
return max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800")))
@@ -1647,13 +1669,14 @@ def _try_openrouter(explicit_api_key: str = None, model: str = None) -> Tuple[Op
pool_present, entry = _select_pool_entry("openrouter")
if pool_present:
or_key = explicit_api_key or _pool_runtime_api_key(entry)
if not or_key:
_mark_provider_unhealthy("openrouter", ttl=60)
return None, None
base_url = _pool_runtime_base_url(entry, OPENROUTER_BASE_URL) or OPENROUTER_BASE_URL
logger.debug("Auxiliary client: OpenRouter via pool")
return OpenAI(api_key=or_key, base_url=base_url,
default_headers=build_or_headers()), model or _OPENROUTER_MODEL
if or_key:
base_url = _pool_runtime_base_url(entry, OPENROUTER_BASE_URL) or OPENROUTER_BASE_URL
logger.debug("Auxiliary client: OpenRouter via pool")
return OpenAI(api_key=or_key, base_url=base_url,
default_headers=build_or_headers()), model or _OPENROUTER_MODEL
# Pool exists but is exhausted (no usable runtime key) — fall through to
# the OPENROUTER_API_KEY env-var path rather than failing outright.
logger.debug("Auxiliary client: OpenRouter pool exhausted, trying OPENROUTER_API_KEY")
or_key = explicit_api_key or os.getenv("OPENROUTER_API_KEY")
if not or_key:
@@ -2256,9 +2279,16 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
if not token:
return None, None
# Allow base URL override from config.yaml model.base_url, but only
# when the configured provider is anthropic — otherwise a non-Anthropic
# base_url (e.g. Codex endpoint) would leak into Anthropic requests.
# Allow base URL override from config.yaml model.base_url, but only when:
# 1. the configured provider is anthropic (otherwise a non-Anthropic
# base_url, e.g. Codex endpoint, would leak into Anthropic requests), AND
# 2. the override URL actually points at an Anthropic-compatible endpoint.
# Without gate (2), operators who route main-session traffic through a
# non-Anthropic provider that accepts Anthropic-format requests (e.g.
# OpenRouter at openrouter.ai/api/v1, with provider=anthropic in config.yaml)
# would have every auxiliary side-channel call (memory extractors,
# reflection, vision, title generation) 401 from the foreign host —
# see issue #52608.
base_url = _pool_runtime_base_url(entry, _ANTHROPIC_DEFAULT_BASE_URL) if pool_present else _ANTHROPIC_DEFAULT_BASE_URL
try:
from hermes_cli.config import load_config
@@ -2268,7 +2298,7 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
if cfg_provider == "anthropic":
cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
if cfg_base_url:
if cfg_base_url and _is_anthropic_compatible_host(cfg_base_url):
base_url = cfg_base_url
except Exception:
pass
@@ -2754,6 +2784,25 @@ def _is_model_incompatible_error(exc: Exception) -> bool:
))
def _is_invalid_aux_response_error(exc: Exception) -> bool:
"""Detect provider responses that authenticated but cannot serve aux shape.
Some OpenAI-compatible routes return HTTP 200 with an empty/malformed
ChatCompletion instead of a normal provider error. That is still a
provider/model capability failure for auxiliary tasks: downstream callers
need ``choices[0].message`` and should be able to continue through the
same fallback path as explicit model-incompatibility errors.
"""
if not isinstance(exc, RuntimeError):
return False
msg = str(exc).lower()
return (
"auxiliary " in msg
and "llm returned invalid response" in msg
and "choices[0].message" in msg
)
def _evict_cached_clients(provider: str) -> None:
"""Drop cached auxiliary clients for a provider so fresh creds are used."""
normalized = _normalize_aux_provider(provider)
@@ -3576,6 +3625,37 @@ def _resolve_auto(
# config.yaml (auxiliary.<task>.provider) still win over this.
main_provider = str(runtime_provider or _read_main_provider() or "")
main_model = str(runtime_model or _read_main_model() or "")
# MoA virtual provider: the "model" is a preset name (e.g. "opus-gpt") and
# there is no real "moa" HTTP endpoint, so resolving an aux client against
# provider="moa"/model=<preset> sends the preset name as the model id and
# the provider 400s ("opus-gpt is not a valid model ID"). Auxiliary tasks
# (title generation, compression, vision, …) don't need the reference
# fan-out — they should run on the aggregator, which is the preset's acting
# model. Resolve the MoA preset to its aggregator slot and continue Step 1
# with that real provider+model. Mirrors the MoA context-length resolution.
if main_provider == "moa":
try:
from hermes_cli.config import load_config
from hermes_cli.moa_config import resolve_moa_preset
_preset = resolve_moa_preset(load_config().get("moa") or {}, main_model)
_agg = _preset.get("aggregator") or {}
_agg_provider = str(_agg.get("provider") or "").strip()
_agg_model = str(_agg.get("model") or "").strip()
if _agg_provider and _agg_model and _agg_provider.lower() != "moa":
main_provider = _agg_provider
main_model = _agg_model
# The MoA virtual runtime carries a non-HTTP base_url
# ("moa://local") and a placeholder api_key; they belong to the
# facade, not the aggregator's real provider. Drop them so the
# aggregator resolves through its own provider credentials.
runtime_base_url = ""
runtime_api_key = ""
runtime_api_mode = ""
except Exception:
logger.debug("MoA aux resolution to aggregator failed", exc_info=True)
if (main_provider and main_model
and main_provider not in {"auto", ""}):
resolved_provider = main_provider
@@ -5445,6 +5525,9 @@ def _validate_llm_response(response: Any, task: str = None) -> Any:
if not choices or not hasattr(choices[0], "message"):
raise AttributeError("missing choices[0].message")
except (AttributeError, TypeError, IndexError) as exc:
recovered = _recover_aux_response_message(response)
if recovered is not None:
return recovered
response_type = type(response).__name__
response_preview = str(response)[:120]
raise RuntimeError(
@@ -5456,6 +5539,64 @@ def _validate_llm_response(response: Any, task: str = None) -> Any:
return response
def _recover_aux_response_message(response: Any) -> Optional[Any]:
"""Synthesize chat-completions shape from Responses-style text fields.
Auxiliary callers consume ``choices[0].message``. Some compatible
endpoints return text outside ``choices`` (for example ``output_text`` or
``output`` items). Preserve that response before declaring it malformed.
"""
text = _extract_aux_response_text(response)
if not text:
return None
choice = SimpleNamespace(
message=SimpleNamespace(content=text),
finish_reason=getattr(response, "finish_reason", None) or "stop",
)
try:
response.choices = [choice]
return response
except Exception:
return SimpleNamespace(
id=getattr(response, "id", ""),
model=getattr(response, "model", ""),
object=getattr(response, "object", "chat.completion"),
choices=[choice],
usage=getattr(response, "usage", None),
)
def _extract_aux_response_text(response: Any) -> str:
output_text = _obj_get(response, "output_text")
if isinstance(output_text, str) and output_text.strip():
return output_text.strip()
output = _obj_get(response, "output")
if not isinstance(output, list):
return ""
parts: List[str] = []
for item in output:
item_type = _obj_get(item, "type")
if item_type and item_type != "message":
continue
for part in (_obj_get(item, "content") or []):
part_type = _obj_get(part, "type")
if part_type in {"output_text", "text", None}:
text = _obj_get(part, "text")
if isinstance(text, str) and text.strip():
parts.append(text.strip())
return "\n".join(parts).strip()
def _obj_get(obj: Any, key: str, default: Any = None) -> Any:
value = getattr(obj, key, default)
if value is default and isinstance(obj, dict):
value = obj.get(key, default)
return value
def call_llm(
task: str = None,
*,
@@ -5853,11 +5994,21 @@ def call_llm(
# When the provider returns a 429 rate-limit (not billing), fall
# back to an alternative provider instead of exhausting retries
# against the same rate-limited endpoint.
#
# ── Auth error fallback (#21165) ─────────────────────────────
# When the resolved provider returns 401 and neither the Nous
# refresh path nor explicit provider credential refresh applies,
# fall back to an alternative provider instead of dropping the
# auxiliary task on the floor (silent compression failure /
# message loss). Auth is NOT a capacity error: it only bypasses
# the explicit-provider gate when the user is in auto mode.
should_fallback = (
_is_payment_error(first_err)
_is_auth_error(first_err)
or _is_payment_error(first_err)
or _is_connection_error(first_err)
or _is_rate_limit_error(first_err)
or _is_model_incompatible_error(first_err)
or _is_invalid_aux_response_error(first_err)
)
# Respect explicit provider choice for transient errors (auth, request
# validation, etc.) but allow fallback when the provider clearly cannot
@@ -5880,9 +6031,12 @@ def call_llm(
or _is_connection_error(first_err)
or _is_rate_limit_error(first_err)
or _is_model_incompatible_error(first_err)
or _is_invalid_aux_response_error(first_err)
)
if should_fallback and (is_auto or is_capacity_error):
if _is_payment_error(first_err):
if _is_auth_error(first_err):
reason = "auth error"
elif _is_payment_error(first_err):
reason = "payment error"
# Resolve the actual provider label (resolved_provider may be
# "auto"; the client's base_url tells us which backend got the
@@ -5895,6 +6049,8 @@ def call_llm(
reason = "rate limit"
elif _is_model_incompatible_error(first_err):
reason = "model incompatible with route"
elif _is_invalid_aux_response_error(first_err):
reason = "invalid provider response"
else:
reason = "connection error"
logger.info("Auxiliary %s: %s on %s (%s), trying fallback",
@@ -6329,11 +6485,17 @@ async def async_call_llm(
raise
# ── Payment / connection / rate-limit fallback (mirrors sync call_llm) ──
# Auth error fallback (#21165): a 401 that survived the refresh path
# falls back in auto mode just like the sync call_llm() path. Auth is
# NOT a capacity error, so on an explicit provider it still respects
# the user's choice (handled by the is_auto/is_capacity_error gate).
should_fallback = (
_is_payment_error(first_err)
_is_auth_error(first_err)
or _is_payment_error(first_err)
or _is_connection_error(first_err)
or _is_rate_limit_error(first_err)
or _is_model_incompatible_error(first_err)
or _is_invalid_aux_response_error(first_err)
)
# Capacity errors (payment/quota/connection/rate-limit) bypass the
# explicit-provider gate — the provider cannot serve the request
@@ -6348,9 +6510,12 @@ async def async_call_llm(
or _is_connection_error(first_err)
or _is_rate_limit_error(first_err)
or _is_model_incompatible_error(first_err)
or _is_invalid_aux_response_error(first_err)
)
if should_fallback and (is_auto or is_capacity_error):
if _is_payment_error(first_err):
if _is_auth_error(first_err):
reason = "auth error"
elif _is_payment_error(first_err):
reason = "payment error"
_mark_provider_unhealthy(
_recoverable_pool_provider(resolved_provider, client) or resolved_provider
@@ -6359,6 +6524,8 @@ async def async_call_llm(
reason = "rate limit"
elif _is_model_incompatible_error(first_err):
reason = "model incompatible with route"
elif _is_invalid_aux_response_error(first_err):
reason = "invalid provider response"
else:
reason = "connection error"
logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback",
+128 -28
View File
@@ -37,6 +37,18 @@ from tools.terminal_tool import is_persistent_env
from utils import base_url_host_matches, base_url_hostname, env_float, env_int
logger = logging.getLogger(__name__)
_OPENROUTER_PROVIDER_SORT_VALUES = {"throughput", "latency", "price"}
# When the fallback chain is fully exhausted on a non-rate-limit failure
# (e.g. every provider returns a non-retryable client error like HTTP 400),
# arm a short cooldown so the NEXT turn's restore_primary_runtime stays gated
# and does not reset _fallback_index=0 to replay the entire chain again.
# Without this, a client/gateway that re-submits immediately would re-marshal
# the full (potentially 80k-token) context once per provider every turn and
# can drive a constrained host into memory/swap exhaustion. Rate-limit /
# billing reasons keep their own 60s cooldown (set above); this is the
# narrower non-rate-limit case. See issue #24996.
_FALLBACK_EXHAUSTED_COOLDOWN_S = 5.0
def _ra():
@@ -115,6 +127,23 @@ def _is_openai_codex_backend(agent) -> bool:
)
def _validated_openrouter_provider_sort(raw_sort: Any) -> Optional[str]:
"""Return a normalized OpenRouter provider.sort value or None."""
if not isinstance(raw_sort, str):
return None
sort_value = raw_sort.strip().lower()
if not sort_value:
return None
if sort_value in _OPENROUTER_PROVIDER_SORT_VALUES:
return sort_value
logger.warning(
"Ignoring invalid OpenRouter provider.sort value %r (allowed: %s)",
raw_sort,
", ".join(sorted(_OPENROUTER_PROVIDER_SORT_VALUES)),
)
return None
def _env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name, str(default)))
@@ -229,6 +258,11 @@ def interruptible_api_call(agent, api_kwargs: dict):
invalidate_runtime_client(region)
raise
result["response"] = normalize_converse_response(raw_response)
elif agent.provider == "moa":
# MoA is a virtual chat-completions provider backed by the
# in-process MoAClient facade. Do not rebuild a request-local
# OpenAI client from the virtual runtime metadata.
result["response"] = agent.client.chat.completions.create(**api_kwargs)
else:
request_client = _set_request_client(
agent._create_request_openai_client(
@@ -698,8 +732,9 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
_prefs["ignore"] = agent.providers_ignored
if agent.providers_order:
_prefs["order"] = agent.providers_order
if agent.provider_sort:
_prefs["sort"] = agent.provider_sort
_provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
if _provider_sort:
_prefs["sort"] = _provider_sort
if agent.provider_require_parameters:
_prefs["require_parameters"] = True
if agent.provider_data_collection:
@@ -1093,8 +1128,22 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
if (not fallback_already_active) or (primary_provider and current_provider == primary_provider):
agent._rate_limited_until = time.monotonic() + 60
if agent._fallback_index >= len(agent._fallback_chain):
# Chain exhausted. If we actually walked a non-empty chain and the
# failure was NOT a rate-limit/billing event (those already armed
# their own 60s cooldown above), arm a short cooldown so the next
# turn's restore_primary_runtime stays gated instead of resetting
# _fallback_index=0 and re-marshaling the whole context across every
# provider again. Guards the cross-turn replay storm in #24996.
if (
len(agent._fallback_chain) > 0
and reason not in {FailoverReason.rate_limit, FailoverReason.billing}
):
_existing_cooldown = getattr(agent, "_rate_limited_until", 0) or 0
agent._rate_limited_until = max(
_existing_cooldown,
time.monotonic() + _FALLBACK_EXHAUSTED_COOLDOWN_S,
)
return False
fb = agent._fallback_chain[agent._fallback_index]
agent._fallback_index += 1
fb_provider = (fb.get("provider") or "").strip().lower()
@@ -1210,14 +1259,16 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
agent._transport_cache.clear()
agent._fallback_activated = True
# Clear the credential pool when the fallback provider doesn't match
# the pool's provider. The pool was seeded for the primary provider;
# leaving it attached means downstream recovery (rate_limit / billing /
# auth) calls ``_swap_credential`` with a primary entry which overwrites
# the agent's ``base_url`` back to the primary's endpoint — every
# fallback request then 404s against the wrong host. See #33163.
# Rebind the credential pool to the fallback provider when the provider
# changes. Keeping the primary pool attached would make downstream
# recovery (rate_limit / billing / auth) mutate the wrong credential
# set and can overwrite the fallback's base_url back to the primary
# endpoint. See #33163.
#
# When the fallback shares the pool's provider (e.g. both openrouter
# entries with different routing) the pool is preserved.
# entries with different routing) the pool is preserved. When the
# providers differ, load the fallback provider's own pool if one exists
# so provider-specific rotation continues to work after the switch.
_existing_pool = getattr(agent, "_credential_pool", None)
if _existing_pool is not None:
_pool_provider = (getattr(_existing_pool, "provider", "") or "").strip().lower()
@@ -1228,6 +1279,22 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
fb_provider, fb_model, _pool_provider,
)
agent._credential_pool = None
if getattr(agent, "_credential_pool", None) is None:
try:
from agent.credential_pool import load_pool
fallback_pool = load_pool(fb_provider)
if fallback_pool and fallback_pool.has_credentials():
agent._credential_pool = fallback_pool
logger.info(
"Fallback to %s/%s: attached fallback credential pool",
fb_provider, fb_model,
)
except Exception as exc:
logger.debug(
"Fallback to %s/%s: could not attach credential pool: %s",
fb_provider, fb_model, exc,
)
# Honor per-provider / per-model request_timeout_seconds for the
# fallback target (same knob the primary client uses). None = use
@@ -1458,8 +1525,9 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
provider_preferences["ignore"] = agent.providers_ignored
if agent.providers_order:
provider_preferences["order"] = agent.providers_order
if agent.provider_sort:
provider_preferences["sort"] = agent.provider_sort
_provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
if _provider_sort:
provider_preferences["sort"] = _provider_sort
if provider_preferences and (
(agent.provider or "").strip().lower() == "openrouter"
or agent._is_openrouter_url()
@@ -2391,12 +2459,19 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
diag=request_client_holder.get("diag"),
)
_close_request_client_once("stream_mid_tool_retry_cleanup")
try:
agent._replace_primary_openai_client(
reason="stream_mid_tool_retry_pool_cleanup"
)
except Exception:
pass
if agent.api_mode == "anthropic_messages":
try:
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
except Exception:
pass
else:
try:
agent._replace_primary_openai_client(
reason="stream_mid_tool_retry_pool_cleanup"
)
except Exception:
pass
continue
# SSE error events from proxies (e.g. OpenRouter sends
@@ -2444,12 +2519,19 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
_close_request_client_once("stream_retry_cleanup")
# Also rebuild the primary client to purge
# any dead connections from the pool.
try:
agent._replace_primary_openai_client(
reason="stream_retry_pool_cleanup"
)
except Exception:
pass
if agent.api_mode == "anthropic_messages":
try:
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
except Exception:
pass
else:
try:
agent._replace_primary_openai_client(
reason="stream_retry_pool_cleanup"
)
except Exception:
pass
continue
# Retries exhausted. Log the final failure with
# full diagnostic detail (chain, headers,
@@ -2561,6 +2643,17 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
_stream_stale_timeout = max(_stream_stale_timeout_base, 240.0)
else:
_stream_stale_timeout = _stream_stale_timeout_base
# Reasoning-model floor: known reasoning models (Nemotron 3 Ultra,
# OpenAI o1/o3, Anthropic Opus 4.x thinking, DeepSeek R1, Qwen QwQ,
# xAI Grok reasoning, etc.) routinely exceed the default 180s chat-
# model threshold during their thinking phase. The cloud gateway
# upstream kills the socket first, surfacing as BrokenPipeError.
# Raises the floor only — never overrides explicit user config
# (handled by get_provider_stale_timeout above).
from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
_reasoning_floor = get_reasoning_stale_timeout_floor(api_kwargs.get("model"))
if _reasoning_floor is not None:
_stream_stale_timeout = max(_stream_stale_timeout, _reasoning_floor)
t = threading.Thread(target=_call, daemon=True)
t.start()
@@ -2609,10 +2702,17 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
pass
# Rebuild the primary client too — its connection pool
# may hold dead sockets from the same provider outage.
try:
agent._replace_primary_openai_client(reason="stale_stream_pool_cleanup")
except Exception:
pass
if agent.api_mode == "anthropic_messages":
try:
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
except Exception:
pass
else:
try:
agent._replace_primary_openai_client(reason="stale_stream_pool_cleanup")
except Exception:
pass
# Reset the timer so we don't kill repeatedly while
# the inner thread processes the closure.
last_chunk_time["t"] = time.time()
+60 -1
View File
@@ -83,6 +83,59 @@ _PROJECT_MARKERS = (
# Agent-instruction files surfaced separately from manifests in the snapshot.
_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
# Source-file extensions that make a git repo a *code* workspace even with no
# manifest. Without this, `git init` on a notes/writing/research folder (a huge
# non-coding use case) would flip the whole session into the coding posture just
# for having a `.git`. A manifest still wins on its own (see `_PROJECT_MARKERS`).
_CODE_EXTENSIONS = frozenset({
".py", ".pyi", ".ipynb", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
".go", ".rs", ".java", ".kt", ".kts", ".scala", ".rb", ".php", ".c", ".h",
".cc", ".cpp", ".hpp", ".cs", ".swift", ".m", ".mm", ".dart", ".ex", ".exs",
".lua", ".sh", ".bash", ".zsh", ".sql", ".vue", ".svelte", ".r", ".jl",
".hs", ".clj", ".erl", ".pl",
})
# Dirs never worth scanning for the code check (deps/build/vcs/venv noise).
_CODE_SCAN_SKIP_DIRS = frozenset({
".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build",
"target", ".next", ".turbo", "vendor",
})
# Bounded sweep: a code workspace reveals itself in the first handful of entries.
_CODE_SCAN_MAX_ENTRIES = 500
def _has_code_files(root: Path) -> bool:
"""Cheap, bounded check for source files in a repo's top two levels.
Lets a git repo of loose scripts (no manifest) still read as a code
workspace while a bare notes/writing repo does not. Scans the root and its
immediate subdirectories only, capped at ``_CODE_SCAN_MAX_ENTRIES`` stats —
a handful of readdirs at session start, not a full walk.
"""
seen = 0
stack = [(root, True)]
while stack:
directory, is_root = stack.pop()
try:
with os.scandir(directory) as entries:
for entry in entries:
seen += 1
if seen > _CODE_SCAN_MAX_ENTRIES:
return False
name = entry.name
try:
if entry.is_file():
if os.path.splitext(name)[1].lower() in _CODE_EXTENSIONS:
return True
elif is_root and entry.is_dir() and name not in _CODE_SCAN_SKIP_DIRS and not name.startswith("."):
stack.append((Path(entry.path), False))
except OSError:
continue
except OSError:
continue
return False
# Lockfile → package manager, checked in priority order.
_PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv"))
_JS_LOCKFILES = (
@@ -368,10 +421,16 @@ def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str:
if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS:
return GENERAL_PROFILE.name
cwd = Path(cwd_str)
# A recognized project root (manifest / AGENTS.md / .cursorrules) is a code
# workspace on its own — cheap stat checks, no scan.
if _marker_root(cwd) is not None:
return CODING_PROFILE.name
git_root = _git_root(cwd)
if git_root is not None and git_root == _home():
git_root = None # dotfiles repo at $HOME — not a code workspace
if git_root is not None or _marker_root(cwd) is not None:
# A bare git repo only counts when it actually holds code, so `git init` on a
# notes/writing/research folder stays in the general posture.
if git_root is not None and _has_code_files(git_root):
return CODING_PROFILE.name
return GENERAL_PROFILE.name
+23
View File
@@ -288,6 +288,29 @@ def replay_compression_warning(agent: Any) -> None:
pass
def conversation_history_after_compression(agent: Any, messages: list) -> Optional[list]:
"""Return the correct flush baseline after a compression boundary.
Legacy compression rotates to a fresh child session. That child has not
seen the compacted transcript through the normal same-turn flush path yet,
so callers must clear ``conversation_history`` to ``None`` and let the next
persistence call write the whole compacted list.
In-place compaction is different: ``archive_and_compact()`` has already
soft-archived the previous active rows and inserted ``messages`` as the new
active live transcript under the same session id. If the same agent turn
continues with ``conversation_history=None``, the identity-based flush path
treats those already-persisted compacted dicts as new and appends them a
second time, doubling the active context and retriggering compression.
A shallow copy is intentional: it captures the current compacted dict
identities as history while allowing later same-turn appends to remain new.
"""
if bool(getattr(agent, "_last_compaction_in_place", False)):
return list(messages)
return None
def compress_context(
agent: Any,
messages: list,
+185 -27
View File
@@ -28,6 +28,7 @@ import uuid
from typing import Any, Dict, List, Optional
from agent.codex_responses_adapter import _summarize_user_message_for_log
from agent.conversation_compression import conversation_history_after_compression
from agent.display import KawaiiSpinner
from agent.error_classifier import FailoverReason, classify_api_error
from agent.iteration_budget import IterationBudget
@@ -502,6 +503,7 @@ def run_conversation(
stream_callback: Optional[callable] = None,
persist_user_message: Optional[str] = None,
persist_user_timestamp: Optional[float] = None,
moa_config: Optional[dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Run a complete conversation with tool calling until completion.
@@ -524,6 +526,19 @@ def run_conversation(
Returns:
Dict: Complete conversation result with final response and message history
"""
if moa_config is None:
try:
from hermes_cli.moa_config import decode_moa_turn
_decoded_message, _decoded_moa_config = decode_moa_turn(user_message)
if _decoded_moa_config is not None:
user_message = _decoded_message
moa_config = _decoded_moa_config
if persist_user_message is None:
persist_user_message = _decoded_message
except Exception:
pass
# ── Per-turn setup (the prologue) ──
# All once-per-turn setup — stdio guarding, retry-counter resets, user
# message sanitization, todo/nudge hydration, system-prompt restore-or-
@@ -573,6 +588,13 @@ def run_conversation(
compression_attempts = 0
_turn_exit_reason = "unknown" # Diagnostic: why the loop ended
# Per-turn tally of consecutive successful credential-pool token refreshes,
# keyed by (provider, pool-entry-id). A persistent upstream 401 lets
# ``try_refresh_current()`` "succeed" forever on a single-entry OAuth pool,
# so this tally caps same-entry refreshes and lets the fallback chain take
# over instead of spinning. Reset here so each turn starts fresh. See #26080.
agent._auth_pool_refresh_counts = {}
# Optional opt-in runtime: if api_mode == codex_app_server, hand the
# turn to the codex app-server subprocess (terminal/file ops/patching
# all run inside Codex). Default Hermes path is bypassed entirely.
@@ -802,6 +824,28 @@ def run_conversation(
if effective_system:
api_messages = [{"role": "system", "content": effective_system}] + api_messages
if moa_config:
try:
from agent.moa_loop import aggregate_moa_context
_moa_context = aggregate_moa_context(
user_prompt=original_user_message if isinstance(original_user_message, str) else str(original_user_message),
api_messages=api_messages,
reference_models=moa_config.get("reference_models") or [],
aggregator=moa_config.get("aggregator") or {},
temperature=float(moa_config.get("reference_temperature", 0.6) or 0.6),
aggregator_temperature=float(moa_config.get("aggregator_temperature", 0.4) or 0.4),
)
if _moa_context:
for _msg in reversed(api_messages):
if _msg.get("role") == "user":
_base = _msg.get("content", "")
if isinstance(_base, str):
_msg["content"] = _base + "\n\n" + _moa_context
break
except Exception as _moa_exc:
logger.warning("MoA context aggregation failed: %s", _moa_exc)
# Inject ephemeral prefill messages right after the system prompt
# but before conversation history. Same API-call-time-only pattern.
if agent.prefill_messages:
@@ -1123,7 +1167,7 @@ def run_conversation(
# stream. Mirror the ACP exclusion used for Responses
# API upgrade (lines ~1083-1085).
elif (
agent.provider == "copilot-acp"
agent.provider in {"copilot-acp", "moa"}
or str(agent.base_url or "").lower().startswith("acp://copilot")
or str(agent.base_url or "").lower().startswith("acp+tcp://")
):
@@ -1974,9 +2018,21 @@ def run_conversation(
agent.thinking_callback("")
api_elapsed = time.time() - api_start_time
agent._vprint(f"{agent.log_prefix}⚡ Interrupted during API call.", force=True)
agent._persist_session(messages, conversation_history)
interrupted = True
final_response = f"{INTERRUPT_WAITING_FOR_MODEL_PREFIX}{api_elapsed:.1f}s elapsed)."
# Preserve any assistant text already streamed to the user
# before the stop landed. Dropping it leaves history with no
# record of the half-finished reply on screen, so the next turn
# the model "forgets" what it just said — exactly what users hit
# when they stop to redirect mid-response.
_partial = agent._strip_think_blocks(
getattr(agent, "_current_streamed_assistant_text", "") or ""
).strip()
if _partial:
messages.append({"role": "assistant", "content": _partial})
final_response = _partial
else:
final_response = f"{INTERRUPT_WAITING_FOR_MODEL_PREFIX}{api_elapsed:.1f}s elapsed)."
agent._persist_session(messages, conversation_history)
break
except Exception as api_error:
@@ -2210,6 +2266,15 @@ def run_conversation(
# "unknown variant `image_url`, expected `text`".
"unknown variant `image_url`, expected `text`",
"unknown variant image_url, expected text",
# OpenRouter routes a request to upstream endpoints and,
# when none of the candidate endpoints for the model accept
# image input, returns HTTP 404 "No endpoints found that
# support image input". Without this phrase the agent never
# strips the images, the retry loop re-sends the same
# rejected request until exhaustion, and the gateway leaves
# every subsequent message queued behind the stuck turn —
# the P1 in issue #21160. The 404 passes the 4xx gate below.
"no endpoints found that support image input",
)
_err_lower = _err_body.lower()
_looks_like_image_rejection = any(
@@ -2781,10 +2846,9 @@ def run_conversation(
approx_tokens=approx_tokens,
task_id=effective_task_id,
)
# Compression created a new session — clear history
# so _flush_messages_to_session_db writes compressed
# messages to the new session, not skipping them.
conversation_history = None
conversation_history = conversation_history_after_compression(
agent, messages
)
if len(messages) < original_len or old_ctx > _reduced_ctx:
agent._buffer_status(
f"🗜️ Context reduced to {_reduced_ctx:,} tokens "
@@ -2796,15 +2860,25 @@ def run_conversation(
# Fall through to normal error handling if compression
# is exhausted or didn't help.
# Eager fallback for rate-limit errors (429 or quota exhaustion).
# When a fallback model is configured, switch immediately instead
# of burning through retries with exponential backoff -- the
# primary provider won't recover within the retry window.
# Eager fallback for rate-limit errors (429 or quota exhaustion)
# and transport errors (connection failure / timeout / provider
# overloaded). Rate limits and billing: switch immediately —
# the primary provider won't recover within the retry window.
# Transport errors: allow 1 retry first (transient hiccups
# recover), then fall back if the provider is truly unreachable.
is_rate_limited = classified.reason in {
FailoverReason.rate_limit,
FailoverReason.billing,
}
if is_rate_limited and agent._fallback_index < len(agent._fallback_chain):
_is_transport_failure = classified.reason in {
FailoverReason.timeout,
FailoverReason.overloaded,
}
_should_fallback = (
is_rate_limited
or (_is_transport_failure and retry_count >= 2)
)
if _should_fallback and agent._fallback_index < len(agent._fallback_chain):
# Don't eagerly fallback if credential pool rotation may
# still recover. See _pool_may_recover_from_rate_limit
# for the single-credential-pool and CloudCode-quota
@@ -2819,6 +2893,10 @@ def run_conversation(
agent._buffer_status(
"⚠️ Billing or credits exhausted — switching to fallback provider..."
)
elif _is_transport_failure:
agent._buffer_status(
"⚠️ Provider unreachable — switching to fallback provider..."
)
else:
agent._buffer_status("⚠️ Rate limited — switching to fallback provider...")
if agent._try_activate_fallback(reason=classified.reason):
@@ -2993,10 +3071,9 @@ def run_conversation(
messages, system_message, approx_tokens=approx_tokens,
task_id=effective_task_id,
)
# Compression created a new session — clear history
# so _flush_messages_to_session_db writes compressed
# messages to the new session, not skipping them.
conversation_history = None
conversation_history = conversation_history_after_compression(
agent, messages
)
# Re-estimate tokens after compression. Same-message-count
# compression (tool-result pruning, in-place summarization)
@@ -3160,10 +3237,9 @@ def run_conversation(
messages, system_message, approx_tokens=approx_tokens,
task_id=effective_task_id,
)
# Compression created a new session — clear history
# so _flush_messages_to_session_db writes compressed
# messages to the new session, not skipping them.
conversation_history = None
conversation_history = conversation_history_after_compression(
agent, messages
)
# Re-estimate tokens after compression. Same-message-count
# compression (tool-result pruning, in-place summarization)
@@ -3490,6 +3566,65 @@ def run_conversation(
force=True,
)
# Detect thinking-timeout pattern: a known reasoning model
# hit a transport-layer error before the first content
# token arrived. Distinct from _is_stream_drop above
# (which fires for large file-write stream drops) and
# from any classifier reason that's not a transport
# timeout. Reuses the reasoning-model allowlist from
# agent/reasoning_timeouts.py (Fixes #52217) so the
# trigger is consistent with what the per-model
# stale-timeout floor covers. After the classifier
# override at agent/error_classifier.py:720-738 (this
# PR), transport disconnects on reasoning models route
# to FailoverReason.timeout rather than
# context_overflow, so this branch actually fires.
# Detection and message text live in
# agent.thinking_timeout_guidance so they're
# unit-testable without driving the full retry loop.
# (Part 2 of Fixes #52310.)
from agent.thinking_timeout_guidance import (
is_thinking_timeout,
)
_is_thinking_timeout = is_thinking_timeout(
classified,
_model,
error_msg,
)
if _is_thinking_timeout:
agent._vprint(
f"{agent.log_prefix} 💡 The model's thinking "
f"phase exceeded the upstream proxy's idle "
f"timeout before the first content token "
f"arrived. This is a known issue with "
f"reasoning models behind cloud gateways "
f"(NVIDIA NIM, OpenAI, Anthropic, DeepSeek).",
force=True,
)
agent._vprint(
f"{agent.log_prefix} Workarounds in priority order:",
force=True,
)
agent._vprint(
f"{agent.log_prefix} 1. Set "
f"`providers.{_provider}.models.{_model}.stale_timeout_seconds: 900` "
f"in `~/.hermes/config.yaml` to extend the per-call "
f"timeout. (Hermes's built-in floor is 600s for "
f"known reasoning models — if you still see this "
f"after raising, the upstream cap is even shorter.)",
force=True,
)
agent._vprint(
f"{agent.log_prefix} 2. Lower `reasoning_budget` or set "
f"`reasoning_effort: medium` on this model if the provider supports it.",
force=True,
)
agent._vprint(
f"{agent.log_prefix} 3. Use a smaller / faster reasoning "
f"model if the task doesn't require deep thinking.",
force=True,
)
logger.error(
"%sAPI call failed after %s retries. %s | provider=%s model=%s msgs=%s tokens=~%s",
agent.log_prefix, max_retries, _final_summary,
@@ -3506,7 +3641,22 @@ def run_conversation(
_final_response += f"\n\n{_billing_guidance}"
else:
_final_response = f"API call failed after {max_retries} retries: {_final_summary}"
if _is_stream_drop:
if _is_thinking_timeout:
# Thinking-timeout guidance overrides the generic
# stream-drop guidance — the latter is wrong for
# this case (it suggests splitting large file
# writes, which isn't what happened). See the
# reasoning-model override at
# agent/error_classifier.py:720-738 and the
# detection block above for context.
from agent.thinking_timeout_guidance import (
build_thinking_timeout_guidance,
)
_final_response += build_thinking_timeout_guidance(
provider=_provider,
model=_model,
)
elif _is_stream_drop:
_final_response += (
"\n\nThe provider's stream connection keeps "
"dropping — this often happens when generating "
@@ -3538,7 +3688,12 @@ def run_conversation(
_ra_raw = _resp_headers.get("retry-after") or _resp_headers.get("Retry-After")
if _ra_raw:
try:
_retry_after = min(float(_ra_raw), 120) # Cap at 2 minutes
# Cap at 10 minutes. Anthropic Tier 1 input-token
# buckets reset in ~171s, so a 120s cap caused us to
# retry before the actual reset window and re-trip the
# limit. 600s covers all realistic provider reset
# windows while still rejecting pathological values. (#26293)
_retry_after = min(float(_ra_raw), 600)
except (TypeError, ValueError):
pass
wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0)
@@ -4193,10 +4348,9 @@ def run_conversation(
approx_tokens=agent.context_compressor.last_prompt_tokens,
task_id=effective_task_id,
)
# Compression created a new session — clear history so
# _flush_messages_to_session_db writes compressed messages
# to the new session (see preflight compression comment).
conversation_history = None
conversation_history = conversation_history_after_compression(
agent, messages
)
# Save session log incrementally (so progress is visible even if interrupted)
agent._session_messages = messages
@@ -4571,7 +4725,11 @@ def run_conversation(
"_verification_stop_synthetic": True,
})
agent._session_messages = messages
agent._emit_status("↻ Verification required before finishing")
# Run the verification-stop loop silently — the nudge is an
# internal turn that should not add noise to the user's
# terminal. Keep a debug breadcrumb in agent.log for tracing.
logger.debug("verification stop-loop nudge issued (attempt %d)",
agent._verification_stop_nudges)
continue
messages.append(final_msg)
+114 -6
View File
@@ -11,6 +11,7 @@ import uuid
import re
from dataclasses import dataclass, fields, replace
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from hermes_constants import OPENROUTER_BASE_URL
@@ -447,6 +448,63 @@ def get_pool_strategy(provider: str) -> str:
DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1
def _write_through_provider_state_to_global_root(
provider_id: str, state: Dict[str, Any]
) -> None:
"""Persist a rotated OAuth ``state`` into the global-root auth.json.
Best-effort write-through for the multi-profile rotation hazard
(#48415 / #43589): nous, openai-codex, and xai-oauth rotate the
refresh_token on refresh, so when a profile pool refresh rotates a grant
it resolved from the root fallback, the rotated chain must land back in
root. Otherwise root keeps a now-revoked refresh token and every other
profile reading the stale root grant dies with ``refresh_token_reused`` /
``invalid_grant`` once its access token expires.
Only updates ``providers.<provider_id>`` in the root store; never touches
the profile store (the caller already saved that). Swallows all errors — a
failed write-through degrades to the pre-existing behavior (root stale), it
must never break the profile's own successful save. Mirrors
``hermes_cli.auth._write_through_xai_oauth_to_global_root`` (which covers
the non-pool xAI refresh path) for the credential-pool refresh path.
"""
try:
global_path = auth_mod._global_auth_file_path()
except Exception:
return
if global_path is None:
# Classic mode (profile == root); the profile save already hit root.
return
# Seat belt: under pytest, refuse to write the real user's
# ~/.hermes/auth.json even when HERMES_HOME points at a profile path
# (mirrors the read-side guard in _load_global_auth_store). Uses the
# unmodified HOME env, not Path.home() which fixtures may monkeypatch.
if os.environ.get("PYTEST_CURRENT_TEST"):
real_home_env = os.environ.get("HOME", "")
if real_home_env:
real_root = Path(real_home_env) / ".hermes" / "auth.json"
try:
if global_path.resolve(strict=False) == real_root.resolve(strict=False):
return
except Exception:
return
try:
if global_path.exists():
global_store = _load_auth_store(global_path)
else:
global_store = {}
if not isinstance(global_store, dict):
return
_store_provider_state(global_store, provider_id, dict(state), set_active=False)
auth_mod._save_auth_store(global_store, global_path)
except Exception as exc: # pragma: no cover - best effort
logger.debug(
"%s pool refresh: write-through to global root failed: %s",
provider_id,
exc,
)
class CredentialPool:
def __init__(self, provider: str, entries: List[PooledCredential]):
self.provider = provider
@@ -479,10 +537,11 @@ class CredentialPool:
self._entries[idx] = new
return
def _persist(self) -> None:
def _persist(self, *, removed_ids: Optional[List[str]] = None) -> None:
write_credential_pool(
self.provider,
[entry.to_dict() for entry in self._entries],
removed_ids=removed_ids,
)
def _is_terminal_auth_failure(
@@ -800,6 +859,28 @@ class CredentialPool:
try:
with _auth_store_lock():
auth_store = _load_auth_store()
# Decide BEFORE writing whether this profile is reading the
# grant from the global root (no own providers.<id> block) vs.
# genuinely shadowing it. A pool refresh rotates single-use
# OAuth refresh tokens, so a profile that resolved the grant
# from root MUST write the rotated chain back to root too —
# otherwise root keeps a revoked refresh token and every other
# profile reading the stale root grant dies with
# refresh_token_reused / invalid_grant once its access token
# expires. This mirrors the xAI write-through in
# hermes_cli.auth._save_xai_oauth_tokens (#43589); the pool
# refresh path is the Codex/xAI analog reported in #48415.
_wt_provider_id = {
"nous": "nous",
"openai-codex": "openai-codex",
"xai-oauth": "xai-oauth",
}.get(self.provider)
write_through_to_root = bool(_wt_provider_id) and not (
isinstance(auth_store.get("providers"), dict)
and isinstance(
auth_store["providers"].get(_wt_provider_id), dict
)
)
if self.provider == "nous":
state = _load_provider_state(auth_store, "nous")
if state is None:
@@ -855,6 +936,10 @@ class CredentialPool:
return
_save_auth_store(auth_store)
if write_through_to_root and _wt_provider_id:
_write_through_provider_state_to_global_root(
_wt_provider_id, state
)
except Exception as exc:
logger.debug("Failed to sync %s pool entry back to auth store: %s", self.provider, exc)
@@ -1040,13 +1125,17 @@ class CredentialPool:
logger.debug(
"Failed to clear terminal xAI OAuth state: %s", clear_exc
)
removed_ids = [
item.id for item in self._entries
if item.source == "loopback_pkce"
]
self._entries = [
item for item in self._entries
if item.source != "loopback_pkce"
]
if self._current_id == entry.id:
self._current_id = None
self._persist()
self._persist(removed_ids=removed_ids)
return None
# For openai-codex: same race as xAI/nous — another Hermes process
# may have consumed the refresh token between our proactive sync
@@ -1106,13 +1195,17 @@ class CredentialPool:
logger.debug(
"Failed to clear terminal Codex OAuth state: %s", clear_exc
)
removed_ids = [
item.id for item in self._entries
if item.source == "device_code"
]
self._entries = [
item for item in self._entries
if item.source != "device_code"
]
if self._current_id == entry.id:
self._current_id = None
self._persist()
self._persist(removed_ids=removed_ids)
return None
# For nous: another process may have consumed the refresh token
# between our proactive sync and the HTTP call. Re-sync from
@@ -1169,13 +1262,17 @@ class CredentialPool:
auth_mod.NOUS_DEVICE_CODE_SOURCE,
f"manual:{auth_mod.NOUS_DEVICE_CODE_SOURCE}",
}
removed_ids = [
item.id for item in self._entries
if item.source in singleton_sources
]
self._entries = [
item for item in self._entries
if item.source not in singleton_sources
]
if self._current_id == entry.id:
self._current_id = None
self._persist()
self._persist(removed_ids=removed_ids)
return None
self._mark_exhausted(entry, None)
return None
@@ -1337,7 +1434,7 @@ class CredentialPool:
pruned_ids = set(entries_to_prune)
self._entries = [e for e in self._entries if e.id not in pruned_ids]
if cleared_any:
self._persist()
self._persist(removed_ids=entries_to_prune)
return available
def _select_unlocked(self) -> Optional[PooledCredential]:
@@ -1511,7 +1608,11 @@ class CredentialPool:
replace(entry, priority=new_priority)
for new_priority, entry in enumerate(self._entries)
]
self._persist()
write_credential_pool(
self.provider,
[entry.to_dict() for entry in self._entries],
removed_ids=[removed.id],
)
if self._current_id == removed.id:
self._current_id = None
return removed
@@ -2173,6 +2274,11 @@ def _seed_custom_pool(pool_key: str, entries: List[PooledCredential]) -> Tuple[b
def load_pool(provider: str) -> CredentialPool:
provider = (provider or "").strip().lower()
raw_entries = read_credential_pool(provider)
disk_ids = {
entry.get("id")
for entry in raw_entries
if isinstance(entry, dict) and entry.get("id")
}
raw_needs_sanitization = any(
isinstance(payload, dict)
and sanitize_borrowed_credential_payload(payload, provider) != payload
@@ -2201,8 +2307,10 @@ def load_pool(provider: str) -> CredentialPool:
changed |= _normalize_pool_priorities(provider, entries)
if changed:
new_ids = {entry.id for entry in entries}
write_credential_pool(
provider,
[entry.to_dict() for entry in sorted(entries, key=lambda item: item.priority)],
removed_ids=disk_ids - new_ids,
)
return CredentialPool(provider, entries)
+15 -4
View File
@@ -377,8 +377,10 @@ CURATOR_REVIEW_PROMPT = (
"bodies + `references/`, `templates/`, and `scripts/` subfiles for "
"session-specific detail — not one-session-one-skill micro-entries.\n\n"
"Hard rules — do not violate:\n"
"1. DO NOT touch bundled or hub-installed skills. The candidate list "
"below is already filtered to agent-created skills only.\n"
"1. DO NOT touch bundled, hub-installed, or external-dir skills "
"(`skills.external_dirs`). The candidate list below is already filtered "
"to local curator-managed skills only; external skills are externally "
"owned and read-only to this background curator.\n"
"2. DO NOT delete any skill. Archiving (moving the skill's directory "
"into ~/.hermes/skills/.archive/) is the maximum destructive action. "
"Archives are recoverable; deletion is not.\n"
@@ -469,8 +471,9 @@ CURATOR_REVIEW_PROMPT = (
"skill, or `absorbed_into=\"\"` when you're truly pruning with no "
"forwarding target. This drives cron-job skill-reference migration — "
"guessing from your YAML summary after the fact is fragile.\n"
" - terminal — mv a sibling into the archive "
"OR move its content into a support subfile\n\n"
" - terminal — move LOCAL candidate content into "
"a support subfile when package integrity requires it; never mv, cp, rm, "
"patch, or rewrite bundled, hub-installed, or external-dir skills\n\n"
"'keep' is a legitimate decision ONLY when the skill is already a "
"class-level umbrella and none of the proposed merges would improve "
"discoverability. 'This is narrow but distinct from its siblings' "
@@ -1843,6 +1846,14 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
# Disable recursive nudges — the curator must never spawn its own review.
review_agent._memory_nudge_interval = 0
review_agent._skill_nudge_interval = 0
# Tag this fork as autonomous background curation so skill_manage's
# background-review write guard fires. Without this the fork inherits
# the default "assistant_tool" origin, is_background_review() is False,
# and the external/bundled/hub-installed skill_manage guards never
# trigger during the curation pass they exist to protect against.
# turn_context.py binds this onto the write-origin ContextVar at turn
# start (see agent/turn_context.py).
review_agent._memory_write_origin = "background_review"
# Redirect the forked agent's stdout/stderr to /dev/null while it
# runs so its tool-call chatter doesn't pollute the foreground
+60 -3
View File
@@ -16,6 +16,7 @@ from pathlib import Path
from typing import Any
from utils import safe_json_loads
from agent.redact import redact_sensitive_text
from agent.tool_result_classification import file_mutation_result_landed
# ANSI escape codes for coloring tool failure indicators
@@ -339,6 +340,62 @@ def _read_file_line_label(args: dict) -> str:
return f"L{offset}-{offset + limit - 1}"
def redact_browser_typed_text_for_display(value: Any, typed_text: Any) -> Any:
"""Apply secret redaction to browser_type text in display-facing payloads.
Backends sometimes echo the attempted input in error strings or fallback
metadata. When the raw typed value contains a recognizable secret (API
key, token, JWT, etc.) the redacted form differs from the raw value, so we
replace every occurrence of the raw value with its redacted form before a
browser_type result reaches logs, callbacks, the model, or chat history.
Normal typed text (search queries, addresses, form fields) matches no
secret pattern, so it passes through unchanged and stays readable.
Redaction is forced here regardless of the global ``security.redact_secrets``
preference: a typed credential leaking into chat history is a security
boundary, not mere log hygiene.
"""
if typed_text is None:
return value
needle = str(typed_text)
if needle == "":
return value
redacted = redact_sensitive_text(needle, force=True)
if redacted == needle:
# Nothing secret-looking in the typed text; leave payload untouched.
return value
if isinstance(value, str):
return value.replace(needle, redacted)
if isinstance(value, dict):
return {
key: redact_browser_typed_text_for_display(item, typed_text)
for key, item in value.items()
}
if isinstance(value, list):
return [redact_browser_typed_text_for_display(item, typed_text) for item in value]
if isinstance(value, tuple):
return tuple(redact_browser_typed_text_for_display(item, typed_text) for item in value)
return value
def redact_tool_args_for_display(tool_name: str, args: dict | None) -> dict | None:
"""Return a copy of tool args safe for logs/progress UI.
For ``browser_type`` the ``text`` argument is run through the same
secret-pattern redactor used for logs. Recognizable credentials (API
keys, tokens) are masked before the value reaches tool progress
notifications; normal typed text is left intact for debuggability.
"""
if not isinstance(args, dict):
return args
if tool_name == "browser_type" and isinstance(args.get("text"), str):
safe_args = dict(args)
safe_args["text"] = redact_sensitive_text(args["text"], force=True)
return safe_args
return args
def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]:
if not isinstance(tasks, list):
return 0, []
@@ -362,13 +419,14 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
max_len = _tool_preview_max_len
if not args:
return None
args = redact_tool_args_for_display(tool_name, args) or args
primary_args = {
"terminal": "command", "web_search": "query", "web_extract": "urls",
"read_file": "path", "write_file": "path", "patch": "path",
"search_files": "pattern", "browser_navigate": "url",
"browser_click": "ref", "browser_type": "text",
"image_generate": "prompt", "text_to_speech": "text",
"vision_analyze": "question", "mixture_of_agents": "user_prompt",
"vision_analyze": "question",
"skill_view": "name", "skills_list": "category",
"cronjob": "action",
"execute_code": "code", "delegate_task": "goal",
@@ -1085,6 +1143,7 @@ def get_cute_tool_message(
When *result* is provided the line is checked for failure indicators.
Failed tool calls get a red prefix and an informational suffix.
"""
args = redact_tool_args_for_display(tool_name, args) or args
dur = f"{duration:.1f}s"
is_failure, failure_suffix = _detect_tool_failure(tool_name, result)
skin_prefix = get_skin_tool_prefix()
@@ -1216,8 +1275,6 @@ def get_cute_tool_message(
return _wrap(f"┊ 🔊 speak {_trunc(args.get('text', ''), 30)} {dur}")
if tool_name == "vision_analyze":
return _wrap(f"┊ 👁️ vision {_trunc(args.get('question', ''), 30)} {dur}")
if tool_name == "mixture_of_agents":
return _wrap(f"┊ 🧠 reason {_trunc(args.get('user_prompt', ''), 30)} {dur}")
if tool_name == "send_message":
return _wrap(f"┊ 📨 send {args.get('target', '?')}: \"{_trunc(args.get('message', ''), 25)}\" {dur}")
if tool_name == "cronjob":
+88 -14
View File
@@ -133,6 +133,31 @@ _RATE_LIMIT_PATTERNS = [
"servicequotaexceededexception",
]
# Patterns that indicate provider-side overload, NOT a per-credential rate
# limit or billing problem. The credential is valid — the server is just
# busy — so the correct recovery is "back off and retry the same key", never
# "rotate the credential" (rotating exhausts the pool while the endpoint is
# still busy; a single-key user has nothing to rotate to). Some providers
# (notably Z.AI / Zhipu) reuse HTTP 429 for server-wide overload, so the 429
# status path matches the body against this list before falling through to
# the rate_limit default. Phrases are kept narrow and overload-flavoured so a
# normal rate-limit message ("you have been rate-limited") doesn't hit this
# bucket. (#14038, #15297)
_OVERLOADED_PATTERNS = [
"overloaded",
"temporarily overloaded",
"service is temporarily overloaded",
"service may be temporarily overloaded",
"server is overloaded",
"server overloaded",
"service overloaded",
"service is overloaded",
"upstream overloaded",
"currently overloaded",
"at capacity",
"over capacity",
]
# Usage-limit patterns that need disambiguation (could be billing OR rate_limit)
_USAGE_LIMIT_PATTERNS = [
"usage limit",
@@ -717,6 +742,26 @@ def classify_api_error(
is_disconnect = any(p in error_msg for p in _SERVER_DISCONNECT_PATTERNS)
if is_disconnect and not status_code:
# Reasoning-model override: a transport disconnect on a reasoning
# model is much more likely the upstream proxy idle-killing a
# long thinking stream than a true context overflow — even on
# large sessions. The default disconnect+large-session routing
# below would otherwise send the user into the compression
# branch (should_compress=True) and silently delete
# conversation history on a phantom context-length error.
# Reasoning models have multi-minute thinking phases that
# routinely exceed the cloud gateway's idle window (NVIDIA
# NIM ~120s — first-party repro at NVIDIA/NemoClaw#4846;
# OpenAI worker / Anthropic stream-idle similar). The
# per-reasoning-model stale-timeout floor in
# agent/reasoning_timeouts.py raises the stale-detector
# threshold to tolerate long thinking, so a true
# transport-layer failure here is recoverable via the retry
# path — not via context compression. Reclassify as timeout.
# (Part 1 of Fixes #52310.)
from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
if get_reasoning_stale_timeout_floor(model) is not None:
return _result(FailoverReason.timeout, retryable=True)
# Absolute token/message-count thresholds are only a proxy for smaller
# context windows. Large-context sessions can have hundreds of
# messages while still being far below their actual token budget.
@@ -843,7 +888,19 @@ def _classify_by_status(
)
if status_code == 429:
# Already checked long_context_tier above; this is a normal rate limit
# Already checked long_context_tier above. Some providers (notably
# Z.AI / Zhipu) reuse HTTP 429 for server-wide overload — same status
# code as a true per-credential rate limit, but the credential is
# valid and the correct recovery is "back off and retry the same key",
# NOT "rotate the credential" (which exhausts the pool while the
# endpoint is still busy, and does nothing for a single-key user).
# Disambiguate on the error body so an overload 429 takes the
# transient-overload path instead of burning the pool. (#14038)
if any(p in error_msg for p in _OVERLOADED_PATTERNS):
return result_fn(
FailoverReason.overloaded,
retryable=True,
)
return result_fn(
FailoverReason.rate_limit,
retryable=True,
@@ -1194,6 +1251,17 @@ def _classify_by_message(
should_fallback=True,
)
# Overloaded / server-busy patterns — must come BEFORE the rate_limit and
# billing checks so that a message-only "overloaded" (no 503/529 status,
# e.g. some Anthropic-compatible proxies) classifies as a transient
# overload (backoff + retry) instead of falling through to `unknown` or
# incorrectly triggering credential rotation.
if any(p in error_msg for p in _OVERLOADED_PATTERNS):
return result_fn(
FailoverReason.overloaded,
retryable=True,
)
# Billing patterns
if any(p in error_msg for p in _BILLING_PATTERNS):
return result_fn(
@@ -1283,19 +1351,25 @@ def _extract_status_code(error: Exception) -> Optional[int]:
def _extract_error_body(error: Exception) -> dict:
"""Extract the structured error body from an SDK exception."""
body = getattr(error, "body", None)
if isinstance(body, dict):
return body
# Some errors have .response.json()
response = getattr(error, "response", None)
if response is not None:
try:
json_body = response.json()
if isinstance(json_body, dict):
return json_body
except Exception:
pass
"""Extract the structured error body from an SDK exception or its cause chain."""
current = error
for _ in range(5): # Match _extract_status_code() traversal depth.
body = getattr(current, "body", None)
if isinstance(body, dict):
return body
# Some errors have .response.json()
response = getattr(current, "response", None)
if response is not None:
try:
json_body = response.json()
if isinstance(json_body, dict):
return json_body
except Exception:
pass
cause = getattr(current, "__cause__", None) or getattr(current, "__context__", None)
if cause is None or cause is current:
break
current = cause
return {}
+25 -12
View File
@@ -77,15 +77,22 @@ def build_write_denied_prefixes(home: str) -> list[str]:
]
def get_safe_write_root() -> Optional[str]:
"""Return the resolved HERMES_WRITE_SAFE_ROOT path, or None if unset."""
root = os.getenv("HERMES_WRITE_SAFE_ROOT", "")
if not root:
return None
try:
return os.path.realpath(os.path.expanduser(root))
except Exception:
return None
def get_safe_write_roots() -> set[str]:
"""Return resolved HERMES_WRITE_SAFE_ROOT paths. Supports multiple directories
separated by ``os.pathsep`` (``:`` on Unix, ``;`` on Windows).
E.g., ``/opt/data:/var/www/html`` on Unix, ``C:\\data;D:\\www`` on Windows."""
env = os.getenv("HERMES_WRITE_SAFE_ROOT", "")
if not env:
return set()
roots: set[str] = set()
for path in env.split(os.pathsep):
if path:
try:
resolved = os.path.realpath(os.path.expanduser(path))
roots.add(resolved)
except (OSError, ValueError):
continue
return roots
def is_write_denied(path: str) -> bool:
@@ -124,9 +131,15 @@ def is_write_denied(path: str) -> bool:
except Exception:
pass
safe_root = get_safe_write_root()
if safe_root and not (resolved == safe_root or resolved.startswith(safe_root + os.sep)):
return True
safe_roots = get_safe_write_roots()
if safe_roots:
allowed = False
for safe_root in safe_roots:
if resolved == safe_root or resolved.startswith(safe_root + os.sep):
allowed = True
break
if not allowed:
return True
return False
+117 -7
View File
@@ -388,14 +388,98 @@ def _sniff_mime_from_bytes(raw: bytes) -> Optional[str]:
# BMP: "BM"
if raw.startswith(b"BM"):
return "image/bmp"
# HEIC/HEIF: ftypheic / ftypheix / ftypmif1 / ftypmsf1 etc.
if len(raw) >= 12 and raw[4:8] == b"ftyp" and raw[8:12] in {
b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1", b"heim", b"heis",
}:
return "image/heic"
# ISO-BMFF family (HEIC/HEIF/AVIF): bytes 4..8 == 'ftyp', major brand at 8..12
if len(raw) >= 12 and raw[4:8] == b"ftyp":
brand = raw[8:12]
if brand in {b"avif", b"avis"}:
return "image/avif"
if brand in {
b"heic", b"heix", b"hevc", b"hevx",
b"mif1", b"msf1", b"heim", b"heis",
}:
return "image/heic"
# TIFF: II*\0 (little-endian) or MM\0* (big-endian)
if raw[:4] in {b"II*\x00", b"MM\x00*"}:
return "image/tiff"
# ICO: 00 00 01 00 (reserved=0, type=1=icon)
if raw[:4] == b"\x00\x00\x01\x00":
return "image/x-icon"
# SVG: text-based, look for an <svg tag near the start (skip BOM/whitespace)
head = raw[:512].lstrip().lower()
if head.startswith(b"<?xml") or head.startswith(b"<svg"):
if b"<svg" in head:
return "image/svg+xml"
return None
# Formats every major vision provider (Anthropic, OpenAI, Gemini, Bedrock)
# accepts natively. Anything outside this set has to be transcoded to PNG
# before we declare media_type, otherwise the provider returns HTTP 400
# ("Could not process image" / "Unsupported image media type") and the
# whole turn fails with no salvage path.
#
# Discord (and a few other chat platforms) freely accept attachments in
# formats outside this set -- AVIF screenshots from Chromium, HEIC from
# iPhones, TIFF from scanners, BMP from old Windows tools, ICO -- so users
# do hit this in practice. SVG is vector and Pillow cannot rasterize it;
# it is skipped (logged) rather than transcoded.
_UNIVERSALLY_SUPPORTED_MIMES = frozenset({
"image/png", "image/jpeg", "image/gif", "image/webp",
})
def _transcode_to_png(raw: bytes) -> Optional[bytes]:
"""Decode arbitrary image bytes with Pillow and re-encode as PNG.
Returns None if Pillow isn't installed or can't decode the input
(rare formats, corrupted bytes, missing optional decoder plugin for
HEIC/AVIF, or vector formats like SVG). Caller falls back to skipping
the image so the rest of the turn still works.
HEIC/HEIF and AVIF need optional Pillow plugins; we try to register
them on demand and swallow ImportError so a missing plugin just
looks like 'Pillow can't decode this' rather than crashing.
"""
try:
from PIL import Image
except ImportError:
logger.info(
"image_routing: Pillow not installed; cannot transcode "
"non-standard image format to PNG. Install with `pip install Pillow` "
"(and `pillow-heif` / `pillow-avif-plugin` for those formats)."
)
return None
# Optional plugin registration. Silent on failure: an unsupported
# format will just fall through to Image.open raising below.
try:
import pillow_heif # type: ignore
pillow_heif.register_heif_opener()
except Exception:
pass
try:
import pillow_avif # type: ignore # noqa: F401 -- registers AVIF on import
except Exception:
pass
try:
from io import BytesIO
with Image.open(BytesIO(raw)) as im:
# Pick an output mode PNG can serialise. Anything other than
# the standard set gets normalised to RGBA so transparency is
# preserved where the source had it.
if im.mode not in {"RGB", "RGBA", "L", "LA", "P"}:
im = im.convert("RGBA")
buf = BytesIO()
im.save(buf, format="PNG", optimize=False)
return buf.getvalue()
except Exception as exc:
logger.info(
"image_routing: Pillow could not transcode image to PNG -- %s", exc
)
return None
def _guess_mime(path: Path, raw: Optional[bytes] = None) -> str:
"""Return image MIME type for *path*.
@@ -431,8 +515,18 @@ def _file_to_data_url(path: Path) -> Optional[str]:
accept large images (OpenAI 49 MB+, Gemini 100 MB) don't pay a silent
quality tax just because one other provider is stricter.
Returns None only if the file can't be read (missing, permission
denied, etc.); the caller reports those paths in ``skipped``.
Format compatibility IS handled here: if the sniffed MIME isn't one
of ``_UNIVERSALLY_SUPPORTED_MIMES`` (i.e. it's something like AVIF,
HEIC, BMP, TIFF, or ICO that some providers reject outright), we
transcode to PNG with Pillow before declaring media_type. This fixes
the user-visible "Could not process image" HTTP 400 from Anthropic on
Discord-attached AVIF/HEIC/BMP files.
Returns None if the file can't be read OR if the format isn't
universally supported AND Pillow can't transcode it (Pillow missing,
HEIC/AVIF plugin missing, vector format like SVG, corrupt bytes). The
caller reports those paths in ``skipped`` and the rest of the turn
proceeds.
"""
try:
raw = path.read_bytes()
@@ -440,6 +534,22 @@ def _file_to_data_url(path: Path) -> Optional[str]:
logger.warning("image_routing: failed to read %s — %s", path, exc)
return None
mime = _guess_mime(path, raw=raw)
if mime not in _UNIVERSALLY_SUPPORTED_MIMES:
transcoded = _transcode_to_png(raw)
if transcoded is None:
logger.warning(
"image_routing: %s is %s which is not accepted by all major "
"vision providers and could not be transcoded to PNG; "
"skipping this attachment.",
path, mime,
)
return None
logger.info(
"image_routing: transcoded %s (%s) -> image/png for provider compatibility",
path.name, mime,
)
raw = transcoded
mime = "image/png"
b64 = base64.b64encode(raw).decode("ascii")
return f"data:{mime};base64,{b64}"
+441
View File
@@ -0,0 +1,441 @@
"""Mixture-of-Agents runtime helpers for /moa turns.
The slash command is deliberately not a model tool. It marks one user turn as
MoA-enabled; the normal Hermes agent loop still owns tool calling and turn
termination, while this module gathers reference-model context before each model
iteration.
"""
from __future__ import annotations
import hashlib
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from agent.auxiliary_client import call_llm
from agent.transports import get_transport
logger = logging.getLogger(__name__)
# Upper bound on concurrent reference-model calls. References are independent
# advisory calls (no tools, no inter-dependence), so we fan them out the same
# way delegate_task runs a batch: all in flight at once, results collected when
# every reference finishes. Presets rarely list more than a handful of
# references; this cap just protects against a pathologically large preset
# opening dozens of sockets at once.
_MAX_REFERENCE_WORKERS = 8
def _slot_label(slot: dict[str, str]) -> str:
return f"{slot.get('provider', '').strip()}:{slot.get('model', '').strip()}"
def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]:
"""Resolve a reference/aggregator slot to real runtime call kwargs.
A MoA slot is just a model selection — it must be called the same way any
model is called elsewhere, not through a bare ``call_llm(provider=...,
model=...)`` that leaves base_url/api_key/api_mode unresolved and lets the
auxiliary auto-detector guess. We route the slot's provider through
``resolve_runtime_provider`` (the canonical provider→api_mode/base_url/
api_key resolver the CLI, gateway, and delegate_task all use), so the slot
gets its provider's real API surface — e.g. MiniMax → anthropic_messages,
GPT-5/o-series → max_completion_tokens, custom endpoints → their base_url.
Returns the kwargs to pass through to ``call_llm`` (provider/model plus the
resolved base_url/api_key when available). Falls back to the bare
provider/model on any resolution error so a misconfigured slot still
attempts the call rather than aborting the whole MoA turn.
"""
provider = str(slot.get("provider") or "").strip()
model = str(slot.get("model") or "").strip()
out: dict[str, Any] = {"provider": provider, "model": model}
try:
from hermes_cli.runtime_provider import resolve_runtime_provider
rt = resolve_runtime_provider(requested=provider, target_model=model)
resolved_provider = str(rt.get("provider") or provider).strip().lower()
# call_llm treats an explicit base_url as a custom endpoint. That is
# correct for ordinary OpenAI-compatible targets, but wrong for OAuth /
# adapter-backed providers whose provider branch adds auth headers and
# request-shape adapters. Keep those providers identified by name.
if resolved_provider in {"openai-codex", "xai-oauth"}:
return out
# Pass the resolved endpoint through so call_llm builds the request for
# the provider's actual API surface instead of auto-detecting. base_url
# routes call_llm to the right adapter (incl. anthropic_messages mode);
# api_key is the resolved credential for that provider.
if rt.get("base_url"):
out["base_url"] = rt["base_url"]
if rt.get("api_key"):
out["api_key"] = rt["api_key"]
except Exception as exc: # pragma: no cover - defensive
logger.debug("MoA slot runtime resolution failed for %s: %s", _slot_label(slot), exc)
return out
def _run_reference(
slot: dict[str, str],
ref_messages: list[dict[str, Any]],
*,
temperature: float | None = None,
max_tokens: int | None = None,
) -> tuple[str, str]:
"""Call one reference model and return ``(label, text)``.
The slot is resolved to its provider's real runtime (via ``_slot_runtime``)
and called through the same ``call_llm`` request-building path any model
uses, so per-model wire-format handling (anthropic_messages,
max_completion_tokens, fixed/forbidden temperature) applies identically to
a reference as it would if that model were the acting model. MoA imposes no
cap of its own (``max_tokens`` defaults to ``None`` → omitted → the model's
real maximum); ``temperature`` is only the user's configured preset value,
which call_llm may still override per model.
Never raises: a failed reference becomes a labelled note so the aggregator
can still act with partial context. Designed to run inside a thread pool —
``call_llm`` is synchronous/blocking, so threads (not asyncio) are the right
concurrency primitive, mirroring ``delegate_task``'s batch fan-out.
"""
label = _slot_label(slot)
try:
response = call_llm(
task="moa_reference",
messages=ref_messages,
temperature=temperature,
max_tokens=max_tokens,
**_slot_runtime(slot),
)
return label, _extract_text(response) or "(empty response)"
except Exception as exc:
logger.warning("MoA reference model %s failed: %s", label, exc)
return label, f"[failed: {exc}]"
def _run_references_parallel(
reference_models: list[dict[str, str]],
ref_messages: list[dict[str, Any]],
*,
temperature: float | None = None,
max_tokens: int | None = None,
) -> list[tuple[str, str]]:
"""Fan out all reference models in parallel, returning outputs in order.
Like ``delegate_task``'s batch mode, every reference is dispatched at once
and we block until all of them finish before handing the joined results to
the aggregator. Output order matches ``reference_models`` so the
``Reference {idx}`` labelling stays stable. MoA presets that reference
another MoA preset are skipped here (recursion guard) with a labelled note.
"""
if not reference_models:
return []
results: list[tuple[str, str] | None] = [None] * len(reference_models)
futures = {}
workers = min(_MAX_REFERENCE_WORKERS, len(reference_models))
with ThreadPoolExecutor(max_workers=workers) as executor:
for idx, slot in enumerate(reference_models):
if slot.get("provider") == "moa":
results[idx] = (
_slot_label(slot),
"[skipped: MoA presets cannot recursively reference MoA]",
)
continue
futures[
executor.submit(
_run_reference,
slot,
ref_messages,
temperature=temperature,
max_tokens=max_tokens,
)
] = idx
# Collect every reference before returning — the aggregator needs the
# complete set, so there is no early-exit / first-completed path here.
for future, idx in futures.items():
results[idx] = future.result()
return [r for r in results if r is not None]
def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Build an advisory-safe view of the conversation for reference models.
Reference calls are advisory: they never call tools and never emit the
``tool_calls`` the main model did. Replaying the full transcript verbatim
(a) re-bills the ~8K-token Hermes system prompt per reference per
iteration and (b) risks 400s from strict providers (Mistral, Fireworks)
that reject orphan ``tool`` messages or ``tool_calls`` the reference never
produced. We keep only the user/assistant *text* turns, dropping the
system prompt, any ``tool``-role messages, and any ``tool_calls`` payloads.
"""
trimmed: list[dict[str, Any]] = []
for msg in messages:
role = msg.get("role")
if role not in ("user", "assistant"):
# Drop system prompt and tool-result messages.
continue
content = msg.get("content")
if not isinstance(content, str):
# Skip non-text (multimodal/tool-call-only) assistant turns.
if not content:
continue
text = content if isinstance(content, str) else ""
if role == "assistant" and not text.strip():
# Assistant turn that was purely tool calls — nothing advisory.
continue
trimmed.append({"role": role, "content": text})
if not trimmed:
# Degenerate case (e.g. first turn was stripped): fall back to a
# minimal user turn so the reference still has something to answer.
for msg in reversed(messages):
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
return [{"role": "user", "content": msg["content"]}]
return trimmed
def _extract_text(response: Any) -> str:
try:
transport = get_transport("chat_completions")
if transport is None:
raise RuntimeError("chat_completions transport unavailable")
normalized = transport.normalize_response(response)
text = (normalized.content or "").strip()
if text:
return text
except Exception:
pass
try:
content = response.choices[0].message.content
return (content or "").strip()
except Exception:
return ""
def aggregate_moa_context(
*,
user_prompt: str,
api_messages: list[dict[str, Any]],
reference_models: list[dict[str, str]],
aggregator: dict[str, str],
temperature: float = 0.6,
aggregator_temperature: float = 0.4,
max_tokens: int | None = None,
) -> str:
"""Run configured reference models and synthesize their advice.
Failures are returned as model-specific notes instead of aborting the normal
agent loop; the main model can still act with partial context.
``max_tokens`` is ``None`` by default: MoA does not cap reference or
aggregator output, so each model uses its own maximum. ``call_llm`` omits
the parameter entirely when it is ``None`` (see its docstring), which also
sidesteps providers that reject ``max_tokens`` outright. A hardcoded cap
here previously truncated long aggregator syntheses.
"""
reference_outputs: list[tuple[str, str]] = []
ref_messages = _reference_messages(api_messages)
reference_outputs = _run_references_parallel(
reference_models,
ref_messages,
temperature=temperature,
max_tokens=max_tokens,
)
joined = "\n\n".join(
f"Reference {idx} — {label}:\n{text}"
for idx, (label, text) in enumerate(reference_outputs, start=1)
)
synth_prompt = (
"You are the aggregator in a Mixture of Agents process. Synthesize the "
"reference responses into concise, actionable guidance for the main "
"Hermes agent. Focus on next steps, tool-use strategy, risks, and any "
"disagreements. Do not answer the user directly unless that is all that "
"is needed; produce context the main agent should use in its normal loop.\n\n"
f"Original user prompt:\n{user_prompt}\n\n"
f"Reference responses:\n{joined}"
)
agg_label = _slot_label(aggregator)
try:
response = call_llm(
task="moa_aggregator",
messages=[{"role": "user", "content": synth_prompt}],
temperature=aggregator_temperature,
max_tokens=max_tokens,
**_slot_runtime(aggregator),
)
synthesis = _extract_text(response)
except Exception as exc:
logger.warning("MoA aggregator model %s failed: %s", agg_label, exc)
synthesis = ""
if not synthesis:
synthesis = joined
return (
"[Mixture of Agents context — use this as private guidance for the "
"normal Hermes agent loop. You may call tools, continue reasoning, or "
"finish normally.]\n"
f"Aggregator: {agg_label}\n"
f"References: {', '.join(_slot_label(slot) for slot in reference_models)}\n\n"
f"{synthesis.strip()}"
)
class MoAChatCompletions:
"""OpenAI-chat-compatible facade where the aggregator is the acting model."""
def __init__(self, preset_name: str, reference_callback: Any = None):
self.preset_name = preset_name or "default"
# Optional display hook. Called as reference outputs become available so
# frontends can show each reference model's answer as a labelled block
# before the aggregator acts. Signature:
# reference_callback(event, **kwargs)
# where event is one of:
# "moa.reference" kwargs: index, count, label, text
# "moa.aggregating" kwargs: aggregator (label), ref_count
# Never raises into the model call — display is best-effort.
self.reference_callback = reference_callback
# Turn-scoped reference cache. The agent loop calls create() once per
# tool-loop iteration, but references are advisory for the whole turn:
# the advisory message view (_reference_messages) is identical across
# iterations (it strips tool/tool_call turns) until a new user message
# arrives. Re-running references every iteration would multiply their
# API cost by the tool-loop depth AND re-emit the same blocks to the
# display on every iteration. So cache outputs keyed by the advisory
# view's signature and reuse them — running and showing references once
# per user turn.
self._ref_cache_key: tuple | None = None
self._ref_cache_outputs: list[tuple[str, str]] = []
def _emit(self, event: str, **kwargs: Any) -> None:
cb = self.reference_callback
if cb is None:
return
try:
cb(event, **kwargs)
except Exception as exc: # pragma: no cover - display must never break the turn
logger.debug("MoA reference_callback failed for %s: %s", event, exc)
def create(self, **api_kwargs: Any) -> Any:
from hermes_cli.config import load_config
from hermes_cli.moa_config import resolve_moa_preset
preset = resolve_moa_preset(load_config().get("moa") or {}, self.preset_name)
messages = list(api_kwargs.get("messages") or [])
reference_models = preset.get("reference_models") or []
aggregator = preset.get("aggregator") or {}
# MoA does not cap reference or aggregator output: each model uses its
# own maximum. Passing max_tokens=None makes call_llm omit the parameter
# (it never caps by default), so a long aggregator synthesis is never
# truncated and providers that reject max_tokens don't 400.
temperature = float(preset.get("reference_temperature", 0.6) or 0.6)
aggregator_temperature = float(preset.get("aggregator_temperature", api_kwargs.get("temperature") or 0.4) or 0.4)
# When the preset is disabled, skip the reference fan-out and let the
# configured aggregator act alone — it is the preset's acting model, so
# a disabled MoA preset is simply "use the aggregator directly."
if not preset.get("enabled", True):
reference_models = []
reference_outputs: list[tuple[str, str]] = []
ref_messages = _reference_messages(messages)
# Turn-scoped cache: only run + display references when the advisory
# view changed (i.e. a new user turn). Within one turn the agent loop
# calls create() once per tool iteration with the same advisory view;
# reuse the cached outputs and skip both the re-run and the re-emit.
_sig = hashlib.sha256(
"\u0000".join(
f"{m.get('role')}:{m.get('content')}" for m in ref_messages
).encode("utf-8", "replace")
).hexdigest()
_cache_key = (self.preset_name, _sig, tuple(_slot_label(s) for s in reference_models))
_refs_from_cache = _cache_key == self._ref_cache_key and bool(self._ref_cache_outputs)
if _refs_from_cache:
reference_outputs = list(self._ref_cache_outputs)
else:
reference_outputs = _run_references_parallel(
reference_models,
ref_messages,
temperature=temperature,
max_tokens=None,
)
self._ref_cache_key = _cache_key
self._ref_cache_outputs = list(reference_outputs)
# Surface each reference model's answer to the display BEFORE the
# aggregator acts — once per turn (only on the iteration that
# actually ran them). The user sees one labelled block per
# reference (rendered like a thinking block) so the MoA process is
# visible rather than a silent pause. Best-effort: never blocks the
# turn.
_ref_count = len(reference_outputs)
for _idx, (_label, _text) in enumerate(reference_outputs, start=1):
self._emit(
"moa.reference",
index=_idx,
count=_ref_count,
label=_label,
text=_text,
)
if _ref_count:
self._emit(
"moa.aggregating",
aggregator=_slot_label(aggregator),
ref_count=_ref_count,
)
agg_messages = [dict(m) for m in messages]
if reference_outputs:
joined = "\n\n".join(
f"Reference {idx} — {label}:\n{text}"
for idx, (label, text) in enumerate(reference_outputs, start=1)
)
guidance = (
"[Mixture of Agents reference context]\n"
f"Preset: {self.preset_name}\n"
f"Aggregator/acting model: {_slot_label(aggregator)}\n"
f"References: {', '.join(label for label, _ in reference_outputs)}\n\n"
"Use the reference responses below as private context. You are the aggregator and acting model: "
"answer the user directly or call tools as needed.\n\n"
f"{joined}"
)
for msg in reversed(agg_messages):
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
msg["content"] = msg["content"] + "\n\n" + guidance
break
else:
agg_messages.append({"role": "user", "content": guidance})
if aggregator.get("provider") == "moa":
raise RuntimeError("MoA aggregator cannot be another MoA preset")
agg_kwargs = dict(api_kwargs)
agg_kwargs["messages"] = agg_messages
# The aggregator is the acting model. Resolve its slot to the provider's
# real runtime (base_url/api_key/api_mode) and call it through the same
# request-building path any model uses — so per-model wire-format
# handling (anthropic_messages, max_completion_tokens, fixed/forbidden
# temperature) applies identically to it. MoA imposes no output cap:
# max_tokens is passed through from the caller (normally None → omitted
# → the model's real maximum). The preset's old hardcoded 4096 default
# is gone — it truncated long syntheses.
return call_llm(
task="moa_aggregator",
messages=agg_messages,
temperature=aggregator_temperature,
max_tokens=agg_kwargs.get("max_tokens"),
tools=agg_kwargs.get("tools"),
extra_body=agg_kwargs.get("extra_body"),
**_slot_runtime(aggregator),
)
class MoAClient:
def __init__(self, preset_name: str, reference_callback: Any = None):
self.chat = type("_MoAChat", (), {})()
self.chat.completions = MoAChatCompletions(preset_name, reference_callback=reference_callback)
+28
View File
@@ -1646,6 +1646,34 @@ def get_model_context_length(
if config_context_length is not None and isinstance(config_context_length, int) and config_context_length > 0:
return config_context_length
# 0a. MoA virtual provider — ``model`` is a preset name, not a real model,
# and ``base_url`` is the local virtual endpoint, so every probe below would
# miss and fall through to the 256K default. The aggregator is the acting
# model, so resolve the context window from the aggregator slot's real
# provider+model instead. References are advisory-only and never bound the
# acting context, so they're ignored here.
if (provider or "").strip().lower() == "moa":
try:
from hermes_cli.config import load_config
from hermes_cli.moa_config import resolve_moa_preset
from hermes_cli.runtime_provider import resolve_runtime_provider
preset = resolve_moa_preset(load_config().get("moa") or {}, model)
agg = preset.get("aggregator") or {}
agg_provider = str(agg.get("provider") or "").strip()
agg_model = str(agg.get("model") or "").strip()
if agg_model and agg_provider and agg_provider.lower() != "moa":
rt = resolve_runtime_provider(requested=agg_provider, target_model=agg_model)
return get_model_context_length(
agg_model,
base_url=rt.get("base_url", "") or "",
api_key=rt.get("api_key", "") or "",
provider=agg_provider,
)
except Exception:
logger.debug("MoA aggregator context-length resolution failed", exc_info=True)
# Fall through to the generic default if aggregator resolution failed.
# 0b. custom_providers per-model override — check before any probe.
# This closes the gap where /model switch and display paths used to fall
# back to 128K despite the user having a per-model context_length set.
+15 -3
View File
@@ -243,7 +243,10 @@ KANBAN_GUIDANCE = (
"- **Workspace.** `cd $HERMES_KANBAN_WORKSPACE` first. For a `worktree` kind "
"with no `.git`, `git worktree add <path> "
"${HERMES_KANBAN_BRANCH:-wt/$HERMES_KANBAN_TASK}` from the main repo, then "
"cd there.\n"
"cd there. For a project-linked task the workspace is a fresh "
"`<repo>/.worktrees/<task-id>` and `$HERMES_KANBAN_BRANCH` a deterministic "
"`<project-slug>/<task-id>` — the main repo is two levels up, so run "
"`git worktree add` from there.\n"
"- **Deliverables.** Files a human wants go in "
"`kanban_complete(artifacts=[<absolute paths>])` (top-level param; paths in "
"`metadata` are NOT uploaded). Files must exist at completion.\n"
@@ -614,7 +617,12 @@ DEVELOPER_ROLE_MODELS = ("gpt-5", "codex")
PLATFORM_HINTS = {
"whatsapp": (
"You are on a text messaging communication platform, WhatsApp. "
"Please do not use markdown as it does not render. "
"Standard markdown (**bold**, *italic*, ~~strike~~, # headers, "
"`code`, ```code blocks```, [links](url)) is auto-converted to "
"WhatsApp's native syntax (*bold*, _italic_, ~strike~, monospace) — "
"feel free to write in markdown, and use bullet lists ('- item') "
"freely. Tables are NOT supported — prefer bullet lists or labeled "
"key:value pairs. "
"You can send media files natively: to deliver a file to the user, "
"include MEDIA:/absolute/path/to/file in your response. The file "
"will be sent as a native WhatsApp attachment — images (.jpg, .png, "
@@ -679,7 +687,11 @@ PLATFORM_HINTS = {
),
"signal": (
"You are on a text messaging communication platform, Signal. "
"Please do not use markdown as it does not render. "
"Standard markdown (**bold**, *italic*, ~~strike~~, # headers, "
"`code`, ```code blocks```) is auto-converted to Signal's native "
"rich formatting — feel free to write in markdown, and use bullet "
"lists ('- item') freely (they render as • bullets). Tables are NOT "
"supported — prefer bullet lists or labeled key:value pairs. "
"You can send media files natively: to deliver a file to the user, "
"include MEDIA:/absolute/path/to/file in your response. Images "
"(.png, .jpg, .webp) appear as photos, audio as attachments, and other "
+216
View File
@@ -0,0 +1,216 @@
"""Per-reasoning-model stale-timeout floor for known reasoning models.
Reasoning models (those that emit extended thinking blocks before their
first content token) routinely exceed Hermes's default chat-model
stale detectors:
* Stream stale detector: ``HERMES_STREAM_STALE_TIMEOUT`` default 180s
``agent/chat_completion_helpers.py:2544``
* Non-stream stale detector: ``HERMES_API_CALL_STALE_TIMEOUT`` default 90s
``run_agent.py:1140``
For NVIDIA Nemotron 3 Ultra on the hosted NIM gateway the empirical
upstream idle kill is ~120s (first-party reproduction at
NVIDIA/NemoClaw#4846 — TTFB ~31s, stream dies at 120s). The same
failure mode exists on OpenAI o1/o3, Anthropic Opus 4.x thinking,
DeepSeek R1, Qwen QwQ, xAI Grok reasoning — every cloud reasoning
model hits upstream-proxies / load-balancers with idle timeouts
shorter than the model's thinking phase. Result: the stale detector
kills the connection mid-think, surfacing as
``BrokenPipeError``/``RemoteProtocolError`` on the next read.
This module provides a floor that the existing stale-detector scaling
blocks consult via :func:`get_reasoning_stale_timeout_floor` and
apply as ``max(default, floor)``. It is a FLOOR:
* Never overrides explicit user config (``providers.<id>.models.<model>.stale_timeout_seconds``
or ``request_timeout_seconds`` already wins — this code never runs
in that branch).
* Never lowers an existing threshold.
* Has zero effect on non-reasoning models — they are not in the
allowlist and the resolver returns ``None``.
Matching uses start-anchored regex on the slug-only component of
the model name (after stripping any aggregator prefix like
``openai/``, ``x-ai/``, ``anthropic/``). The right-anchor matches
end-of-string or a ``-``/``.``/``_`` slug separator, so ``qwen3-235b``
matches the ``qwen3`` family entry (a future model slug would be
``qwen3-235b-instruct`` and would also match) but ``some-other-qwen3``
does NOT match ``qwen3`` (the ``-qwen3`` is not at start of slug).
The ``o1`` case is the most delicate: a model named
``llama-4-70b-o1-preview`` is a hypothetical community derivative that
should NOT trigger the reasoning-model floor for the user (the user
chose a non-OpenAI model, not a reasoning model). The start-of-slug
anchor naturally excludes this — the matched ``o1-preview`` is at
position 11 of the slug, not at position 0. The previous substring-
with-trailing-hyphen design would have over-matched here, which is
why start-of-slug anchoring is the right shape.
Fixes #52217.
"""
from __future__ import annotations
import re
from typing import Optional
# (slug, floor_seconds). Each slug is matched as a discrete
# word-boundary component via the wrapper regex in ``_match_any``
# below. Order is irrelevant — the first regex match wins.
_REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = (
# NVIDIA Nemotron — reasoning models behind hosted NIM with
# documented 60-180s upstream idle kill (NVIDIA/NemoClaw#4846:
# 120s measured).
("nemotron-3-ultra", 600),
("nemotron-3-super", 600),
("nemotron-3-nano", 300),
# DeepSeek — R1 reasoning model on hosted NIM / DeepSeek direct.
("deepseek-r1", 600),
("deepseek-reasoner", 600),
# Qwen — QwQ reasoning + Qwen3 thinking variants. QwQ-32B
# preview is the stable slug; ``qwen3`` covers the family of
# thinking-mode Qwen3 models (qwen3-235b-a22b, qwen3-32b, etc.)
# without over-matching every Qwen3 instruct variant — the
# right-anchor requires the slug to be at the start of the
# remaining model name, so ``qwen3-235b-instruct`` (instruct is
# NOT a thinking variant) would still match. Acceptable
# trade-off: instruct variants of qwen3 get the 180s floor
# even though they don't reason. The cost is a slightly longer
# wait on a hung provider; the alternative (matching only
# ``qwen3-.*-thinking``) breaks the moment NVIDIA or Alibaba
# ships a slightly different naming shape.
("qwq-32b", 300),
("qwen3", 180),
# OpenAI o-series — known multi-minute TTFB. Each variant
# enumerated explicitly so bare ``o1`` doesn't over-match
# ``olmo-1`` or hypothetical future community derivatives.
("o1", 600),
("o1-mini", 600),
("o1-pro", 600),
("o1-preview", 600),
("o3", 600),
("o3-pro", 600),
("o3-mini", 300),
("o4-mini", 300),
# Anthropic Claude 4.x thinking variants. Anchored at
# ``claude-opus-4`` so non-thinking Claude 3.x or future
# non-reasoning Claude variants don't match.
("claude-opus-4", 240),
("claude-sonnet-4.5", 180),
("claude-sonnet-4.6", 180),
# xAI Grok reasoning variants. Explicit reasoning-only keys
# plus one for the ``non-reasoning`` variant so users picking
# the fast variant don't get the 300s floor. Bare ``grok-3``,
# ``grok-4`` etc. don't match — only the explicit reasoning /
# non-reasoning pairs.
("grok-4-fast-reasoning", 300),
("grok-4.20-reasoning", 300),
("grok-4-fast-non-reasoning", 180),
)
# Pre-compile each pattern. Wrapper = start-of-slug + slug + end-or-
# separator, where ``start-of-slug`` means start-of-string OR
# immediately after the last ``/`` (aggregator separator) and
# ``end-or-separator`` means end-of-string OR a ``-``/``.``/``_``.
#
# Why start-of-slug and not start-of-string: aggregator prefixes
# like ``openai/`` should not affect matching — the slug identity is
# the part after the last ``/``. Stripping the aggregator prefix in
# :func:`get_reasoning_stale_timeout_floor` before regex matching
# gives the wrapper a clean start-of-string anchor.
#
# Why end-or-separator on the right: ``openai/o3-mini`` must match
# the ``o3-mini`` slug (the right anchor is end-of-string). And
# ``openai/o3-mini-2025-01-31`` must also match ``o3-mini`` (the right
# anchor is the ``-`` separator). But ``openai/o3-mini-fork`` should
# NOT match ``o3-mini`` if we wanted to exclude forks — though the
# pattern ``o3-mini-fork`` would be matched as a derivative anyway,
# so we accept that community forks inheriting the same prefix are
# treated as reasoning models (a reasonable default — the upstream
# gateway timing is the same).
_PATTERN_CACHE: dict[str, re.Pattern[str]] = {}
def _get_pattern(slug: str) -> re.Pattern[str]:
compiled = _PATTERN_CACHE.get(slug)
if compiled is None:
compiled = re.compile(
r"^"
+ re.escape(slug)
+ r"(?:$|[\-._])"
)
_PATTERN_CACHE[slug] = compiled
return compiled
def _match_any(model_lower: str) -> Optional[float]:
"""Return the floor for the first matching slug, else None.
Each table entry is matched as a start-of-slug prefix with the
slug-separator-or-end-of-string right-anchor. Table iteration
order is irrelevant: longest slug wins (so ``o3-mini`` beats
``o3`` on a model like ``openai/o3-mini``).
"""
# Sort by slug length descending so longer / more-specific slugs
# win on shared prefixes (o3-mini beats o3).
sorted_floors = sorted(
_REASONING_STALE_TIMEOUT_FLOORS, key=lambda kv: -len(kv[0])
)
for slug, floor in sorted_floors:
if _get_pattern(slug).search(model_lower):
return float(floor)
return None
def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]:
"""Return the stale-timeout floor (seconds) for a known reasoning model.
Returns ``None`` when the model is not in the allowlist or the
argument is empty / not a string. Matching uses
word-boundary-anchored regex on the lowercased model name, so
``openai/o3-mini`` matches the ``o3-mini`` slug but
``olmo-1`` does NOT match ``o1`` (the ``o1`` substring is not
at a word boundary inside ``olmo-1``).
Aggregator prefixes (``openai/``, ``x-ai/``, ``anthropic/`` etc.)
are preserved through matching — the ``/`` is itself a word
boundary, so ``openai/o3-mini`` matches ``o3-mini`` because the
``/`` before ``o3-mini`` satisfies the left-anchor alternation.
This is a FLOOR — callers must apply it as ``max(default, floor)``
and only when no explicit user-configured per-model
``stale_timeout_seconds`` exists.
>>> get_reasoning_stale_timeout_floor("nvidia/nemotron-3-ultra-550b-a55b")
600.0
>>> get_reasoning_stale_timeout_floor("openai/o3-mini")
300.0
>>> get_reasoning_stale_timeout_floor("deepseek/deepseek-r1")
600.0
>>> get_reasoning_stale_timeout_floor("qwen/qwen3-235b-a22b-thinking")
180.0
>>> get_reasoning_stale_timeout_floor("x-ai/grok-4-fast-reasoning")
300.0
>>> get_reasoning_stale_timeout_floor("anthropic/claude-opus-4-6")
240.0
>>> get_reasoning_stale_timeout_floor("gpt-4o") is None
True
>>> get_reasoning_stale_timeout_floor("olmo-1") is None
True
>>> get_reasoning_stale_timeout_floor(None) is None
True
"""
if not model or not isinstance(model, str):
return None
name = model.strip().lower()
if not name:
return None
# Strip aggregator prefix (everything before and including the
# last ``/``). The wrapper regex anchors at start-of-string, so
# the slug identity is the bare model name.
if "/" in name:
name = name.rsplit("/", 1)[1]
return _match_any(name)
+65 -1
View File
@@ -107,12 +107,60 @@ _PREFIX_PATTERNS = [
r"ntn_[A-Za-z0-9]{10,}", # Notion internal integration token
]
# ENV assignment patterns: KEY=value where KEY contains a secret-like name
# ENV assignment patterns: KEY=value where KEY contains a secret-like name.
# Uppercase keys tolerate spaces around "=" (e.g. ``FOO_SECRET = bar``) because
# an all-caps key is almost never prose/code.
_SECRET_ENV_NAMES = r"(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)"
_ENV_ASSIGN_RE = re.compile(
rf"([A-Z0-9_]{{0,50}}{_SECRET_ENV_NAMES}[A-Z0-9_]{{0,50}})\s*=\s*(['\"]?)(\S+)\2",
)
# Lowercase / dotted / hyphenated config keys from config files
# (application.properties, .env, YAML-ish dumps): ``spring.datasource.password=secret``,
# ``app.api.key=xyz``, ``password=secret``. The uppercase _ENV_ASSIGN_RE above
# never matched these, so config-file passwords leaked verbatim (issue #16413).
#
# These run only in a config-file context, NOT in prose, code, or URLs — three
# carve-outs preserved from the original design (#4367 + the documented
# web-URL passthrough below):
# 1. The value is bounded by ``[^\s&]`` (stops at whitespace AND ``&``) so
# form-urlencoded bodies are handled pair-by-pair (by _redact_form_body),
# not greedily swallowed.
# 2. _CFG_DOTTED_RE only matches when the key is NAMESPACED (contains a dot),
# which is unambiguously a config key — never a prose word.
# 3. _CFG_ANCHORED_RE matches a bare secret-word key only at line start
# (optionally after ``export``), so conversational ``I have password=foo``
# mid-sentence is left alone.
# The colon-form URL guard (skip when ``://`` present) lives at the call site.
_SECRET_CFG_NAMES = r"(?:api[ _.\-]?key|token|secret|passwd|password|credential|auth)"
_CFG_VALUE = r"(['\"]?)([^\s&]+?)\2(?=[\s&]|$)"
# Namespaced (dotted) key: the secret word may sit anywhere in a dotted path.
_CFG_DOTTED_RE = re.compile(
rf"((?:[A-Za-z0-9_\-]+\.)+[A-Za-z0-9_.\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\-]*"
rf"|[A-Za-z0-9_.\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\-]*\.[A-Za-z0-9_.\-]+)"
rf"={_CFG_VALUE}",
re.IGNORECASE,
)
# Line-anchored bare key: ``password=…`` / ``export api_key=…`` at start of line.
_CFG_ANCHORED_RE = re.compile(
rf"(^[ \t]*(?:export[ \t]+)?[A-Za-z0-9_\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_\-]*)={_CFG_VALUE}",
re.IGNORECASE | re.MULTILINE,
)
# Unquoted YAML / colon config (e.g. ``password: secret``,
# ``spring.datasource.password: hunter2``). The secret keyword must be part of
# the KEY (anchored to the start of the line/indent), and the value is a single
# whitespace-free token — so prose like ``note: secret meeting`` (keyword in the
# value) and ``error: token expired`` are left alone. Bare ``auth`` is excluded
# from the key set so ``Authorization:`` / ``author:`` don't match (the former
# is masked by _AUTH_HEADER_RE); ``auth_token``/``auth-token`` still match via
# the ``token`` keyword. Quoted values defer to _JSON_FIELD_RE via the lookahead.
_YAML_CFG_NAMES = r"(?:api[ _.\-]?key|token|secret|passwd|password|credential)"
_YAML_ASSIGN_RE = re.compile(
rf"(^[ \t]*[A-Za-z0-9_.\-]*{_YAML_CFG_NAMES}[A-Za-z0-9_.\-]*)(:[ \t]*)(?!['\"])([^\s&]+)",
re.IGNORECASE | re.MULTILINE,
)
# JSON field patterns: "apiKey": "value", "token": "value", etc.
_JSON_KEY_NAMES = r"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|secret_input|key_material)"
_JSON_FIELD_RE = re.compile(
@@ -382,6 +430,13 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
name, quote, value = m.group(1), m.group(2), m.group(3)
return f"{name}={quote}{_mask_token(value)}{quote}"
text = _ENV_ASSIGN_RE.sub(_redact_env, text)
# Lowercase/dotted config keys (issue #16413). Skip URLs entirely —
# web-URL query params are intentionally passed through (see note
# near the bottom of this function); _DB_CONNSTR_RE still guards
# connection-string passwords.
if "://" not in text:
text = _CFG_DOTTED_RE.sub(_redact_env, text)
text = _CFG_ANCHORED_RE.sub(_redact_env, text)
# JSON fields: "apiKey": "***" (skip for code files — false positives)
if ":" in text and '"' in text:
@@ -390,6 +445,15 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
return f'{key}: "{_mask_token(value)}"'
text = _JSON_FIELD_RE.sub(_redact_json, text)
# Unquoted YAML / colon config: password: *** (after JSON so quoted
# values are handled there; the lookahead in _YAML_ASSIGN_RE skips
# quotes). Skip URLs — web-URL query params pass through by design.
if ":" in text and "://" not in text:
def _redact_yaml(m):
key, sep, value = m.group(1), m.group(2), m.group(3)
return f"{key}{sep}{_mask_token(value)}"
text = _YAML_ASSIGN_RE.sub(_redact_yaml, text)
# Authorization headers — _AUTH_HEADER_RE matches any scheme after
# "[Proxy-]Authorization:" case-insensitively, so "uthorization" is the
# cheapest substring gate that covers every casing without a casefold().
+28
View File
@@ -507,6 +507,34 @@ def get_all_skills_dirs() -> List[Path]:
return dirs
def _resolve_for_skill_ownership(path) -> Path:
path_obj = path if isinstance(path, Path) else Path(str(path))
try:
return path_obj.expanduser().resolve()
except (OSError, RuntimeError):
return path_obj.expanduser().absolute()
def is_external_skill_path(path) -> bool:
"""Return True when ``path`` lives under a configured external skills dir.
``skills.external_dirs`` are externally owned: Hermes can discover and view
their skills, and foreground user-directed tool calls may still edit them,
but autonomous lifecycle maintenance must treat them as read-only. This
helper centralizes the ownership boundary so curator/reporting/tool paths do
not each need to re-interpret the config.
"""
candidate = _resolve_for_skill_ownership(path)
for root in get_external_skills_dirs():
resolved_root = _resolve_for_skill_ownership(root)
try:
candidate.relative_to(resolved_root)
return True
except ValueError:
continue
return False
# ── Condition extraction ──────────────────────────────────────────────────
+136
View File
@@ -0,0 +1,136 @@
"""Thinking-timeout detection and user-facing guidance for reasoning models.
When a known reasoning model (NVIDIA Nemotron 3 Ultra, OpenAI o1/o3,
Anthropic Opus 4.x thinking, DeepSeek R1, Qwen QwQ, xAI Grok reasoning)
hits a transport-layer error before the first content token arrives, the
upstream proxy has almost certainly idle-killed a long thinking stream —
not a true context overflow or a configuration error. The user needs
distinct guidance for this case:
"The model's thinking phase exceeded the upstream proxy's idle
timeout before the first content token arrived. This is a known
issue with reasoning models behind cloud gateways (NVIDIA NIM,
OpenAI, Anthropic, DeepSeek). Workarounds in priority order:
1. Set `providers.<provider>.models.<model>.stale_timeout_seconds: 900`
in `~/.hermes/config.yaml` to extend the per-call timeout...
2. Lower `reasoning_budget` or set `reasoning_effort: medium`...
3. Use a smaller / faster reasoning model..."
The existing `_is_stream_drop` guidance at
``agent/conversation_loop.py:3464-3486`` fires for large-file-write
stream drops ("try execute_code with Python's open() for large files")
which is the WRONG advice for the thinking-timeout case. This module
provides the detection and the message as standalone helpers so the
detection logic is unit-testable without driving the full retry loop,
and the message text can be regression-tested for spelling and accuracy.
Part 2 of Fixes #52310.
"""
from __future__ import annotations
from typing import Optional
# Substring set that identifies a transport-layer failure on the
# response stream. Same shape as the existing
# ``_SERVER_DISCONNECT_PATTERNS`` in ``agent/error_classifier.py:394``
# but extended to also catch the OSS-level error signature
# (``broken pipe`` / ``errno 32``) that the upstream kill surfaces
# to the OpenAI SDK wrapper.
_THINKING_TIMEOUT_SUBSTRINGS: tuple[str, ...] = (
"broken pipe",
"errno 32",
"remote protocol",
"connection reset",
"connection lost",
"peer closed",
"server disconnected",
)
def is_thinking_timeout(classified: object, model: str, error_msg: str) -> bool:
"""Return True when a reasoning model's thinking phase hit a transport kill.
Args:
classified: a :class:`agent.error_classifier.ClassifiedError` instance
(duck-typed here to avoid an import cycle in unit tests).
model: the model slug at failure time (e.g.
``"nvidia/nemotron-3-ultra-550b-a55b"``).
error_msg: lowercased string representation of the underlying
exception (typically ``str(api_error).lower()``).
Returns True when ALL conditions hold:
1. ``classified.reason == FailoverReason.timeout`` (the classifier
override at ``agent/error_classifier.py:720-738`` ensures this
is the case for reasoning models even on large sessions).
2. ``api_error`` has no ``.status_code`` attribute set (transport
disconnect, not an HTTP error).
3. ``model`` is in the reasoning-model allowlist (reuses
``agent.reasoning_timeouts.get_reasoning_stale_timeout_floor``).
4. ``error_msg`` contains one of the transport-kill substrings.
Non-reasoning models always return False. Non-transport errors
(billing / rate_limit / auth / context_overflow / format_error)
always return False. HTTP-status errors always return False.
"""
# Import here (not at module top) to keep this helper cheap to
# import even from callers that don't need it. ``agent.reasoning_timeouts``
# is small and dependency-free.
from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
# Condition 1: classifier says timeout. Use a string/value check
# rather than importing FailoverReason so this module has zero
# import cycles from the error_classifier package.
reason = getattr(classified, "reason", None)
reason_value = getattr(reason, "value", None)
if reason_value != "timeout":
return False
# Condition 2: no HTTP status code (transport, not API error).
# Caller is expected to gate on ``getattr(api_error, "status_code", None) is None``
# before calling this helper; the surface here is just the post-gate
# boolean so the caller can pass an already-prepped error_msg.
# Condition 3: reasoning model allowlist.
if get_reasoning_stale_timeout_floor(model) is None:
return False
# Condition 4: transport-kill substring in the error message.
error_msg_lower = (error_msg or "").lower()
return any(p in error_msg_lower for p in _THINKING_TIMEOUT_SUBSTRINGS)
def build_thinking_timeout_guidance(
provider: str, model: str, model_label: Optional[str] = None,
) -> str:
"""Return the user-facing guidance string appended to ``_final_response``.
Args:
provider: provider slug (e.g. ``"nvidia"``, ``"openai"``).
model: bare model slug the user would put in their config
(e.g. ``"nemotron-3-ultra-550b-a55b"`` if the user uses
NVIDIA direct, or the full ``"nvidia/nemotron-3-ultra-550b-a55b"``
if they go through an aggregator). Used verbatim in the
config snippet so the user can copy-paste.
model_label: optional short label for the model name in the
prose (e.g. ``"Nemotron 3 Ultra"``). Falls back to the
slug if not provided.
"""
label = model_label or model
return (
"\n\nThe model's thinking phase exceeded the upstream proxy's "
"idle timeout before the first content token arrived. This is a "
f"known issue with reasoning models (like {label}) behind cloud "
"gateways (NVIDIA NIM, OpenAI, Anthropic, DeepSeek). Workarounds "
"in priority order:\n"
f"1. Set `providers.{provider}.models.{model}.stale_timeout_seconds: 900` "
"in `~/.hermes/config.yaml` to extend the per-call timeout. "
"(Hermes's built-in floor is 600s for known reasoning models — "
"if you still see this after raising, the upstream cap is even "
"shorter.)\n"
"2. Lower `reasoning_budget` or set `reasoning_effort: medium` on this "
"model if the provider supports it.\n"
"3. Use a smaller / faster reasoning model if the task doesn't "
"require deep thinking."
)
+29 -17
View File
@@ -26,6 +26,7 @@ from agent.display import (
build_tool_preview as _build_tool_preview,
get_cute_tool_message as _get_cute_tool_message_impl,
get_tool_emoji as _get_tool_emoji,
redact_tool_args_for_display as _redact_tool_args_for_display,
_detect_tool_failure,
)
from agent.tool_guardrails import ToolGuardrailDecision
@@ -469,10 +470,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
print(f" ⚡ Concurrent: {num_tools} tool calls — {tool_names_str}")
for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls, 1):
args_str = json.dumps(args, ensure_ascii=False)
display_args = _redact_tool_args_for_display(name, args) or args
args_str = json.dumps(display_args, ensure_ascii=False)
if agent.verbose_logging:
print(f" 📞 Tool {i}: {name}({list(args.keys())})")
print(agent._wrap_verbose("Args: ", json.dumps(args, indent=2, ensure_ascii=False)))
print(f" 📞 Tool {i}: {name}({list(display_args.keys())})")
print(agent._wrap_verbose("Args: ", json.dumps(display_args, indent=2, ensure_ascii=False)))
else:
args_preview = args_str[:agent.log_prefix_chars] + "..." if len(args_str) > agent.log_prefix_chars else args_str
print(f" 📞 Tool {i}: {name}({list(args.keys())}) - {args_preview}")
@@ -482,8 +484,9 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
continue
if agent.tool_progress_callback:
try:
preview = _build_tool_preview(name, args)
agent.tool_progress_callback("tool.started", name, preview, args)
display_args = _redact_tool_args_for_display(name, args) or args
preview = _build_tool_preview(name, display_args)
agent.tool_progress_callback("tool.started", name, preview, display_args)
except Exception as cb_err:
logging.debug(f"Tool progress callback error: {cb_err}")
@@ -492,7 +495,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
continue
if agent.tool_start_callback:
try:
agent.tool_start_callback(tc.id, name, args)
display_args = _redact_tool_args_for_display(name, args) or args
agent.tool_start_callback(tc.id, name, display_args)
except Exception as cb_err:
logging.debug(f"Tool start callback error: {cb_err}")
@@ -792,7 +796,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
if not blocked and agent.tool_complete_callback:
try:
agent.tool_complete_callback(tc.id, name, args, function_result)
display_args = _redact_tool_args_for_display(name, args) or args
agent.tool_complete_callback(tc.id, name, display_args, function_result)
except Exception as cb_err:
logging.debug(f"Tool complete callback error: {cb_err}")
@@ -954,10 +959,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
agent._iters_since_skill = 0
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
args_str = json.dumps(function_args, ensure_ascii=False)
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
args_str = json.dumps(display_args, ensure_ascii=False)
if agent.verbose_logging:
print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())})")
print(agent._wrap_verbose("Args: ", json.dumps(function_args, indent=2, ensure_ascii=False)))
print(f" 📞 Tool {i}: {function_name}({list(display_args.keys())})")
print(agent._wrap_verbose("Args: ", json.dumps(display_args, indent=2, ensure_ascii=False)))
else:
args_preview = args_str[:agent.log_prefix_chars] + "..." if len(args_str) > agent.log_prefix_chars else args_str
print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())}) - {args_preview}")
@@ -978,14 +984,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
if not _execution_blocked and agent.tool_progress_callback:
try:
preview = _build_tool_preview(function_name, function_args)
agent.tool_progress_callback("tool.started", function_name, preview, function_args)
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
preview = _build_tool_preview(function_name, display_args)
agent.tool_progress_callback("tool.started", function_name, preview, display_args)
except Exception as cb_err:
logging.debug(f"Tool progress callback error: {cb_err}")
if not _execution_blocked and agent.tool_start_callback:
try:
agent.tool_start_callback(tool_call.id, function_name, function_args)
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
agent.tool_start_callback(tool_call.id, function_name, display_args)
except Exception as cb_err:
logging.debug(f"Tool start callback error: {cb_err}")
@@ -1215,7 +1223,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
if agent._should_emit_quiet_tool_messages():
face = random.choice(KawaiiSpinner.get_waiting_faces())
emoji = _get_tool_emoji(function_name)
preview = _build_tool_preview(function_name, function_args) or function_name
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
preview = _build_tool_preview(function_name, display_args) or function_name
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
spinner.start()
_ce_result = None
@@ -1248,7 +1257,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
if agent._should_emit_quiet_tool_messages() and agent._should_start_quiet_spinner():
face = random.choice(KawaiiSpinner.get_waiting_faces())
emoji = _get_tool_emoji(function_name)
preview = _build_tool_preview(function_name, function_args) or function_name
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
preview = _build_tool_preview(function_name, display_args) or function_name
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
spinner.start()
_mem_result = None
@@ -1279,7 +1289,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
if agent._should_emit_quiet_tool_messages() and agent._should_start_quiet_spinner():
face = random.choice(KawaiiSpinner.get_waiting_faces())
emoji = _get_tool_emoji(function_name)
preview = _build_tool_preview(function_name, function_args) or function_name
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
preview = _build_tool_preview(function_name, display_args) or function_name
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
spinner.start()
_spinner_result = None
@@ -1441,7 +1452,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
if not _execution_blocked and agent.tool_complete_callback:
try:
agent.tool_complete_callback(tool_call.id, function_name, function_args, function_result)
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
agent.tool_complete_callback(tool_call.id, function_name, display_args, function_result)
except Exception as cb_err:
logging.debug(f"Tool complete callback error: {cb_err}")
+4 -1
View File
@@ -28,6 +28,7 @@ import uuid
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from agent.conversation_compression import conversation_history_after_compression
from agent.iteration_budget import IterationBudget
from agent.model_metadata import (
estimate_messages_tokens_rough,
@@ -400,7 +401,9 @@ def build_turn_context(
_orig_len, len(messages), _orig_tokens, _preflight_tokens
):
break # Cannot compress further: neither rows nor tokens moved
conversation_history = None
conversation_history = conversation_history_after_compression(
agent, messages
)
agent._empty_content_retries = 0
agent._thinking_prefill_retries = 0
agent._last_content_with_tools = None
+145 -5
View File
@@ -15,9 +15,135 @@ from typing import Any, Iterable
_MAX_CHANGED_PATHS_IN_NUDGE = 8
# Non-code file extensions whose edits carry no verifiable runtime behavior:
# documentation, prose, and data/markup that no test/build exercises. When a
# turn touches ONLY these, verify-on-stop has nothing to check, so the nudge is
# suppressed (this is fix "C" for the doc/markdown/skill false-positive — a
# SKILL.md or README edit must never demand a /tmp verification script). A turn
# that edits any non-listed path (a real source/code/config file) still nudges.
_NON_CODE_VERIFY_EXTENSIONS = frozenset(
{
".md",
".markdown",
".mdx",
".rst",
".txt",
".text",
".adoc",
".asciidoc",
".org",
".log",
".csv",
".tsv",
}
)
# Filenames (case-insensitive, extension-less or otherwise) that are pure prose
# even without a recognized doc extension.
_NON_CODE_VERIFY_FILENAMES = frozenset(
{
"license",
"licence",
"notice",
"authors",
"contributors",
"changelog",
"codeowners",
}
)
def _is_non_code_path(raw: str) -> bool:
"""Return True when a changed path is documentation/prose with nothing to verify."""
try:
p = Path(str(raw))
except Exception:
return False
suffix = p.suffix.lower()
if suffix in _NON_CODE_VERIFY_EXTENSIONS:
return True
if not suffix and p.name.lower() in _NON_CODE_VERIFY_FILENAMES:
return True
return False
def _filter_verifiable_paths(paths: Iterable[str]) -> list[str]:
"""Drop documentation/prose paths; keep paths that could have verifiable behavior."""
return [p for p in paths if p and not _is_non_code_path(p)]
# Session identities (platform or source) that are NOT human conversational
# messaging surfaces: interactive coding surfaces (CLI, TUI, desktop, codex,
# local, gateway) and programmatic callers (API server, webhooks, tools).
# Verify-on-stop stays ON by default for these. Any other resolved gateway
# platform is a conversational messaging surface (Telegram, Discord, WhatsApp,
# Signal, Slack, etc.) where the verification narrative would reach a human as
# chat noise, so it defaults OFF. Mirrors LOCAL_SESSION_SOURCE_IDS in
# apps/desktop/src/lib/session-source.ts; keep roughly in sync when adding a
# local or programmatic surface. Default-deny by design: an unrecognized
# identity is treated as messaging (OFF) so a new chat platform never leaks the
# verification receipt before this set is updated.
_NON_MESSAGING_SESSION_SURFACES = frozenset(
{
"",
"cli",
"codex",
"desktop",
"gateway",
"local",
"tui",
"tool",
"api_server",
"webhook",
"msgraph_webhook",
}
)
def _session_is_messaging_surface() -> bool:
"""Return whether this turn is delivered over a human messaging channel.
The gateway binds the platform value (e.g. ``telegram``) to
``HERMES_SESSION_PLATFORM``; the CLI and TUI set ``HERMES_SESSION_SOURCE``
(e.g. ``cli``, ``tui``) instead. Both are consulted via the session-context
helper (with an ``os.environ`` fallback), alongside the ``HERMES_PLATFORM``
override, matching the sibling platform resolution in
``agent/skill_commands.py`` and ``agent/prompt_builder.py``. A turn is a
messaging surface when a resolved identity is present and is not a known
non-messaging surface.
"""
try:
from gateway.session_context import get_session_env
platform = (
os.getenv("HERMES_PLATFORM")
or get_session_env("HERMES_SESSION_PLATFORM", "")
)
source = get_session_env("HERMES_SESSION_SOURCE", "")
except Exception:
platform = os.getenv("HERMES_PLATFORM", "") or os.environ.get(
"HERMES_SESSION_PLATFORM", ""
)
source = os.environ.get("HERMES_SESSION_SOURCE", "")
for identity in (platform, source):
identity = str(identity or "").strip().lower()
if identity and identity not in _NON_MESSAGING_SESSION_SURFACES:
return True
return False
def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool:
"""Return whether edit -> verify-before-finish behavior is enabled."""
"""Return whether edit -> verify-before-finish behavior is enabled.
Precedence: an explicit ``HERMES_VERIFY_ON_STOP`` env var wins, then an
explicit ``agent.verify_on_stop`` config value. The config default is
``False`` (see ``DEFAULT_CONFIG``) — verify-on-stop is OFF unless the user
opts in. The legacy ``"auto"`` sentinel is still honored for anyone who
sets it explicitly: it resolves to ON for interactive coding surfaces
(CLI, TUI, desktop) and programmatic callers, and OFF for conversational
messaging surfaces (Telegram, Discord, etc.). A missing/unknown value
falls back to OFF.
"""
env = os.environ.get("HERMES_VERIFY_ON_STOP")
if env is not None:
return env.strip().lower() not in {"0", "false", "no", "off"}
@@ -29,9 +155,20 @@ def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool:
except Exception:
config = {}
agent_cfg = (config or {}).get("agent") if isinstance(config, dict) else None
if isinstance(agent_cfg, dict) and "verify_on_stop" in agent_cfg:
return bool(agent_cfg.get("verify_on_stop"))
return True
cfg_val = agent_cfg.get("verify_on_stop") if isinstance(agent_cfg, dict) else None
if isinstance(cfg_val, bool):
return cfg_val
if isinstance(cfg_val, str):
token = cfg_val.strip().lower()
if token in {"1", "true", "yes", "on"}:
return True
if token in {"0", "false", "no", "off"}:
return False
if token == "auto":
# Explicit opt-in to the legacy surface-aware behavior.
return not _session_is_messaging_surface()
# Missing or unknown value -> OFF (the new default).
return False
def _candidate_cwds(paths: Iterable[str]) -> list[Path]:
@@ -114,7 +251,10 @@ def build_verify_on_stop_nudge(
max_attempts: int = 2,
) -> str | None:
"""Return a synthetic follow-up when edited code lacks fresh verification."""
paths = sorted({str(p) for p in changed_paths if p})
# Drop documentation/prose paths (markdown, skills, README, LICENSE, ...) —
# they carry no verifiable behavior, so a turn that touched only those has
# nothing to verify and must not nudge.
paths = sorted({str(p) for p in _filter_verifiable_paths(changed_paths)})
if not paths or attempts >= max_attempts:
return None
+1 -1
View File
@@ -17,5 +17,5 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"iconLibrary": "tabler"
}
+1 -4
View File
@@ -61,10 +61,7 @@ function buildDesktopBackendPath({
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES
return appendUniquePathEntries(
[hermesNodeBin, venvBin, currentPath, saneEntries],
{ delimiter }
)
return appendUniquePathEntries([hermesNodeBin, venvBin, currentPath, saneEntries], { delimiter })
}
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) } = {}) {
+2 -8
View File
@@ -76,10 +76,7 @@ test('normalizeHermesHomeRoot maps profile homes back to the global Hermes root'
normalizeHermesHomeRoot('C:\\Users\\test\\AppData\\Local\\hermes\\profiles\\oracle', { pathModule: path.win32 }),
'C:\\Users\\test\\AppData\\Local\\hermes'
)
assert.equal(
normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }),
'/Users/test/.hermes'
)
assert.equal(normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }), '/Users/test/.hermes')
})
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
@@ -104,8 +101,5 @@ test('Windows PATH casing and delimiter are preserved without POSIX sane entries
})
test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => {
assert.equal(
appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }),
'/a:/b:/c'
)
assert.equal(appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }), '/a:/b:/c')
})
+70 -1
View File
@@ -1,3 +1,5 @@
const fs = require('node:fs')
const _READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m
// The announcement clock starts the instant the backend process is spawned —
@@ -94,9 +96,76 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
})
}
function readDashboardReadyFile(readyFile) {
if (!readyFile) return null
try {
const parsed = JSON.parse(fs.readFileSync(readyFile, 'utf8'))
const port = Number(parsed?.port)
return Number.isInteger(port) && port > 0 ? port : null
} catch {
return null
}
}
function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnnounceTimeoutMs()) {
return new Promise((resolve, reject) => {
let done = false
let interval = null
function cleanup() {
if (done) return
done = true
clearTimeout(timer)
if (interval) clearInterval(interval)
child.off('exit', onExit)
child.off('error', onError)
}
function check() {
const port = readDashboardReadyFile(readyFile)
if (port) {
cleanup()
resolve(port)
}
}
function onExit(code, signal) {
cleanup()
reject(new Error(`Hermes backend: exited before port announcement (${signal || code})`))
}
function onError(err) {
cleanup()
reject(err)
}
const timer = setTimeout(() => {
cleanup()
reject(new Error(`Timed out waiting for Hermes backend port announcement (${timeoutMs}ms)`))
}, timeoutMs)
child.on('exit', onExit)
child.on('error', onError)
interval = setInterval(check, 50)
if (typeof interval.unref === 'function') interval.unref()
check()
})
}
function waitForDashboardPortAnnouncement(child, options = {}) {
const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs()
if (options.readyFile) {
return waitForDashboardReadyFile(options.readyFile, child, timeoutMs)
}
return waitForDashboardPort(child, timeoutMs)
}
module.exports = {
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile,
readDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS
}
+79 -1
View File
@@ -14,12 +14,18 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const { EventEmitter } = require('node:events')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const {
readDashboardReadyFile,
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS
} = require('./backend-ready.cjs')
// A minimal stand-in for a spawned child process: an EventEmitter with a
@@ -119,3 +125,75 @@ test('a late announcement after timeout does not throw (listeners torn down)', a
child.stdout.emit('data', 'HERMES_DASHBOARD_READY port=9999\n')
})
})
// ---------------------------------------------------------------------------
// ready-file port announcement
// ---------------------------------------------------------------------------
function mkTmpReadyFile() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-test-'))
return {
dir,
file: path.join(dir, 'ready.json'),
cleanup: () => fs.rmSync(dir, { recursive: true, force: true })
}
}
test('readDashboardReadyFile returns a valid port from JSON', () => {
const tmp = mkTmpReadyFile()
try {
fs.writeFileSync(tmp.file, JSON.stringify({ port: 4567 }))
assert.equal(readDashboardReadyFile(tmp.file), 4567)
} finally {
tmp.cleanup()
}
})
test('readDashboardReadyFile ignores missing, malformed, or invalid files', () => {
const tmp = mkTmpReadyFile()
try {
assert.equal(readDashboardReadyFile(tmp.file), null)
fs.writeFileSync(tmp.file, '{')
assert.equal(readDashboardReadyFile(tmp.file), null)
fs.writeFileSync(tmp.file, JSON.stringify({ port: 0 }))
assert.equal(readDashboardReadyFile(tmp.file), null)
} finally {
tmp.cleanup()
}
})
test('waitForDashboardReadyFile resolves when the ready file appears', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 8765 })), 20)
assert.equal(await p, 8765)
} finally {
tmp.cleanup()
}
})
test('waitForDashboardPortAnnouncement uses ready file when provided', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardPortAnnouncement(child, { readyFile: tmp.file, timeoutMs: 1000 })
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 9876 })), 20)
assert.equal(await p, 9876)
} finally {
tmp.cleanup()
}
})
test('waitForDashboardReadyFile rejects when the child exits before file readiness', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
child.emit('exit', 1, null)
await assert.rejects(p, /exited before port announcement/)
} finally {
tmp.cleanup()
}
})
+20 -10
View File
@@ -179,7 +179,13 @@ function downloadInstallScript(commit, destPath) {
})
}
async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit, _download = downloadInstallScript }) {
async function resolveInstallScript({
installStamp,
sourceRepoRoot,
hermesHome,
emit,
_download = downloadInstallScript
}) {
// 1. Dev shortcut: prefer a local checkout's installer so we can iterate
// without pushing. SOURCE_REPO_ROOT comes from main.cjs (path.resolve
// of APP_ROOT/../..).
@@ -293,15 +299,19 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
// Pass HERMES_HOME through so install.ps1 respects the caller's
// choice rather than re-computing the default.
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
}
}))
const child = spawn(
ps,
fullArgs,
hiddenWindowsChildOptions({
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
// Pass HERMES_HOME through so install.ps1 respects the caller's
// choice rather than re-computing the default.
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
}
})
)
let stdout = ''
let stderr = ''
+1 -6
View File
@@ -261,12 +261,7 @@ function cookiesHaveSession(cookies) {
*/
function cookiesHaveLiveSession(cookies) {
if (!Array.isArray(cookies)) return false
return cookies.some(
c =>
c &&
c.value &&
(AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name))
)
return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name)))
}
module.exports = {
+10 -5
View File
@@ -138,10 +138,7 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
if (pythonPath) {
lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`)
}
lines.push(
`cd ${q(agentRoot)} 2>/dev/null || true`,
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`
)
lines.push(`cd ${q(agentRoot)} 2>/dev/null || true`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`)
if (appPath) {
lines.push(`rm -rf ${q(appPath)} || true`)
}
@@ -169,7 +166,15 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
* Removal: even after the desktop PID is gone, Windows releases directory
* handles lazily, so a single `rmdir /s /q` can half-fail — retry up to 10x.
*/
function buildWindowsCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
function buildWindowsCleanupScript({
desktopPid,
pythonExe,
pythonPath,
agentRoot,
uninstallArgs,
appPath,
hermesHome
}) {
const pid = Number(desktopPid) || 0
// cmd.exe has no string escaping inside quotes; strip embedded quotes (paths
// under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be
@@ -101,10 +101,7 @@ test('resolveRemovableAppPath uses APPIMAGE on Linux when set', () => {
})
test('resolveRemovableAppPath finds the unpacked dir on Linux', () => {
assert.equal(
resolveRemovableAppPath('/opt/hermes/linux-unpacked/hermes', 'linux', {}),
'/opt/hermes/linux-unpacked'
)
assert.equal(resolveRemovableAppPath('/opt/hermes/linux-unpacked/hermes', 'linux', {}), '/opt/hermes/linux-unpacked')
// A system-package install (/usr/bin) → null, left to apt/dnf.
assert.equal(resolveRemovableAppPath('/usr/bin/hermes', 'linux', {}), null)
})
+48
View File
@@ -0,0 +1,48 @@
'use strict'
const { session } = require('electron')
const EMBED_SESSION_PARTITION = 'persist:hermes-embed'
const EMBED_REFERER = 'https://www.youtube.com/'
const YOUTUBE_REFERER_HOST_RE =
/(^|\.)(youtube\.com|youtube-nocookie\.com|googlevideo\.com|ytimg\.com|youtubei\.googleapis\.com)$/i
function installEmbedRefererForSession(embedSession) {
if (!embedSession) {
return
}
embedSession.webRequest.onBeforeSendHeaders((details, callback) => {
let host = ''
try {
host = new URL(details.url).hostname
} catch {
host = ''
}
if (!YOUTUBE_REFERER_HOST_RE.test(host)) {
callback({ requestHeaders: details.requestHeaders })
return
}
const headers = { ...details.requestHeaders }
if (!headers.Referer && !headers.referer) {
headers.Referer = EMBED_REFERER
}
callback({ requestHeaders: headers })
})
}
/** Stamp Referer on YouTube requests in the embed webview partition only. */
function installEmbedReferer() {
try {
installEmbedRefererForSession(session.fromPartition(EMBED_SESSION_PARTITION))
} catch {
// Non-fatal: embeds still render; YouTube may show referer errors.
}
}
module.exports = { installEmbedReferer }
+1 -3
View File
@@ -92,9 +92,7 @@ async function readDirForIpc(dirPath, options = {}) {
try {
const dirents = await fsImpl.promises.readdir(resolved, { withFileTypes: true })
const visibleDirents = dirents.filter(dirent => !FS_READDIR_HIDDEN.has(dirent.name))
const entries = await mapWithStatConcurrency(visibleDirents, dirent =>
entryForDirent(dirent, resolved, fsImpl)
)
const entries = await mapWithStatConcurrency(visibleDirents, dirent => entryForDirent(dirent, resolved, fsImpl))
entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
+5 -5
View File
@@ -349,7 +349,10 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
assert.equal(result.error, undefined)
assert.equal(result.entries.length, names.length)
assert.equal(statCalls.length, names.length)
assert.equal(statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)), false)
assert.equal(
statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)),
false
)
assert.ok(peak > 1, `expected concurrent stats, observed peak ${peak}`)
assert.ok(peak <= 16, `expected at most 16 concurrent stats, observed peak ${peak}`)
assert.deepEqual(
@@ -357,8 +360,5 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
expectedNames
)
assert.equal(result.entries.find(entry => entry.name === failedName)?.isDirectory, false)
assert.equal(
result.entries.filter(entry => entry.isDirectory).length,
successfulDirectoryNames.size
)
assert.equal(result.entries.filter(entry => entry.isDirectory).length, successfulDirectoryNames.size)
})
+96
View File
@@ -0,0 +1,96 @@
'use strict'
// Repo-first discovery: walk bounded roots for git repos using only Node's `fs`
// — no native addon, so it just works for anyone who pulls main (no
// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git`
// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the
// first scan stays fast. Results are cached by the backend after the first run.
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const fsp = fs.promises
// Shallow on purpose: real projects live a few levels under home
// (`~/www/repo`, `~/code/org/repo`); deeper `.git` dirs are almost always
// fixtures/vendored/eval checkouts (e.g. `~/www/ha-evals/tasks/*/repo`). Repos
// you actually use but keep deeper still surface via session-derived discovery,
// so this only prunes noise, never repos with history.
const DEFAULT_MAX_DEPTH = 3
const MAX_CONCURRENCY = 32
// Big trees that are never themselves repos and would waste the walk. Anything
// hidden (dotdirs like .cache/.Trash/.npm) is skipped wholesale below, so this
// only needs the non-hidden heavyweights.
const JUNK_DIRS = new Set(['Applications', 'Library', 'node_modules', 'site-packages', 'vendor', 'venv'])
async function mapLimit(items, limit, fn) {
let cursor = 0
async function worker() {
while (cursor < items.length) {
const index = cursor
cursor += 1
await fn(items[index])
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
}
/**
* Scan `roots` (default: the home dir) for git repositories. Returns deduped
* `{ root, label }` entries. `options.maxDepth` caps recursion (default 3).
*/
async function scanGitRepos(roots, options = {}) {
const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH
const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
const found = new Map()
async function walk(dir, depth) {
if (depth > maxDepth) {
return
}
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return // unreadable / permission denied
}
// A `.git` DIRECTORY marks a real repo root (a main checkout). A `.git`
// FILE is a linked worktree or submodule — those belong to their parent
// repo as lanes, not as separate projects, so we don't list them (and we
// keep descending in case a real repo sits deeper). This is what kills the
// worktree/eval-repo duplicate explosion.
if (entries.some(entry => entry.name === '.git' && entry.isDirectory())) {
const root = dir.replace(/[/\\]+$/, '')
found.set(root, path.basename(root) || root)
return
}
const subdirs = []
for (const entry of entries) {
// Real directories only (skip symlinks to avoid loops), no hidden dirs, no
// known heavy trees.
if (!entry.isDirectory() || entry.name.startsWith('.') || JUNK_DIRS.has(entry.name)) {
continue
}
subdirs.push(path.join(dir, entry.name))
}
await mapLimit(subdirs, MAX_CONCURRENCY, sub => walk(sub, depth + 1))
}
await mapLimit(searchRoots.map(root => String(root || '').trim()).filter(Boolean), MAX_CONCURRENCY, root =>
walk(root, 0)
)
return [...found.entries()].map(([root, label]) => ({ label, root }))
}
module.exports = { scanGitRepos }
+684
View File
@@ -0,0 +1,684 @@
'use strict'
// Git ops backing the coding rail + Codex-style review pane. Built on `simple-git`
// (a maintained wrapper around the system git binary — same git the rest of the
// app shells to, no native build) so we read structured status()/diffSummary()
// results instead of hand-parsing porcelain. Reads degrade to null/empty on a
// non-repo / remote backend; mutations reject so the renderer can toast.
const { execFile } = require('node:child_process')
const fs = require('node:fs/promises')
const path = require('node:path')
const simpleGit = require('simple-git')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000
const COMMIT_CONTEXT_UNTRACKED_MAX = 80
const UNTRACKED_LINE_COUNT_CONCURRENCY = 16
const UNTRACKED_LINE_COUNT_MAX_BYTES = 1024 * 1024
// GUI-launched Electron apps on macOS inherit only a minimal PATH (no
// /opt/homebrew/bin or /usr/local/bin), so `gh` — and the `git` gh shells out
// to — aren't found. Augment PATH with the resolved gh dir + the common
// package-manager bins so gh runs the same way it does in a terminal.
function ghEnv(ghBin) {
const extra = [ghBin ? path.dirname(ghBin) : '', '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'].filter(
dir => dir && dir !== '.'
)
return { ...process.env, PATH: [...extra, process.env.PATH].filter(Boolean).join(path.delimiter) }
}
// Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on
// availability/auth without a throw. gh missing/unauthed → ok:false.
function runGh(args, cwd, ghBin) {
return new Promise(resolve => {
execFile(
ghBin || 'gh',
args,
{ cwd, env: ghEnv(ghBin), windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 },
(err, stdout) => resolve({ ok: !err, stdout: String(stdout || '') })
)
})
}
function gitFor(cwd, gitBin) {
return simpleGit({ baseDir: cwd, binary: gitBin || 'git', maxConcurrentProcesses: 4, trimmed: false })
}
// simple-git reports renames as `old => new` (and `dir/{old => new}/f`); resolve
// to the NEW path so the row addresses the real file for diff/stage.
function resolveRenamePath(raw) {
const path = String(raw || '').trim()
if (!path.includes(' => ')) {
return path
}
const brace = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/)
if (brace) {
const [, prefix, , to, suffix] = brace
return `${prefix}${to}${suffix}`.replace(/\/{2,}/g, '/')
}
return path.split(' => ').pop().trim()
}
// DiffResult.files → Map<path, {added, removed}> (binary files carry no line
// delta).
function countsByPath(summary) {
const map = new Map()
for (const file of summary.files) {
map.set(resolveRenamePath(file.file), {
added: file.binary ? 0 : file.insertions,
removed: file.binary ? 0 : file.deletions
})
}
return map
}
// Untracked files don't appear in diffSummary(); count insertions from disk so
// the review tree can show +N for new files (matches an all-add diff view).
// Insertions = line count: newline bytes, plus one for a final unterminated
// line. Binary (NUL byte) → 0, mirroring git numstat's "-".
async function untrackedInsertions(cwd, relPath) {
try {
const fullPath = path.join(cwd, relPath)
const stat = await fs.stat(fullPath)
if (!stat.isFile() || stat.size > UNTRACKED_LINE_COUNT_MAX_BYTES) {
return 0
}
const buf = await fs.readFile(fullPath)
if (buf.includes(0)) {
return 0
}
let lines = 0
for (const byte of buf) {
if (byte === 10) {
lines++
}
}
return buf.length > 0 && buf[buf.length - 1] !== 10 ? lines + 1 : lines
} catch {
return 0
}
}
function capText(text, maxChars, label = 'truncated') {
const value = String(text || '')
if (value.length <= maxChars) {
return value
}
return `${value.slice(0, maxChars)}\n# ${label}: ${value.length - maxChars} chars omitted\n`
}
async function fillUntrackedCounts(cwd, files) {
const pending = files.filter(file => file.status === '?' && file.added === 0 && file.removed === 0)
for (let i = 0; i < pending.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) {
await Promise.all(
pending.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(async file => {
file.added = await untrackedInsertions(cwd, file.path)
})
)
}
}
// Resolve the base ref for "all branch changes": merge-base with the remote
// default branch (origin/HEAD), falling back to common trunk names.
async function branchBase(git) {
const candidates = []
try {
const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim()
if (head) {
candidates.push(head)
}
} catch {
// No origin/HEAD configured.
}
candidates.push('origin/main', 'origin/master', 'main', 'master')
for (const ref of candidates) {
try {
const base = (await git.raw(['merge-base', 'HEAD', ref])).trim()
if (base) {
return base
}
} catch {
// Ref doesn't exist; try the next candidate.
}
}
return null
}
// Resolve the repo's default branch NAME ("main" / "master" / …), preferring
// the remote's HEAD, then common local trunk names. Null when none is found
// (e.g. a fresh repo with only a feature branch). Used to offer "branch off the
// trunk" regardless of which branch you're currently on.
async function defaultBranchName(git) {
try {
const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim()
// "origin/main" → "main"; skip the bare "origin/HEAD" placeholder.
if (head && head !== 'origin/HEAD') {
return head.replace(/^origin\//, '')
}
} catch {
// No origin/HEAD configured.
}
// Prefer a local trunk, then a remote-only one (returns the clean name either
// way) so "branch off main" works even before main is checked out locally.
for (const ref of [
'refs/heads/main',
'refs/heads/master',
'refs/remotes/origin/main',
'refs/remotes/origin/master'
]) {
try {
await git.raw(['rev-parse', '--verify', '--quiet', ref])
return ref.replace(/^refs\/(?:heads|remotes\/origin)\//, '')
} catch {
// Ref doesn't exist; try the next candidate.
}
}
return null
}
// A status file's single-letter classification, preferring the staged (index)
// code over the worktree code; untracked wins (simple-git marks both '?').
function statusLetter(file) {
if (file.index === '?' || file.working_dir === '?') {
return '?'
}
const code = file.index && file.index !== ' ' ? file.index : file.working_dir
return (code || 'M').toUpperCase()
}
const isStaged = file => Boolean(file.index && file.index !== ' ' && file.index !== '?')
async function reviewList(repoPath, scope, baseRef, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review list' })
} catch {
return { files: [], base: null }
}
const git = gitFor(cwd, gitBin)
try {
if (scope === 'branch' || scope === 'lastTurn') {
const base = scope === 'branch' ? await branchBase(git) : baseRef
if (!base) {
return { files: [], base: null }
}
const range = scope === 'branch' ? `${base}...HEAD` : base
const summary = await git.diffSummary([range])
const files = summary.files.map(file => ({
path: resolveRenamePath(file.file),
added: file.binary ? 0 : file.insertions,
removed: file.binary ? 0 : file.deletions,
status: 'M',
staged: false
}))
// "Last turn" also surfaces files created since the baseline (untracked).
if (scope === 'lastTurn') {
const status = await git.status()
for (const path of status.not_added) {
if (!files.some(f => f.path === path)) {
files.push({ path, added: 0, removed: 0, status: '?', staged: false })
}
}
}
files.sort((a, b) => a.path.localeCompare(b.path))
await fillUntrackedCounts(cwd, files)
return { files, base }
}
// Default: uncommitted (staged + unstaged + untracked), one row per path.
const [status, staged, unstaged] = await Promise.all([
git.status(),
git.diffSummary(['--cached']),
git.diffSummary([])
])
const stagedCounts = countsByPath(staged)
const unstagedCounts = countsByPath(unstaged)
const files = status.files.map(file => {
const filePath = resolveRenamePath(file.path)
const sc = stagedCounts.get(filePath) || { added: 0, removed: 0 }
const uc = unstagedCounts.get(filePath) || { added: 0, removed: 0 }
return {
path: filePath,
added: sc.added + uc.added,
removed: sc.removed + uc.removed,
status: statusLetter(file),
staged: isStaged(file)
}
})
files.sort((a, b) => a.path.localeCompare(b.path))
await fillUntrackedCounts(cwd, files)
return { files, base: null }
} catch {
return { files: [], base: null }
}
}
async function reviewDiff(repoPath, filePath, scope, baseRef, staged, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review diff' })
} catch {
return ''
}
const git = gitFor(cwd, gitBin)
const safe = args => git.diff(args).catch(() => '')
if (scope === 'branch') {
const base = await branchBase(git)
return base ? safe([`${base}...HEAD`, '--', filePath]) : ''
}
if (scope === 'lastTurn') {
return baseRef ? safe([baseRef, '--', filePath]) : ''
}
if (staged) {
return safe(['--cached', '--', filePath])
}
const worktree = await safe(['--', filePath])
if (worktree.trim()) {
return worktree
}
// Untracked file: no worktree diff exists, so synthesize an all-add diff via
// --no-index (exits non-zero by design when files differ, so go around
// simple-git's reject-on-nonzero with a raw execFile).
return new Promise(resolve => {
execFile(
gitBin || 'git',
['diff', '--no-index', '--', '/dev/null', filePath],
{ cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 },
(_err, stdout) => resolve(String(stdout || ''))
)
})
}
// Working-tree-vs-HEAD diff for ONE file — the "what changed since the last
// commit" view used by the file preview. Unlike reviewDiff this never synthesizes
// a full-add for a clean tracked file (so a pristine file shows no diff); it only
// all-adds a genuinely untracked file.
async function fileDiffVsHead(repoPath, filePath, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'File diff' })
} catch {
return ''
}
const git = gitFor(cwd, gitBin)
const head = await git.diff(['HEAD', '--', filePath]).catch(() => '')
if (head.trim()) {
return head
}
// No tracked changes vs HEAD. Only synthesize an all-add diff for a file git
// doesn't know yet; a clean tracked file must return empty.
const status = await git.raw(['status', '--porcelain', '--', filePath]).catch(() => '')
if (!status.trim().startsWith('??')) {
return ''
}
return new Promise(resolve => {
execFile(
gitBin || 'git',
['diff', '--no-index', '--', '/dev/null', filePath],
{ cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 },
(_err, stdout) => resolve(String(stdout || ''))
)
})
}
async function reviewStage(repoPath, filePath, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review stage' })
await gitFor(cwd, gitBin).raw(filePath ? ['add', '--', filePath] : ['add', '-A'])
return { ok: true }
}
async function reviewUnstage(repoPath, filePath, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review unstage' })
await gitFor(cwd, gitBin).raw(filePath ? ['reset', '-q', 'HEAD', '--', filePath] : ['reset', '-q', 'HEAD'])
return { ok: true }
}
// Discard changes back to the committed state. Destructive — the renderer
// confirms first. Restores tracked files and removes untracked ones.
async function reviewRevert(repoPath, filePath, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review revert' })
const git = gitFor(cwd, gitBin)
if (filePath) {
await git.raw(['checkout', 'HEAD', '--', filePath]).catch(() => undefined)
await git.raw(['clean', '-fd', '--', filePath]).catch(() => undefined)
} else {
await git.raw(['checkout', 'HEAD', '--', '.']).catch(() => undefined)
await git.raw(['clean', '-fd']).catch(() => undefined)
}
return { ok: true }
}
// Resolve a ref to a commit sha (captures the turn baseline for "Last turn").
async function reviewRevParse(repoPath, ref, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review rev-parse' })
} catch {
return null
}
try {
return (await gitFor(cwd, gitBin).revparse([ref || 'HEAD'])).trim() || null
} catch {
return null
}
}
// Commit the working tree. Mirrors VS Code: if nothing is staged, stage
// everything first ("commit all"), then commit. Optionally push afterward,
// setting upstream on the first push.
async function reviewCommit(repoPath, message, push, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit' })
const git = gitFor(cwd, gitBin)
const status = await git.status()
if (status.staged.length === 0) {
await git.raw(['add', '-A'])
}
await git.commit(message)
if (push) {
const fresh = await git.status()
if (fresh.tracking) {
await git.push()
} else if (fresh.current) {
await git.raw(['push', '-u', 'origin', fresh.current])
}
}
return { ok: true }
}
// Gather the context the model needs to draft a commit message: the diff of
// what *will* be committed (staged when anything is staged, else everything
// vs HEAD — mirroring reviewCommit's "stage all when nothing staged" rule),
// the names of untracked files (which carry no diff), and recent commit
// subjects for style. Diff is capped so the payload stays bounded. Reads only.
async function reviewCommitContext(repoPath, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit context' })
} catch {
return { diff: '', recent: '' }
}
const git = gitFor(cwd, gitBin)
const safe = args => git.diff(args).catch(() => '')
let status
try {
status = await git.status()
} catch {
return { diff: '', recent: '' }
}
// What will land: staged changes if any, otherwise all tracked changes vs HEAD.
let diff = capText(
status.staged.length > 0 ? await safe(['--cached']) : await safe(['HEAD']),
COMMIT_CONTEXT_DIFF_MAX_CHARS,
'diff truncated for commit-message generation'
)
// Untracked files have no diff — list them so new files aren't invisible.
const untracked = status.not_added || []
if (untracked.length > 0) {
const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX)
const omitted = untracked.length - visible.length
const note =
`\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` +
(omitted > 0 ? `# ... ${omitted} more omitted\n` : '')
diff = diff ? `${diff}${note}` : note
}
const recent = await git.raw(['log', '-n', '10', '--pretty=format:%s']).catch(() => '')
return { diff: diff || '', recent: String(recent || '').trim() }
}
async function reviewPush(repoPath, gitBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review push' })
const git = gitFor(cwd, gitBin)
const status = await git.status()
if (status.tracking) {
await git.push()
} else if (status.current) {
await git.raw(['push', '-u', 'origin', status.current])
}
return { ok: true }
}
// gh availability + auth + whether this branch already has a PR. Reads only;
// drives the PR button's enabled/label state. `ghReady` is false when gh is
// missing OR not authenticated — either way the PR action can't run.
async function reviewShipInfo(repoPath, ghBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review ship info' })
} catch {
return { ghReady: false, pr: null }
}
const auth = await runGh(['auth', 'status'], cwd, ghBin)
if (!auth.ok) {
return { ghReady: false, pr: null }
}
const view = await runGh(['pr', 'view', '--json', 'url,state,number'], cwd, ghBin)
if (!view.ok) {
// gh exits non-zero when no PR exists for the branch — that's not an error.
return { ghReady: true, pr: null }
}
try {
const pr = JSON.parse(view.stdout)
return { ghReady: true, pr: pr && pr.url ? { url: pr.url, state: pr.state, number: pr.number } : null }
} catch {
return { ghReady: true, pr: null }
}
}
// Create a PR for the current branch (pushing first so gh has a remote ref),
// letting gh fill title/body from the commits. Returns the new PR url.
async function reviewCreatePr(repoPath, gitBin, ghBin) {
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review create PR' })
await reviewPush(repoPath, gitBin).catch(() => undefined)
const created = await runGh(['pr', 'create', '--fill'], cwd, ghBin)
if (!created.ok) {
throw new Error('gh pr create failed (is gh installed and authenticated?)')
}
const url = created.stdout.trim().split('\n').filter(Boolean).pop() || ''
return { url }
}
// Compact working-tree status for the composer coding rail: branch, ahead/behind,
// per-state change counts, +/- vs HEAD, and a capped changed-file list.
async function repoStatus(repoPath, gitBin) {
let cwd
try {
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Repo status' })
} catch {
return null
}
// Session cwds can point at a deleted worktree for a moment (or forever in a
// stale row). simple-git throws at construction time on a missing baseDir, so
// fail soft and hide the coding rail instead of spamming IPC handler errors.
try {
const stat = await fs.stat(cwd)
if (!stat.isDirectory()) {
return null
}
} catch {
return null
}
let git
try {
git = gitFor(cwd, gitBin)
} catch {
return null
}
let status
try {
status = await git.status()
} catch {
// Not a repo / git unavailable / remote backend.
return null
}
const detached = typeof status.detached === 'boolean' ? status.detached : !status.current
const files = status.files.map(file => ({
path: file.path,
staged: isStaged(file),
unstaged: Boolean(file.working_dir && file.working_dir !== ' ' && file.working_dir !== '?'),
untracked: file.index === '?' || file.working_dir === '?',
conflicted: file.index === 'U' || file.working_dir === 'U'
}))
const result = {
branch: detached ? null : status.current || null,
defaultBranch: await defaultBranchName(git),
detached,
ahead: status.ahead || 0,
behind: status.behind || 0,
staged: files.filter(f => f.staged).length,
unstaged: files.filter(f => f.unstaged).length,
untracked: status.not_added.length,
conflicted: status.conflicted.length,
changed: files.length,
added: 0,
removed: 0,
files: files.slice(0, 200)
}
// +/- vs HEAD (staged + unstaged tracked changes). No HEAD yet → leave 0.
try {
const summary = await git.diffSummary(['HEAD'])
result.added = summary.insertions
result.removed = summary.deletions
} catch {
// No commits yet.
}
// `git diff HEAD` ignores untracked files, so a turn that only creates new
// files (the common case — a fresh module, a demo dir) showed +0 in the rail
// while the review pane counted them. Fold untracked insertions into `added`
// so the rail matches reality. Bounded (size cap + concurrency) like the
// review tree; only the capped file slice is counted so a huge untracked tree
// can't stall the probe.
try {
const untracked = status.not_added.slice(0, 500)
for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) {
const batch = await Promise.all(
untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path))
)
result.added += batch.reduce((sum, n) => sum + n, 0)
}
} catch {
// Best-effort: a probe failure just leaves untracked lines uncounted.
}
return result
}
module.exports = {
branchBase,
fileDiffVsHead,
repoStatus,
resolveRenamePath,
reviewCommit,
reviewCommitContext,
reviewCreatePr,
reviewDiff,
reviewList,
reviewPush,
reviewRevParse,
reviewRevert,
reviewShipInfo,
reviewStage,
reviewUnstage
}
@@ -0,0 +1,22 @@
'use strict'
const assert = require('node:assert/strict')
const test = require('node:test')
const { resolveRenamePath } = require('./git-review-ops.cjs')
test('resolveRenamePath: plain path is unchanged', () => {
assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts')
})
test('resolveRenamePath: simple rename resolves to the new path', () => {
assert.equal(resolveRenamePath('old.ts => new.ts'), 'new.ts')
})
test('resolveRenamePath: brace rename resolves to the new path', () => {
assert.equal(resolveRenamePath('src/{old => new}/file.ts'), 'src/new/file.ts')
})
test('resolveRenamePath: brace rename collapsing a segment', () => {
assert.equal(resolveRenamePath('src/{lib => }/file.ts'), 'src/file.ts')
})
+350
View File
@@ -0,0 +1,350 @@
'use strict'
// Git-driven worktree operations for the desktop "Start work" flow: spin up a
// fresh worktree the lightest way (`git worktree add -b`), list real worktrees,
// and remove them. Git is the source of truth; the renderer just drives these.
const path = require('node:path')
const fs = require('node:fs')
const { execFile } = require('node:child_process')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
function runGit(gitBin, args, cwd) {
return new Promise((resolve, reject) => {
execFile(
gitBin,
args,
{ cwd, windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 },
(err, stdout, stderr) => {
if (err) {
err.stderr = String(stderr || '')
reject(err)
return
}
resolve(String(stdout || ''))
}
)
})
}
// Parse `git worktree list --porcelain`. The first record is the main worktree.
function parseWorktrees(out) {
const trees = []
let cur = null
for (const line of out.split('\n')) {
if (line.startsWith('worktree ')) {
if (cur) {
trees.push(cur)
}
cur = { path: line.slice(9).trim(), branch: null, detached: false, bare: false, locked: false }
} else if (!cur) {
continue
} else if (line.startsWith('branch ')) {
cur.branch = line
.slice(7)
.trim()
.replace(/^refs\/heads\//, '')
} else if (line === 'detached') {
cur.detached = true
} else if (line === 'bare') {
cur.bare = true
} else if (line.startsWith('locked')) {
cur.locked = true
}
}
if (cur) {
trees.push(cur)
}
return trees
}
async function listWorktrees(repoPath, gitBin) {
let resolved
try {
resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree list' })
} catch {
return []
}
try {
const out = await runGit(gitBin, ['worktree', 'list', '--porcelain'], resolved)
return parseWorktrees(out).map((tree, index) => ({
path: tree.path,
branch: tree.branch,
isMain: index === 0,
detached: tree.detached,
locked: tree.locked
}))
} catch {
return []
}
}
// A git-ref-safe branch name (spaces → "-", drop forbidden chars, trim edges),
// or "" when nothing usable remains. Mirrors the renderer's `gitRef`, so a bad
// value can't reach `git` no matter the caller (the GUI also enforces live).
function sanitizeBranch(name) {
return String(name || '')
.replace(/\s+/g, '-')
.replace(/[^\w./-]/g, '')
.replace(/-{2,}/g, '-')
.replace(/\/{2,}/g, '/')
.replace(/\.{2,}/g, '.')
.replace(/^[-./]+|[-./]+$/g, '')
}
function slugify(name) {
const slug = String(name || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
.replace(/-+$/g, '')
return slug || 'work'
}
const TRUNK_BRANCHES = ['main', 'master']
async function gitLine(gitBin, args, cwd) {
try {
return (await runGit(gitBin, args, cwd)).trim()
} catch {
return ''
}
}
async function defaultBranch(gitBin, cwd) {
const remote = (
await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd)
).replace(/^origin\//, '')
if (remote) {
return remote
}
const configured = await gitLine(gitBin, ['config', '--get', 'init.defaultBranch'], cwd)
if (configured) {
return configured
}
for (const branch of TRUNK_BRANCHES) {
if (await gitLine(gitBin, ['show-ref', '--verify', `refs/heads/${branch}`], cwd)) {
return branch
}
}
return ''
}
// A brand-new project folder isn't a git repo — and a freshly-init'd one has no
// commit to branch from — so `git worktree add` would fail. Make the dir a repo
// with a root commit on the user's behalf so worktrees "just work". No-op for a
// repo that already has commits; never touches the user's files (the seed commit
// is `--allow-empty`), and never inits a dir that already lives inside a repo.
async function ensureGitRepo(gitBin, dir) {
let needsRoot = false
try {
const inside = (await runGit(gitBin, ['rev-parse', '--is-inside-work-tree'], dir)).trim()
if (inside !== 'true') {
await runGit(gitBin, ['init'], dir)
needsRoot = true
} else {
// Repo exists; a worktree still needs a HEAD to branch from.
try {
await runGit(gitBin, ['rev-parse', '--verify', 'HEAD'], dir)
} catch {
needsRoot = true
}
}
} catch {
await runGit(gitBin, ['init'], dir)
needsRoot = true
}
if (needsRoot) {
// Inline identity so the seed commit lands even with no global git config.
await runGit(
gitBin,
[
'-c',
'user.email=hermes@localhost',
'-c',
'user.name=Hermes',
'commit',
'--allow-empty',
'-m',
'Initial commit'
],
dir
)
}
}
// Resolve the repo's MAIN worktree root, so `.worktrees/` always nests under the
// primary checkout even when called from a linked worktree.
async function mainRoot(gitBin, cwd) {
const list = await listWorktrees(cwd, gitBin)
const main = list.find(tree => tree.isMain)
return main ? main.path : cwd
}
function uniqueDir(base) {
let dir = base
let n = 1
while (fs.existsSync(dir)) {
n += 1
dir = `${base}-${n}`
}
return dir
}
async function addExistingBranchWorktree(gitBin, root, name) {
const branch = sanitizeBranch(name)
if (!branch) {
throw new Error('Branch name is required.')
}
if (branch === (await defaultBranch(gitBin, root))) {
await runGit(gitBin, ['switch', branch], root)
return { path: root, branch, repoRoot: root }
}
const dir = uniqueDir(path.join(root, '.worktrees', slugify(branch)))
await runGit(gitBin, ['worktree', 'add', dir, branch], root)
return { path: dir, branch, repoRoot: root }
}
async function addWorktree(repoPath, options, gitBin) {
const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree add' })
// A new project's folder may not be a git repo yet — init it (with a root
// commit) so the worktree has something to branch from.
await ensureGitRepo(gitBin, resolved)
const root = await mainRoot(gitBin, resolved)
const opts = options || {}
if (opts.existingBranch) {
return addExistingBranchWorktree(gitBin, root, opts.existingBranch)
}
const slug = slugify(opts.name || `work-${Date.now().toString(36)}`)
const branch = sanitizeBranch(opts.branch) || `hermes/${slug}`
const dir = uniqueDir(path.join(root, '.worktrees', slug))
const args = ['worktree', 'add', '-b', branch, dir]
if (opts.base) {
args.push(String(opts.base))
}
try {
await runGit(gitBin, args, root)
} catch (err) {
// Branch name may already exist — retry checking out the existing branch
// into a fresh worktree dir instead of failing the whole flow.
if (/already exists/i.test(err.stderr || '')) {
await runGit(gitBin, ['worktree', 'add', dir, branch], root)
} else {
throw err
}
}
return { path: dir, branch, repoRoot: root }
}
async function removeWorktree(repoPath, worktreePath, options, gitBin) {
const resolvedRepo = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree remove (repo)' })
const resolvedTree = resolveRequestedPathForIpc(worktreePath, { purpose: 'Worktree remove (tree)' })
const root = await mainRoot(gitBin, resolvedRepo)
const args = ['worktree', 'remove']
if (options && options.force) {
args.push('--force')
}
args.push(resolvedTree)
await runGit(gitBin, args, root)
return { removed: resolvedTree }
}
// List local branches for the "convert a branch into a worktree" picker, most
// recently committed first. Each carries whether it's already checked out in a
// worktree and, when checked out, that worktree's path. Empty on a non-repo /
// remote backend where the probe can't run.
async function listBranches(repoPath, gitBin) {
let resolved
try {
resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch list' })
} catch {
return []
}
try {
const out = await runGit(
gitBin,
['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'],
resolved
)
const trees = await listWorktrees(resolved, gitBin)
const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path]))
const trunk = await defaultBranch(gitBin, resolved)
return out
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.map(name => ({
name,
checkedOut: pathByBranch.has(name),
isDefault: Boolean(trunk && name === trunk),
worktreePath: pathByBranch.get(name) || null
}))
} catch {
return []
}
}
async function switchBranch(repoPath, branch, gitBin) {
const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch switch' })
const target = sanitizeBranch(branch)
if (!target) {
throw new Error('Branch name is required.')
}
await runGit(gitBin, ['switch', target], resolved)
return { branch: target }
}
module.exports = {
addWorktree,
ensureGitRepo,
listBranches,
listWorktrees,
parseWorktrees,
removeWorktree,
sanitizeBranch,
switchBranch
}
@@ -0,0 +1,214 @@
'use strict'
const assert = require('node:assert/strict')
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const {
addWorktree,
ensureGitRepo,
listBranches,
parseWorktrees,
sanitizeBranch,
switchBranch
} = require('./git-worktree-ops.cjs')
test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => {
assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes')
assert.equal(sanitizeBranch('feat/cool thing'), 'feat/cool-thing')
assert.equal(sanitizeBranch(' wip~^:? '), 'wip')
assert.equal(sanitizeBranch('///'), '')
})
test('parseWorktrees: main checkout + linked worktree', () => {
const out = [
'worktree /repo',
'HEAD abc123',
'branch refs/heads/main',
'',
'worktree /repo/.worktrees/feat',
'HEAD def456',
'branch refs/heads/hermes/feat',
''
].join('\n')
const trees = parseWorktrees(out)
assert.equal(trees.length, 2)
assert.equal(trees[0].path, '/repo')
assert.equal(trees[0].branch, 'main')
assert.equal(trees[1].path, '/repo/.worktrees/feat')
assert.equal(trees[1].branch, 'hermes/feat')
})
test('parseWorktrees: detached + locked flags', () => {
const out = ['worktree /repo/wt', 'HEAD abc', 'detached', 'locked reason', ''].join('\n')
const trees = parseWorktrees(out)
assert.equal(trees.length, 1)
assert.equal(trees[0].detached, true)
assert.equal(trees[0].locked, true)
assert.equal(trees[0].branch, null)
})
test('parseWorktrees: empty input', () => {
assert.deepEqual(parseWorktrees(''), [])
})
test('ensureGitRepo: inits a plain dir with a root commit so worktrees branch', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-wt-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
assert.match(git('rev-parse', '--verify', 'HEAD'), /^[0-9a-f]{7,}$/)
// The whole point: a worktree can now branch off the seeded root commit.
execFileSync('git', ['worktree', 'add', '-b', 'wt', path.join(dir, '.worktrees', 'wt')], { cwd: dir })
assert.ok(fs.existsSync(path.join(dir, '.worktrees', 'wt')))
// Idempotent: an already-committed repo gets no extra commit.
await ensureGitRepo('git', dir)
assert.equal(git('rev-list', '--count', 'HEAD'), '1')
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('switchBranch: switches a normal checkout branch', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-switch-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
execFileSync('git', ['branch', 'feature'], { cwd: dir })
await switchBranch(dir, 'feature', 'git')
assert.equal(git('branch', '--show-current'), 'feature')
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('listBranches: lists locals and flags the checked-out branch', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-'))
try {
await ensureGitRepo('git', dir)
const current = execFileSync('git', ['branch', '--show-current'], { cwd: dir }).toString().trim()
execFileSync('git', ['branch', 'feature'], { cwd: dir })
const branches = await listBranches(dir, 'git')
const names = branches.map(b => b.name).sort()
assert.deepEqual(names, [current, 'feature'].sort())
// The repo's own checkout is flagged; the unused branch is convertible.
assert.equal(branches.find(b => b.name === current).checkedOut, true)
assert.equal(branches.find(b => b.name === current).isDefault, true)
assert.equal(fs.realpathSync(branches.find(b => b.name === current).worktreePath), fs.realpathSync(dir))
assert.equal(branches.find(b => b.name === 'feature').checkedOut, false)
assert.equal(branches.find(b => b.name === 'feature').isDefault, false)
assert.equal(branches.find(b => b.name === 'feature').worktreePath, null)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('listBranches: flags a free default branch as default, not checked out', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-default-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
const trunk = git('branch', '--show-current')
execFileSync('git', ['switch', '-c', 'rawr'], { cwd: dir })
const branches = await listBranches(dir, 'git')
const defaultBranch = branches.find(b => b.name === trunk)
assert.equal(defaultBranch.checkedOut, false)
assert.equal(defaultBranch.isDefault, true)
assert.equal(defaultBranch.worktreePath, null)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('listBranches: a branch claimed by a worktree is flagged checked out', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-wt-'))
try {
await ensureGitRepo('git', dir)
execFileSync('git', ['branch', 'feature'], { cwd: dir })
// addWorktree converts the existing "feature" branch into a worktree.
const result = await addWorktree(dir, { existingBranch: 'feature' }, 'git')
assert.equal(result.branch, 'feature')
assert.ok(fs.existsSync(result.path))
const branches = await listBranches(dir, 'git')
assert.equal(branches.find(b => b.name === 'feature').checkedOut, true)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('listBranches: empty on a non-repo path', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-nonrepo-'))
try {
assert.deepEqual(await listBranches(dir, 'git'), [])
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('addWorktree: existingBranch checks the branch out without a new branch', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-convert-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
execFileSync('git', ['branch', 'cool/feature'], { cwd: dir })
const before = git('branch', '--list').split('\n').length
const result = await addWorktree(dir, { existingBranch: 'cool/feature' }, 'git')
// No new branch was created — only the existing one is checked out.
assert.equal(git('branch', '--list').split('\n').length, before)
assert.equal(result.branch, 'cool/feature')
// Dir is named off the branch slug, nested under the main repo's .worktrees.
assert.match(result.path, /[/\\]\.worktrees[/\\]cool-feature/)
assert.equal(
execFileSync('git', ['branch', '--show-current'], { cwd: result.path }).toString().trim(),
'cool/feature'
)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('addWorktree: existing default branch switches the main checkout, not .worktrees/main', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-convert-default-'))
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
try {
await ensureGitRepo('git', dir)
const trunk = git('branch', '--show-current')
execFileSync('git', ['switch', '-c', 'rawr'], { cwd: dir })
const result = await addWorktree(dir, { existingBranch: trunk }, 'git')
assert.equal(result.branch, trunk)
assert.equal(fs.realpathSync(result.path), fs.realpathSync(dir))
assert.equal(git('branch', '--show-current'), trunk)
assert.equal(fs.existsSync(path.join(dir, '.worktrees', trunk)), false)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
-174
View File
@@ -1,174 +0,0 @@
'use strict'
// Resolve git-worktree relationships for a set of session cwds, reading git's
// on-disk metadata directly (no `git` spawn per path):
//
// - A normal checkout has a `.git` DIRECTORY at its root → it's the main
// worktree; its repo root IS that directory's parent.
// - A linked worktree has a `.git` FILE: `gitdir: <repo>/.git/worktrees/<name>`.
// That admin dir's `commondir` points back at the shared `<repo>/.git`, whose
// parent is the main repo root.
//
// Grouping by repoRoot therefore clusters a repo's main checkout with all of its
// linked worktrees, regardless of how the worktree directories are named. The
// branch (read from the worktree's own HEAD) gives each worktree a meaningful
// label.
const fs = require('node:fs')
const path = require('node:path')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
// Walk up from `start` to the nearest ancestor that carries a `.git` entry
// (file for a linked worktree, dir for the main checkout). Capped so a stray
// path can't loop forever.
function findGitHost(start, fsImpl) {
let dir = start
for (let i = 0; i < 64; i += 1) {
const dotgit = path.join(dir, '.git')
try {
if (fsImpl.existsSync(dotgit)) {
return dir
}
} catch {
return null
}
const parent = path.dirname(dir)
if (parent === dir) {
return null
}
dir = parent
}
return null
}
function readBranch(gitDir, fsImpl) {
try {
const head = fsImpl.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim()
const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/)
if (ref) {
return ref[1]
}
// Detached HEAD: surface a short sha so the worktree still gets a label.
return /^[0-9a-f]{7,40}$/i.test(head) ? head.slice(0, 8) : null
} catch {
return null
}
}
// Given the directory that owns the `.git` entry, resolve its worktree identity.
function resolveFromHost(host, fsImpl) {
const dotgit = path.join(host, '.git')
let stat
try {
stat = fsImpl.statSync(dotgit)
} catch {
return null
}
if (stat.isDirectory()) {
return {
repoRoot: host,
worktreeRoot: host,
isMainWorktree: true,
branch: readBranch(dotgit, fsImpl)
}
}
// Linked worktree: `.git` is a file pointing at the admin dir.
let contents
try {
contents = fsImpl.readFileSync(dotgit, 'utf8').trim()
} catch {
return null
}
const match = contents.match(/^gitdir:\s*(.+)$/m)
if (!match) {
return null
}
const adminDir = path.resolve(host, match[1].trim())
// `commondir` resolves to the shared `<repo>/.git`; fall back to walking two
// levels up from `<repo>/.git/worktrees/<name>` if it's missing.
let commonDir
try {
const rel = fsImpl.readFileSync(path.join(adminDir, 'commondir'), 'utf8').trim()
commonDir = path.resolve(adminDir, rel)
} catch {
commonDir = path.dirname(path.dirname(adminDir))
}
return {
repoRoot: path.dirname(commonDir),
worktreeRoot: host,
isMainWorktree: false,
branch: readBranch(adminDir, fsImpl)
}
}
function resolveWorktree(startPath, fsImpl = fs) {
let resolved
try {
resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Worktree lookup' })
} catch {
return null
}
let start = resolved
try {
const stat = fsImpl.statSync(resolved)
if (!stat.isDirectory()) {
start = path.dirname(resolved)
}
} catch {
return null
}
const host = findGitHost(start, fsImpl)
if (!host) {
return null
}
return resolveFromHost(host, fsImpl)
}
// Batch entry point for the renderer: maps each requested cwd to its worktree
// info (or null when it isn't inside a git checkout / can't be read). Dedupes so
// many sessions sharing a cwd cost one lookup.
async function worktreesForIpc(cwds, options = {}) {
const fsImpl = options.fs || fs
const list = Array.isArray(cwds) ? cwds : []
const out = {}
for (const cwd of list) {
if (typeof cwd !== 'string' || !cwd.trim() || cwd in out) {
continue
}
out[cwd] = resolveWorktree(cwd, fsImpl)
}
return out
}
module.exports = {
resolveWorktree,
worktreesForIpc
}
+8 -2
View File
@@ -186,7 +186,10 @@ async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) {
if (code === 'ENOENT' || code === 'ENOTDIR') {
throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`)
}
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
throw ipcPathError(
code || 'read-error',
`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
)
}
}
@@ -201,7 +204,10 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) {
return realPath
} catch (error) {
const code = error && typeof error === 'object' ? error.code : ''
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
throw ipcPathError(
code || 'read-error',
`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
)
}
}
+476 -73
View File
@@ -21,10 +21,10 @@ const crypto = require('node:crypto')
const fs = require('node:fs')
const http = require('node:http')
const https = require('node:https')
const net = require('node:net')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
const { installEmbedReferer } = require('./embed-referer.cjs')
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { runBootstrap } = require('./bootstrap-runner.cjs')
const {
@@ -38,11 +38,13 @@ const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { createLinkTitleWindow } = require('./link-title-window.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
const { waitForDashboardPort } = require('./backend-ready.cjs')
const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs')
const { nativeOverlayWidth: computeNativeOverlayWidth } = require('./titlebar-overlay-width.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
const { readLiveUpdateMarker } = require('./update-marker.cjs')
const {
@@ -55,7 +57,23 @@ const {
buildRelaunchScript
} = require('./update-relaunch.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs')
const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs')
const {
fileDiffVsHead,
repoStatus,
reviewCommit,
reviewCommitContext,
reviewCreatePr,
reviewDiff,
reviewList,
reviewPush,
reviewRevParse,
reviewRevert,
reviewShipInfo,
reviewStage,
reviewUnstage
} = require('./git-review-ops.cjs')
const { scanGitRepos } = require('./git-repo-scan.cjs')
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
@@ -170,6 +188,16 @@ if (REMOTE_DISPLAY_REASON) {
)
}
// WSLg: Chromium blocklists the Mesa vGPU → software compositing → typing lag.
// /dev/dxg means a real GPU is available; un-blocklist it. Skipped when a remote
// display already forced software (SSH'd-into-WSL).
if (IS_WSL && !REMOTE_DISPLAY_REASON && fs.existsSync('/dev/dxg')) {
app.commandLine.appendSwitch('ignore-gpu-blocklist')
app.commandLine.appendSwitch('enable-gpu-rasterization')
app.commandLine.appendSwitch('enable-zero-copy')
console.log('[hermes] WSL GPU passthrough (/dev/dxg) detected; enabling GPU acceleration')
}
ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON)
// Keep the renderer running at full speed while the window is in the background
@@ -302,9 +330,7 @@ function hermesManagedNodePathEntries() {
}
function pathWithHermesManagedNode(...entries) {
return [...hermesManagedNodePathEntries(), ...entries, process.env.PATH]
.filter(Boolean)
.join(path.delimiter)
return [...hermesManagedNodePathEntries(), ...entries, process.env.PATH].filter(Boolean).join(path.delimiter)
}
// ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path
@@ -382,14 +408,10 @@ const WINDOW_BUTTON_POSITION = {
x: 24,
y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2
}
// Width Electron reserves for the Windows/Linux native min/max/close cluster
// when `titleBarOverlay` is enabled. The OS paints these buttons in the
// top-right corner of the renderer; we have to leave that much room on the
// right edge so our system tools (file browser, haptics, settings) don't sit
// underneath them. macOS uses left-side traffic lights instead and reports a
// position via getWindowButtonPosition(), so this width is non-zero only on
// non-macOS platforms.
const NATIVE_OVERLAY_BUTTON_WIDTH = 144
// Right-edge window-control reservation lives in titlebar-overlay-width.cjs
// (pure + unit-testable); computeNativeOverlayWidth() applies it per platform.
// It's only the pre-layout fallback — the renderer measures the exact overlay
// width live via the Window Controls Overlay API.
const APP_ICON_PATHS = [
path.join(APP_ROOT, 'public', 'apple-touch-icon.png'),
path.join(APP_ROOT, 'dist', 'apple-touch-icon.png'),
@@ -503,25 +525,48 @@ function getWindowBackgroundColor() {
return nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f7f7'
}
// Transparent WCO — renderer chrome shows through. rgba(0,0,0,0) can fall back
// to GetFrameColor() on some Electron builds; rgba(1,0,0,0) is the escape hatch.
const TITLEBAR_OVERLAY_COLOR = 'rgba(1, 0, 0, 0)'
function getTitleBarOverlayOptions() {
if (IS_MAC) {
return { height: TITLEBAR_HEIGHT }
}
if (rendererTitleBarTheme) {
return {
color: rendererTitleBarTheme.background,
height: TITLEBAR_HEIGHT,
symbolColor: rendererTitleBarTheme.foreground
}
// Windows + WSLg paint WCO natively; plain Linux disables it (frameless hidden
// titlebar still applies).
if (!IS_WINDOWS && !IS_WSL) {
return false
}
const useDarkColors = nativeTheme.shouldUseDarkColors
return {
color: useDarkColors ? '#111111' : '#f7f7f7',
color: TITLEBAR_OVERLAY_COLOR,
height: TITLEBAR_HEIGHT,
symbolColor: useDarkColors ? '#f7f7f7' : '#242424'
symbolColor:
rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.foreground)
? rendererTitleBarTheme.foreground
: nativeTheme.shouldUseDarkColors
? '#f7f7f7'
: '#242424'
}
}
// Push refreshed overlay options to a live window after a theme/appearance
// change. No-op only on plain (non-WSL) Linux, where getTitleBarOverlayOptions()
// returns false; the try/catch additionally guards builds where
// setTitleBarOverlay isn't supported.
function applyTitleBarOverlay(win) {
const options = getTitleBarOverlayOptions()
if (!options || typeof options !== 'object') {
return
}
try {
win?.setTitleBarOverlay?.(options)
} catch {
// Overlay not supported on this platform/build — leave the frameless
// titlebar as-is.
}
}
@@ -744,6 +789,9 @@ let rendererReloadTimes = []
// instead of re-running install.ps1 in a hot loop. Cleared explicitly by
// the renderer's "Reload and retry" path or by quitting the app.
let bootstrapFailure = null
// Latched non-bootstrap backend spawn failure — stops getConnection() from
// respawning hermes dashboard children in a tight loop while boot is broken.
let backendStartFailure = null
// Active first-launch install, so the renderer's Cancel button (and app quit)
// can abort the in-flight install.sh/ps1 instead of leaving it running.
let bootstrapAbortController = null
@@ -1254,6 +1302,36 @@ function isCommandScript(command) {
return IS_WINDOWS && /\.(cmd|bat)$/i.test(command || '')
}
function unwrapWindowsVenvHermesCommand(command, dashboardArgs) {
if (!IS_WINDOWS || !command || isCommandScript(command)) return null
const resolved = path.resolve(String(command))
if (!/^hermes(?:\.exe)?$/i.test(path.basename(resolved))) return null
const scriptsDir = path.dirname(resolved)
if (path.basename(scriptsDir).toLowerCase() !== 'scripts') return null
const venvRoot = path.dirname(scriptsDir)
const python = getNoConsoleVenvPython(venvRoot)
if (!fileExists(python)) return null
const root = path.dirname(venvRoot)
return {
label: `existing Hermes no-console Python at ${python}`,
command: python,
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
bootstrap: false,
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)],
venvRoot
}),
kind: 'python',
readyFile: true,
shell: false
}
}
function normalizeExecutablePathForCompare(commandPath) {
if (!commandPath) return null
@@ -1474,6 +1552,97 @@ function getVenvPython(venvRoot) {
return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python'))
}
function readVenvHome(venvRoot) {
try {
const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8')
const match = cfg.match(/^home\s*=\s*(.+?)\s*$/im)
return match ? match[1].trim() : null
} catch {
return null
}
}
function getNoConsoleVenvPython(venvRoot) {
if (!IS_WINDOWS) return getVenvPython(venvRoot)
// Prefer the venv's own pythonw shim — it carries pyvenv.cfg / site-packages
// wiring. Falling back to the base uv/python.org pythonw.exe skips the venv
// and breaks imports (yaml, hermes_cli, …) even when PYTHONPATH is patched.
const venvPythonw = path.join(venvRoot, 'Scripts', 'pythonw.exe')
if (fileExists(venvPythonw)) return venvPythonw
const baseHome = readVenvHome(venvRoot)
if (baseHome) {
const basePythonw = path.join(baseHome, 'pythonw.exe')
if (fileExists(basePythonw)) return basePythonw
}
return venvPythonw
}
function toNoConsolePython(pythonPath) {
if (!IS_WINDOWS || !pythonPath) return pythonPath
const resolved = String(pythonPath)
if (/pythonw\.exe$/i.test(resolved)) return resolved
if (/python\.exe$/i.test(resolved)) {
const pythonw = path.join(path.dirname(resolved), 'pythonw.exe')
if (fileExists(pythonw)) return pythonw
}
return pythonPath
}
function applyWindowsNoConsoleSpawnHints(backend) {
if (!IS_WINDOWS || !backend?.command) return backend
const usesHermesModule =
backend.kind === 'python' ||
(Array.isArray(backend.args) && backend.args[0] === '-m' && backend.args[1] === 'hermes_cli.main')
if (!usesHermesModule) return backend
backend.command = toNoConsolePython(backend.command)
if (/pythonw\.exe$/i.test(path.basename(String(backend.command || '')))) {
backend.readyFile = true
}
return backend
}
function getVenvSitePackagesEntries(venvRoot) {
const entries = []
if (!venvRoot) return entries
if (IS_WINDOWS) {
const sitePackages = path.join(venvRoot, 'Lib', 'site-packages')
if (directoryExists(sitePackages)) entries.push(sitePackages)
return entries
}
const version = (() => {
try {
const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8')
const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im)
return match ? match[1].trim() : null
} catch {
return null
}
})()
if (version) {
const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages')
if (directoryExists(sitePackages)) entries.push(sitePackages)
}
return entries
}
function makeDashboardReadyFile() {
const dir = path.join(app.getPath('userData'), 'backend-ready')
fs.mkdirSync(dir, { recursive: true })
return path.join(dir, `dashboard-${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.json`)
}
// resolveGitBinary — locate git.exe on Windows. A fresh installer-driven
// install only has PortableGit under %LOCALAPPDATA%\hermes\git (never on
// PATH), so a bare spawn('git') ENOENTs and self-update checks fail with
@@ -1503,6 +1672,30 @@ function resolveGitBinary() {
return _gitBinaryCache
}
// resolveGhBinary — locate the GitHub CLI. GUI-launched apps get a minimal PATH
// that omits Homebrew (/opt/homebrew/bin, /usr/local/bin) where `gh` usually
// lives, so a bare spawn('gh') ENOENTs even though `gh` works in the user's
// terminal. Check the common install locations first, then PATH. Cached.
let _ghBinaryCache = null
function resolveGhBinary() {
if (_ghBinaryCache) return _ghBinaryCache
const candidates = []
if (IS_WINDOWS) {
candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'GitHub CLI', 'gh.exe'))
if (process.env.LOCALAPPDATA) {
candidates.push(path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links', 'gh.exe'))
}
} else {
const home = app.getPath('home')
candidates.push('/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh', path.join(home, '.local', 'bin', 'gh'))
}
_ghBinaryCache = candidates.find(fileExists) || findOnPath('gh') || 'gh'
return _ghBinaryCache
}
function recentHermesLog() {
return hermesLog.slice(-20).join('\n')
}
@@ -1982,7 +2175,8 @@ async function applyUpdates(opts = {}) {
emitUpdateProgress({
stage: 'restart',
message: 'Updating Hermes — this window will close and the updater will open. Don’t reopen Hermes yourself; it restarts automatically when the update finishes.',
message:
'Updating Hermes — this window will close and the updater will open. Don’t reopen Hermes yourself; it restarts automatically when the update finishes.',
percent: 100
})
repairMacUpdaterHelper(updater)
@@ -2065,7 +2259,9 @@ async function handOffWindowsBootstrapRecovery(reason) {
})
child.unref()
rememberLog(`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`)
rememberLog(
`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`
)
// Same dwell as the in-app update hand-off (#50419): give the updater's
// window time to appear before we vanish, so the recovery doesn't look like
// a crash and provoke a mid-recovery relaunch.
@@ -2590,20 +2786,24 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
const python = findPythonForRoot(root)
if (!python) return null
return {
const venvRoot = path.join(root, 'venv')
const venvPython = getVenvPython(venvRoot)
const command = IS_WINDOWS && fileExists(venvPython) ? getNoConsoleVenvPython(venvRoot) : toNoConsolePython(python)
return applyWindowsNoConsoleSpawnHints({
kind: 'python',
label,
command: python,
command,
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
pythonPathEntries: [root],
venvRoot: path.join(root, 'venv')
venvRoot
}),
root,
bootstrap: Boolean(options.bootstrap),
shell: false
}
})
}
// createActiveBackend — build a backend pointing at ACTIVE_HERMES_ROOT, the
@@ -2612,11 +2812,12 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
// ensureRuntime() to create / refresh it before launch.
function createActiveBackend(dashboardArgs) {
const venvPython = getVenvPython(VENV_ROOT)
const command = fileExists(venvPython) ? getNoConsoleVenvPython(VENV_ROOT) : toNoConsolePython(findSystemPython())
return {
return applyWindowsNoConsoleSpawnHints({
kind: 'python',
label: `Hermes at ${ACTIVE_HERMES_ROOT}`,
command: fileExists(venvPython) ? venvPython : findSystemPython(),
command,
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
@@ -2626,7 +2827,7 @@ function createActiveBackend(dashboardArgs) {
root: ACTIVE_HERMES_ROOT,
bootstrap: true,
shell: false
}
})
}
function resolveHermesBackend(dashboardArgs) {
@@ -2687,6 +2888,11 @@ function resolveHermesBackend(dashboardArgs) {
}
if (hermesCommand) {
const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs)
if (unwrapped) {
return unwrapped
}
// Smoke-test the candidate before trusting it. A `hermes` shim
// left behind by a half-uninstalled pip install (or a venv
// entry-point pointing at a deleted interpreter) still resolves
@@ -2696,15 +2902,17 @@ function resolveHermesBackend(dashboardArgs) {
// and lets the resolver fall through to step 6 / bootstrap.
const shellForProbe = isCommandScript(hermesCommand)
if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
return {
label: `existing Hermes CLI at ${hermesCommand}`,
command: hermesCommand,
args: dashboardArgs,
bootstrap: false,
env: {},
kind: 'command',
shell: shellForProbe
}
return (
unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) || {
label: `existing Hermes CLI at ${hermesCommand}`,
command: hermesCommand,
args: dashboardArgs,
bootstrap: false,
env: {},
kind: 'command',
shell: shellForProbe
}
)
}
rememberLog(
`Ignoring existing Hermes CLI at ${hermesCommand}: --version probe failed; falling through to bootstrap.`
@@ -2726,15 +2934,15 @@ function resolveHermesBackend(dashboardArgs) {
// failure, fall through to step 6 so the bootstrap runner pulls
// a uv-managed 3.11 into %LOCALAPPDATA%\hermes\hermes-agent\venv.
if (canImportHermesCli(python)) {
return {
return applyWindowsNoConsoleSpawnHints({
kind: 'python',
label: `installed hermes_cli module via ${python}`,
command: python,
command: toNoConsolePython(python),
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
bootstrap: false,
env: {},
shell: false
}
})
}
rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`)
}
@@ -2768,7 +2976,7 @@ function resolveHermesBackend(dashboardArgs) {
async function ensureRuntime(backend) {
if (!backend.bootstrap) {
await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32)
return backend
return applyWindowsNoConsoleSpawnHints(backend)
}
// backend.kind === 'bootstrap-needed' means resolveHermesBackend couldn't
@@ -2784,7 +2992,9 @@ async function ensureRuntime(backend) {
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) {
const handoffError = new Error('Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.')
const handoffError = new Error(
'Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.'
)
handoffError.isBootstrapFailure = true
handoffError.bootstrapHandedOff = true
bootstrapFailure = handoffError
@@ -2908,7 +3118,7 @@ async function ensureRuntime(backend) {
)
}
backend.command = venvPython
backend.command = getNoConsoleVenvPython(VENV_ROOT)
backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})`
updateBootProgress({
phase: 'runtime.ready',
@@ -2917,10 +3127,9 @@ async function ensureRuntime(backend) {
running: true,
error: null
})
return backend
return applyWindowsNoConsoleSpawnHints(backend)
}
function fetchJson(url, token, options = {}) {
return new Promise((resolve, reject) => {
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
@@ -3579,11 +3788,7 @@ function getWindowButtonPosition() {
}
function getNativeOverlayWidth() {
// macOS reports traffic-light coords via windowButtonPosition; the
// titlebarOverlay there doesn't reserve right-edge space. Windows/Linux
// render the native window-controls overlay on the right, so the renderer
// needs to inset its right cluster by this much to clear them.
return IS_MAC ? 0 : NATIVE_OVERLAY_BUTTON_WIDTH
return computeNativeOverlayWidth({ isWindows: IS_WINDOWS, isWsl: IS_WSL })
}
function getWindowState() {
@@ -4831,6 +5036,7 @@ function resetBootProgressForReconnect() {
function resetHermesConnection() {
connectionPromise = null
backendStartFailure = null
if (hermesProcess && !hermesProcess.killed) {
hermesProcess.kill('SIGTERM')
@@ -4992,6 +5198,7 @@ async function spawnPoolBackend(profile, entry) {
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
const hermesCwd = resolveHermesCwd()
const webDist = resolveWebDist()
const readyFile = backend.readyFile ? makeDashboardReadyFile() : null
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
@@ -5012,7 +5219,8 @@ async function spawnPoolBackend(profile, entry) {
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
HERMES_DESKTOP: '1',
HERMES_WEB_DIST: webDist
HERMES_WEB_DIST: webDist,
...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {})
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
@@ -5045,7 +5253,10 @@ async function spawnPoolBackend(profile, entry) {
})
// Discover the ephemeral port the child bound to
const port = await Promise.race([waitForDashboardPort(child), startFailed])
const port = await Promise.race([waitForDashboardPortAnnouncement(child, { readyFile }), startFailed])
if (readyFile) {
fs.unlink(readyFile, () => {})
}
entry.port = port
const baseUrl = `http://127.0.0.1:${port}`
@@ -5158,6 +5369,9 @@ async function startHermes() {
if (bootstrapFailure) {
throw bootstrapFailure
}
if (backendStartFailure) {
throw backendStartFailure
}
if (connectionPromise) return connectionPromise
connectionPromise = (async () => {
@@ -5211,6 +5425,7 @@ async function startHermes() {
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
const hermesCwd = resolveHermesCwd()
const webDist = resolveWebDist()
const readyFile = backend.readyFile ? makeDashboardReadyFile() : null
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
rememberLog(`Starting Hermes backend via ${backend.label}`)
@@ -5237,7 +5452,8 @@ async function startHermes() {
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
HERMES_DESKTOP: '1',
HERMES_WEB_DIST: webDist
HERMES_WEB_DIST: webDist,
...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {})
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
@@ -5293,12 +5509,19 @@ async function startHermes() {
await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86)
// Discover the ephemeral port the child bound to
const port = await Promise.race([waitForDashboardPort(hermesProcess), backendStartFailed])
const port = await Promise.race([
waitForDashboardPortAnnouncement(hermesProcess, { readyFile }),
backendStartFailed
])
if (readyFile) {
fs.unlink(readyFile, () => {})
}
const baseUrl = `http://127.0.0.1:${port}`
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
backendReady = true
backendStartFailure = null
const authToken = await adoptServedDashboardToken(baseUrl, token, {
// The exit/error handlers null hermesProcess when the child dies.
childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed,
@@ -5324,6 +5547,7 @@ async function startHermes() {
}
})().catch(error => {
const message = error instanceof Error ? error.message : String(error)
backendStartFailure = error instanceof Error ? error : new Error(message)
updateBootProgress(
{
error: message,
@@ -5623,7 +5847,7 @@ function createWindow() {
if (!nativeThemeListenerInstalled) {
nativeThemeListenerInstalled = true
nativeTheme.on('updated', () => {
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
applyTitleBarOverlay(mainWindow)
})
}
}
@@ -5807,19 +6031,32 @@ ipcMain.handle('hermes:pet-overlay:close', async () => {
return { ok: true }
})
// Drag: the overlay reports a new absolute screen position (it already knows the
// pointer's screen coords), we just move the window.
// Drag/resize: the overlay reports new absolute screen bounds (it already knows
// the pointer's screen coords). Drag keeps the size constant; the wheel-to-scale
// gesture grows/shrinks it so the sprite is never cropped by the window edge.
// The window is created non-resizable (no stray edge-drag on the transparent
// frameless panel), which on Windows/Linux also blocks programmatic setBounds
// sizing — so briefly flip resizable on whenever the size actually changes.
ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => {
if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) {
return
}
petOverlayWindow.setBounds({
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(80, Math.round(bounds.width)),
height: Math.max(80, Math.round(bounds.height))
})
const win = petOverlayWindow
const width = Math.max(80, Math.round(bounds.width))
const height = Math.max(80, Math.round(bounds.height))
const [curW, curH] = win.getSize()
const resizing = width !== curW || height !== curH
if (resizing && !win.isResizable()) {
win.setResizable(true)
}
win.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width, height })
if (resizing) {
win.setResizable(false)
}
})
// Click-through: the overlay window is a full rectangle but only the pet pixels
// should be interactive. The renderer toggles this as the cursor enters/leaves
@@ -5889,6 +6126,7 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
await teardownPrimaryBackendAndWait()
bootstrapFailure = null
backendStartFailure = null
bootstrapState = {
active: false,
manifest: null,
@@ -5915,6 +6153,7 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`)
}
bootstrapFailure = null
backendStartFailure = null
resetHermesConnection()
return { ok: true }
})
@@ -6283,11 +6522,21 @@ ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => {
ipcMain.handle('hermes:saveClipboardImage', async () => {
const image = clipboard.readImage()
if (!image || image.isEmpty()) {
return ''
if (image && !image.isEmpty()) {
return writeComposerImage(image.toPNG(), '.png')
}
return writeComposerImage(image.toPNG(), '.png')
// WSL2/WSLg doesn't bridge clipboard *images* from the Windows host to the
// Linux clipboard Electron reads, so a host screenshot looks empty above.
// Pull it straight off the Windows clipboard via PowerShell as a fallback.
if (IS_WSL) {
const png = readWslWindowsClipboardImage()
if (png) {
return writeComposerImage(png, '.png')
}
}
return ''
})
ipcMain.handle('hermes:normalizePreviewTarget', (_event, target, baseDir) =>
@@ -6307,7 +6556,7 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
background: payload.background,
foreground: payload.foreground
}
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
applyTitleBarOverlay(mainWindow)
})
// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).
@@ -6596,7 +6845,160 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dir
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds))
// Reveal a path in the OS file manager (Finder / Explorer / Files).
ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
const target = String(targetPath || '').trim()
if (!target) {
return false
}
try {
shell.showItemInFolder(target)
return true
} catch {
return false
}
})
// Rename a file/folder in place. The renderer passes the existing path + a new
// base name; the destination is resolved in the SAME parent dir so a rename can
// never move the item elsewhere or traverse out. Rejects on a name collision.
ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => {
const src = String(targetPath || '').trim()
const name = String(newName || '').trim()
if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
throw new Error('Invalid rename')
}
const dst = path.join(path.dirname(src), name)
if (dst === src) {
return { path: dst }
}
if (fs.existsSync(dst)) {
throw new Error(`"${name}" already exists`)
}
await fs.promises.rename(src, dst)
return { path: dst }
})
// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path
// is hardened (resolveRequestedPathForIpc) and the parent must already exist —
// this never creates directory trees or escapes the allowed roots, and content
// is size-capped so it can't be abused as a bulk-write primitive.
ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => {
const raw = String(filePath || '').trim()
if (!raw) {
throw new Error('Invalid path')
}
const text = String(content ?? '')
if (text.length > 1_000_000) {
throw new Error('Content too large')
}
const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' })
if (!directoryExists(path.dirname(resolved))) {
throw new Error('Parent directory does not exist')
}
await fs.promises.writeFile(resolved, text, 'utf8')
return { path: resolved }
})
// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete"
// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform.
ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
const target = String(targetPath || '').trim()
if (!target) {
throw new Error('Invalid delete')
}
await shell.trashItem(target)
return true
})
// Git-driven worktree management ("Start work" flow). Errors surface to the
// renderer as rejected promises so it can toast a friendly message.
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
addWorktree(repoPath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
switchBranch(repoPath, branch, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
// Compact repo status (branch, ahead/behind, change counts + files) for the
// composer coding rail. Returns null on a non-repo / remote backend so the rail
// hides cleanly rather than erroring.
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
// Codex-style review pane: list changed files for a scope, fetch one file's
// unified diff, and stage / unstage / revert. Reads return empty on failure;
// mutations reject so the renderer can toast.
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
reviewList(repoPath, scope, baseRef, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
)
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
reviewRevParse(repoPath, ref, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
reviewCommitContext(repoPath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
)
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
// no native addon). Never throws to the renderer — failures yield an empty list.
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
try {
return await scanGitRepos(roots || [], options || {})
} catch {
return []
}
})
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
if (!nodePty) {
@@ -7046,6 +7448,7 @@ app.whenReady().then(() => {
}
installMediaPermissions()
registerMediaProtocol()
installEmbedReferer()
registerDeepLinkProtocol()
ensureWslWindowsFonts()
configureSpellChecker()
@@ -30,5 +30,8 @@ test('setJsonRequestHeaders does not set Electron-restricted Content-Length', ()
setJsonRequestHeaders(request)
assert.deepEqual(headers, [['Content-Type', 'application/json']])
assert.equal(headers.some(([name]) => name.toLowerCase() === 'content-length'), false)
assert.equal(
headers.some(([name]) => name.toLowerCase() === 'content-length'),
false
)
})
+29 -1
View File
@@ -82,7 +82,35 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
worktrees: cwds => ipcRenderer.invoke('hermes:fs:worktrees', cwds),
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName),
writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content),
trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath),
git: {
worktreeList: repoPath => ipcRenderer.invoke('hermes:git:worktreeList', repoPath),
worktreeAdd: (repoPath, options) => ipcRenderer.invoke('hermes:git:worktreeAdd', repoPath, options),
worktreeRemove: (repoPath, worktreePath, options) =>
ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options),
branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch),
branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath),
repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath),
fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath),
scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options),
review: {
list: (repoPath, scope, baseRef) => ipcRenderer.invoke('hermes:git:review:list', repoPath, scope, baseRef),
diff: (repoPath, filePath, scope, baseRef, staged) =>
ipcRenderer.invoke('hermes:git:review:diff', repoPath, filePath, scope, baseRef, staged),
stage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:stage', repoPath, filePath),
unstage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:unstage', repoPath, filePath),
revert: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:revert', repoPath, filePath),
revParse: (repoPath, ref) => ipcRenderer.invoke('hermes:git:review:revParse', repoPath, ref),
commit: (repoPath, message, push) => ipcRenderer.invoke('hermes:git:review:commit', repoPath, message, push),
commitContext: repoPath => ipcRenderer.invoke('hermes:git:review:commitContext', repoPath),
push: repoPath => ipcRenderer.invoke('hermes:git:review:push', repoPath),
shipInfo: repoPath => ipcRenderer.invoke('hermes:git:review:shipInfo', repoPath),
createPr: repoPath => ipcRenderer.invoke('hermes:git:review:createPr', repoPath)
}
},
terminal: {
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),
@@ -0,0 +1,11 @@
// Pre-layout fallback for WCO right-edge reservation (--titlebar-tools-right).
// Live width comes from navigator.windowControlsOverlay in the renderer.
const OVERLAY_FALLBACK_WIDTH = 144
/** @param {{ isWindows?: boolean, isWsl?: boolean }} opts */
function nativeOverlayWidth({ isWindows = false, isWsl = false } = {}) {
return isWindows || isWsl ? OVERLAY_FALLBACK_WIDTH : 0
}
module.exports = { OVERLAY_FALLBACK_WIDTH, nativeOverlayWidth }
@@ -0,0 +1,29 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const { OVERLAY_FALLBACK_WIDTH, nativeOverlayWidth } = require('./titlebar-overlay-width.cjs')
// This static reservation is only the pre-layout FALLBACK. Once laid out the
// renderer reads the exact width from navigator.windowControlsOverlay
// (use-window-controls-overlay-width.ts) and uses these values only when the WCO
// API is unavailable.
test('Windows reserves the overlay fallback width', () => {
assert.equal(nativeOverlayWidth({ isWindows: true }), OVERLAY_FALLBACK_WIDTH)
})
test('WSLg paints the same WCO, so it reserves the same fallback width', () => {
// The original bug: WSL fell through to 0, so the right tools sat under the
// controls and the title overran into them.
assert.equal(nativeOverlayWidth({ isWsl: true }), OVERLAY_FALLBACK_WIDTH)
})
test('plain Linux and macOS reserve nothing', () => {
assert.equal(nativeOverlayWidth({ isWindows: false, isWsl: false }), 0)
assert.equal(nativeOverlayWidth(), 0)
assert.equal(nativeOverlayWidth({}), 0)
})
test('the fallback width is a sane positive pixel value', () => {
assert.ok(Number.isInteger(OVERLAY_FALLBACK_WIDTH) && OVERLAY_FALLBACK_WIDTH > 0)
})
+80 -32
View File
@@ -7,45 +7,81 @@ const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
// unconditionally, so a shallow checkout with no merge-base surfaced the bogus
// rev-list count (e.g. 12104). This asserts the new shallow/no-merge-base branch.
test('shallow checkout with no merge-base does NOT trust the bogus rev-list count', () => {
assert.equal(resolveBehindCount({
countStr: '12104', currentSha: 'aaa', targetSha: 'bbb',
isShallow: true, hasMergeBase: false,
}), 1)
assert.equal(
resolveBehindCount({
countStr: '12104',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: true,
hasMergeBase: false
}),
1
)
})
test('shallow checkout with no merge-base but identical SHA reports up-to-date', () => {
assert.equal(resolveBehindCount({
countStr: '12104', currentSha: 'abc', targetSha: 'abc',
isShallow: true, hasMergeBase: false,
}), 0)
assert.equal(
resolveBehindCount({
countStr: '12104',
currentSha: 'abc',
targetSha: 'abc',
isShallow: true,
hasMergeBase: false
}),
0
)
})
test('shallow checkout WITH a merge-base keeps the exact count (reliable)', () => {
assert.equal(resolveBehindCount({
countStr: '3', currentSha: 'aaa', targetSha: 'bbb',
isShallow: true, hasMergeBase: true,
}), 3)
assert.equal(
resolveBehindCount({
countStr: '3',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: true,
hasMergeBase: true
}),
3
)
})
test('full (non-shallow) clone keeps the exact count path unchanged', () => {
assert.equal(resolveBehindCount({
countStr: '7', currentSha: 'aaa', targetSha: 'bbb',
isShallow: false, hasMergeBase: true,
}), 7)
assert.equal(
resolveBehindCount({
countStr: '7',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: false,
hasMergeBase: true
}),
7
)
})
test('up-to-date full clone reports 0', () => {
assert.equal(resolveBehindCount({
countStr: '0', currentSha: 'x', targetSha: 'x',
isShallow: false, hasMergeBase: true,
}), 0)
assert.equal(
resolveBehindCount({
countStr: '0',
currentSha: 'x',
targetSha: 'x',
isShallow: false,
hasMergeBase: true
}),
0
)
})
test('non-numeric count falls back to 0 (defensive, unchanged behaviour)', () => {
assert.equal(resolveBehindCount({
countStr: '', currentSha: 'aaa', targetSha: 'bbb',
isShallow: false, hasMergeBase: true,
}), 0)
assert.equal(
resolveBehindCount({
countStr: '',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: false,
hasMergeBase: true
}),
0
)
})
// shouldCountCommits gates the expensive `rev-list --count` in checkUpdates().
@@ -68,12 +104,24 @@ test('full (non-shallow) clone always runs the count', () => {
// The skip path produces an empty countStr; resolveBehindCount must NOT trust
// it and must fall through to the SHA compare (mirrors the live call site).
test('skipped-count path resolves via SHA compare, never via empty countStr', () => {
assert.equal(resolveBehindCount({
countStr: '', currentSha: 'aaa', targetSha: 'bbb',
isShallow: true, hasMergeBase: false,
}), 1)
assert.equal(resolveBehindCount({
countStr: '', currentSha: 'same', targetSha: 'same',
isShallow: true, hasMergeBase: false,
}), 0)
assert.equal(
resolveBehindCount({
countStr: '',
currentSha: 'aaa',
targetSha: 'bbb',
isShallow: true,
hasMergeBase: false
}),
1
)
assert.equal(
resolveBehindCount({
countStr: '',
currentSha: 'same',
targetSha: 'same',
isShallow: true,
hasMergeBase: false
}),
0
)
})
@@ -62,7 +62,10 @@ test('resolveUnpackedRelease is null for AppImage / .deb / .rpm / dev / unresolv
assert.equal(resolveUnpackedRelease('/usr/lib/hermes/hermes', ROOT, 'linux'), null)
assert.equal(resolveUnpackedRelease('/opt/Hermes/hermes', ROOT, 'linux'), null)
// dev electron
assert.equal(resolveUnpackedRelease('/home/u/.hermes/hermes-agent/node_modules/electron/dist/electron', ROOT, 'linux'), null)
assert.equal(
resolveUnpackedRelease('/home/u/.hermes/hermes-agent/node_modules/electron/dist/electron', ROOT, 'linux'),
null
)
// empty / missing
assert.equal(resolveUnpackedRelease('', ROOT, 'linux'), null)
assert.equal(resolveUnpackedRelease(path.join(UNPACKED, 'hermes'), '', 'linux'), null)
+3 -1
View File
@@ -39,7 +39,9 @@ function canonicalGitHubRemote(url) {
}
function isSshRemote(url) {
const value = String(url || '').trim().toLowerCase()
const value = String(url || '')
.trim()
.toLowerCase()
return value.startsWith('git@') || value.startsWith('ssh://')
}
+12 -2
View File
@@ -26,7 +26,11 @@ const REQUEST_TIMEOUT_MS = 20_000
const ID_RE = /^[\w-]+\.[\w-]+$/
/** Minimal HTTPS helper with redirect-following, timeout, and a size cap. */
function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, redirectsLeft = MAX_REDIRECTS) {
function request(
url,
{ method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {},
redirectsLeft = MAX_REDIRECTS
) {
return new Promise((resolve, reject) => {
const req = https.request(url, { method, headers }, res => {
const status = res.statusCode ?? 0
@@ -42,7 +46,13 @@ function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MA
const next = new URL(res.headers.location, url).toString()
res.resume()
// Redirects to the CDN are plain GETs (drop the POST body).
resolve(request(next, { method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes }, redirectsLeft - 1))
resolve(
request(
next,
{ method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes },
redirectsLeft - 1
)
)
return
}
+19 -4
View File
@@ -26,7 +26,16 @@ const LAPTOP = [{ workArea: { x: 0, y: 0, width: 1366, height: 728 } }]
// ─── sanitizeWindowState ───────────────────────────────────────────────────
test('sanitizeWindowState rejects missing/garbage input', () => {
for (const bad of [null, undefined, 'nope', 42, {}, { width: 'x', height: 800 }, { width: NaN, height: 800 }, { width: 1000 }]) {
for (const bad of [
null,
undefined,
'nope',
42,
{},
{ width: 'x', height: 800 },
{ width: NaN, height: 800 },
{ width: 1000 }
]) {
assert.equal(sanitizeWindowState(bad), null)
}
})
@@ -112,9 +121,13 @@ test('computeWindowOptions does not clamp when displays are unknown', () => {
test('debounce coalesces a burst into one trailing run', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
let calls = 0
const d = debounce(() => { calls += 1 }, 250)
const d = debounce(() => {
calls += 1
}, 250)
d(); d(); d()
d()
d()
d()
assert.equal(calls, 0)
t.mock.timers.tick(249)
assert.equal(calls, 0)
@@ -125,7 +138,9 @@ test('debounce coalesces a burst into one trailing run', t => {
test('debounce.flush runs now and cancels the pending timer', t => {
t.mock.timers.enable({ apis: ['setTimeout'] })
let calls = 0
const d = debounce(() => { calls += 1 }, 250)
const d = debounce(() => {
calls += 1
}, 250)
d()
d.flush()
@@ -12,7 +12,8 @@ function readElectronFile(name) {
}
function requireHiddenChildOptions(source, needle) {
const index = source.indexOf(needle)
const match = needle instanceof RegExp ? needle.exec(source) : null
const index = needle instanceof RegExp ? (match?.index ?? -1) : source.indexOf(needle)
assert.notEqual(index, -1, `missing call site: ${needle}`)
const snippet = source.slice(index, index + 700)
assert.match(
@@ -28,14 +29,28 @@ test('desktop background child processes opt into hidden Windows consoles', () =
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
requireHiddenChildOptions(source, 'execFileSync(pyExe')
requireHiddenChildOptions(source, 'spawn(resolveGitBinary()')
requireHiddenChildOptions(source, /execFileSync\(\s*pyExe/)
requireHiddenChildOptions(source, /spawn\(\s*resolveGitBinary\(\)/)
requireHiddenChildOptions(source, "execFileSync('taskkill'")
requireHiddenChildOptions(source, 'spawn(command, args')
requireHiddenChildOptions(source, /spawn\(\s*command,\s*args/)
requireHiddenChildOptions(source, "spawn('curl'")
requireHiddenChildOptions(source, 'spawn(backend.command, backend.args')
requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args')
requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']")
requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/)
requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/)
assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, dashboardArgs\)/)
assert.match(source, /existing Hermes no-console Python at/)
assert.match(source, /function getNoConsoleVenvPython\(venvRoot\)/)
assert.match(source, /function toNoConsolePython\(pythonPath\)/)
assert.match(source, /function applyWindowsNoConsoleSpawnHints\(backend\)/)
assert.match(source, /function readVenvHome\(venvRoot\)/)
assert.match(source, /path\.join\(venvRoot, 'Scripts', 'pythonw\.exe'\)/)
assert.match(source, /backendStartFailure/)
assert.match(source, /HERMES_DESKTOP_READY_FILE/)
assert.match(source, /readyFile: true/)
assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/)
assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/)
assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.dashboardArgs\]/)
})
test('intentional or interactive desktop child processes stay documented', () => {
+2 -6
View File
@@ -21,8 +21,7 @@ const { execFileSync } = require('node:child_process')
// the requested value line isn't present.
function parseRegQueryValue(stdout, name) {
if (!stdout || !name) return null
const typePattern =
/^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
const typePattern = /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
for (const rawLine of String(stdout).split(/\r?\n/)) {
const line = rawLine.trim()
const match = line.match(typePattern)
@@ -47,10 +46,7 @@ function expandWindowsEnvRefs(value, env = process.env) {
// Read a User-scoped env var from HKCU\Environment. Windows-only: returns null
// off-Windows (without spawning), on any spawn error, when `reg` exits non-zero
// (the value doesn't exist), or when the value is empty.
function readWindowsUserEnvVar(
name,
{ platform = process.platform, env = process.env, exec = execFileSync } = {}
) {
function readWindowsUserEnvVar(name, { platform = process.platform, env = process.env, exec = execFileSync } = {}) {
if (platform !== 'win32' || !name) return null
let stdout
try {
@@ -1,21 +1,12 @@
const assert = require('node:assert/strict')
const { test } = require('node:test')
const {
expandWindowsEnvRefs,
parseRegQueryValue,
readWindowsUserEnvVar
} = require('./windows-user-env.cjs')
const { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } = require('./windows-user-env.cjs')
// ── parseRegQueryValue ─────────────────────────────────────────────────────
test('parseRegQueryValue extracts a REG_SZ value', () => {
const out = [
'',
'HKEY_CURRENT_USER\\Environment',
' HERMES_HOME REG_SZ F:\\Hermes\\data',
''
].join('\r\n')
const out = ['', 'HKEY_CURRENT_USER\\Environment', ' HERMES_HOME REG_SZ F:\\Hermes\\data', ''].join('\r\n')
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'F:\\Hermes\\data')
})
@@ -39,10 +30,7 @@ test('parseRegQueryValue returns null when the value line is absent', () => {
// ── expandWindowsEnvRefs ───────────────────────────────────────────────────
test('expandWindowsEnvRefs expands %VAR% case-insensitively', () => {
assert.equal(
expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }),
'C:\\Users\\jeff\\h'
)
assert.equal(expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }), 'C:\\Users\\jeff\\h')
})
test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => {
+1 -5
View File
@@ -14,11 +14,7 @@ function isPackagedInstallPath(dir, { installRoots, isPackaged }) {
return false
}
const roots = new Set(
(installRoots ?? [])
.filter(Boolean)
.map(candidate => path.resolve(String(candidate)))
)
const roots = new Set((installRoots ?? []).filter(Boolean).map(candidate => path.resolve(String(candidate))))
for (const root of roots) {
if (resolved === root) {
+4 -16
View File
@@ -13,33 +13,21 @@ const { isPackagedInstallPath } = require('./workspace-cwd.cjs')
const installRoot = path.resolve('/opt/Hermes')
test('isPackagedInstallPath returns false when not packaged', () => {
assert.equal(
isPackagedInstallPath(installRoot, { isPackaged: false, installRoots: [installRoot] }),
false
)
assert.equal(isPackagedInstallPath(installRoot, { isPackaged: false, installRoots: [installRoot] }), false)
})
test('isPackagedInstallPath flags the install root itself', () => {
assert.equal(
isPackagedInstallPath(installRoot, { isPackaged: true, installRoots: [installRoot] }),
true
)
assert.equal(isPackagedInstallPath(installRoot, { isPackaged: true, installRoots: [installRoot] }), true)
})
test('isPackagedInstallPath flags paths nested under the install root', () => {
const nested = path.join(installRoot, 'resources', 'app.asar')
assert.equal(
isPackagedInstallPath(nested, { isPackaged: true, installRoots: [installRoot] }),
true
)
assert.equal(isPackagedInstallPath(nested, { isPackaged: true, installRoots: [installRoot] }), true)
})
test('isPackagedInstallPath ignores paths outside the install root', () => {
const homeProject = path.resolve('/home/user/projects/demo')
assert.equal(
isPackagedInstallPath(homeProject, { isPackaged: true, installRoots: [installRoot] }),
false
)
assert.equal(isPackagedInstallPath(homeProject, { isPackaged: true, installRoots: [installRoot] }), false)
})
@@ -0,0 +1,92 @@
// Pull a Windows-host clipboard image from inside WSL2 via PowerShell (WSLg
// bridges text but not images). Returns PNG bytes or null; exec injectable.
const { execFileSync } = require('node:child_process')
// STA is mandatory: System.Windows.Forms.Clipboard throws ThreadStateException
// off a single-threaded apartment. We emit base64 (not raw bytes) so the PNG
// survives stdout's text decoding intact, and write with [Console]::Out.Write
// to avoid a trailing newline.
const PS_SCRIPT = [
'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
'$img = [System.Windows.Forms.Clipboard]::GetImage()',
'if ($null -eq $img) { exit 0 }',
'$ms = New-Object System.IO.MemoryStream',
'$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)',
'[Console]::Out.Write([System.Convert]::ToBase64String($ms.ToArray()))'
].join('\n')
// PowerShell's -EncodedCommand takes UTF-16LE base64. Encoding the whole script
// this way sidesteps every layer of WSL→Windows quoting (spaces, quotes,
// brackets, newlines) that plain -Command arguments would mangle.
function encodePowerShellCommand(script) {
return Buffer.from(String(script), 'utf16le').toString('base64')
}
// Locate powershell.exe. The bare name resolves through WSL's Windows-interop
// PATH on every standard WSL2 setup; the absolute fallback covers a stripped
// PATH. Returns the first candidate — execFile surfaces ENOENT if it's wrong
// and we fall back to null.
function powershellCandidates() {
return ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
}
function decodeClipboardImageBase64(stdout) {
const b64 = String(stdout || '').trim()
if (!b64) return null
let buffer
try {
buffer = Buffer.from(b64, 'base64')
} catch {
return null
}
// Guard against partial / garbage output: require a real PNG signature.
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
if (buffer.length < PNG_SIGNATURE.length || !buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
return null
}
return buffer
}
// Read the Windows clipboard image from inside WSL. Returns a PNG Buffer, or
// null when there's no image, PowerShell is unreachable, or output is invalid.
// Linux-only by contract (caller gates on IS_WSL); never throws.
function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powershellCandidates() } = {}) {
const encoded = encodePowerShellCommand(PS_SCRIPT)
for (const ps of candidates) {
try {
const stdout = exec(
ps,
['-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
{
encoding: 'utf8',
windowsHide: true,
timeout: 8000,
// A 4K screenshot base64s to a few MB; give stdout generous headroom.
maxBuffer: 64 * 1024 * 1024,
// PowerShell writes progress/CLIXML noise to stderr — ignore it.
stdio: ['ignore', 'pipe', 'ignore']
}
)
const decoded = decodeClipboardImageBase64(stdout)
if (decoded) return decoded
// Empty stdout = no image on the clipboard; stop, don't try fallbacks.
if (String(stdout || '').trim() === '') return null
} catch {
// This powershell.exe candidate is missing/failed — try the next one.
}
}
return null
}
module.exports = {
decodeClipboardImageBase64,
encodePowerShellCommand,
powershellCandidates,
readWslWindowsClipboardImage
}
@@ -0,0 +1,114 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const {
decodeClipboardImageBase64,
encodePowerShellCommand,
powershellCandidates,
readWslWindowsClipboardImage
} = require('./wsl-clipboard-image.cjs')
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
function fakePngBuffer(extraBytes = 16) {
return Buffer.concat([PNG_SIGNATURE, Buffer.alloc(extraBytes, 0x42)])
}
test('encodePowerShellCommand produces UTF-16LE base64 PowerShell can decode', () => {
const encoded = encodePowerShellCommand('Write-Output "hi"')
const roundTripped = Buffer.from(encoded, 'base64').toString('utf16le')
assert.equal(roundTripped, 'Write-Output "hi"')
})
test('decodeClipboardImageBase64 returns a Buffer for valid PNG base64', () => {
const png = fakePngBuffer()
const decoded = decodeClipboardImageBase64(png.toString('base64'))
assert.ok(Buffer.isBuffer(decoded))
assert.ok(decoded.equals(png))
})
test('decodeClipboardImageBase64 trims surrounding whitespace before decoding', () => {
const png = fakePngBuffer()
const decoded = decodeClipboardImageBase64(`\n ${png.toString('base64')} \r\n`)
assert.ok(decoded && decoded.equals(png))
})
test('decodeClipboardImageBase64 returns null for empty / whitespace input', () => {
assert.equal(decodeClipboardImageBase64(''), null)
assert.equal(decodeClipboardImageBase64(' \n '), null)
assert.equal(decodeClipboardImageBase64(null), null)
assert.equal(decodeClipboardImageBase64(undefined), null)
})
test('decodeClipboardImageBase64 rejects base64 without a PNG signature', () => {
// Valid base64, but the decoded bytes are not a PNG.
const notPng = Buffer.from('this is not a png at all').toString('base64')
assert.equal(decodeClipboardImageBase64(notPng), null)
})
test('readWslWindowsClipboardImage decodes the first candidate that returns a PNG', () => {
const png = fakePngBuffer()
const calls = []
const exec = (cmd, args) => {
calls.push({ cmd, args })
return png.toString('base64')
}
const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe'] })
assert.ok(result && result.equals(png))
assert.equal(calls.length, 1)
assert.equal(calls[0].cmd, 'powershell.exe')
// -STA is mandatory for System.Windows.Forms.Clipboard.
assert.ok(calls[0].args.includes('-STA'))
assert.ok(calls[0].args.includes('-EncodedCommand'))
})
test('readWslWindowsClipboardImage returns null and stops when stdout is empty (no image)', () => {
let count = 0
const exec = () => {
count += 1
return ''
}
const result = readWslWindowsClipboardImage({
exec,
candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
})
assert.equal(result, null)
// Empty stdout means "no image on the clipboard" — don't probe further candidates.
assert.equal(count, 1)
})
test('readWslWindowsClipboardImage falls through to the next candidate when one throws', () => {
const png = fakePngBuffer()
const seen = []
const exec = cmd => {
seen.push(cmd)
if (cmd === 'powershell.exe') {
throw Object.assign(new Error('not found'), { code: 'ENOENT' })
}
return png.toString('base64')
}
const result = readWslWindowsClipboardImage({
exec,
candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
})
assert.ok(result && result.equals(png))
assert.deepEqual(seen, ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'])
})
test('readWslWindowsClipboardImage returns null when every candidate throws', () => {
const exec = () => {
throw new Error('boom')
}
const result = readWslWindowsClipboardImage({ exec, candidates: ['a', 'b'] })
assert.equal(result, null)
})
test('powershellCandidates lists the bare name first, then the absolute fallback', () => {
const candidates = powershellCandidates()
assert.equal(candidates[0], 'powershell.exe')
assert.ok(candidates.some(c => c.endsWith('WindowsPowerShell/v1.0/powershell.exe')))
})
+11 -2
View File
@@ -18,7 +18,7 @@
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && npm run postbuild",
"postbuild": "node scripts/assert-dist-built.cjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs",
@@ -37,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -51,11 +51,17 @@
"@assistant-ui/react-streamdown": "^0.1.11",
"@audiowave/react": "^0.6.2",
"@chenglou/pretext": "^0.0.6",
"@codemirror/commands": "^6.10.4",
"@codemirror/language": "^6.12.4",
"@codemirror/language-data": "^6.5.2",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.43.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hermes/shared": "file:../shared",
"@icons-pack/react-simple-icons": "=13.11.1",
"@lezer/highlight": "^1.2.3",
"@nanostores/react": "^1.1.0",
"@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4",
@@ -75,11 +81,13 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dnd-core": "^14.0.1",
"dompurify": "^3.4.11",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.2",
"ignore": "^7.0.5",
"katex": "^0.16.45",
"leva": "^0.10.1",
"mermaid": "^11.15.0",
"motion": "^12.38.0",
"nanostores": "^1.3.0",
"node-pty": "1.1.0",
@@ -93,6 +101,7 @@
"remark-math": "^6.0.0",
"remend": "^1.3.0",
"shiki": "^4.0.2",
"simple-git": "^3.36.0",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
@@ -0,0 +1,33 @@
#!/usr/bin/env node
// bundle-electron-main.mjs — bundles electron/main.cjs into a single
// self-contained file so the nix build doesn't need to ship node_modules/.
//
// `electron` is provided by the runtime; `node-pty` is staged separately
// via stage-native-deps.cjs. `preload.cjs` is NOT require()'d by main —
// Electron loads it via path.join(__dirname, 'preload.cjs') — so it stays
// as a separate file and doesn't need bundling.
import { build } from 'esbuild'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { renameSync } from 'node:fs'
const here = dirname(fileURLToPath(import.meta.url))
const root = resolve(here, '..')
const entry = resolve(root, 'electron/main.cjs')
const tmp = resolve(root, 'electron/main.bundled.cjs')
await build({
entryPoints: [entry],
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node20',
outfile: tmp,
external: ['electron', 'node-pty'],
logLevel: 'info'
})
// Overwrite the original with the bundled version.
renameSync(tmp, entry)
console.log(`bundled ${entry}`)
+3 -2
View File
@@ -3,10 +3,11 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { Codicon } from '@/components/ui/codicon'
import { FadeText } from '@/components/ui/fade-text'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { type Translations, useI18n } from '@/i18n'
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import {
@@ -209,7 +210,7 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
if (tree.length === 0) {
return (
<div className="grid place-items-center gap-3 py-12 text-center">
<Sparkles className="size-6 text-muted-foreground/60" />
<Codicon className="text-muted-foreground/60" name="hubot" size="1.5rem" />
<p className="text-sm font-medium text-foreground/90">{t.agents.emptyTitle}</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">{t.agents.emptyDesc}</p>
</div>
+15 -11
View File
@@ -477,17 +477,20 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}, [artifacts])
const openArtifact = useCallback(async (href: string) => {
try {
if (window.hermesDesktop?.openExternal) {
await window.hermesDesktop.openExternal(href)
} else {
window.open(href, '_blank', 'noopener,noreferrer')
const openArtifact = useCallback(
async (href: string) => {
try {
if (window.hermesDesktop?.openExternal) {
await window.hermesDesktop.openExternal(href)
} else {
window.open(href, '_blank', 'noopener,noreferrer')
}
} catch (err) {
notifyError(err, a.openFailed)
}
} catch (err) {
notifyError(err, a.openFailed)
}
}, [a])
},
[a]
)
const markImageFailed = useCallback((id: string) => {
setFailedImageIds(current => {
@@ -839,7 +842,8 @@ const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [
{
Cell: PrimaryCell,
bodyClassName: 'p-0',
header: (filter, a) => (filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault),
header: (filter, a) =>
filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault,
id: 'primary',
width: filter => (filter === 'link' ? 'w-[50%]' : 'w-[35%]')
},
@@ -2,9 +2,9 @@ import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { I18nProvider } from '@/i18n/context'
import type { ComposerAttachment } from '@/store/composer'
import { AttachmentList } from './attachments'
import type { ComposerAttachment } from '@/store/composer'
function makeAttachment(id: string, label = 'test.pdf'): ComposerAttachment {
return { id, kind: 'file', label }
@@ -32,7 +32,10 @@ describe('AttachmentList', () => {
it('renders empty list without error', () => {
renderWithI18n(<AttachmentList attachments={[]} />)
const container = screen.getByTestId?.('composer-attachments') ?? document.querySelector('[data-slot="composer-attachments"]')
const container =
screen.getByTestId?.('composer-attachments') ?? document.querySelector('[data-slot="composer-attachments"]')
expect(container).toBeDefined()
})
@@ -55,10 +58,7 @@ describe('AttachmentList', () => {
})
it('does not crash when attachments array contains null entries', () => {
const attachments = [
null as unknown as ComposerAttachment,
makeAttachment('a', 'valid.txt')
]
const attachments = [null as unknown as ComposerAttachment, makeAttachment('a', 'valid.txt')]
expect(() => {
renderWithI18n(<AttachmentList attachments={attachments} />)
@@ -73,7 +73,11 @@ export function ContextMenu({
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
{c.images}
</ContextMenuItem>
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
<ContextMenuItem
disabled={!onPasteClipboardImage}
icon={Clipboard}
onSelect={onPasteClipboardImage ? () => void onPasteClipboardImage() : undefined}
>
{c.pasteImage}
</ContextMenuItem>
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
@@ -167,7 +171,7 @@ interface ContextMenuItemProps {
interface ContextMenuProps {
onInsertText: (text: string) => void
onOpenUrlDialog: () => void
onPasteClipboardImage?: () => void
onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise<boolean> | void
onPickFiles?: () => void
onPickFolders?: () => void
onPickImages?: () => void
@@ -59,8 +59,10 @@ function Harness({
}
const editor = editorRef.current
if (editor) {
const domText = composerPlainText(editor)
if (domText !== draftRef.current) {
draftRef.current = domText
setDraft(domText)
@@ -127,9 +129,11 @@ function Harness({
describe('composer Enter submit — live DOM vs stale composer state (#39630)', () => {
it('sends the just-typed text on Enter even when composer state has not synced', async () => {
const onSubmit = vi.fn()
const { getByTestId } = render(
<Harness onCancel={vi.fn()} onDrain={vi.fn()} onQueue={vi.fn()} onSubmit={onSubmit} />
)
const editor = getByTestId('editor')
// Fast typing: the DOM has the text but NO input event fired, so `draft`
@@ -146,9 +150,11 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
const onQueue = vi.fn()
const onDrain = vi.fn()
const onCancel = vi.fn()
const { getByTestId } = render(
<Harness busy onCancel={onCancel} onDrain={onDrain} onQueue={onQueue} onSubmit={vi.fn()} queued={['queued-1']} />
)
const editor = getByTestId('editor')
await act(async () => {
@@ -165,9 +171,11 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
const onCancel = vi.fn()
const onSubmit = vi.fn()
const onQueue = vi.fn()
const { getByTestId } = render(
<Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSubmit={onSubmit} />
)
const editor = getByTestId('editor')
await act(async () => {
@@ -183,9 +191,11 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {
const onDrain = vi.fn()
const onSubmit = vi.fn()
const { getByTestId } = render(
<Harness onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
)
const editor = getByTestId('editor')
await act(async () => {
@@ -200,9 +210,18 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
it('keeps reconnect drafts editable but blocks Enter submit until the gateway returns', async () => {
const onSubmit = vi.fn()
const onDrain = vi.fn()
const { getByTestId } = render(
<Harness disabled onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
<Harness
disabled
onCancel={vi.fn()}
onDrain={onDrain}
onQueue={vi.fn()}
onSubmit={onSubmit}
queued={['queued-1']}
/>
)
const editor = getByTestId('editor')
await act(async () => {
+24 -1
View File
@@ -10,8 +10,8 @@
* steal focus from the composer effect.
*/
import { RICH_INPUT_SLOT } from './rich-editor'
import type { InlineRefInput } from './inline-refs'
import { RICH_INPUT_SLOT } from './rich-editor'
export type ComposerTarget = 'edit' | 'main'
export type ComposerInsertMode = 'block' | 'inline'
@@ -34,8 +34,14 @@ interface InsertRefsDetail {
const FOCUS_EVENT = 'hermes:composer-focus'
const INSERT_EVENT = 'hermes:composer-insert'
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
const SUBMIT_EVENT = 'hermes:composer-submit'
const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle'
interface SubmitDetail {
target: ComposerTarget
text: string
}
let activeTarget: ComposerTarget = 'main'
const resolve = (target: ComposerTarget | 'active') => (target === 'active' ? activeTarget : target)
@@ -106,6 +112,23 @@ export const requestComposerInsertRefs = (
export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) =>
subscribe<InsertRefsDetail>(INSERT_REFS_EVENT, handler)
/** Submit a prompt through a composer as if the user typed + sent it. Lets
* external panels (e.g. the review pane's "let the agent ship it" button) hand
* the agent a task without the user round-tripping through the input. */
export const requestComposerSubmit = (
text: string,
{ target = 'active' }: { target?: ComposerTarget | 'active' } = {}
) => {
const trimmed = text.trim()
if (trimmed) {
dispatch<SubmitDetail>(SUBMIT_EVENT, { target: resolve(target), text: trimmed })
}
}
export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) =>
subscribe<SubmitDetail>(SUBMIT_EVENT, handler)
/** Toggle the active composer's voice conversation — the `composer.voice`
* hotkey (Ctrl+B) reaching into the composer that owns the voice state. */
export const requestVoiceToggle = () => dispatch<{ at: number }>(VOICE_TOGGLE_EVENT, { at: Date.now() })
@@ -33,7 +33,7 @@ export function HelpHint() {
<Section title={c.hotkeys}>
{COMPOSER_HOTKEY_ROWS.map(row => (
<HotkeyRow description={c.hotkeyDescs[row.id] ?? ''} combos={[...row.combos]} key={row.id} />
<HotkeyRow combos={[...row.combos]} description={c.hotkeyDescs[row.id] ?? ''} key={row.id} />
))}
</Section>
@@ -59,7 +59,11 @@ function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
return new Error(copy.microphoneStartFailed)
}
export function useMicRecorder(copy: MicRecorderErrorCopy): { handle: MicRecorderHandle; level: number; recording: boolean } {
export function useMicRecorder(copy: MicRecorderErrorCopy): {
handle: MicRecorderHandle
level: number
recording: boolean
} {
const [level, setLevel] = useState(0)
const [recording, setRecording] = useState(false)
@@ -1,19 +1,12 @@
import {
type PointerEvent as ReactPointerEvent,
type RefObject,
useCallback,
useEffect,
useRef,
useState
} from 'react'
import { type PointerEvent as ReactPointerEvent, type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import {
POPOUT_ESTIMATED_HEIGHT,
POPOUT_WIDTH_REM,
readPopoutBounds,
setComposerPopoutPosition,
type PopoutPosition,
type PopoutSize
type PopoutSize,
readPopoutBounds,
setComposerPopoutPosition
} from '@/store/composer-popout'
// Floating surface long-press before it becomes draggable (the 5px platform drags
@@ -80,6 +73,7 @@ function dockProximityOf(rect: DOMRect) {
const verticalGap = window.innerHeight - DOCK_ZONE_BOTTOM_PX - rect.bottom
const v = verticalGap <= 0 ? 1 : Math.max(0, 1 - verticalGap / DOCK_VERTICAL_FALLOFF_PX)
const h =
horizontalDist <= DOCK_ZONE_CENTER_TOLERANCE_PX
? 1
@@ -98,12 +98,14 @@ export function useSlashCompletions(options: {
const matches = (
needle
? $sessions.get().filter(
session =>
sessionTitle(session).toLowerCase().includes(needle) ||
(session.preview ?? '').toLowerCase().includes(needle) ||
session.id.toLowerCase().includes(needle)
)
? $sessions
.get()
.filter(
session =>
sessionTitle(session).toLowerCase().includes(needle) ||
(session.preview ?? '').toLowerCase().includes(needle) ||
session.id.toLowerCase().includes(needle)
)
: $sessions.get()
).slice(0, SESSION_INLINE_LIMIT)
@@ -135,9 +137,7 @@ export function useSlashCompletions(options: {
// Prefer the categorized layout so the popover renders section headers
// (Session, Tools & Skills, ...). Fall back to the flat list when the
// backend didn't categorize.
const sections = catalog.categories?.length
? catalog.categories
: [{ name: '', pairs: catalog.pairs ?? [] }]
const sections = catalog.categories?.length ? catalog.categories : [{ name: '', pairs: catalog.pairs ?? [] }]
const items = sections.flatMap(section =>
section.pairs.map(([command, meta]) => ({
@@ -151,10 +151,9 @@ export function useSlashCompletions(options: {
return { items, query }
}
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>(
'complete.slash',
{ text }
)
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>('complete.slash', {
text
})
// Arg-completion items (replace_from > 1) carry just the arg stub —
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`
@@ -220,22 +220,25 @@ export function useVoiceConversation({
}
}, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed])
const speak = useCallback(async (text: string) => {
setStatus('speaking')
const speak = useCallback(
async (text: string) => {
setStatus('speaking')
try {
await playSpeechText(text, { source: 'voice-conversation' })
} catch (error) {
notifyError(error, voiceCopy.playbackFailed)
} finally {
if (enabledRef.current) {
pendingStartRef.current = true
setStatus('idle')
} else {
setStatus('idle')
try {
await playSpeechText(text, { source: 'voice-conversation' })
} catch (error) {
notifyError(error, voiceCopy.playbackFailed)
} finally {
if (enabledRef.current) {
pendingStartRef.current = true
setStatus('idle')
} else {
setStatus('idle')
}
}
}
}, [voiceCopy.playbackFailed])
},
[voiceCopy.playbackFailed]
)
const start = useCallback(async () => {
if (!onTranscribeAudio) {
@@ -255,7 +258,14 @@ export function useVoiceConversation({
consumePendingResponse()
pendingStartRef.current = true
await startListening()
}, [consumePendingResponse, onFatalError, onTranscribeAudio, startListening, voiceCopy.configureSpeechToText, voiceCopy.unavailable])
}, [
consumePendingResponse,
onFatalError,
onTranscribeAudio,
startListening,
voiceCopy.configureSpeechToText,
voiceCopy.unavailable
])
const end = useCallback(async () => {
pendingStartRef.current = false
+190 -22
View File
@@ -45,8 +45,8 @@ import {
$composerPoppedOut,
POPOUT_WIDTH_REM,
readPopoutBounds,
setComposerPoppedOut,
setComposerPopoutPosition
setComposerPopoutPosition,
setComposerPoppedOut
} from '@/store/composer-popout'
import {
$queuedPromptsBySession,
@@ -60,8 +60,11 @@ import {
updateQueuedPrompt
} from '@/store/composer-queue'
import { $statusItemsBySession } from '@/store/composer-status'
import { $previewStatusBySession } from '@/store/preview-status'
import { notify } from '@/store/notifications'
import { $previewStatusBySession } from '@/store/preview-status'
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
import { $activeSessionAwaitingInput } from '@/store/prompts'
import { toggleReview } from '@/store/review'
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { isSecondaryWindow } from '@/store/windows'
@@ -80,6 +83,7 @@ import {
onComposerFocusRequest,
onComposerInsertRefsRequest,
onComposerInsertRequest,
onComposerSubmitRequest,
onComposerVoiceToggleRequest
} from './focus'
import { HelpHint } from './help-hint'
@@ -108,6 +112,7 @@ import {
slashChipElement
} from './rich-editor'
import { ComposerStatusStack } from './status-stack'
import { CodingStatusRow } from './status-stack/coding-row'
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
@@ -225,6 +230,11 @@ export function ChatBar({
const statusItemsBySession = useStore($statusItemsBySession)
const previewStatusBySession = useStore($previewStatusBySession)
const scrolledUp = useStore($threadScrolledUp)
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
// not interrupt it — there's nothing actively running to stop, and stopping
// would discard a question the user may want to come back to. The blocking
// prompt owns its own dismissal (Skip, Reject, dialog close).
const awaitingInput = useStore($activeSessionAwaitingInput)
// Pop-out is a shared, persisted state — but secondary windows (the Ctrl+Shift+N
// tiny window, subagent watch windows) always start docked and can't pop out:
// a floating composer makes no sense in a single-session side window, and it
@@ -274,14 +284,17 @@ export function ChatBar({
poppedOut ? handleComposerDock() : handleComposerPopOut()
}, [handleComposerDock, handleComposerPopOut, poppedOut])
const { dockProximity, dragging, onPointerDown: onComposerGesturePointerDown } =
useComposerPopoutGestures({
composerRef,
onDock: handleComposerDock,
onPopOut: handleComposerPopOut,
poppedOut,
position: popoutPosition
})
const {
dockProximity,
dragging,
onPointerDown: onComposerGesturePointerDown
} = useComposerPopoutGestures({
composerRef,
onDock: handleComposerDock,
onPopOut: handleComposerPopOut,
poppedOut,
position: popoutPosition
})
const draftRef = useRef(draft)
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
@@ -780,6 +793,16 @@ export function ChatBar({
if (!pastedText) {
event.preventDefault()
// Under WSL2/WSLg the Windows host clipboard doesn't bridge *images* to
// the Linux clipboard the DOM paste event reads, so a host screenshot
// arrives as an empty paste (no blobs, no text). Fall back to the main
// process, which pulls the image straight off the Windows clipboard.
// Silent so a genuinely-empty paste doesn't pop a "no image" warning.
if (onPasteClipboardImage) {
triggerHaptic('selection')
void onPasteClipboardImage({ silent: true })
}
return
}
@@ -812,8 +835,7 @@ export function ChatBar({
// Suppress the "No matches" empty state once a slash command is past its name:
// a no-arg command has nothing to offer, and a fully-typed arg commits on
// Space/Tab — neither should dead-end on a popover.
const argStageEmpty =
trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
const argStageEmpty = trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
const closeTrigger = () => {
setTrigger(null)
@@ -840,7 +862,14 @@ export function ChatBar({
id: text,
type: 'slash',
label: text.slice(1),
metadata: { command: slashCommandToken(trigger.query), display: text, meta: '', group: '', action: '', rawText: text }
metadata: {
command: slashCommandToken(trigger.query),
display: text,
meta: '',
group: '',
action: '',
rawText: text
}
})
}
@@ -980,10 +1009,7 @@ export function ChatBar({
// Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large
// drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through.
if (
(event.key === 'Backspace' || event.key === 'Delete') &&
deleteSelectionInEditor(event.currentTarget)
) {
if ((event.key === 'Backspace' || event.key === 'Delete') && deleteSelectionInEditor(event.currentTarget)) {
event.preventDefault()
flushEditorToDraft(event.currentTarget)
@@ -1194,8 +1220,10 @@ export function ChatBar({
return
}
// Otherwise Esc interrupts the running turn (Stop-button parity).
if (busy) {
// Otherwise Esc interrupts the running turn (Stop-button parity) — unless
// the turn is parked waiting on the user, where Esc must not discard the
// pending prompt.
if (busy && !awaitingInput) {
event.preventDefault()
triggerHaptic('cancel')
void Promise.resolve(onCancel())
@@ -1351,6 +1379,80 @@ export function ChatBar({
}
}, [setComposerText])
// Hand a worktree off to the controller: open a fresh session anchored there,
// carrying the composer draft as its first turn. Clearing here means the draft
// travels to the new session instead of getting stashed under this one.
const openInWorktree = useCallback(
(path: string) => {
const text = draftRef.current
clearDraft()
clearComposerAttachments()
requestStartWorkSession(path, text)
},
[clearDraft]
)
// Branch off into a NEW worktree (base = branch name, or current HEAD). A
// create failure throws back to the row (which toasts) before we touch the
// draft; a missing cwd / remote backend no-ops (the row hides the affordance).
const handleBranchOff = useCallback(
async (branch: string, base?: string) => {
const repoPath = cwd?.trim()
const result = repoPath && (await startWorkInRepo(repoPath, { base, branch, name: branch }))
if (result) {
openInWorktree(result.path)
}
},
[cwd, openInWorktree]
)
// Convert an EXISTING branch into a fresh worktree + session (no new branch).
// Mirrors handleBranchOff's hand-off: create the worktree, then open a session
// anchored there carrying the draft.
const handleConvertBranch = useCallback(
async (branch: string, path?: null | string, isDefault?: boolean) => {
if (path?.trim()) {
openInWorktree(path)
return
}
const repoPath = cwd?.trim()
if (repoPath && isDefault) {
await switchBranchInRepo(repoPath, branch)
openInWorktree(repoPath)
return
}
const result = repoPath && (await startWorkInRepo(repoPath, { existingBranch: branch }))
if (result) {
openInWorktree(result.path)
}
},
[cwd, openInWorktree]
)
const handleListBranches = useCallback(async () => {
const repoPath = cwd?.trim()
return repoPath ? listRepoBranches(repoPath) : []
}, [cwd])
const handleSwitchBranch = useCallback(
async (branch: string) => {
const repoPath = cwd?.trim()
if (repoPath) {
await switchBranchInRepo(repoPath, branch)
}
},
[cwd]
)
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
draftRef.current = text
setComposerText(text)
@@ -1674,6 +1776,46 @@ export function ChatBar({
}
}, [autoDrainNext, busy, queuedPrompts.length])
// Esc cancels the in-flight turn when the CHAT has focus — not just the
// composer input (which has its own handler above). Clicking into the
// transcript and hitting Esc now stops the run, matching the Stop button.
// Intentional only: we bail if (a) the composer/another field already
// handled Esc (defaultPrevented), (b) focus is in any input/textarea/
// contenteditable (you're typing, not stopping), or (c) a dialog/popover is
// open — Esc must close that overlay, never double as canceling the stream
// behind it. A latest-handler ref keeps the listener registered once.
const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {})
escCancelRef.current = (event: globalThis.KeyboardEvent) => {
// `awaitingInput`: the turn is parked on a clarify / approval / sudo / secret
// prompt, which owns Esc (or is meant to persist) — never cancel the stream
// out from under it.
if (event.key !== 'Escape' || event.defaultPrevented || !busy || awaitingInput) {
return
}
const active = document.activeElement as HTMLElement | null
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
return
}
if (document.querySelector('[role="dialog"],[role="alertdialog"],[data-radix-popper-content-wrapper]')) {
return
}
event.preventDefault()
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
useEffect(() => {
const onKeyDown = (event: globalThis.KeyboardEvent) => escCancelRef.current(event)
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
// Queue-edit cleanup: on session swap the scope effect already stashed the
// edit snapshot; only restore into the composer when still on the same scope.
useEffect(() => {
@@ -1706,6 +1848,22 @@ export function ChatBar({
.catch(restore)
}
// External "submit this prompt" requests (e.g. the review pane's agent-ship
// button) route through the same send path. A ref keeps the listener stable
// while always calling the latest dispatchSubmit closure.
const dispatchSubmitRef = useRef(dispatchSubmit)
dispatchSubmitRef.current = dispatchSubmit
useEffect(
() =>
onComposerSubmitRequest(({ target, text }) => {
if (target === 'main' && !inputDisabled) {
dispatchSubmitRef.current(text)
}
}),
[inputDisabled]
)
const submitDraft = () => {
if (disabled) {
return
@@ -2099,7 +2257,7 @@ export function ChatBar({
<div className="relative w-full rounded-[inherit]">
<div
className={cn(
'group/composer-surface relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out focus-within:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
'group/composer-surface relative z-4 isolate grid grid-rows-[auto_1fr] overflow-hidden rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))]',
COMPOSER_DROP_FADE_CLASS,
dragActive && COMPOSER_DROP_ACTIVE_CLASS
)}
@@ -2114,10 +2272,20 @@ export function ChatBar({
composerSurfaceGlass
)}
/>
<CodingStatusRow
onBranchOff={handleBranchOff}
onConvertBranch={handleConvertBranch}
onListBranches={handleListBranches}
onOpen={toggleReview}
onOpenWorktree={openInWorktree}
onSwitchBranch={handleSwitchBranch}
/>
<div
className={cn(
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100' : 'opacity-100'
scrolledUp
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100'
: 'opacity-100'
)}
data-slot="composer-fade"
>
@@ -3,12 +3,7 @@ import { contextPath } from '@/lib/chat-runtime'
import type { DroppedFile } from '../hooks/use-composer-actions'
import {
composerPlainText,
normalizeComposerEditorDom,
placeCaretEnd,
refChipElement
} from './rich-editor'
import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipElement } from './rich-editor'
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
export type InlineRefInput = string | { kind: string; label?: string; value: string }
@@ -159,6 +154,7 @@ export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonl
editor.focus({ preventScroll: true })
const selection = window.getSelection()
const range =
selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer)
? selection.getRangeAt(0)
@@ -94,13 +94,7 @@ export function ModelPill({
<DropdownMenu onOpenChange={setOpen} open={open}>
<Tip label={title} side="top">
<DropdownMenuTrigger asChild>
<Button
aria-label={title}
className={pillClass}
disabled={disabled}
type="button"
variant="ghost"
>
<Button aria-label={title} className={pillClass} disabled={disabled} type="button" variant="ghost">
{label}
</Button>
</DropdownMenuTrigger>
@@ -0,0 +1,469 @@
import { useStore } from '@nanostores/react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { StatusRow } from '@/components/chat/status-row'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { DiffCount } from '@/components/ui/diff-count'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { SanitizedInput } from '@/components/ui/sanitized-input'
import type { HermesGitBranch } from '@/global'
import { useI18n } from '@/i18n'
import { gitRef } from '@/lib/sanitize'
import { $repoStatus, $repoWorktrees } from '@/store/coding-status'
import { notifyError } from '@/store/notifications'
import { $newWorktreeRequest } from '@/store/projects'
// Tiny uppercase section header, matching the composer "+" menu's labels.
const MENU_SECTION = 'text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)'
interface BranchActionCopy {
branchCreateWorktree: string
branchOpenExisting: string
branchSwitchHome: string
}
const branchActionLabel = (branch: HermesGitBranch, copy: BranchActionCopy) => {
if (branch.checkedOut) {
return copy.branchOpenExisting
}
return branch.isDefault ? copy.branchSwitchHome : copy.branchCreateWorktree
}
interface CodingStatusRowProps {
/** Branch the current draft off into a fresh worktree + session, based on
* `base` (a branch name; omitted = current HEAD). The composer owns the
* draft, so it supplies the orchestration; the row just collects the new
* branch name + base. Omitted (e.g. remote backend) hides the affordance. */
onBranchOff?: (branch: string, base?: string) => Promise<void>
/** Check an existing branch out into a fresh worktree + session (no new
* branch). Drives the dialog's "convert a branch" picker. */
onConvertBranch?: (branch: string, path?: null | string, isDefault?: boolean) => Promise<void>
/** List the repo's local branches for the "convert a branch" picker. */
onListBranches?: () => Promise<HermesGitBranch[]>
/** Open the review pane (changed files + diffs). */
onOpen?: () => void
/** Jump into an existing worktree (open a fresh session anchored there). */
onOpenWorktree?: (path: string) => void
/** Switch the current repo checkout to another branch. */
onSwitchBranch?: (branch: string) => Promise<void>
}
/**
* The always-on coding-context row, the BASE of the composer status stack:
* current branch, dirty summary (+/-), and ahead/behind. A touch more prominent
* than the per-turn rows above it (larger branch label, accent glyph), and the
* entry point to the review pane. Hidden when the active session isn't in a
* local git repo (the probe returns null).
*/
export const CodingStatusRow = memo(function CodingStatusRow({
onBranchOff,
onConvertBranch,
onListBranches,
onOpen,
onOpenWorktree,
onSwitchBranch
}: CodingStatusRowProps) {
const { t } = useI18n()
const s = t.statusStack.coding
const p = t.sidebar.projects
const status = useStore($repoStatus)
const worktrees = useStore($repoWorktrees)
const [branchOpen, setBranchOpen] = useState(false)
const [branchName, setBranchName] = useState('')
const [branchBase, setBranchBase] = useState<string | undefined>(undefined)
const [branchPending, setBranchPending] = useState(false)
const [convertMode, setConvertMode] = useState(false)
const [branches, setBranches] = useState<HermesGitBranch[]>([])
const [branchesLoading, setBranchesLoading] = useState(false)
const loadBranches = useCallback(async () => {
if (!onListBranches) {
return
}
setBranchesLoading(true)
try {
setBranches(await onListBranches())
} catch {
setBranches([])
} finally {
setBranchesLoading(false)
}
}, [onListBranches])
// Open the name dialog for a chosen base. Deferred so the dropdown finishes
// closing before the dialog grabs focus (Radix focus-trap handoff races
// otherwise).
const startBranch = (base: string | undefined) => {
setBranchBase(base)
setBranchName('')
setConvertMode(false)
setTimeout(() => setBranchOpen(true), 0)
}
const startConvert = () => {
setBranchBase(undefined)
setBranchName('')
setConvertMode(true)
void loadBranches()
setTimeout(() => setBranchOpen(true), 0)
}
const enterConvert = () => {
setConvertMode(true)
void loadBranches()
}
const convertBranch = async (branch: HermesGitBranch) => {
if (branchPending || !branch || !onConvertBranch) {
return
}
setBranchPending(true)
try {
await onConvertBranch(branch.name, branch.worktreePath, branch.isDefault)
setBranchOpen(false)
} catch (err) {
notifyError(err, p.startWorkFailed)
} finally {
setBranchPending(false)
}
}
// Global ⌘⇧B (workspace.newWorktree): open the name dialog for a worktree off
// current HEAD. The rail only renders inside a repo, so the hotkey naturally
// no-ops elsewhere. Guarded by a token ref so it fires on the keypress, not on
// mount or unrelated re-renders.
const worktreeReq = useStore($newWorktreeRequest)
const lastWorktreeReqRef = useRef(worktreeReq)
useEffect(() => {
if (worktreeReq === lastWorktreeReqRef.current) {
return
}
lastWorktreeReqRef.current = worktreeReq
if (!onBranchOff) {
return
}
setBranchBase(undefined)
setBranchName('')
setConvertMode(false)
setBranchOpen(true)
}, [onBranchOff, worktreeReq])
const submitBranch = async () => {
const branch = branchName.trim()
if (branchPending || !branch || !onBranchOff) {
return
}
setBranchPending(true)
try {
await onBranchOff(branch, branchBase)
setBranchOpen(false)
setBranchName('')
} catch (err) {
notifyError(err, p.startWorkFailed)
} finally {
setBranchPending(false)
}
}
const switchToBranch = async (branch: string) => {
if (!onSwitchBranch) {
return
}
try {
await onSwitchBranch(branch)
} catch (err) {
notifyError(err, s.switchFailed(branch))
}
}
if (!status) {
return null
}
const branchLabel = status.detached ? s.detached : status.branch || s.noBranch
// The kebab offers branching off the trunk and/or the current branch. The
// worktree-add bases the new branch on `base` (a branch name; undefined =
// current HEAD). We dedupe so "on main" shows a single trunk entry, and fall
// back to a plain off-HEAD branch when no trunk is detected.
const current = status.detached ? null : status.branch
const branchTargets: { base: string | undefined; label: string }[] = []
// Current branch first (the 99% "branch off where I am"), then the trunk just
// below it ("New branch from main"), deduped when they're the same.
if (current) {
branchTargets.push({ base: current, label: s.branchOffFrom(current) })
}
if (status.defaultBranch && status.defaultBranch !== current) {
branchTargets.push({ base: status.defaultBranch, label: s.branchOffFrom(status.defaultBranch) })
}
if (branchTargets.length === 0) {
branchTargets.push({ base: undefined, label: s.newBranch })
}
const switchTarget =
onSwitchBranch && current && status.defaultBranch && status.defaultBranch !== current ? status.defaultBranch : null
// Other worktrees to jump into — everything except the one we're already in
// (matched by its checked-out branch) and the bare/main placeholder entry.
const otherWorktrees = onOpenWorktree
? worktrees.filter(w => w.path && !w.detached && w.branch && w.branch !== current)
: []
const hasLineDelta = status.added > 0 || status.removed > 0
// Untracked files carry no line delta vs HEAD, so surface them as a count when
// they're the only change (otherwise +/- tells the story).
const untrackedOnly = !hasLineDelta && status.untracked > 0
return (
<>
<StatusRow
// The base "where am I working" strip is part of the composer surface
// itself, so it inherits the composer's width and clipped top radius.
className="coding-status-bar min-h-7 rounded-t-[inherit] rounded-b-none border-b border-(--ui-stroke-tertiary) px-3.5 py-1.5 hover:bg-transparent"
// Static branch glyph — never the loading spinner. This row only renders
// once `status` exists, so a spinner here only ever fired on *refreshes*
// of an already-loaded repo (window focus, turn settle), reading as an
// annoying icon "blip" with no first-load value. Refreshes are silent.
leading={<Codicon className="text-(--ui-green)" name="git-branch" size="0.8rem" />}
onActivate={onOpen}
>
<div className="flex min-w-0 flex-1 items-center gap-1">
<span
className="min-w-0 truncate text-xs font-normal text-muted-foreground/92 transition-colors group-hover/status-row:text-foreground/90"
title={branchLabel}
>
{branchLabel}
</span>
{/* Branch actions kebab — same pattern as the session/worktree rows.
ALWAYS laid out; only its opacity flips on hover/focus/open, so
revealing it never reflows the row (no layout shift). pointer-events
follow opacity so the invisible trigger isn't clickable at rest. */}
{onBranchOff && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={s.newBranch}
className="pointer-events-none size-4 shrink-0 text-muted-foreground/60 opacity-0 transition hover:text-foreground group-hover/status-row:pointer-events-auto group-hover/status-row:opacity-100 group-focus-within/status-row:pointer-events-auto group-focus-within/status-row:opacity-100 data-[state=open]:pointer-events-auto data-[state=open]:opacity-100"
onClick={event => event.stopPropagation()}
onKeyDown={event => {
// The row's onActivate also fires on Enter/Space; keep it from
// opening the review pane when the kebab is the focus target.
if (event.key === 'Enter' || event.key === ' ') {
event.stopPropagation()
}
}}
size="icon-xs"
variant="ghost"
>
<Codicon name="kebab-vertical" size="0.8rem" />
</Button>
</DropdownMenuTrigger>
{/* The row sits at the bottom of the screen (above the composer),
so the menu opens upward. */}
<DropdownMenuContent align="end" className="w-60" side="top" sideOffset={6}>
<DropdownMenuLabel className={MENU_SECTION}>{s.newBranch}</DropdownMenuLabel>
{branchTargets.map(target => (
<DropdownMenuItem key={target.base ?? '__head__'} onSelect={() => startBranch(target.base)}>
<span className="truncate">{target.label}</span>
</DropdownMenuItem>
))}
{switchTarget && (
<DropdownMenuItem onSelect={() => void switchToBranch(switchTarget)}>
<span className="truncate">{s.switchTo(switchTarget)}</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuLabel className={MENU_SECTION}>{s.worktrees}</DropdownMenuLabel>
{otherWorktrees.map(worktree => (
<DropdownMenuItem key={worktree.path} onSelect={() => onOpenWorktree?.(worktree.path)}>
<span className="truncate">{worktree.branch}</span>
</DropdownMenuItem>
))}
{/* Create a fresh worktree off the current HEAD (the generic
"spin up a worktree here", mirroring the sidebar's + button). */}
<DropdownMenuItem onSelect={() => startBranch(undefined)}>
<span className="truncate">{p.startWork}</span>
</DropdownMenuItem>
{/* Check an EXISTING branch out into a worktree (no new branch). */}
{onConvertBranch && (
<DropdownMenuItem onSelect={() => startConvert()}>
<span className="truncate">{p.convertBranch}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{(status.ahead > 0 || status.behind > 0) && (
<span className="ml-auto flex shrink-0 items-center gap-1.5 text-[0.68rem] leading-4 text-muted-foreground/75 tabular-nums">
{status.ahead > 0 && (
<span className="flex items-center gap-0.5" title={s.ahead(status.ahead)}>
<span aria-hidden>↑</span>
{status.ahead}
</span>
)}
{status.behind > 0 && (
<span className="flex items-center gap-0.5" title={s.behind(status.behind)}>
<span aria-hidden>↓</span>
{status.behind}
</span>
)}
</span>
)}
{hasLineDelta ? (
<DiffCount
added={status.added}
className={`text-[0.72rem] leading-4 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
removed={status.removed}
/>
) : untrackedOnly ? (
<span
className={`shrink-0 text-[0.72rem] leading-4 text-amber-500/90 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
>
{s.changed(status.untracked)}
</span>
) : null}
</StatusRow>
<Dialog onOpenChange={open => !branchPending && setBranchOpen(open)} open={branchOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{convertMode ? p.convertBranchTitle : p.newWorktreeTitle}</DialogTitle>
<DialogDescription>
{convertMode ? p.convertBranchDesc : p.newWorktreeDesc}
{!convertMode && branchBase && (
<span className="mt-1 block text-(--ui-text-secondary)">{s.branchOffFrom(branchBase)}</span>
)}
</DialogDescription>
</DialogHeader>
{convertMode ? (
<Command
className="rounded-md border border-(--ui-stroke-tertiary)"
// The branch name is the authoritative key; filter on it directly.
filter={(value, search) => (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}
>
<CommandInput autoFocus disabled={branchPending} placeholder={p.convertBranchPlaceholder} />
<CommandList className="max-h-64">
<CommandEmpty>{branchesLoading ? p.branchesLoading : p.noBranches}</CommandEmpty>
<CommandGroup>
{branches.map(branch => (
<CommandItem
disabled={branchPending}
key={branch.name}
onSelect={() => void convertBranch(branch)}
value={branch.name}
>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
<span className="truncate">{branch.name}</span>
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
{branchActionLabel(branch, p)}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
) : (
<SanitizedInput
autoFocus
disabled={branchPending}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submitBranch()
} else if (event.key === 'Escape') {
setBranchOpen(false)
}
}}
onValueChange={setBranchName}
placeholder={p.branchPlaceholder}
sanitize={gitRef}
value={branchName}
/>
)}
{convertMode ? (
<DialogFooter className="sm:justify-start">
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={branchPending}
onClick={() => setConvertMode(false)}
type="button"
variant="link"
>
{t.common.cancel}
</Button>
</DialogFooter>
) : (
<DialogFooter className="sm:justify-between">
{onConvertBranch ? (
<Button
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
disabled={branchPending}
onClick={enterConvert}
type="button"
variant="link"
>
{p.convertBranchInstead}
</Button>
) : (
<span />
)}
<div className="flex items-center gap-2">
<Button disabled={branchPending} onClick={() => setBranchOpen(false)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button
disabled={branchPending || !branchName.trim()}
onClick={() => void submitBranch()}
type="button"
>
{p.startWork}
</Button>
</div>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</>
)
})
@@ -30,6 +30,19 @@ import { StatusItemRow } from './status-row'
// emit no event when they die). Only armed while a running row is on screen.
const BACKGROUND_POLL_MS = 5_000
// A localhost/loopback preview is only meaningful while its dev server is up, so
// we tie it to a live background process rather than persisting dismissals or
// letting dead URLs pile up. File previews (a real on-disk artifact) stand alone.
const isLocalhostPreview = (target: string): boolean => /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0)\b/i.test(target)
// Real codicons per group (no sparkles): a checklist for todos, a bot for
// subagents, a background process glyph for background tasks.
const GROUP_ICON: Record<StatusGroup['type'], string> = {
todo: 'checklist',
subagent: 'hubot',
background: 'server-process'
}
const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => {
if (group.type === 'todo') {
return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length)
@@ -74,6 +87,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
const hasRunningBackground = groups.some(g => g.type === 'background' && g.items.some(i => i.state === 'running'))
// Drop localhost previews once no dev server is left running — that's what made
// dead `localhost:5174` chips stick around. On-disk file previews are kept.
const visiblePreviews = previews.filter(item => hasRunningBackground || !isLocalhostPreview(item.target))
useEffect(() => {
if (!sessionId || !hasRunningBackground) {
return
@@ -89,6 +106,18 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
const openSubagent = (item: ComposerStatusItem) =>
item.sessionId ? void openSessionInNewWindow(item.sessionId, { watch: true }) : openAgents()
// Preview links live as child rows of the background group — a localhost dev
// server and its preview are the same thing — so they no longer float as an
// odd, differently-indented standalone block under the stack.
const previewRows =
visiblePreviews.length > 0 && sessionId
? visiblePreviews.map(item => (
<PreviewStatusRow item={item} key={item.id} onDismiss={id => dismissPreviewArtifact(sessionId, id)} />
))
: []
const hasBackgroundGroup = groups.some(g => g.type === 'background')
const sections: { key: string; node: ReactNode }[] = groups.map(group => ({
key: group.type,
node: (
@@ -107,11 +136,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
) : undefined
}
defaultCollapsed={group.type !== 'todo'}
icon={
group.type === 'todo' ? (
<Codicon className="text-muted-foreground/70" name="checklist" size="0.8rem" />
) : undefined
}
icon={<Codicon className="text-muted-foreground/70" name={GROUP_ICON[group.type]} size="0.8rem" />}
label={groupLabel(group, t.statusStack)}
>
{group.items.map(item => (
@@ -120,25 +145,20 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
key={item.id}
onDismiss={sessionId ? id => dismissBackgroundProcess(sessionId, id) : undefined}
onOpen={() => openSubagent(item)}
onStop={sessionId ? id => stopBackgroundProcess(sessionId, id) : undefined}
onStop={sessionId ? id => void stopBackgroundProcess(sessionId, id) : undefined}
/>
))}
{group.type === 'background' && previewRows}
</StatusSection>
)
}))
if (previews.length > 0 && sessionId) {
// No background group to host them (e.g. a standalone on-disk file preview):
// keep the previews as their own row block so they don't disappear.
if (previewRows.length > 0 && !hasBackgroundGroup) {
sections.push({
key: 'preview',
// Not a collapsible group — preview links just sit there, one line each,
// each individually closeable.
node: (
<div className="px-1 py-0.5">
{previews.map(item => (
<PreviewStatusRow item={item} key={item.id} onDismiss={id => dismissPreviewArtifact(sessionId, id)} />
))}
</div>
)
node: <div className="px-1 py-0.5">{previewRows}</div>
})
}
@@ -190,12 +210,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
return (
<div
// Sits above the composer (bottom-full), nudged down by the shell's 0.5rem
// top pad (pt-2 on composer-root) plus 1px so its bottom edge overlaps the
// composer surface's top border. z BELOW the surface (z-4) so the surface's
// top border paints over our transparent bottom border — one seam, no
// double line.
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-[calc(0.5rem+1px)] overflow-y-auto"
// Sits in the overlay lane above the composer. The composer root has pt-2
// before the actual surface; translate by that amount so the stack returns
// to its original attachment point without intruding into the repo strip.
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-2 overflow-y-auto"
onPointerDownCapture={() => blurComposerInput()}
ref={stackRef}
>
@@ -205,17 +223,19 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
Rounded top, square bottom; the bottom border is TRANSPARENT — the
composer surface's visible top border (which sits at a higher z) is the
single shared seam, so the two read as one fused capsule. */}
<div className={cn(composerDockCard('top'), 'mx-2 rounded-b-none border-b border-b-transparent pt-0.5 pb-1')}>
<div
className={cn(
'transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
)}
>
{sections.map(section => (
<div key={section.key}>{section.node}</div>
))}
</div>
<div
className={cn(
composerDockCard('top'),
// Inset (mx-2) so the stack reads slightly narrower than the composer
// surface below it — the original look.
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
'transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
)}
>
{sections.map(section => (
<div key={section.key}>{section.node}</div>
))}
</div>
</div>
)
@@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronRight, X } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { PREVIEW_PANE_ID } from '@/store/layout'
@@ -76,50 +75,52 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss
return (
<StatusRow
leading={<ChevronRight aria-hidden className="size-3 text-muted-foreground/80" />}
onActivate={() => void togglePreview()}
leading={
<Codicon
aria-hidden
className={cn('text-muted-foreground/70', opening && 'animate-pulse')}
name="globe"
size="0.8rem"
/>
}
// Plain click opens the link in the browser; ⌘/Ctrl-click opens it in the
// in-app preview pane instead. (isOpen still toggles the pane closed.)
onActivate={event => {
if (event.metaKey || event.ctrlKey) {
void togglePreview()
} else {
void openInBrowser()
}
}}
trailing={
<span className="-my-1 flex items-center gap-0.5">
<Tip label={t.preview.openInBrowser}>
<Button
aria-label={t.preview.openInBrowser}
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
void openInBrowser()
}}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="link-external" size="0.75rem" />
</Button>
</Tip>
<Tip label={t.statusStack.dismiss}>
<Button
aria-label={t.statusStack.dismiss}
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
onDismiss(item.id)
}}
size="icon-xs"
type="button"
variant="ghost"
>
<X size={12} />
</Button>
</Tip>
</span>
<Tip label={t.statusStack.dismiss}>
<Button
aria-label={t.statusStack.dismiss}
className="-my-1 size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
onDismiss(item.id)
}}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
</Tip>
}
trailingVisible
>
<span className="min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4 text-foreground/92" title={item.target}>
{item.label}
</span>
<span className={cn('shrink-0 text-[0.62rem] leading-4 text-muted-foreground/70', opening && 'animate-pulse')}>
{opening ? t.preview.opening : isOpen ? t.preview.hide : t.preview.openPreview}
</span>
<Tip
label={
<span className="flex flex-col gap-0.5">
<span>{item.target}</span>
<span className="opacity-70">{t.preview.linkHint}</span>
</span>
}
>
<span className="min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4 text-foreground/92">{item.label}</span>
</Tip>
</StatusRow>
)
})
@@ -8,7 +8,6 @@ import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { ArrowUpRight, X } from '@/lib/icons'
import type { TodoStatus } from '@/lib/todos'
import { cn } from '@/lib/utils'
import type { ComposerStatusItem } from '@/store/composer-status'
@@ -50,7 +49,7 @@ function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']):
return (
<GlyphSpinner
ariaLabel={s.running}
className="text-[0.9rem] leading-none text-muted-foreground/80"
className="text-[0.85rem] leading-none text-muted-foreground/80"
spinner="braille"
/>
)
@@ -117,11 +116,11 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp
type="button"
variant="ghost"
>
<X size={12} />
<Codicon name="close" size="0.75rem" />
</Button>
</Tip>
) : canOpen ? (
<ArrowUpRight aria-hidden className="size-3.5 text-muted-foreground/55" />
<Codicon aria-hidden className="text-muted-foreground/55" name="link-external" size="0.85rem" />
) : undefined
}
>
@@ -11,7 +11,14 @@ function renderPopover(kind: '@' | '/', loading = false) {
const rendered = render(
<I18nProvider configClient={null} initialLocale="zh">
<ComposerTriggerPopover activeIndex={0} items={[]} kind={kind} loading={loading} onHover={onHover} onPick={onPick} />
<ComposerTriggerPopover
activeIndex={0}
items={[]}
kind={kind}
loading={loading}
onHover={onHover}
onPick={onPick}
/>
</I18nProvider>
)
+1 -1
View File
@@ -46,7 +46,7 @@ export interface ChatBarProps {
onAddUrl?: (url: string) => void
onAttachImageBlob?: (blob: Blob) => Promise<boolean | void> | boolean | void
onAttachDroppedItems?: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
onPasteClipboardImage?: () => void
onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise<boolean> | void
onPickFiles?: () => void
onPickFolders?: () => void
onPickImages?: () => void
@@ -226,9 +226,10 @@ const attachToMain = (attachment: ComposerAttachment) => {
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
const { t } = useI18n()
const copy = t.desktop
const addTextToDraft = useCallback((text: string) => {
requestComposerInsert(text, { mode: 'block' })
}, [copy.imagePreviewFailed])
}, [])
const addTerminalSelectionAttachment = useCallback((text: string, label = 'selection') => {
const trimmed = text.trim()
@@ -329,35 +330,38 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
[currentCwd]
)
const attachImagePath = useCallback(async (filePath: string) => {
if (!filePath) {
return false
}
const baseAttachment: ComposerAttachment = {
id: attachmentId('image', filePath),
kind: 'image',
label: pathLabel(filePath),
detail: filePath,
path: filePath
}
attachToMain(baseAttachment)
try {
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })
const attachImagePath = useCallback(
async (filePath: string) => {
if (!filePath) {
return false
}
return true
} catch (err) {
notifyError(err, copy.imagePreviewFailed)
const baseAttachment: ComposerAttachment = {
id: attachmentId('image', filePath),
kind: 'image',
label: pathLabel(filePath),
detail: filePath,
path: filePath
}
return true
}
}, [])
attachToMain(baseAttachment)
try {
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })
}
return true
} catch (err) {
notifyError(err, copy.imagePreviewFailed)
return true
}
},
[copy.imagePreviewFailed]
)
const attachImageBlob = useCallback(
async (blob: Blob) => {
@@ -411,25 +415,36 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
}
}, [attachImagePath, copy.attachImages, currentCwd, t.composer.images])
const pasteClipboardImage = useCallback(async () => {
try {
const path = await window.hermesDesktop?.saveClipboardImage()
const pasteClipboardImage = useCallback(
async ({ silent = false }: { silent?: boolean } = {}) => {
try {
const path = await window.hermesDesktop?.saveClipboardImage()
if (!path) {
notify({
kind: 'warning',
title: copy.clipboard,
message: copy.noClipboardImage
})
if (!path) {
if (!silent) {
notify({
kind: 'warning',
title: copy.clipboard,
message: copy.noClipboardImage
})
}
return
return false
}
await attachImagePath(path)
return true
} catch (err) {
if (!silent) {
notifyError(err, copy.clipboardPasteFailed)
}
return false
}
await attachImagePath(path)
} catch (err) {
notifyError(err, copy.clipboardPasteFailed)
}
}, [attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage])
},
[attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage]
)
const attachContextFolderPath = useCallback(
(folderPath: string) => {
+8 -3
View File
@@ -75,7 +75,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
maxVoiceRecordingSeconds?: number
onAttachImageBlob: (blob: Blob) => Promise<boolean | void> | boolean | void
onAttachDroppedItems: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
onPasteClipboardImage: () => void
onPasteClipboardImage: (opts?: { silent?: boolean }) => Promise<boolean> | void
onPickFiles: () => void
onPickFolders: () => void
onPickImages: () => void
@@ -88,7 +88,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
onRestoreToMessage?: (messageId: string) => Promise<void>
onRestoreToMessage?: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void>
onRetryResume: (sessionId: string) => void
onTranscribeAudio?: (audio: Blob) => Promise<string>
onDismissError?: (messageId: string) => void
@@ -317,7 +317,12 @@ export function ChatView({
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
// scratch window, not the full-height empty state.
const showIntro =
!isSecondaryWindow() && freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
!isSecondaryWindow() &&
freshDraftReady &&
!isRoutedSessionView &&
!selectedSessionId &&
!activeSessionId &&
messagesEmpty
// Session is still loading if the route references a session we haven't
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
@@ -6,7 +6,7 @@ import type {
MouseEvent as ReactMouseEvent,
ReactNode
} from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import ShikiHighlighter from 'react-shiki'
import { Streamdown } from 'streamdown'
@@ -14,15 +14,31 @@ import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/comp
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection'
import { CodeEditor } from '@/components/chat/code-editor'
import { FileDiffPanel } from '@/components/chat/diff-lines'
import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window'
import { PageLoader } from '@/components/page-loader'
import { translateNow, useI18n } from '@/i18n'
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
import {
desktopFileDiff,
desktopGitRoot,
readDesktopFileDataUrl,
readDesktopFileText,
writeDesktopFileText
} from '@/lib/desktop-fs'
import { Check, Pencil, X } from '@/lib/icons'
import { shikiLanguageForFilename } from '@/lib/markdown-code'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
import { setPreviewDirty } from '@/store/preview-edit'
import { $currentCwd } from '@/store/session'
import { notifyWorkspaceChanged } from '@/store/workspace-events'
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
const SOURCE_CHUNK_LINES = 200
const SOURCE_LINE_PX = 20
const SOURCE_OVERSCAN_LINES = 400
type EmptyStateTone = 'neutral' | 'warning'
@@ -126,6 +142,8 @@ interface LocalPreviewState {
binary?: boolean
byteSize?: number
dataUrl?: string
/** Working-tree-vs-HEAD unified diff, when the file has uncommitted changes. */
diff?: string
error?: string
language?: string
loading: boolean
@@ -133,6 +151,19 @@ interface LocalPreviewState {
truncated?: boolean
}
// True when focus is in a field that should swallow plain keystrokes (so the
// bare-`e` edit shortcut never fires while the user is typing in the composer,
// a search box, or the editor itself).
function isTypableElement(el: Element | null): boolean {
if (!el) {
return false
}
const tag = el.tagName
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (el as HTMLElement).isContentEditable
}
function filePathForTarget(target: PreviewTarget) {
if (target.path) {
return target.path
@@ -299,27 +330,92 @@ function MarkdownPreview({ text }: { text: string }) {
)
}
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
function PreviewModeSwitcher({
active,
modes,
onSelect,
trailing
}: {
active: PreviewViewMode
modes: PreviewViewMode[]
onSelect: (mode: PreviewViewMode) => void
trailing?: ReactNode
}) {
const { t } = useI18n()
const showModes = modes.length > 1
if (!showModes && !trailing) {
return null
}
const label: Record<PreviewViewMode, string> = {
diff: t.preview.diff,
rendered: t.preview.renderedPreview,
source: t.preview.source
}
return (
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-transparent px-3 py-1 backdrop-blur">
<button
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
onClick={onToggle}
type="button"
>
{asSource ? t.preview.renderedPreview : t.preview.source}
</button>
// Fixed height so the header is byte-identical between read and edit modes —
// swapping the trailing controls must never move the body below it.
<div className="flex h-7 shrink-0 items-center justify-end gap-3 border-b border-border/40 px-3">
{showModes &&
modes.map(mode => (
<button
className={cn(
'text-[0.625rem] font-bold underline-offset-4 transition-colors',
mode === active
? 'text-foreground underline decoration-current/30'
: 'text-muted-foreground hover:text-foreground'
)}
key={mode}
onClick={() => onSelect(mode)}
type="button"
>
{label[mode]}
</button>
))}
{trailing && <div className="flex items-center gap-1.5">{trailing}</div>}
</div>
)
}
// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so
// each line aligns vertically. The selection overlay relies on the same
// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself.
const SOURCE_LINE_HEIGHT_REM = 1.21875
const SOURCE_PAD_Y_REM = 0.75
// Cancel / Save controls rendered as the header's trailing slot (not a bar of
// their own) so edit mode reuses the read-mode header row verbatim.
function EditControls({
dirty,
onCancel,
onSave,
saving
}: {
dirty: boolean
onCancel: () => void
onSave: () => void
saving: boolean
}) {
const { t } = useI18n()
return (
<>
<button
className="flex items-center gap-1 rounded-md px-1.5 text-[0.625rem] font-bold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onCancel}
type="button"
>
<X className="size-3" />
{t.common.cancel}
</button>
<button
className="flex items-center gap-1 rounded-md bg-primary px-2 py-0.5 text-[0.625rem] font-bold text-primary-foreground shadow-xs transition-opacity hover:opacity-90 disabled:opacity-50"
disabled={!dirty || saving}
onClick={onSave}
type="button"
>
<Check className="size-3" />
{saving ? t.common.saving : t.common.save}
</button>
</>
)
}
interface LineSelection {
end: number
@@ -337,7 +433,18 @@ function startLineDrag(event: ReactDragEvent<HTMLElement>, filePath: string, { e
function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
const { t } = useI18n()
const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
const chunks = useMemo(() => chunkTextLines(text, SOURCE_CHUNK_LINES), [text])
const lastChunk = chunks.at(-1)
const totalLines = lastChunk ? lastChunk.start + lastChunk.lines.length : 0
const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({
overscanRows: SOURCE_OVERSCAN_LINES,
rowPx: SOURCE_LINE_PX,
rowsPerChunk: SOURCE_CHUNK_LINES,
totalRows: totalLines
})
const visibleChunks = chunks.slice(startChunk, endChunk + 1)
const [selection, setSelection] = useState<LineSelection | null>(null)
const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
@@ -394,69 +501,97 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
}, [filePath, selection])
return (
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
<div className="select-none py-3 text-right text-muted-foreground/55">
{Array.from({ length: lineCount }, (_, index) => {
const line = index + 1
const selected = inSelection(line)
<div className="h-full overflow-auto" onScroll={onScroll} ref={scrollerRef}>
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-[0.7rem] leading-relaxed">
{beforeRows > 0 && <div aria-hidden className="col-span-2" style={{ height: beforeRows * SOURCE_LINE_PX }} />}
{visibleChunks.map(chunk => (
<Fragment key={chunk.start}>
<div className="select-none text-right text-muted-foreground/55">
{chunk.lines.map((_lineText, offset) => {
const line = chunk.start + offset + 1
const selected = inSelection(line)
return (
<div
className={cn(
'cursor-pointer px-3 tabular-nums transition-colors',
selected
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
: 'hover:text-foreground'
)}
draggable
key={line}
onClick={event => handleLineClick(event, line)}
onDragStart={event => handleDragStart(event, line)}
title={t.preview.sourceLineTitle}
>
{line}
return (
<div
className={cn(
'h-5 w-9 cursor-pointer pr-2 leading-5 tabular-nums transition-colors',
selected
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
: 'hover:text-foreground'
)}
draggable
key={line}
onClick={event => handleLineClick(event, line)}
onDragStart={event => handleDragStart(event, line)}
title={t.preview.sourceLineTitle}
>
{line}
</div>
)
})}
</div>
)
})}
</div>
<div
className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!"
data-selectable-text="true"
>
{selection && (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bg-amber-200/35 dark:bg-amber-300/10"
style={{
top: `calc(${SOURCE_PAD_Y_REM}rem + ${selection.start - 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`,
height: `calc(${selection.end - selection.start + 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`
}}
/>
)}
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{text}
</ShikiHighlighter>
<div className="preview-source-code min-w-0 [&_pre]:m-0" data-selectable-text="true">
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{chunk.text}
</ShikiHighlighter>
</div>
</Fragment>
))}
{afterRows > 0 && <div aria-hidden className="col-span-2" style={{ height: afterRows * SOURCE_LINE_PX }} />}
</div>
</div>
)
}
type PreviewViewMode = 'diff' | 'rendered' | 'source'
export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
const { t } = useI18n()
const [state, setState] = useState<LocalPreviewState>({ loading: true })
const [forcePreview, setForcePreview] = useState(false)
const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false)
// User-picked view; null = auto (diff when changed, else rendered markdown,
// else source). Reset when the previewed file changes.
const [userMode, setUserMode] = useState<null | PreviewViewMode>(null)
// Spot-editor state. The editor owns its buffer (keyed by `editorKey`); the
// live draft + the snapshot the user started from live in refs so typing
// never re-renders this (large) component — `dirty` is the only render-worthy
// signal and it flips just once when crossing the clean↔dirty boundary.
// `selfReload` re-runs the load after a save without the parent.
const [editing, setEditing] = useState(false)
const draftRef = useRef('')
const baselineRef = useRef('')
const [dirty, setDirty] = useState(false)
const [editorKey, setEditorKey] = useState(0)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<null | string>(null)
const [conflict, setConflict] = useState(false)
const [selfReload, setSelfReload] = useState(0)
// For the bare-`e` shortcut: the read-view root (to detect focus-within) and a
// hover flag (no state — only the keydown handler reads it).
const readViewRef = useRef<HTMLDivElement>(null)
const hoverRef = useRef(false)
const filePath = filePathForTarget(target)
const isImage = target.previewKind === 'image'
useEffect(() => {
setUserMode(null)
setEditing(false)
setDirty(false)
setSaving(false)
setSaveError(null)
setConflict(false)
draftRef.current = ''
baselineRef.current = ''
}, [filePath, reloadKey])
// HTML files are rendered as source code, not in a webview - so they take
// the same path as plain text files. `previewKind === 'binary'` arrives
// when the file is forcibly previewed past the binary refusal screen.
@@ -508,6 +643,22 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
text: shouldBlock ? undefined : result.text,
truncated: result.truncated
})
// Best-effort: fetch the file's working-tree-vs-HEAD diff so the
// preview can offer a DIFF view when there are uncommitted changes.
// Empty (clean file / not a repo / remote) just hides the option.
if (!shouldBlock) {
try {
const root = await desktopGitRoot(filePath)
const diff = root ? await desktopFileDiff(root, filePath) : ''
if (active && diff.trim()) {
setState(prev => (prev.text === result.text ? { ...prev, diff } : prev))
}
} catch {
// No diff available; the preview just shows source.
}
}
}
} catch (error) {
if (active) {
@@ -524,7 +675,188 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
return () => {
active = false
}
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.dataUrl, target.language])
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, selfReload, target.dataUrl, target.language])
// Editing is only offered for whole, readable text — never images, binaries,
// or files we only loaded the first 512 KB of (saving would drop the tail).
const canEdit =
isText && !isImage && !blockedByTarget && state.text !== undefined && !state.truncated && !state.binary
// Per-keystroke: update the draft ref (no render) and only set `dirty` when it
// actually changes — React bails on an identical value, so a long typing run
// triggers a single re-render at most.
const handleEditorChange = useCallback((value: string) => {
draftRef.current = value
const next = value !== baselineRef.current
setDirty(prev => (prev === next ? prev : next))
}, [])
// Publish the unsaved state to the rail so the tab can show a modified dot.
// Keyed by url; cleared on unmount/tab-change so a stale dot never lingers.
useEffect(() => {
setPreviewDirty(target.url, editing && dirty)
return () => setPreviewDirty(target.url, false)
}, [target.url, editing, dirty])
const beginEdit = () => {
const text = state.text ?? ''
baselineRef.current = text
draftRef.current = text
setDirty(false)
setEditorKey(key => key + 1)
setSaving(false)
setSaveError(null)
setConflict(false)
setEditing(true)
}
// Latest `beginEdit` for the keydown listener, so the listener can stay
// subscribed across renders without recreating itself or going stale.
const beginEditRef = useRef(beginEdit)
beginEditRef.current = beginEdit
// Bare `e` enters edit mode when the file pane is hovered or focused and no
// typable field has focus — a fast, button-free path (double-click felt laggy
// because of the browser's click-disambiguation delay).
useEffect(() => {
if (!canEdit || editing) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'e' || event.metaKey || event.ctrlKey || event.altKey) {
return
}
if (isTypableElement(document.activeElement)) {
return
}
const root = readViewRef.current
const focusWithin = Boolean(root && document.activeElement && root.contains(document.activeElement))
if (!hoverRef.current && !focusWithin) {
return
}
event.preventDefault()
beginEditRef.current()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [canEdit, editing])
const cancelEdit = () => {
setEditing(false)
setSaveError(null)
setConflict(false)
}
const discardAndReload = () => {
setEditing(false)
setConflict(false)
setSaveError(null)
setSelfReload(n => n + 1)
}
const saveEdit = async (force = false) => {
if (saving) {
return
}
setSaving(true)
setSaveError(null)
try {
// Stale-on-disk guard: re-read what's on disk now and compare to the
// snapshot the user started from. If something changed underneath (an
// agent edit, an external save), don't clobber it silently — surface the
// choice. `force` is the user picking "overwrite" from that banner.
if (!force) {
try {
const current = await readTextPreview(filePath)
if (!current.binary && (current.text ?? '') !== baselineRef.current) {
setConflict(true)
setSaving(false)
return
}
} catch {
// Couldn't re-read for the check — fall through and attempt the write.
}
}
await writeDesktopFileText(filePath, draftRef.current)
baselineRef.current = draftRef.current
setDirty(false)
setConflict(false)
setEditing(false)
notifyWorkspaceChanged()
setSelfReload(n => n + 1)
} catch (error) {
setSaveError(error instanceof Error ? error.message : String(error))
} finally {
setSaving(false)
}
}
// Rendered before the loading/error branches so a background re-read (file
// watcher, workspace tick) can't unmount the editor and drop the draft. Uses
// the SAME container + fixed-height header as the read view so entering edit
// never shifts the body — only the trailing controls and the body swap.
if (editing) {
return (
<div className="flex h-full flex-col overflow-hidden bg-transparent">
<PreviewModeSwitcher
active="source"
modes={[]}
onSelect={() => {}}
trailing={<EditControls dirty={dirty} onCancel={cancelEdit} onSave={() => void saveEdit()} saving={saving} />}
/>
{conflict && (
<div className="shrink-0 border-b border-amber-400/40 bg-amber-50 px-3 py-2 text-[0.7rem] text-amber-900 dark:border-amber-300/30 dark:bg-amber-300/10 dark:text-amber-100">
<div className="font-semibold">{t.preview.diskChangedTitle}</div>
<div className="mt-0.5 leading-relaxed">{t.preview.diskChangedBody}</div>
<div className="mt-1.5 flex gap-3">
<button
className="font-bold underline underline-offset-4 transition-opacity hover:opacity-80"
onClick={() => void saveEdit(true)}
type="button"
>
{t.preview.overwrite}
</button>
<button
className="font-bold underline underline-offset-4 transition-opacity hover:opacity-80"
onClick={discardAndReload}
type="button"
>
{t.preview.discardReload}
</button>
</div>
</div>
)}
{saveError && (
<div className="shrink-0 border-b border-destructive/40 bg-destructive/10 px-3 py-1.5 text-[0.7rem] text-destructive">
{t.preview.saveFailed(saveError)}
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
<CodeEditor
filePath={filePath}
initialValue={baselineRef.current}
key={editorKey}
onCancel={cancelEdit}
onChange={handleEditorChange}
onSave={() => void saveEdit()}
/>
</div>
</div>
)
}
if (state.loading) {
return <PageLoader label={t.preview.loading} />
@@ -544,11 +876,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
return (
<PreviewEmptyState
body={
binary
? t.preview.binaryBody(target.label)
: t.preview.largeBody(target.label, formatBytes(size))
}
body={binary ? t.preview.binaryBody(target.label) : t.preview.largeBody(target.label, formatBytes(size))}
primaryAction={{ label: t.preview.previewAnyway, onClick: () => setForcePreview(true) }}
title={binary ? t.preview.binaryTitle : t.preview.largeTitle}
tone="warning"
@@ -571,29 +899,79 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
if (isText && state.text !== undefined) {
const isMarkdown = (state.language || target.language) === 'markdown'
const showRendered = isMarkdown && !renderMarkdownAsSource
const hasDiff = Boolean(state.diff && state.diff.trim())
// Order the toggle reads left→right; default lands on the most useful view.
const modes: PreviewViewMode[] = []
if (isMarkdown) {
modes.push('rendered')
}
modes.push('source')
if (hasDiff) {
modes.push('diff')
}
const autoMode: PreviewViewMode = hasDiff ? 'diff' : isMarkdown ? 'rendered' : 'source'
const mode = userMode && modes.includes(userMode) ? userMode : autoMode
return (
<div className="h-full overflow-auto bg-transparent">
<div
className="flex h-full flex-col overflow-hidden bg-transparent"
onMouseEnter={() => {
hoverRef.current = true
}}
onMouseLeave={() => {
hoverRef.current = false
}}
ref={readViewRef}
>
{state.truncated && (
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
{t.preview.truncated}
</div>
)}
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
{showRendered ? (
<MarkdownPreview text={state.text} />
) : (
<SourceView filePath={filePath} language={state.language || 'text'} text={state.text} />
)}
<PreviewModeSwitcher
active={mode}
modes={modes}
onSelect={setUserMode}
trailing={
canEdit ? (
<button
className="flex items-center gap-1 text-[0.625rem] font-bold text-muted-foreground underline-offset-4 transition-colors hover:text-foreground"
onClick={beginEdit}
title={`${t.preview.edit} (e)`}
type="button"
>
<Pencil className="size-3" />
{t.preview.edit}
</button>
) : null
}
/>
<div className="min-h-0 flex-1 overflow-auto">
{mode === 'rendered' ? (
<MarkdownPreview text={state.text} />
) : mode === 'diff' ? (
<FileDiffPanel
className="mx-0 mb-0 h-full max-h-none"
diff={state.diff ?? ''}
fullText={state.text}
path={filePath}
showLineNumbers
/>
) : (
<SourceView
filePath={filePath}
language={shikiLanguageForFilename(filePath) || state.language || 'text'}
text={state.text}
/>
)}
</div>
</div>
)
}
return (
<PreviewEmptyState
body={t.preview.noInlineBody(target.mimeType || '')}
title={t.preview.noInlineTitle}
/>
)
return <PreviewEmptyState body={t.preview.noInlineBody(target.mimeType || '')} title={t.preview.noInlineTitle} />
}
@@ -7,7 +7,9 @@ import { PreviewPane } from './preview-pane'
describe('PreviewPane console state', () => {
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0))
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(Date.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
})
+110 -53
View File
@@ -3,10 +3,19 @@ import { useEffect, useMemo } from 'react'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import { Codicon } from '@/components/ui/codicon'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { Tip } from '@/components/ui/tooltip'
import { translateNow, useI18n } from '@/i18n'
import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import {
$panesFlipped,
$rightRailActiveTabId,
RIGHT_RAIL_PREVIEW_TAB_ID,
type RightRailTabId,
@@ -16,10 +25,13 @@ import {
$filePreviewTabs,
$previewReloadRequest,
$previewTarget,
closeOtherRightRailTabs,
closeRightRail,
closeRightRailTab,
closeRightRailTabsToRight,
type PreviewTarget
} from '@/store/preview'
import { $dirtyPreviewUrls } from '@/store/preview-edit'
import { PreviewPane } from './preview-pane'
@@ -56,12 +68,16 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
const { t } = useI18n()
const previewReloadRequest = useStore($previewReloadRequest)
const activeTabId = useStore($rightRailActiveTabId)
const panesFlipped = useStore($panesFlipped)
const filePreviewTabs = useStore($filePreviewTabs)
const previewTarget = useStore($previewTarget)
const dirtyPreviewUrls = useStore($dirtyPreviewUrls)
const tabs = useMemo<readonly RailTab[]>(
() => [
...(previewTarget ? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: t.preview.tab, target: previewTarget } as RailTab] : []),
...(previewTarget
? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: t.preview.tab, target: previewTarget } as RailTab]
: []),
...filePreviewTabs.map(({ id, target }) => ({ id, label: tabLabelFor(target), target }) as RailTab)
],
[filePreviewTabs, previewTarget, t.preview.tab]
@@ -82,68 +98,109 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID
return (
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)">
<aside
className={cn(
'relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)',
panesFlipped ? 'border-r' : 'border-l'
)}
// Windows/WSLg paint Electron's Window Controls Overlay across our
// titlebar band, so the editor-style tab strip (which normally sits IN that
// band) would land under the fixed titlebar tools. --right-rail-top-inset
// (set by AppShell only when the overlay is present) drops the rail one
// titlebar-height so it opens below the band. 0px elsewhere → unchanged.
style={{ paddingTop: 'var(--right-rail-top-inset, 0px)' }}
>
<div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 border-b border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background)">
<div
className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
>
{tabs.map(tab => {
{tabs.map((tab, index) => {
const active = tab.id === activeTab.id
const hasOthers = tabs.length > 1
const hasTabsToRight = index < tabs.length - 1
const dirty = Boolean(dirtyPreviewUrls[tab.target.url])
return (
<div
className={cn(
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
active
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
key={tab.id}
// Middle-click closes the tab, matching browser/IDE muscle
// memory. `onMouseDown` swallows the middle-button press so
// Chromium doesn't switch into autoscroll mode.
onAuxClick={event => {
if (event.button !== 1) {
return
}
<ContextMenu key={tab.id}>
<ContextMenuTrigger asChild>
<div
className={cn(
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
active
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
// Middle-click closes the tab, matching browser/IDE muscle
// memory. `onMouseDown` swallows the middle-button press so
// Chromium doesn't switch into autoscroll mode.
onAuxClick={event => {
if (event.button !== 1) {
return
}
event.preventDefault()
closeRightRailTab(tab.id)
}}
onMouseDown={event => {
if (event.button === 1) {
event.preventDefault()
}
}}
>
{active && (
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
)}
<Tip label={tab.label}>
<button
aria-selected={active}
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
type="button"
event.preventDefault()
closeRightRailTab(tab.id)
}}
onMouseDown={event => {
if (event.button === 1) {
event.preventDefault()
}
}}
>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
</Tip>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
/>
<button
aria-label={t.preview.closeTab(tab.label)}
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
onClick={() => closeRightRailTab(tab.id)}
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
</div>
{active && (
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
)}
<Tip label={tab.target.path || tab.target.url || tab.label}>
<button
aria-selected={active}
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
type="button"
>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
</Tip>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
/>
{dirty && (
<span
aria-hidden="true"
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center opacity-100 transition-opacity group-hover/tab:opacity-0 group-focus-within/tab:opacity-0"
>
{/* Amber (our warn color); a tab-bg ring + soft drop keeps it
legible where it overlaps the filename. */}
<span className="size-2 rounded-full bg-amber-500 shadow-[0_0_0_2px_var(--tab-bg),0_1px_2px_rgba(0,0,0,0.45)] dark:bg-amber-400" />
</span>
)}
<button
aria-label={t.preview.closeTab(tab.label)}
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
onClick={() => closeRightRailTab(tab.id)}
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => closeRightRailTab(tab.id)}>
{t.common.close}
<span className="ml-auto pl-4 text-(--ui-text-tertiary)">{formatCombo('mod+w')}</span>
</ContextMenuItem>
<ContextMenuItem disabled={!hasOthers} onSelect={() => closeOtherRightRailTabs(tab.id)}>
{t.preview.closeOthers}
</ContextMenuItem>
<ContextMenuItem disabled={!hasTabsToRight} onSelect={() => closeRightRailTabsToRight(tab.id)}>
{t.preview.closeToRight}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={closeRightRail}>{t.preview.closeAll}</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
</div>

Some files were not shown because too many files have changed in this diff Show More