Compare commits

...
Author SHA1 Message Date
alt-glitchandJanHranicky c97f0a6c82 refactor(apify): move Actor tools into bundled plugin
Re-shelve the three Apify tools from core into plugins/apify/, matching
the Spotify plugin pattern for optional third-party SaaS integrations.
tools/ is reserved for foundational capabilities; third-party service
integrations live in plugins/.

- plugins/apify/{__init__,tools,client}.py + plugin.yaml + README
  (kind: backend, auto-loads; registers via ctx.register_tool())
- remove apify_* from _HERMES_CORE_TOOLS in toolsets.py
  (TOOLSETS["apify"] entry kept, mirroring spotify)
- tests moved tests/tools/test_apify_tool.py -> tests/plugins/test_apify.py,
  import paths updated (34 tests pass)
- add JanHranicky to AUTHOR_MAP in scripts/release.py (CI gate)

The tools_config.py / config.py / lazy_deps.py / pyproject.toml setup +
config UX from the original commit is retained unchanged.

Co-authored-by: JanHranicky <jan.hranicky@seznam.cz>
2026-06-08 15:03:11 +05:30
JanHranicky 58e921a819 feat(apify): Actor execution tools — discover, start, collect
Cherry-picked from PR #41932 (JanHranicky). Original implementation
registered the three Apify tools as built-in core tools; the follow-up
commit moves them into a bundled plugin (plugins/apify/).

Co-authored-by: JanHranicky <jan.hranicky@seznam.cz>
2026-06-08 15:00:57 +05:30
teknium1 00c46b8ff9 test(tui): cover heapdump opt-in gate + retention; add AUTHOR_MAP
On-disk vitest coverage for the auto-heapdump disk-safety guard: opt-in
gating (suppressed diagnostics-only path), truthy-spelling acceptance,
manual-trigger passthrough, and the retention prune. Test approach
adapted from #21780 (briandevans) and #21822 (LeonSGP43), reconciled to
the merged gate semantics. Maps alarcritty into AUTHOR_MAP for CI.
2026-06-08 02:20:49 -07:00
alarcritty 8ae0d054f4 fix(tui): guard automatic heap dumps against disk fill
Automatic heap dumps from the TUI memory monitor could write multi-GiB
  .heapsnapshot files on every threshold cross, growing ~/.hermes/heapdumps
  to tens of GiB. Add four layered safeguards:

  - Gate auto-high/auto-critical snapshots behind HERMES_AUTO_HEAPDUMP=1;
    manual dumps remain unchanged.
  - Always write the lightweight diagnostics JSON sidecar so users still
    get an actionable artifact when the snapshot is suppressed.
  - Cap total bytes in the dump dir (HERMES_HEAPDUMP_MAX_BYTES, default
    2 GiB), evicting oldest first, retaining the newest.
  - Add a cooldown between auto dumps (HERMES_AUTO_HEAPDUMP_COOLDOWN_MS,
    default 10 min) so an oscillating heap can't re-trigger.

  Closes #21767
2026-06-08 02:20:49 -07:00
teknium1andKristian Vastveit dd0d1222a2 fix(agent): don't retry interrupt-induced transport errors (cascading-interrupt hang)
When agent.interrupt() fires during an active LLM call, the main poll loop
force-closes the worker-local httpx client to stop token generation. That
raises a transport error (RemoteProtocolError) on the worker thread — the
EXPECTED consequence of our own close, not a network bug.

The streaming retry loop misclassified it as a transient connection error
and retried; each doomed retry stalled for the full stream-stale timeout
(up to 300s). Because the gateway caches AIAgent instances per session, the
stale worker outlived the interrupted turn and raced the next turn's request
on shared client state — the root of the multi-minute cascading-interrupt
hang reported in the wild.

Fix: a request-local _request_cancelled token set by the poll loop right
before the force-close, in both interruptible_api_call (non-streaming) and
interruptible_streaming_api_call. The worker's exception handler checks the
token and exits cleanly — no retry, no fallback, no 'reconnecting' status —
instead of treating the forced error as transient. The token is request-
local (not agent._interrupt_requested, which is cleared at turn boundaries)
so a stale worker outliving its turn still recognizes its own forced close.

Original diagnosis and fix by @kristianvast (PR #6600), against the then-
inline methods in run_agent.py. Those were since extracted into
agent/chat_completion_helpers.py, so the fix is reapplied there.

Co-authored-by: Kristian Vastveit <kristianvast@users.noreply.github.com>
2026-06-08 02:19:13 -07:00
Teknium aa6f2775fa fix(memory): run end-of-turn sync off the turn thread (#41945)
A misconfigured/slow external memory provider could hold the agent in
the 'running' state for minutes after the final response was delivered.
MemoryManager.sync_all / queue_prefetch_all looped provider.sync_turn /
queue_prefetch INLINE on the turn-completion path; a provider making a
blocking network/daemon call (a broken Hindsight daemon was observed
blocking ~298s before failing) blocked run_conversation from returning.
Because every interface (CLI, TUI, gateway) marks the agent 'running'
until run_conversation returns, the agent stayed busy for the full block
and any follow-up message triggered an aggressive interrupt that dropped
the message.

Dispatch provider sync/prefetch to a lazily-created single-worker
background executor. sync_all / queue_prefetch_all return immediately;
work completes (or fails, logged) in the background. A single worker
serializes writes so turn N lands before turn N+1. flush_pending()
provides a barrier for session boundaries and deterministic tests.
shutdown_all() drains the executor with a bounded timeout so a wedged
provider can never hang teardown.

Builtin-only / no-provider sessions spawn no executor (zero new threads
in the common case).
2026-06-08 02:18:59 -07:00
xxxigm a5c12f5f59 fix(install): move broken checkout aside instead of deleting it
Review feedback (#40998): `rm -rf` / `Remove-Item -Recurse -Force` on the
install dir is destructive -- a user might still want whatever is there.
Rename the broken checkout to a timestamped `<dir>.broken-<ts>` backup and
re-clone fresh, so nothing is ever deleted. Transient cleanup of a clone
attempt that fails within the same run is left as-is.
2026-06-08 02:18:21 -07:00
xxxigm 5d7abf9114 test(install): cover commit-less checkout handling (#40998)
Behavioral coverage for install.sh's clone_repo() guard (removes a
commit-less checkout, keeps a real one, ignores a non-repo dir) plus a
contract check that install.ps1's repo-validity gate requires a resolvable
HEAD.
2026-06-08 02:18:21 -07:00
xxxigm fc0900d120 fix(install): re-clone interrupted (commit-less) checkout instead of failing
An interrupted previous clone leaves the install dir's .git present but with
no initial commit. rev-parse --is-inside-work-tree and git status both still
succeed there, so the installer entered the update path and ran `git stash`,
which aborts with "You do not have the initial commit yet" and failed the
desktop install at the "Cloning Hermes repository" stage.

- install.ps1: add a `git rev-parse --verify HEAD` probe to the repo-validity
  check so a commit-less checkout is treated as broken and re-cloned fresh.
- install.sh: mirror it at the top of clone_repo() — drop a partial checkout
  with no resolvable HEAD so the fresh-clone path handles it (POSIX parity).

Fixes #40998
2026-06-08 02:18:21 -07:00
teknium1 0904bc7ea2 refactor(cli): extract 32 slash-command handlers into CLICommandsMixin (god-file Phase 4)
Lift the `_handle_*_command` cluster (2,077 LOC) out of HermesCLI into
hermes_cli/cli_commands_mixin.py; HermesCLI now inherits CLICommandsMixin so
every self.<handler> call resolves unchanged via the MRO. Behavior-neutral.

Import discipline mirrors gateway/slash_commands.py (PR #41886): neutral deps
imported at the mixin module top level; cli.py-internal helpers/constants
(_cprint, _ACCENT, save_config_value, ...) imported lazily inside each handler
via 'from cli import ...' so the mixin never imports cli at module scope.

cli.py 16215 -> 14139 LOC. One test mock repointed (cli.is_browser_debug_ready
-> hermes_cli.cli_commands_mixin.is_browser_debug_ready).
2026-06-08 02:13:07 -07:00
kshitij 4eb8972390 Merge pull request #33817 from sweetcornna/fix/28503-busy-input-fifo
fix(gateway): use FIFO queue for busy_input_mode pending messages
2026-06-08 02:02:02 -07:00
Gille 039fbb41fc fix(desktop): show newly configured model providers (#41545) 2026-06-08 01:39:37 -07:00
floory 15c99b437f fix(cli): set PYTHON env for node-gyp native builds on NixOS (#40690)
* fix(cli): set PYTHON env for node-gyp native builds on NixOS

node-gyp (triggered by node-pty during npm ci) looks for python3 on
PATH, which fails on NixOS because python3 lives in the nix store and
is not on the system PATH.

Add _nixos_build_env() — a two-tier helper that detects NixOS and:
1. Fast path: hermes venv python3 (~0s)
2. Fallback: nix-shell which python3 (~2-5s)

Wire it into _run_npm_install_deterministic() via a new env= parameter,
then pass it through cmd_gui() and _update_node_dependencies().

Non-NixOS systems: _nixos_build_env() returns None, behavior unchanged.

* fix(cli): merge _nixos_build_env() with os.environ, fix NixOS detection, add explicit return None

- Critical fix: both Tier 1 (venv) and Tier 2 (nix-shell) now return
  {**os.environ, "PYTHON": ...} instead of {"PYTHON": ...} — subprocess.run
  with env= replaces the entire environment, so the old code wiped PATH
  and broke npm/node on NixOS entirely.
- Uses re.search(r"^ID=nixos$", ...) for anchored NixOS detection instead
  of unanchored substring match (could match ID_LIKE=...nixos).
- Removes redundant Path.exists() guard before read_text(); just catches
  OSError (one filesystem read instead of two).
- Adds explicit return None at end of function for type-hint consistency.
2026-06-08 13:57:37 +05:30
teknium1 7a5827c8b0 test: repoint percentage-clamp source guard to gateway/slash_commands.py
test_gateway_run_clamped read gateway/run.py asserting the /usage stats handler
clamps pct with min(100, ...). That handler moved to gateway/slash_commands.py
in this PR's extraction; repoint the guard so it still fires on clamp removal.

tests/run_agent/ + tests/gateway/ 8024 passed / 0 failed.
2026-06-08 01:25:35 -07:00
teknium1 de5fe2fa7d test(gateway): repoint slash-command mocks after mixin extraction
Tests for the extracted handlers mocked symbols at gateway.run.*; the handlers
now resolve top-level-imported deps (atomic_json_write, fetch_account_usage,
render_account_usage_lines) and __file__ from gateway.slash_commands. Repoint
those mocks. run.py-resident methods (_increment_restart_failure_counts,
_clear_restart_failure_count) keep their gateway.run.atomic_json_write mock —
only the moved handlers' mocks change.

tests/gateway/ 6415 passed / 0 failed.
2026-06-08 01:25:35 -07:00
teknium1 619bd78273 refactor(gateway): extract 42 slash-command handlers into GatewaySlashCommandsMixin (god-file Phase 3b)
The in-session slash commands (/model, /reset, /usage, /compress, /voice, ...)
— 42 _handle_*_command handlers, ~3,200 LOC — move out of gateway/run.py into a
mixin GatewayRunner inherits. self._handle_*_command dispatch + all test
references resolve unchanged via the MRO.

Neutral deps (MessageEvent, EphemeralReply, Platform, t, cfg_get, atomic_*_write,
account-usage helpers, stdlib) imported at the mixin top level. The ~10 run.py-
internal helpers (_hermes_home, _load_gateway_config, _resolve_gateway_model,
_AGENT_PENDING_SENTINEL, ...) imported lazily inside the handlers that need them
to avoid an import cycle.

gateway/run.py 19157 -> 15870 LOC; GatewayRunner direct methods 214 -> 172.

Behavior-neutral: voice/update/model/compress command test suites pass; all 42
resolve to the mixin via MRO.
2026-06-08 01:25:35 -07:00
teknium1andARegalado1 02a4d66951 fix(auxiliary): retry transient transport error once before fallback (#16587)
A one-off transient transport failure (streaming-close / incomplete
chunked read / 5xx / 408) on an auxiliary LLM call escalated straight to
provider/model fallback (or, for context compression, dropped the summary
and entered cooldown), even when an immediate retry on the same provider
would have succeeded.

Add a single same-target retry at the top of call_llm() and
async_call_llm() — before the existing except-chain — gated on a new
_is_transient_transport_error() that reuses the canonical
_is_connection_error() detector plus a 5xx/408 status check. A second
failure (or any non-transient error: auth, other 4xx, malformed payload)
falls through to first_err and the existing fallback handling unchanged.

This lives in call_llm so every auxiliary task (compression, memory flush,
title generation, session search, vision) shares one transient-retry
surface, rather than each caller re-implementing it. The context
compressor needs no change — it calls call_llm and inherits the retry; its
existing fallback-to-main path (#18458) now composes naturally (retry the
aux model once, then fall back to main only if the retry also fails).

Co-authored-by: ARegalado1 <alberto.regalado@ymail.com>
2026-06-08 01:05:45 -07:00
kshitij 4107076128 Merge pull request #41155 from kshitijk4poor/fix/cli-modal-direct-invalidate-41098
fix(cli): paint approval/clarify/sudo/secret modal prompts directly, not via the throttle (#41098)
2026-06-08 01:01:51 -07:00
Teknium 4d18717b6c fix(gateway): drop --replace from systemd unit templates (#41892)
Under systemd's Restart=always, --replace turns every restart into a
self-kill loop: the new instance reads gateway.pid, kills the previous
process, writes its own PID, and on the next restart the cycle repeats.
A process supervisor owns the lifecycle — --replace is for manual
one-shot takeovers and fights the supervisor.

Remove --replace from both the system-level and user-level systemd
ExecStart lines. The --replace flag stays available for manual
'hermes gateway run --replace' and on the macOS launchd fallback path
(#23387), which is a deliberate manual takeover, not a supervised unit.

Also drop RestartMaxDelaySec / RestartSteps from the templates — they
require systemd v255+ and are silently ignored on older versions. The
_strip_optional_systemd_directives normalizer stays so existing installs
whose on-disk unit still carries those directives aren't flagged as
outdated.

Credit: reported and diagnosed by @Skippy-the-Magnificent-one (PR #37145);
reimplemented here under project authorship because the original commit
was authored under a non-existent email.
2026-06-08 00:20:08 -07:00
Siddharth Balyan d02a59b679 fix(nix): cold npm builds + fix-lockfiles real-build verification + auto-fix workflow (#41867)
* fix(nix): fix-lockfiles real-build verification + point auto-fix at nix/lib.nix

Two related fixes to the npm lockfile-hash tooling that, together, let a
broken nix build slip onto main and stay there:

1. fix-lockfiles trusted prefetch-npm-deps. It computes the hash from the
   lockfile *contents* and early-exited "ok" whenever that matched the pin,
   never running the real fetchNpmDeps + npmConfigHook build. Those two can
   disagree (the --apply path already works around it), so `--check`
   reported "ok" while a cold build was actually broken (e.g. lockfile
   engines/os/cpu fields the pinned nixpkgs strips from the deps cache,
   tripping npmConfigHook's consistency diff). Now, when prefetch says the
   hash matches, confirm with `nix build .#<attr>` before believing it:
   adopt the real fetchNpmDeps hash if nix reports a 'got:' mismatch,
   surface non-hash failures honestly (exit 1) instead of claiming "ok",
   and keep the transient-cache-failure skip.

2. nix-lockfile-fix.yml's auto-fix-main (and the PR-fix job) whitelisted and
   staged nix/tui.nix + nix/web.nix, but the single npmDepsHash moved to
   nix/lib.nix. So fix-lockfiles --apply edited nix/lib.nix, the guard
   flagged it as an "unexpected modified file", and the job exited without
   committing — the auto-healer could never push a fix. Point the guard
   regex and both `git add` lines at nix/lib.nix.

* fix(nix): fix cold npm builds — adopt the deps-cache lockfile in patchPhase

hermes-tui/hermes-agent could not be built from source on the pinned nixpkgs:
prefetch-npm-deps strips advisory lockfile fields (engines/os/cpu/funding/
bin/…) that newer npm writes into package-lock.json, then npmConfigHook
byte-compares the source lockfile against the cache's stripped copy and fails
on the difference. CI only stayed green because it substitutes the prebuilt
hermes-tui from Cachix and never cold-builds it; anyone building cold (e.g. a
local path: input, or a cache miss) hit the failure.

mkNpmPassthru's patchPhase now copies the cache's own normalized
package-lock.json over the source before npmConfigHook runs, so the
consistency check is trivially satisfied. The resolved dependency set
(version/resolved/integrity/dependencies) is identical — fetchNpmDeps derived
the cache from this very lockfile — so `npm ci` installs the same tree; only
advisory metadata is dropped. Genuine drift is still caught by the
fixed-output npmDepsHash check, which runs before this phase.

Verified by cold-building .#tui and .#default (full hermes-agent) from scratch
on the pinned nixpkgs (6201e2) — both succeed where they previously failed at
npmConfigHook.
2026-06-08 12:41:37 +05:30
Teknium e45b745835 fix(file-tools): reject sentinel TERMINAL_CWD; anchor worktree edits before live cwd exists (#41861)
Completes the worktree-misroute fix from #35399, which made misroutes
visible (resolved_path) but did not prevent them: its divergence warning
only fired once a terminal command had populated the live cwd registry.
A fresh worktree session (registry still empty) with a stale TERMINAL_CWD='.'
got neither a worktree anchor nor a warning, so a relative write_file/patch
silently landed in the MAIN checkout.

Two changes in tools/file_tools.py:
- Treat sentinel TERMINAL_CWD values ('', '.', './', 'auto', 'cwd') and any
  relative value as UNSET rather than a literal anchor. Previously '.' was
  joined onto the process cwd, silently routing edits to wherever the process
  happened to be (the main repo, in a worktree session). The gateway already
  sanitizes the same set at import time; the file-tool layer now matches.
- New _authoritative_workspace_root(): prefers the live terminal cwd, else a
  sentinel-free absolute TERMINAL_CWD (the worktree path cli.py/main.py set
  for -w). _resolve_base_dir() and _path_resolution_warning() both use it, so
  a worktree session resolves into — and warns about escaping — the worktree
  from the very first write, before any cd has run.

Validation: 11 new/parametrized tests (sentinel handling, empty-registry
anchoring, early divergence warning, live-cwd precedence). 32/32 pass under
scripts/run_tests.sh. Live E2E: relative write in an empty-registry worktree
session lands in the worktree, main untouched.
2026-06-07 23:58:47 -07:00
LeonSGP43andHermes e02f4c03c3 fix(gateway): abort --replace when old PID survives SIGKILL
When --replace force-kills an unresponsive old gateway, SIGKILL can fail
to reap it (uninterruptible sleep, zombie-reaping parent, etc.). The old
code unconditionally cleared the PID file and scoped locks and started a
fresh instance anyway, leaving two live gateways fighting over the same
bot token — a duplicate-gateway failure mode of #19471.

Re-verify the process is actually gone (via the Windows-safe _pid_exists
helper) after the force-kill; if it still appears alive, clear the
takeover marker and abort the replacement instead of duplicating.

Co-authored-by: Hermes <noreply@nousresearch.com>
2026-06-07 23:57:32 -07:00
konsisumer 3714caa1b9 fix(session): follow compression continuations for transcript reads 2026-06-07 23:57:20 -07:00
teknium1 329c33dac3 fix(terminal): read cwd overrides under raw task_id after container collapse
PR #41822 collapsed CWD-only overrides to the shared 'default' container
via _resolve_container_task_id, but three call sites kept routing the
*env/override lookup* through that collapsed id:

  - the foreground exec path read _task_env_overrides[effective_task_id],
    yet register_task_env_overrides writes under the raw task_id, so a
    CWD-only override's cwd was silently dropped (env spun up at the wrong
    root, exit 126);
  - the get-or-create env lookup keyed solely on effective_task_id, so an
    env cached under the raw task_id was missed and duplicated;
  - register_task_env_overrides synced the new cwd onto the env under the
    collapsed id, missing a live env cached under the raw task_id.

Container *identity* still collapses to 'default' (sharing preserved);
only the per-session env/override *lookup* now prefers the raw task_id and
falls back to the collapsed id. Fixes the 3 regressions in
test_terminal_task_cwd.py left red by #41822.
2026-06-07 23:44:04 -07:00
teknium1 d759c13c09 chore(salvage): lint fix + AUTHOR_MAP for desktop source-folders PR #40272
eslint --fix (import sort + padding-line-between-statements) on sidebar/index.tsx
after cherry-picking @dangelo352's commits; add release.py AUTHOR_MAP entry so
CI doesn't block on the unmapped author email.
2026-06-07 23:44:04 -07:00
D'Angelo Rodriguez 694adec635 Smooth desktop sidebar drag sorting 2026-06-07 23:44:04 -07:00
D'Angelo Rodriguez f0fcaa1e54 Preserve dragged order inside source folders 2026-06-07 23:44:04 -07:00
D'Angelo Rodriguez 0f500fc41d Render grouped sessions when local list is empty 2026-06-07 23:44:04 -07:00
D'Angelo Rodriguez 3fc67b7333 Persist desktop sidebar drag order 2026-06-07 23:44:04 -07:00
D'Angelo Rodriguez ede4f5a4a3 Show messaging source folders in desktop sessions 2026-06-07 23:44:04 -07:00
D'Angelo Rodriguez 9d6992ee8a Show platform sources in desktop sessions 2026-06-07 23:44:04 -07:00
teknium1 1c68f6f81f refactor(gateway): extract kanban watcher loops into GatewayKanbanWatchersMixin (god-file Phase 3)
gateway/run.py is the largest god file (20k LOC, GatewayRunner with 220
methods). This lifts the cohesive kanban-watcher cluster — _kanban_notifier_watcher,
_kanban_dispatcher_watcher, _kanban_advance/unsub/rewind, _deliver_kanban_artifacts
(~1,035 LOC, 6 methods) — into gateway/kanban_watchers.py as a mixin that
GatewayRunner inherits.

Mixin (not free functions) because the methods use only self state: inheriting
keeps every self._kanban_* call site working unchanged via the MRO, making this
a behavior-neutral move. The methods' lazy imports (_kb, _decomp, _load_config,
Platform) travel with them; the mixin needs only stdlib + a matching
logging.getLogger('gateway.run').

run.py 20187 -> 19157 LOC; GatewayRunner direct methods 220 -> 214.

Behavior-neutral: gateway test suite 6582 passed / 0 failed; start() still wires
both watchers via self._kanban_*; MRO resolves all 6 to the mixin. One test
(corrupt-board quarantine retry) keyed its time-travel mock on the caller's
filename being gateway/run.py — updated to also accept gateway/kanban_watchers.py.

Establishes the mixin-extraction pattern for further GatewayRunner decomposition
(the 2406-LOC _run_agent and 1164-LOC _handle_message remain, but their callback
closures need a context-object redesign — deferred).
2026-06-07 23:14:18 -07:00
liuhao1024 6459b3d991 fix(terminal): collapse CWD-only overrides to shared container
When register_task_env_overrides is called with only a 'cwd' key
(ACP adapter workspace tracking), the task_id should collapse to
'default' so all interactive surfaces (TUI, gateway, dashboard)
share one long-lived container.

Previously, any override registration — even CWD-only — caused
_resolve_container_task_id to return the session key unchanged,
spinning up a separate container per session. This made it
impossible to authenticate into external services once and have
that auth available across all surfaces.

Now only overrides containing isolation keys (docker_image,
modal_image, singularity_image, daytona_image, env_type) trigger
per-task container isolation.

Fixes #37361
2026-06-07 23:04:54 -07:00
teknium1 1a626470ca refactor(cli): promote 9 closure handlers to top-level + extract their parsers (god-file Phase 2 follow-up)
Subcommands whose handler was a closure defined inside main() — memory, acp,
tools, insights, skills, pairing, plugins, mcp, claw — have their handler
promoted to a top-level function and their parser block extracted into
hermes_cli/subcommands/<name>.py (build_<name>_parser, injected handler).

These 9 had zero closure-over-main-locals, so promotion is a pure relocation.
acp/mcp parser blocks use the shared add_accept_hooks_flag helper.

main() 1798 -> 954 LOC (71% below the 3297 Phase-2 starting point);
add_parser calls in main.py 89 -> 28.

Deferred: sessions, computer-use, secrets handlers reference <name>_parser
(for a no-subcommand print_help fallback) — left in place to avoid the
_self_parser indirection; minority, low value.

Behavior-neutral: all 9 subcommands' --help (incl nested subactions) byte-
identical to pre-extraction (diff-verified). tests/hermes_cli/ 6519 passed /
0 failed; new test_subcommands_followup.py covers the 9 builders.
2026-06-07 22:56:23 -07:00
teknium1 524453dab5 refactor(agent): consolidate inner-retry-loop recovery flags into TurnRetryState (god-file Phase 1b)
run_conversation's inner retry loop tracked recovery state in ~15 scattered
bare booleans (per-provider OAuth refresh guards, format-recovery guards,
restart signals). They are now fields on a single TurnRetryState dataclass the
loop mutates in place (_retry.<flag>), giving the recovery bookkeeping a named,
testable home.

Loop-control vars (retry_count, max_retries, max_compression_attempts) stay as
plain locals — they're while-mechanics, not recovery bookkeeping.

Behavior-neutral: pure local→attribute rewrite of 42 references; kwarg NAMES
preserved (e.g. has_retried_429=_retry.has_retried_429). Live simple + tool
turns OK.

Validation: tests/run_agent/ 1615 passed / 0 failed under per-file process
isolation; new test_turn_retry_state.py pins the field contract.
2026-06-07 22:42:05 -07:00
teknium1 4d926f248d chore(release): add AUTHOR_MAP entry for rodboev 2026-06-07 22:39:51 -07:00
Rod Boev 648706936d test(gateway): add compression session_id rotation integration tests (#34089) 2026-06-07 22:39:51 -07:00
teknium1 39c4ac3af1 chore(release): add AUTHOR_MAP entry for JimStenstrom 2026-06-07 22:30:02 -07:00
JimStenstrom cb5c24e37d fix(agent): sync logging session context on compaction id rotation
When context compaction rotates agent.session_id, it updates the gateway/tools
session context (set_current_session_id -> HERMES_SESSION_ID env + ContextVar)
but never updates the separate logging session context. The [session_id] tag on
log lines comes from hermes_logging._session_context (set once per turn in
conversation_loop.py), so post-compaction log lines in the same turn carry the
STALE old id while the message/DB/gateway state carry the new one — breaking log
correlation exactly at the compaction boundary.

Call hermes_logging.set_session_context(agent.session_id) alongside the existing
set_current_session_id, guarded so a logging failure can't regress the routing
update. Logs-only; no runtime or caching impact.

Refs #34089
2026-06-07 22:30:02 -07:00
Teknium 8e223b36ed fix(curator): protect load-bearing built-in skills from archival/consolidation (#41817)
The curator's idle-archival path (apply_automatic_transitions under
prune_builtins) could archive the bundled `plan` skill, killing the
/plan slash command silently — typing /plan then returned 'Unknown
command' with no signal that a skill had vanished. The archived skill's
hash stays in .bundled_manifest, so 'hermes update' wouldn't re-seed it.

Add PROTECTED_BUILTIN_SKILLS ({plan}) enforced at the master gate
is_curation_eligible() (covers archive_skill + the transition walk) and
in the candidate enumerator (so the LLM consolidation pass never sees
them). Immune to prune_builtins, pin state, and LLM judgment.
2026-06-07 22:23:29 -07:00
Teknium 777dc9da62 feat(acp): emit session provenance metadata for compression rotation (#41724)
Closes #33617. Adds additive _meta.hermes.sessionProvenance to ACP session
surfaces so clients can detect compression-driven internal session rotation
without parsing status text, guessing from token drops, or reading state.db.

Derived on demand from the existing compression chain (parent_session_id /
end_reason) — no new persisted state, no schema change, no ACP protocol change.
ACP session_id stays the stable client handle.

- acp_adapter/provenance.py: derive provenance from SessionDB
- server.py: attach _meta to new/load/resume responses; emit a
  session_info_update when the internal head rotates during a prompt
2026-06-07 22:22:21 -07:00
teknium1 240c5d4543 chore: map martin.alca@gmail.com -> draix in AUTHOR_MAP
Salvage follow-up for PR #33221 — the cherry-picked commit is authored
under martin.alca@gmail.com (not the draixagent@gmail.com already mapped),
which would fail the CI author-attribution gate.
2026-06-07 22:22:01 -07:00
Martín Alcalá Rubí 132d6fe6d6 fix(volcengine): strip XML attribute fragments from tool_use.name (#33007)
VolcEngine's api/plan endpoint occasionally leaks raw XML attribute
fragments into tool_use.name when its protocol-translation layer
converts the model's native XML-style tool emission to Anthropic
Messages tool_use blocks, producing names like:

  terminal" parameter="command" string="true
  execute_code" parameter="code" string="true
  session_search" parameter="session_id" string="true

The corruption happens server-side at the provider, but it breaks
every tool call for affected users — no normalization rule in
repair_tool_call can rescue them, so each request runs through three
retries and then aborts as partial.

Add an early sanitizer in agent_runtime_helpers.repair_tool_call that
trims at the first ' " ', " ' ", '<', or '>' character (idx > 0
only) so the rest of the existing repair pipeline (lowercase /
snake_case / fuzzy match) can resolve the cleaned name normally.

Whitespace is deliberately NOT a separator — the legitimate
"write file" -> write_file repair path (covered by
test_space_to_underscore) must keep working.

Tests: 11 new regression cases in TestVolcEngineXmlPollution
covering all three observed polluted names, CamelCase + pollution
mix, single-quote variants, angle-bracket variants, clean-name
passthrough, and the whitespace-preservation guard. All 18 pre-
existing repair tests still pass (29 total in the file).
2026-06-07 22:22:01 -07:00
teknium1 f5bd09af4b refactor(acp): share interrupt-sentinel prefix, simplify guard
Replace the ACP-local prefix/suffix matcher + helper with a single
startswith() check against INTERRUPT_WAITING_FOR_MODEL_PREFIX, now
defined once in conversation_loop.py where the sentinel is produced.
Keeps the source of truth in one place so the guard cannot drift if
the status string changes. Net -17 LOC in server.py.

Also add lsaether to release.py AUTHOR_MAP.
2026-06-07 22:20:43 -07:00
lsaether 9b631e4ae1 fix(acp): suppress cancel interrupt sentinel 2026-06-07 22:20:43 -07:00
TekniumandProgramCaiCai 2789bf4e25 fix(auxiliary): route Codex Responses path through shared converter (#5709)
The auxiliary Codex adapter maintained its own chat->Responses conversion
loop that forwarded every non-system message's role verbatim into
Responses input[]. When flush_memories()/compression replayed session
history containing assistant tool_calls + role=tool results, those tool
messages leaked into the request and the Responses API rejected them with
HTTP 400: Invalid value: 'tool'.

Route _CodexCompletionsAdapter.create() through the same shared converter
the main agent transport uses (_chat_messages_to_responses_input), so tool
calls become function_call items and tool results become function_call_output
items with a valid call_id. Single conversion path means no future drift.

Also remove the now-dead _convert_content_for_responses() helper — its only
caller was the private conversion loop this change deletes.

Co-authored-by: ProgramCaiCai <techxacm@gmail.com>
2026-06-07 22:18:31 -07:00
teknium1 568e127612 refactor(cli): extract 25 more subcommand parsers into hermes_cli/subcommands/
Batch extraction of every remaining subcommand whose handler is top-level and
whose parser block is pure argparse: model, setup, postinstall, whatsapp, slack,
login, logout, auth, status, webhook, hooks, doctor, security, dump, debug,
backup, import, config, version, update, uninstall, dashboard, gui, logs,
prompt-size.

Each becomes hermes_cli/subcommands/<name>.py with build_<name>_parser() and an
injected handler (no main import). dashboard also injects cmd_dashboard_register
for its nested 'register' action.

Behavior-neutral: all 25 subcommands' --help output (and nested subaction help)
diff-verified byte-identical to pre-extraction. Two RawDescriptionHelpFormatter
epilogs (debug, logs) needed their multi-line string interiors preserved at
column 0 — caught by the --help diff, not compile.

main() 3297 -> 1798 LOC across this PR; add_parser calls in main.py 179 -> 89.

Validation: tests/hermes_cli/ 6476 passed / 0 failed under per-file process
isolation; new test_subcommands_batch.py smoke-tests all 25 builders + the
dashboard two-handler case.
2026-06-07 22:18:14 -07:00
teknium1 4da45e8727 refactor(cli): extract profile + gateway/proxy parsers into hermes_cli/subcommands/
Follow-on to the cron extraction in the same Phase 2 PR. Same pattern:
per-group build_<name>_parser() functions with injected handlers, no main
import.

- subcommands/profile.py: build_profile_parser (190-line block out of main()).
- subcommands/gateway.py: build_gateway_parser (gateway + proxy, 238-line block;
  they shared one inline section). Imports argparse for SUPPRESS defaults.
- main(): two more inline blocks become single builder calls.

Behavior-neutral: 'profile [sub] --help' and 'gateway/proxy [sub] --help'
byte-identical to pre-extraction (diff-verified).

main() now 2723 LOC (was 3297 at Phase 2 start); add_parser calls in main.py
179 -> 141.

Validation: tests/hermes_cli/ 6476 passed / 0 failed under per-file process
isolation; new builder unit tests cover subactions, aliases, dispatch, flags.
2026-06-07 22:18:14 -07:00
teknium1 b2e6053243 refactor(cli): extract hermes cron parser into hermes_cli/subcommands/ (god-file Phase 2)
Phase 2 of the god-file decomposition plan. main()'s argparse tree is 179
inline add_parser calls in one 3,297-line function. This establishes the
hermes_cli/subcommands/ package and extracts the first group (cron) as the
proof-of-pattern:

- hermes_cli/subcommands/_shared.py: shared parser helpers (add_accept_hooks_flag),
  re-exported from main.py for backwards compat.
- hermes_cli/subcommands/cron.py: build_cron_parser(subparsers, cmd_cron=...).
  Handler injected so the module never imports main (cycle avoidance).
- main()'s ~155-line inline cron block becomes one build_cron_parser() call.

Behavior-neutral: 'hermes cron create --help' output is byte-identical to
origin/main. main() 3297 -> 3143 LOC.

Validation: tests/hermes_cli/ 6466 passed / 0 failed under per-file process
isolation; new test_subcommands_cron.py covers subactions, aliases, options,
no-agent tristate, injected dispatch, and --accept-hooks.
2026-06-07 22:18:14 -07:00
teknium1 54870847cb refactor(agent): extract run_conversation prologue into agent/turn_context.py
Phase 1 of the god-file decomposition plan. run_conversation's ~470-line
once-per-turn setup block (stdio guarding, retry-counter resets, user-message
sanitization, todo/nudge hydration, system-prompt restore-or-build,
crash-resilience persistence, preflight compression, the pre_llm_call hook, and
external-memory prefetch) is moved verbatim into build_turn_context(), which
returns a TurnContext dataclass the loop unpacks.

Behavior-neutral move-and-name refactor: the builder mutates `agent` exactly as
the inline code did; only the locals the loop reads back are returned.

- run_conversation: 4602 -> 4217 LOC (-385)
- agent/conversation_loop.py: 4965 -> ~4580 LOC
- new agent/turn_context.py: focused, dependency-injected, unit-tested in isolation

Tests: tests/run_agent/ 1570 passed / 0 failed under per-file process isolation.
Relocation follow-ups: 413_compression mocks now patch both module references;
nudge/on_turn_start source-inspection guards point at the extracted module.
2026-06-07 22:17:35 -07:00
Teknium 86c537d209 fix(memory): instruct in-turn consolidation + retry on overflow (#41755)
* fix(memory): make overflow errors instruct in-turn consolidation + retry

When bounded memory is full, the add/replace overflow errors now explicitly
tell the model to consolidate (merge/remove/shorten) and retry the write in
the same turn, matching the documented behavior. The replace-overflow path
now also echoes current_entries + usage for parity with add-overflow, so the
model has the same context to act on.

Closes #23378 (working-as-documented; this sharpens runtime to match docs).

* fix(memory): broaden overflow remediation hint beyond 'stale'

Say 'stale or less important' — entries don't have to be stale to be the
right ones to drop when making room.
2026-06-07 22:16:28 -07:00
teknium1 2a10da3a16 fix(gateway): keep /model + /reasoning overrides on topic recovery & compression splits
Session-scoped /model and /reasoning overrides were silently lost on
Telegram DM/forum topics and after compression session splits (#30479).

Root cause: _handle_message_with_agent rewrites source.thread_id via
_recover_telegram_topic_thread_id (lobby/stripped reply -> the user's
bound topic) before deriving the session key. The /model and /reasoning
handlers derived their override key from the raw inbound event.source,
skipping that recovery, so the override was stored under one key and the
next message turn read a different key.

Fix: add _normalize_source_for_session_key (applies the same recovery a
message turn does) and use it in both handlers before deriving the key.
session_id rotation on compression was never the cause — overrides are
keyed by the durable session_key; the split path preserves it.

Author: teknium1 <127238744+teknium1@users.noreply.github.com>
2026-06-07 22:10:32 -07:00
Hariharan Ayappane b8469a81e3 fix(weixin): add rate-limit circuit breaker 2026-06-07 22:10:17 -07:00
Teknium 2e62862784 fix(telegram): use get_running_loop in polling-conflict retry reschedule (#41716)
The conflict-retry path called asyncio.get_event_loop() to reschedule
itself when a retry's start_polling raised. On Python 3.11+ (our floor)
that raises 'RuntimeError: There is no current event loop in thread
MainThread' when no loop is attached to the thread, which is what
happens when PTB dispatches this error callback. The retry never gets
scheduled, the adapter goes silent-but-alive, and gateway --replace
keeps spawning fresh instances that hit the same wall — the crash loop
reported in #19471 (worse under multi-profile, where two bots hold the
same conflict open).

We are inside a coroutine here, so asyncio.get_running_loop() is the
correct, guaranteed-valid replacement. Only get_event_loop() call in
any platform adapter, so no sibling sites.

Fixes #19471
2026-06-07 22:10:03 -07:00
teknium1 b5f7a1f299 chore(release): add basilalshukaili to AUTHOR_MAP 2026-06-07 22:09:45 -07:00
dusterbloom cca3b77a4b fix(compression): clear _previous_summary on session end (defense-in-depth)
ContextCompressor inherited a no-op on_session_end() from ContextEngine, so
per-session iterative-summary state (_previous_summary) survived a real session
boundary on a reused compressor instance. Override it to clear the summary the
moment the owning session ends, complementing the point-of-use guard in
compress(). Closes the cross-session contamination path in #38788.

Co-authored-by: dusterbloom <32869278+dusterbloom@users.noreply.github.com>
2026-06-07 22:09:45 -07:00
Basil Al Shukaili 8513a6aec7 fix(compression): guard against cross-session stale _previous_summary contamination
When a cron or background session compacts, it sets _previous_summary for
iterative updates. If that session ends without /new or /reset (which calls
on_session_reset()), the stale summary survives on the ContextCompressor
instance. A subsequent live messaging session's compaction then injects it as
'PREVIOUS SUMMARY:' into the summarizer prompt — contaminating the live
session with unrelated content from the prior session.

Add an else guard in compress(): when no handoff summary is found in the
current messages but _previous_summary is non-empty, discard it so
_generate_summary() starts fresh instead of iteratively updating a stale
cross-session summary.

Fixes #38788
2026-06-07 22:09:45 -07:00
Teknium ad8e57793d fix(hermes_time): implement reset_cache() referenced in docstrings (#41728)
The module docstring and get_timezone()/cache comments documented a
reset_cache() helper for forcing tz re-resolution after config changes,
but the function was never defined — doc-followers calling it hit
AttributeError. Adds the helper to clear the cached tz state.

Surfaced in #32043.
2026-06-07 22:08:01 -07:00
Teknium 5408013369 fix(gateway): isolate DM sessions on user_id when chat_id is absent (#41764)
build_session_key collapsed every DM that arrived without a chat_id into
one shared 'agent:main:<platform>:dm' key. A single cached AIAgent then
served multiple users' conversations, bleeding history across senders.

DMs now fall back to the sender's user_id_alt/user_id (mirroring the
group-path participant precedence and the telegram auth-path fallback)
before the bare per-platform sink. Telegram's normal event path always
sets chat_id, so this hardens the synthetic-source / non-standard-adapter
paths that don't.
2026-06-07 22:07:07 -07:00
Teknium a77bc2c08d fix(compression): disable compression on background-review fork to prevent cross-turn stale-parent fork (#41708)
The per-session compression lock prevents same-window concurrent forks but
not cross-turn ones: the background-review fork shares the parent's
session_id, so if it won a compression race its new child session was never
adopted by the gateway (the fork is single-lifecycle). The next foreground
turn then started from the stale parent and compressed it again, leaving the
same parent with two sibling children.

Set review_agent.compression_enabled = False so the fork never triggers
compression. Both trigger sites in conversation_loop.py gate on
compression_enabled before calling _compress_context, so the fork can never
rotate the shared parent. Review needs full context anyway — compressing
would degrade the memory/skill summary.

The per-session lock is kept as defense-in-depth for any future shared-session
path. Adds a regression test that fails without the flag and passes with it.

Closes #38727
2026-06-07 22:06:48 -07:00
Teknium 48ae8029aa fix(delegate): resolve custom-endpoint subagent pools by endpoint identity (#41730)
Subagents delegated to a custom endpoint were misrouted when the parent
ran on a different custom endpoint. Both runtimes collapse to
provider="custom", so _resolve_child_credential_pool() treated them as
interchangeable and handed the child the parent's pool. Leasing from it
then overwrote the child's delegated base_url with the parent's endpoint
via _swap_credential() — the child sent the delegated model name to the
wrong endpoint.

Custom runtimes now resolve by endpoint identity (the custom:<name> pool
key derived from base_url). The parent pool is reused only when both
parent and child resolve to the same custom endpoint; unregistered raw
endpoints return None so the child keeps its fixed delegated credential.
Non-custom provider paths are unchanged.

Fixes #7833.
2026-06-07 22:05:14 -07:00
Teknium bddc5fd087 fix(desktop): fail loudly instead of blank-paging when the renderer bundle is missing (#41729)
A packaged desktop app launches to a blank page with a bare
ERR_FILE_NOT_FOUND when dist/index.html isn't in the bundle (#39484).
This happens when the build step fails (e.g. a stale checkout that
fails typecheck) but electron-builder packages anyway, shipping an
empty dist/.

- build-time: scripts/assert-dist-built.cjs runs at the tail of the
  `build` script and aborts before electron-builder if dist/index.html
  or the vite JS bundle is missing/empty. Every packaging path
  (pack, dist*) inherits it via `npm run build &&`.
- runtime: resolveRendererIndex() now logs a clear 'packaged without a
  renderer bundle — rebuild with hermes desktop --force-build' message
  when no index.html exists, instead of silently loading a missing path.
- runtime: resolveWebDist() logs when it falls back to an asar-internal
  dist that isn't a real directory (the dashboard 404 class, #41327/#39472),
  rather than returning an unservable path silently.

Adds scripts/assert-dist-built.test.cjs (node:test) covering the guard.
2026-06-07 22:04:39 -07:00
liuhao1024 53a2ac8f2d fix(desktop): unpack dist/ from asar so dashboard static files are servable
The dashboard backend serves HTTP 404 on all static routes (/, /assets,
/health) in packaged builds because resolveWebDist() points at
app.asar.unpacked/dist/, but dist/** was not listed in asarUnpack.

Add dist/** to the asarUnpack glob list so electron-builder extracts the
built frontend assets alongside the asar archive, making them accessible
to the Express static file server at runtime.

Fixes #41327
2026-06-07 22:04:36 -07:00
Teknium ace4b722dc feat(skills): add simplify-code skill — parallel 3-agent code review and cleanup (#41691)
Inspired by Claude Code's /simplify. A bundled skill that captures recent
changes via git diff, fans out three focused reviewers (reuse, quality,
efficiency) via delegate_task batch mode, then aggregates findings and
applies the fixes worth applying.

Zero core changes — orchestrates existing tools (terminal/git, search_files,
delegate_task). Supports focus, dry-run, and scoped-diff modifiers.

Closes #379.
2026-06-07 22:02:41 -07:00
teknium1 0c67d4015f chore(release): map islam666 for as-is salvage batch 2026-06-07 21:50:57 -07:00
islam666 78e2101cd2 fix: reap zombie subprocesses in web_server action status and meet_bot cleanup
- web_server.py: after proc.poll() returns a non-None exit code, call
  proc.wait() to reap the child and move the entry from _ACTION_PROCS
  to _ACTION_RESULTS. Previously .poll() alone left <defunct> zombies.
- meet_bot.py: terminate and wait on the pcm_pump subprocess (paplay/
  ffmpeg) during the finally-block teardown. Previously leaked on every
  normal bot exit.
- tests: add test_action_status_reaps_completed_process and
  test_action_status_ignores_wait_failure covering both the happy path
  and the wait()-raises-OSError edge case.

Closes #38032
2026-06-07 21:50:57 -07:00
islam666 e53b74c394 fix(dist): stop USER_OWNED_EXCLUDE from filtering nested directories
The copytree ignore lambda in _copy_dist_payload applied USER_OWNED_EXCLUDE
recursively at every directory depth. This caused nested directories whose
names matched exclude entries (bin, logs, cache, etc.) to be silently dropped
during distribution install/update.

Fix: only apply USER_OWNED_EXCLUDE filtering at the root of the staged tree,
matching the two-tier pattern used by _clone_all_copytree_ignore and
_default_export_ignore in profiles.py.

Add 5 tests covering nested bin/logs/cache preservation and top-level
filtering still working.

Fixes #37954
2026-06-07 21:50:57 -07:00
islam666 09a5548628 fix(weixin): refresh typing ticket on expiry to prevent stuck indicator (#38085)
The WeChat iLink typing ticket has a 600-second TTL. When a long-running
session exceeds that window, the cached ticket evicts from TypingTicketCache.
Both send_typing and stop_typing silently returned early when the ticket was
None, meaning the TYPING_STOP=2 signal was never sent to iLink. The WeChat
client then showed the typing indicator indefinitely.

Fix: add _ensure_typing_ticket() that transparently refreshes the ticket
via getConfig when the cached one has expired or is missing. Both send_typing
and stop_typing now call this method instead of silently no-oping.

Fixes #38085
2026-06-07 21:50:57 -07:00
islam666 2e61de0638 fix(model_metadata): consult DEFAULT_CONTEXT_LENGTHS before 256K fallback on custom endpoints
Problem: get_model_context_length() had an early return at the end of the
custom-endpoint probe branch (step 3) that returned DEFAULT_FALLBACK_CONTEXT
(256K) without ever consulting the hardcoded DEFAULT_CONTEXT_LENGTHS catalog
(step 8). Models served through a custom/proxied gateway (e.g. corporate
Anthropic proxy) that didn't expose Ollama or local-server endpoints would
hit this path and get capped at 256K, even when the model name clearly
matched a known entry in the catalog (e.g. claude-opus-4-8 → 1M).

Changes:
- agent/model_metadata.py: Before returning DEFAULT_FALLBACK_CONTEXT at the
  end of the custom-endpoint branch, consult DEFAULT_CONTEXT_LENGTHS using
  the same longest-key-first fuzzy matching as step 8. Only fall through
  to 256K if no catalog entry matches.
- tests/agent/test_model_metadata.py: Updated existing test and added new
  test covering the custom-endpoint → catalog fallback behavior.

Fixes #38865
2026-06-07 21:50:57 -07:00
islam666 f1d3afb151 fix(profiles): skip 'default' in named profiles scan to prevent duplicates
When ~/.hermes/profiles/default/ exists as a directory, list_profiles()
returns 'default' twice: once as the built-in default profile (~/.hermes)
and once from the directory scan (~/.hermes/profiles/default).

This causes the cron dashboard API (profile=all) to read the same
jobs.json twice, showing every default-profile job duplicated in the UI.

Fix: skip name=='default' in the named profiles loop, since it's already
added as the built-in default at the top of the function.

Fixes #39346
2026-06-07 21:50:57 -07:00
islam666 9513793ad7 fix(vision): proactive downgrade for providers rejecting list-type tool content (#41072)
Xiaomi MiMo (and potentially other providers) support multimodal user
messages but reject list-type tool message content with 400 'text is not
set'. Previously this was handled reactively — the API call would fail,
images would be stripped, and the request retried, losing visual info.

Fix: add supports_vision_tool_messages field to ProviderProfile (default
True). Xiaomi sets it to False. _tool_result_content_for_active_model
now checks this field proactively and returns a text summary instead of
list content, avoiding the round-trip failure entirely.
2026-06-07 21:50:57 -07:00
islam666 41f0714287 fix(vision): honor custom_providers per-model supports_vision (#41036)
_supports_vision_override() in image_routing.py checked model.supports_vision
and providers.<name>.models, but not the legacy list-style custom_providers
config. A custom provider entry like:

  custom_providers:
    - name: my-provider
      models:
        my-model:
          supports_vision: true

was ignored, causing image_input_mode=auto to route through the auxiliary
vision_analyze path instead of natively attaching images.

Fix: added a lookup step for custom_providers list entries, matching by
provider name (including 'custom:<name>' variants at runtime).
providers.<name>.models still takes precedence over custom_providers.

13 new tests covering: true/false override, custom: prefix matching,
no-match fallback, non-dict entries, empty lists, models key missing.
2026-06-07 21:50:57 -07:00
islam666 18c085b1a4 fix(gateway): normalize optional systemd directives in stale-check (#41119)
On older systemd versions that don't support RestartMaxDelaySec /
RestartSteps, the installed unit file has those directives silently
dropped. systemd_unit_is_current() did a strict text comparison, so
the unit was perpetually flagged as outdated.

Fix: _strip_optional_systemd_directives() removes RestartMaxDelaySec
and RestartSteps from both the installed and expected text before
comparison. Units that differ only by these optional directives are
now correctly considered current.
2026-06-07 21:50:57 -07:00
islam666 b18490b890 fix(compaction): prevent infinite loop when transcript fits in tail budget
When summary_target_ratio is large (e.g. 0.45) and the context_length is
moderate (e.g. 96000), the soft_ceiling (token_budget * 1.5) can exceed
the total transcript size.  _find_tail_cut_by_tokens walks the entire
transcript without breaking early, and the resulting compress window is
either empty (compress_start >= compress_end) or a single message whose
summary-of-one overhead saves ~0 tokens.

Both outcomes cause a no-op compression that does not increment
_ineffective_compression_count, so should_compress() returns True on
every subsequent turn and the loop repeats endlessly.

Fix (two layers):
1. _find_tail_cut_by_tokens: when the backward walk consumed the entire
   transcript without breaking (cut_idx <= head_end and accumulated <=
   soft_ceiling), re-walk with the raw (non-inflated) token budget to
   find a meaningful cut that gives the summarizer a useful middle window.
2. compress(): when compress_start >= compress_end, increment
   _ineffective_compression_count and log a warning so the existing
   anti-thrashing guard in should_compress() can break the loop.

Fixes #40803
2026-06-07 21:50:57 -07:00
teknium1 38d1a414a1 chore: add islam666 to AUTHOR_MAP for salvaged PR #39624 2026-06-07 21:50:25 -07:00
islam666 09ec26c66a fix(ollama): set default_max_tokens for custom/Ollama provider
The custom/Ollama provider profile had no default_max_tokens, so no
max_tokens was sent on requests and Ollama fell back to its internal
num_predict=128 — truncating responses after a few tokens with
finish_reason='length' (#39281, e.g. gemma4).

max_tokens resolution is ephemeral > user model.max_tokens > profile
default, so this is only a floor used when the user hasn't set their own
cap. Set it to 65536 (matching the qwen-oauth tier) rather than a
conservative value, since users can always override per-model.

Fixes #39281
2026-06-07 21:50:25 -07:00
Brian D. EvansandClaude Opus 4.7 ab0a6270c3 fix(slack): align thread_ts check with is_thread_reply invariant (Copilot #15464)
Two findings from Copilot's review on #15464, both addressed:

1. ``event.get("thread_ts")`` truthy vs
   ``event_thread_ts != ts``: the new channel branch treated ANY
   truthy ``thread_ts`` as a real thread reply, but three lines below
   ``is_thread_reply`` is defined with the stricter
   ``event_thread_ts and event_thread_ts != ts`` invariant.  If Slack
   ever ships a payload where ``thread_ts == ts`` on a thread root,
   the stricter check would treat it as a top-level message for the
   ``is_thread_reply`` path but as a thread reply for session keying
   — divergent behaviour.  Aligned this branch to the same
   ``and event_thread_ts_raw != ts`` invariant.

2. ``test_top_level_reply_to_id_stays_none_when_shared`` docstring
   had the ternary logic backwards ("None != ts → reply_to_message_id
   IS set").  The code reads
   ``reply_to_message_id = thread_ts if thread_ts != ts else None`` —
   with ``thread_ts = None``, the condition is True so the expression
   evaluates to ``thread_ts`` itself (None), meaning the reply stays
   un-threaded.  The test asserted the correct end-state; only the
   explanatory docstring was wrong.  Rewrote the docstring to match
   the actual code flow, with the note that Copilot caught the
   reversal.

7/7 tests still pass.  No behaviour change for the existing
test_thread_reply_scopes_by_thread_even_when_shared case because
``event_thread_ts_raw = "1700000000.000000"`` and ``ts =
"1700000000.000005"`` are distinct — the new
``!= ts`` guard is a no-op there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 21:19:59 -07:00
Brian D. EvansandClaude Opus 4.7 133e0271e2 fix(slack): scope top-level channel messages by channel-only when reply_in_thread=false (#15421)
Top-level Slack channel messages previously fell back to the message's
own ``ts`` as a synthetic ``thread_ts``:

    thread_ts = event.get("thread_ts") or ts  # ts fallback for channels

That value flows into ``build_source(thread_id=thread_ts)`` at
line 1247.  The gateway session store keys sessions by
``(platform, channel_id, thread_id)``, so every top-level channel
message ended up on a unique session.  Operators who set
``reply_in_thread: false`` in ``config.yaml`` expected all top-level
channel messages to share one session (the whole point of that flag)
— instead each one spawned a fresh conversation with no context
carry-over.

### Fix

Three explicit cases in the channel branch:

| event.thread_ts | reply_in_thread | thread_ts for session keying |
|---|---|---|
| non-null (real thread reply) | either | event.thread_ts |
| null (top-level) | true (default) | ts (legacy: own-thread sessions) |
| null (top-level) | false | **None** (shared channel session) |

The outbound-reply gate at line 1264 (``reply_to_message_id =
thread_ts if thread_ts != ts else None``) still works correctly in
all three cases without further changes: ``None != ts`` is True, so
shared-channel top-level messages don't get their reply threaded
either — matching the operator's ``reply_in_thread=false`` intent
end-to-end.

Genuine thread replies still scope per-thread under both modes so
multi-person threaded conversations can't collide with unrelated
channel chatter.

### Tests (7 new in ``tests/gateway/test_slack_channel_session_scope.py``)

All drive the real ``SlackAdapter._handle_slack_message`` code path
(not a re-implementation) via the standard pytest fixture pattern
used by ``tests/gateway/test_slack.py``.  Messages @mention the bot
so the mention gate doesn't drop them — the tests are specifically
about what happens once the handler decides to emit a ``MessageEvent``.

* ``TestChannelSessionScopeDefault`` (2 cases):
  - Explicit ``reply_in_thread: true`` keeps ``thread_id = ts``
    (legacy behaviour — regression guard)
  - Unset config behaves like ``reply_in_thread: true`` (pins the
    default)
* ``TestChannelSessionScopeShared`` (3 cases):
  - ``reply_in_thread: false`` + top-level → ``thread_id is None``
    (the #15421 bug 1 fix)
  - ``reply_to_message_id is None`` in the same case (no threaded
    outbound reply)
  - Genuine thread reply still scopes per-thread when shared mode is
    on — only TOP-LEVEL messages collapse to the channel session
* ``TestThreadReplyAlwaysScopesByThread`` (2 parametrised cases):
  - Thread replies get ``thread_id = event.thread_ts`` regardless of
    ``reply_in_thread`` — critical invariant for multi-thread
    channels; a regression here would leak per-thread context across
    threads

**Regression guard verified**: reverted the else-branch to the legacy
``thread_ts = event.get("thread_ts") or ts`` one-liner;
``test_top_level_maps_to_none_when_reply_in_thread_false`` correctly
failed (asserts ``thread_id is None`` but got ``"1700000000.000003"``).
Restored → 182 slack tests pass (175 existing + 7 new).

Scope: this fixes #15421 bug 1 only.  Bug 2 (sessions.json not
persisting across compression) lives elsewhere in the session
manager and is left for a separate diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 21:19:59 -07:00
brooklyn! b5a457c033 fix(desktop): persist zoom level via renderer localStorage (#41747)
Desktop zoom shortcuts (Cmd/Ctrl +/-/0) and the View menu only called
webContents.setZoomLevel(), which mutates the live renderer but persists
nothing. On reload, renderer crash/restart, or page recreation the app
snapped back to the default zoom, so the shortcuts felt broken for users
who need larger text.

Persist the selected zoom in the renderer's own localStorage rather than a
main-process JSON file. localStorage is per-origin and survives the
renderer lifecycle automatically, so there's no atomic-write/userData file
machinery to maintain. The main process still owns setZoomLevel: every
zoom change is mirrored into localStorage via executeJavaScript, and the
value is read back and re-applied on did-finish-load (covering reloads and
crash recovery). Clamping to Electron's [-9, 9] range now happens once in
setAndPersistZoomLevel instead of at each call site.
2026-06-07 22:43:09 -05:00
brooklyn! d65b513f23 feat(desktop): hover-reveal collapsed sidebars as fixed overlays (#41670)
* feat(desktop): hover-reveal collapsed chat sidebar as a fixed overlay

When the sessions sidebar is collapsed, hovering the left edge now floats
it back in as a fixed overlay over the main content instead of just being
hidden. The collapsed grid track stays at 0px so the panel never reserves
space — it slides over whatever's underneath and retracts on pointer-leave.

- PaneShell: new hoverReveal prop. When a pane is collapsed + hoverReveal,
  render an edge hot-zone + a side-anchored floating panel (absolute, full
  height, honors any persisted resize width) that slides in on hover/focus.
- ChatSidebar: force the (otherwise opacity-0 when collapsed) sidebar fully
  visible + interactive while the overlay is revealed, via an
  in-data-[pane-hover-reveal=open] variant.
- desktop-controller: opt the chat-sidebar pane into hoverReveal.

* feat(desktop): lower window minWidth 900→400

Lets the window shrink to a narrow rail (e.g. for the collapsed
hover-reveal sidebar) instead of being floored at 900px.

* fix(desktop): render full sidebar content in hover-reveal overlay

The hover-reveal overlay showed only the nav rail — session rows, search,
pinned/recents were gated behind `sidebarOpen` (false while collapsed), so
they never mounted in the floated panel.

Add a $sidebarRevealed store the PaneShell overlay drives via a new
onHoverRevealChange callback, and gate ChatSidebar's content on
`sidebarOpen || sidebarRevealed` (contentVisible) instead of raw open
state. The overlay now shows the complete sidebar.

* fix(desktop): drop shadow on hover-reveal sidebar overlay

* feat(desktop): hover-reveal the file-browser sidebar too

The reveal mechanism already lives in the shared Pane primitive — the
right rail just opts in with hoverReveal. Its content renders
unconditionally, so (unlike the chat sidebar) it needs no extra
content-visibility gating.

* clean(desktop): tighten hover-reveal pane code

KISS pass — flatten the translate ternary, derive a single `revealed`,
inline the edge style, drop the redundant set-guard, and trim comments to
the house one-liner style. No behavior change.

* fix(desktop): stop hiding sidebar nav labels on narrow windows

The nav labels (New session, Skills, …) and the ⌘N hint were gated on a
viewport breakpoint (max-[46.25rem]:hidden), so shrinking the window hid
them even when the sidebar itself was wide — including in the hover-reveal
overlay. Drop the gate; the label already truncates (min-w-0 flex-1) so it
ellipsizes gracefully in a narrow rail, and contentVisible already hides it
when collapsed to the icon rail.

* feat(desktop): auto-collapse both sidebars below 600px into hover-reveal

Add a Pane `forceCollapsed` prop — collapses the track without writing to
the store (so the saved open state restores when the window widens) while
keeping hoverReveal alive (unlike `disabled`, which suppresses it).

desktop-controller watches (max-width: 600px) and force-collapses the chat
sidebar + file browser, so on a narrow window both rails get out of the way
and the hover-reveal overlay becomes the way in.

* feat(desktop): hover-intent + refined easing for sidebar reveal

- Gate the reveal on pointer velocity: the full-height edge hot-zone now
  only arms on a slow, deliberate pass (<=0.55 px/ms). Fast sweeps toward
  the titlebar/statusbar — or off the window — blow past the threshold and
  never trigger, so the wide hit area stops being a nuisance.
- Swap the slide easing to cubic-bezier(0.32,0.72,0,1) at 260ms (snappy-out,
  soft-land) for a more serious-app feel.

* fix(desktop): don't reveal sidebar during window resize

Resizing the window parks the cursor on the screen edge and fires slow
pointermoves over the hot-zone, reading as deliberate intent. Guard the
reveal on (a) e.buttons !== 0 — any button-held drag, incl. edge-resize —
and (b) a 250ms cooldown after any window resize event.

* feat(desktop): hoverIntent-style poll gate + inert contents during slide

Replace the single-sample velocity check (too eager — fired on any one slow
move, incl. resize drift) with a port of Brian Cherne's hoverIntent: poll
the pointer every 90ms and only arm once it has *settled* (moved <5px between
two consecutive polls inside the edge zone). Fly-bys, pass-throughs, and
resize drift never produce two close samples in a row, so they don't trigger.

Also keep the revealed panel's CONTENTS pointer-events-none until the slide-in
transition finishes (onTransitionEnd → settled), so you can't misclick a
session row mid-animation. Resets on retract.

* fix(desktop): no cursor/hit-test leak before reveal settles

The edge hot-zone showed cursor:pointer the instant the pointer touched it —
before the panel was armed or in view. And contents were inert but the panel
itself still hit-tested, so the cursor could flip mid-slide. Fix: hot-zone is
cursor-default (it's invisible), and the whole panel is pointer-events-none
until revealed && settled, so the cursor never changes or lands on a row
before the slide-in finishes.

* fix(desktop): geometry-driven close so revealed panel always retracts

The revealed panel relied on its own onPointerLeave to close — but a panel
that slid in under a still cursor (or whose contents were inert during the
slide) never fires enter/leave, so it got stuck open (esp. the file browser).
onTransitionEnd also bubbled from the file-tree's own row transitions,
tripping the settled flag wrongly.

Replace with a document-level pointermove watcher that closes once the cursor
leaves the panel's bounding rect + a 24px grace — independent of pointer-events
state or what the contents do. Gate interactivity on a simple slide-duration
timer (interactive) instead of the fragile transitionEnd, so the cursor still
can't flip or land on a row before the panel is in view.

* feat(desktop): make sidebar toggle shortcuts reveal when force-collapsed

mod+b / mod+j were no-ops on a narrow (force-collapsed) window — they
flipped the store but the pane ignores it. Now the toggle handlers also
dispatch PANE_TOGGLE_REVEAL_EVENT; a force-collapsed Pane listens (only while
overlayActive) and flips its hover-reveal, so the shortcut floats the rail in
(and back out) at this responsive breakpoint.

* refactor(desktop): name the 600px sidebar collapse breakpoint

Hoist the inline '(max-width: 600px)' literal into
SIDEBAR_COLLAPSE_BREAKPOINT_PX + SIDEBAR_COLLAPSE_MEDIA_QUERY in
layout-constants, so the responsive collapse point is a single named source
of truth instead of a magic string in the controller.

* tweak(desktop): sidebar auto-collapse breakpoint 600px -> 768px

768 is the standard md breakpoint and a more honest 'no room to dock' point.

* tweak(desktop): halve sidebar reveal slide duration 260ms -> 130ms

* Revert "tweak(desktop): halve sidebar reveal slide duration 260ms -> 130ms"

This reverts commit 6009a13200.

* perf(desktop): pre-mount hover-reveal contents to kill slide-in stall

The reveal mounted the (heavy, virtualized) sidebar contents in the same
frame the slide started, so the browser stalled painting the transform until
the mount finished — a ~100-200ms beat before the panel moved, very visible
on the instant keyboard toggle (hover masked it via the 90ms intent poll).

Report overlayActive (collapsed-overlay mode) rather than the live reveal
state to the mount consumer, so contents stay mounted off-screen while
collapsed and reveal is a pure transform. Visibility is still driven
separately by the data-pane-hover-reveal attr + the slide transform.

* fix(desktop): make reveal hotkey spammable

Two throttles on the reveal toggle:
- The handler fired both the reveal event AND toggleSidebarOpen() per press;
  the store write hits localStorage synchronously every keystroke + recomputes
  the grid, janking rapid presses. When collapsed, only dispatch the reveal
  event (the store toggle was a no-op anyway).
- The geometry close-watcher slammed a keyboard-opened panel shut on the first
  stray pointermove (trackpad jitter), fighting hotkey spam. Keyboard reveals
  now ignore geometry until the cursor actually enters the panel, then the
  mouse takes over.

* fix(desktop): inset reveal hot-zone past the OS window-resize gutter

The hot-zone sat flush at the window edge (left-0/right-0), overlapping the
OS resize grab strip — reaching to drag-resize naturally slows the cursor
there, which hoverIntent reads as settled and reveals before the resize drag
even starts. Inset the hot-zone 8px so the outermost edge stays a pure
resize/drag region and only an intentful move just inside it arms a reveal.

* fix(desktop): keep reveal hot-zone at edge, gate arming past resize gutter

Insetting the hot-zone made it unreachable when moving fast. Instead, anchor
the zone flush at the edge (w-4, always captures the pointer) but only ARM the
reveal when the cursor settles >=8px in from the edge — so a resize-reach that
parks on the outermost OS grab strip never triggers, while a deliberate move
into the zone still does. Keeps polling while in the gutter so moving inward
still arms.

* refactor(desktop): rebuild hover-reveal as pure CSS, delete the JS state machine

The hand-rolled pointer state machine (hoverIntent poll, refs, timers, document
pointermove geometry-close, interactive gate, resize cooldowns, keyboard-held
suppression) was fragile and side/instance-specific — hover broke on the right
rail, keyboard toggles triggered phantom animations, resize popped it open.

Replace all of it with the native primitive: CSS group-hover drives the slide
transform; a transition-delay on enter (instant on leave) is the hover-intent
gate (a fast pass-by doesn't dwell long enough to open); a thin edge trigger
inset past the OS resize grab strip arms it; and a single `forced` bool
(data-forced, toggled by the keyboard event) pins it open. Side-agnostic by
construction — group-hover doesn't care which edge or which pane.

Net: ~200 lines of imperative pointer logic → ~40 lines of declarative CSS.

* fix(desktop): don't animate hover-reveal panel across viewport on side flip

Flipping panes changed the off-screen transform from -translateX (off the
left) to +translateX (off the right). transition-transform interpolated
between them, passing through translate-x-0 (fully on-screen) mid-way — so the
hidden panel visibly slid across the window to reach its new hiding spot.
Key the panel on side so it remounts off-screen on the new edge with no
transition to play.

* clean(desktop): tighten hover-reveal markup

KISS pass on the CSS-driven reveal: reuse the existing `side` instead of a
local `left`, move the static duration/ease to inline style (drop two
single-use CSS vars + their arbitrary-value classes, keep only the
state-dependent enter-delay var), and trim comments to the house one-liner
density. No behavior change.

* fix(desktop): inset titlebar past traffic lights when sidebar is force-collapsed

The titlebar content inset (clearing the macOS traffic lights) keyed off the
stored sidebarOpen/fileBrowserOpen, but below the collapse breakpoint both
rails are force-collapsed so the left edge is uncovered while the store still
says open — content (the intro wordmark) overflowed under the lights. Gate
leftEdgePaneOpen on !narrowViewport using the shared SIDEBAR_COLLAPSE_MEDIA_QUERY.

Also rename the now-misleading reveal plumbing to match what it actually does:
onHoverRevealChange -> onOverlayActiveChange, $sidebarRevealed ->
$sidebarOverlayMounted (+ setter/consumer). It reports/stores collapsed-overlay
mode (mount gate), not live reveal state.

* feat(desktop): small --nous-shadow lift on revealed hover-reveal panels

Add a --nous-shadow token (white-based on light, black-based on dark) and apply
it to the floating sidebar panel only while revealed (group-hover / data-forced)
so it reads as lifted off the surface. No shadow on the off-screen panel.

* feat(desktop): shadow-reveal lift on revealed hover-reveal panels

Mirror the --shadow-nous layered falloff into a new --shadow-reveal token whose
drop color flips per mode (white on light, black on dark) via --shadow-reveal-raw
set in :root / :root.dark. Apply the generated shadow-reveal utility to the
floated panel only while revealed (group-hover / data-forced). Leaves the shared
--shadow-nous untouched.

* feat(desktop): use tuned reveal shadow, drop per-mode token

Replace the --shadow-reveal token machinery with Brooklyn's tuned literal
(0 -18px 18px -5px #0000003b) inline per-panel via --reveal-shadow, y-offset
sign flipped for the right side. Same color both modes. Reverts styles.css to
pristine (token removed).

* fix(desktop): use the reveal shadow verbatim, don't invert it per side

Flipping the y-offset sign for the right side inverted the shadow's direction
(cast-up -> cast-down), making it read heavier — not a mirror. The mirror axis
for a left/right panel is offset-x, which is 0 here, so both sides take the
tuned value as-is: 0 -18px 18px -5px #0000003b.

* clean(desktop): hoist reveal shadow to a named const

Move the inline reveal-shadow literal to HOVER_REVEAL_SHADOW alongside the
other HOVER_REVEAL_* tuning consts; drop the now-stale per-side comment.

* fix(desktop): truncate titlebar title before the right tool cluster

The session title used a hardcoded max-w-[52vw] that's blind to where the
right-side tools start, so it ran under them at narrow widths / with pane
tools present. Bound the title container by the same vars the titlebar drag
region uses (--titlebar-content-inset + --titlebar-tools-right +
--titlebar-tools-width) so it truncates exactly at the cluster's left edge.

* fix(desktop): responsive markdown tables — floor width + nowrap headers

The wrapper had overflow-x-auto but the table was w-full with auto layout, so
instead of scrolling it crushed columns until even header words broke mid-word
(Tim/e, Nig/ht). Add a min-w-[18rem] floor so it scrolls horizontally when the
column is narrower than readable, and whitespace-nowrap on th so headers never
break mid-word. Above the floor it still wraps cells naturally.

* fix intro
2026-06-07 22:41:21 -05:00
Shannon Sands 86e5efb0ae Preserve Telegram onboarding fallback errors 2026-06-07 19:48:09 -07:00
Shannon Sands ba29010902 Use httpx for Telegram onboarding worker calls 2026-06-07 19:48:09 -07:00
TekniumandSNooZyy2 e3b8b6d32c feat(hooks): expose thread_id and chat_type in agent:start/end context (#41672)
Adds thread_id and chat_type to the agent:start/end plugin hook context
(via getattr with safe defaults; both are real `source` attrs already used
in gateway/run.py). agent:end inherits them via **hook_ctx. Purely additive
— no prompt/history mutation. Documents the full ctx dict in hooks.py.

Co-authored-by: SNooZyy2 <SNooZyy2@users.noreply.github.com>
2026-06-07 19:16:36 -07:00
brooklyn! fa42ac094d feat(desktop): Shift+click the status-bar zap to toggle YOLO globally (#41666)
The status-bar zap currently toggles per-session approval bypass (the same
scope as the TUI's Shift+Tab). This adds a global escape hatch: Shift+clicking
the zap flips the persistent approvals.mode in config.yaml between "off"
(bypass on) and "manual" (bypass off), affecting every session, the CLI, the
TUI, and cron — and it survives restarts.

- statusbar-controls: thread the click's shiftKey through onSelect via a new
  StatusbarSelectModifiers arg.
- yolo-session: add setGlobalYolo() that calls config.set with scope="global".
- use-statusbar-items: branch toggleYolo on modifiers.shiftKey; plain click
  stays per-session, Shift+click goes global.
- tui_gateway config.set "yolo" key: add scope="global" that reads/writes
  approvals.mode through the gateway's own (mtime-cached) config view, honors
  an explicit value, and re-emits session.info to every live session so each
  window's zap reflects the flip immediately.
- i18n: tooltip copy in en/ja/zh/zh-hant notes Shift+click toggles globally.

Tests: two new tui_gateway tests cover the global toggle and explicit-value
paths; existing session/process-scope yolo tests still pass.
2026-06-07 20:57:08 -05:00
Tekniumandtfournet 30c7913617 fix(api_server): report hermes version on /health and /health/detailed (#40620)
Salvaged from #40479; re-verified on main, tightened, tested.

Co-authored-by: tfournet <tfournet@users.noreply.github.com>
2026-06-07 18:38:54 -07:00
Tekniumandziwon d3b670e63e docs(codex): document --sandbox danger-full-access for gateway bubblewrap failures (#40619)
Salvaged from #40435; re-verified on main, tightened, tested.

Co-authored-by: ziwon <ziwon@users.noreply.github.com>
2026-06-07 18:36:18 -07:00
TekniumandHeLLGURD b97cd81c78 refactor(insights): drop dead pricing/duration wrappers, call usage_pricing directly (#40618)
Salvaged from #40527; re-verified on main, tightened, tested.

Co-authored-by: HeLLGURD <HeLLGURD@users.noreply.github.com>
2026-06-07 18:33:20 -07:00
Tekniumandjiangkoumo ad399b9229 docs(update): document updates.* config keys (pre_update_backup, backup_keep, non_interactive_local_changes) (#40617)
Salvaged from #40540; re-verified on main, tightened, tested.

Co-authored-by: jiangkoumo <jiangkoumo@users.noreply.github.com>
2026-06-07 18:29:56 -07:00
Tekniumandgauravsaxena1997 2aa316ec9c docs(windows): fix Get-Command PATH guidance to venv\Scripts\hermes.exe (#40613)
Closes #40464.

Salvaged from #40488; re-verified on main, tightened, tested.

Co-authored-by: gauravsaxena1997 <gauravsaxena1997@users.noreply.github.com>
2026-06-07 18:28:23 -07:00
Tekniumandpsionic73 4ce9caed04 fix(tui): type execFileNoThrow stdio/ChildProcess and make memoryMonitor critical test heap-independent (#40612)
Salvaged from #40415; re-verified on main, tightened, tested.

Co-authored-by: psionic73 <psionic73@users.noreply.github.com>
2026-06-07 18:23:42 -07:00
TekniumandGanesh0690 6bdc4c0231 test: skip curses tests on Windows where _curses is unavailable (#40611)
Salvaged from #40447; re-verified on main, tightened, tested.

Co-authored-by: Ganesh0690 <Ganesh0690@users.noreply.github.com>
2026-06-07 18:21:03 -07:00
Tekniumandxxxigm 628780b4f3 fix(desktop): pin empty PostCSS config so Vite stops walking up the home tree (#40609)
Salvaged from #40526; re-verified on main, tightened, tested.

Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
2026-06-07 18:10:32 -07:00
xxxigm c50fb560ef Merge pull request #40433 from xxxigm/fix/desktop-chat-autoscroll
fix(desktop): stop chat transcript from jumping/flickering while reading (#37549)
2026-06-07 20:09:55 -05:00
TekniumandYLChen-007 69a293b419 hardening(todo): bound TodoStore item content length and count
The todo list is re-injected into the model's context after every
context-compression event (TodoStore.format_for_injection), so an oversized
todo item or an unbounded number of items defeats the compression it is meant
to ride through. TodoStore.write/_validate previously enforced no size or count
bounds, so a single 50KB item produced a ~50KB re-injection block on every
subsequent turn.

Add two caps:
- MAX_TODO_CONTENT_CHARS (4000): per-item content is truncated with a marker.
  Routed through a shared _cap_content() so the merge-update path (which writes
  content directly, bypassing _validate) is capped too.
- MAX_TODO_ITEMS (256): total list length is bounded, keeping the
  highest-priority head (list order is priority).

Both caps are generous relative to real plans — a todo item is a short task
description and active lists are a handful of items.

NOT a security fix. Raised externally via GHSA-5g4g-6jrg-mw3g, which framed a
caller-supplied conversation_history on the authenticated API server replaying
into _hydrate_todo_store as a DoS. That path is authenticated (the API server
refuses to start without API_SERVER_KEY) and self-scoped (the caller supplies
their own entire history and can only inflate their own response chain — forged
role=tool entries are never persisted to the session DB), so it is out of scope
as a vulnerability under SECURITY.md 3.2. These bounds are footgun containment
that also applies to the trusted agent path, where the model itself authors the
todos. Credit to the reporter for the observation.

Co-authored-by: YLChen-007 <30854794+YLChen-007@users.noreply.github.com>
2026-06-07 18:06:27 -07:00
teknium1 9c5d1afbe9 chore: add giladbau to AUTHOR_MAP for salvaged PR #20182 2026-06-07 18:05:58 -07:00
Gilad Bauman ae82eed2b1 fix(gateway): use OGG for Telegram auto TTS 2026-06-07 18:05:58 -07:00
Tekniumandmaxmilian cb83149dc6 fix(yuanbao): bound ws.close() so an idle server can't stall shutdown ~5s (#40607)
Salvaged from #40421; re-verified on main, tightened, tested.

Co-authored-by: maxmilian <maxmilian@users.noreply.github.com>
2026-06-07 17:49:38 -07:00
AMIKandAMIK-coorporations 2b119baac1 docs: add Urdu translation of README (#40578)
Co-authored-by: AMIK-coorporations <info@amik.co>
2026-06-08 06:15:27 +05:30
Tekniumandskylarbpayne 09d66037f8 fix(hindsight): send only new-turn delta on append retains instead of whole session (#40605)
Closes #40503.

Salvaged from #40519; re-verified on main, tightened, tested.

Co-authored-by: skylarbpayne <skylarbpayne@users.noreply.github.com>
2026-06-07 17:41:10 -07:00
Teknium dde9c0d19d feat(gateway): render terminal tool calls as native bash code blocks on markdown platforms (#41215)
Tool-progress now shows a terminal command in a ```bash fenced block —
full command, no surrounding quotes, no label, no 40-char truncation —
instead of the noisy `terminal: "cmd…"` line, on every platform that
renders markdown code blocks (Telegram, Slack, Matrix, WhatsApp, Feishu,
Weixin, Discord). Plain-text platforms keep the compact preview line.

Gated on a new `BasePlatformAdapter.supports_code_blocks` capability
(default False) rather than a hardcoded platform list, so plugin adapters
(Discord lives in plugins/platforms/) opt in by setting the flag. Applies
to both all/new and verbose progress modes, with a safe fallback when the
command arg is missing or blank.
2026-06-07 17:29:55 -07:00
Teknium e029b7597b feat(desktop): stop the chat viewport from following streaming output (#41414)
The desktop chat GUI pinned the viewport to the bottom on every content
growth while a turn streamed, so the window chased tokens as they arrived.
Remove that follow behavior: once a turn is running the viewport stays
exactly where the user left it.

- Delete the streaming ResizeObserver re-pin loop in useThreadScrollAnchor.
- Delete the post-run bottom lock (kept pinning ~1.2s after completion).
- Keep the one-time jump-to-bottom on user submit / new turn / session
  change so a freshly submitted message still lands in view.
- Update streaming.test.tsx to assert the viewport no longer follows
  streaming growth or snaps down on final code-highlight remeasure.
2026-06-07 17:29:32 -07:00
teknium1 1c7ae46f0e chore(release): map AlchemistChaos co-author email for #40135 salvage 2026-06-07 17:29:12 -07:00
teknium1andAlchemistChaos cadb74adad fix(desktop): recover chat after sleep/wake by revalidating a stale remote backend
After sleep/wake, a remote (global-remote) primary backend can become
unreachable, but it has no child process whose 'exit' clears the main
process's cached connectionPromise. The renderer then re-dials the same
dead remote forever and the composer stays stuck on "Starting Hermes…";
only a quit+reopen recovered.

Fix: the renderer's existing backoff-paced reconnect loop now asks the
main process to revalidate the cached connection before re-dialing. The
main process liveness-probes the cached REMOTE backend's public
/api/status and, if unreachable, drops the cache (resetHermesConnection
only nulls connectionPromise for a remote — no child to SIGTERM) so the
next getConnection() rebuilds a reachable descriptor. Local backends are
never touched here; they self-heal via the child 'exit' handler. The
renderer's loop already provides retry pacing and rides out transient
blips, so no streak/episode bookkeeping is needed in the main process.

The boot hook dismisses the boot-progress overlay on the post-rebuild
'open' so an in-place rebuild can't leave it stuck at ~94%.

Reimplements #40135 by @AlchemistChaos on a smaller, more interpretable
path (63 added lines vs 555): no extracted helper module, no
failure-streak / episode-window state, the renderer's backoff loop is
the retry mechanism. Original diagnosis and fix by @AlchemistChaos.

Co-authored-by: AlchemistChaos <alchemistchaos@protonmail.com>
2026-06-07 17:29:12 -07:00
kshitij c986377236 Merge pull request #41482 from kshitijk4poor/salvage/searxng-config-env-34306
fix(web): honor Hermes config-aware SEARXNG_URL lookup (salvage #34306 + auto-detect follow-up)
2026-06-07 12:54:32 -07:00
kshitijk4poor 7df81d0557 fix(web): make _has_env config-aware so SEARXNG_URL auto-detect honors Hermes config
Follow-up to #34306. The provider fix made SearXNG *usable* with a
config-only SEARXNG_URL, but tools/web_tools._has_env still read raw
os.getenv, so the backend auto-detect cascade and check_web_api_key
remained blind to it — SearXNG worked when explicitly selected but was
never auto-selected. Route _has_env (and the SearXNG diagnostic print)
through a config-aware _env_value helper mirroring the provider's
_searxng_url(). Fixing the shared helper covers every provider key in
one place. Adds regression tests for config-only auto-detect and
check_web_api_key. See #34290.
2026-06-08 01:12:32 +05:30
Kailigithub 2ee8c983c0 fix(web): honor Hermes config-aware SEARXNG_URL lookup 2026-06-08 01:11:08 +05:30
kshitij 0c0fbf763b Merge pull request #41430 from helix4u/fix-url-tools-unicode-normalization
fix(tools): percent-encode non-ascii URL components
2026-06-07 12:39:30 -07:00
kshitijk4poor 8e71b5136b fix(cli): paint approval/clarify/sudo/secret modal prompts directly, not via the throttle (#41098)
In classic CLI mode the dangerous-command approval prompt (and the clarify,
sudo, and secret-capture prompts) could fail to render: the user saw
'⏱ Timeout — denying command' after 60s without ever seeing the panel,
making approvals.mode: manual unusable.

Root cause. These prompts run their wait loop on the agent/background thread:
they set modal state that a ConditionalContainer's filter reads, then call
self._invalidate() to repaint so the panel appears. _invalidate() is a
THROTTLED wrapper built for high-frequency background repaints (spinner frames,
streaming) — it (a) returns early while a SIGWINCH resize-recovery is pending,
and (b) otherwise only repaints if 250ms elapsed since the last paint. Under
either condition the modal's entry paint is silently dropped, the
ConditionalContainer never re-evaluates, and the prompt times out unseen.

The throttle never belonged on these paths. Originally the callbacks painted
with a direct self._app.invalidate() and worked; a throttle PR blanket-replaced
every invalidate (including these rare, one-shot, user-blocking modal paints)
with the throttled _invalidate(); a later commit removed an idle 1Hz repaint
that had been masking dropped modal paints, surfacing the bug. Notably the
modal KEY-BINDING handlers (↑/↓/Enter) already paint with a direct
event.app.invalidate(), never the throttle — the background-thread callbacks
were the inconsistent ones.

Fix. Add a small _paint_now() helper that paints directly (guarded for a
missing _app, exception-safe) and route the four modal paths' entry, response,
countdown, and teardown paints through it — matching the key-handler idiom.
This covers approval, clarify, sudo, and the secret-capture teardown
(_submit_secret_response, which previously used the throttled _invalidate() so
its panel could linger after submit). _invalidate() is left untouched and its
docstring now states it is for high-frequency background repaints only;
modal/interactive paints must use _paint_now()/_app.invalidate() directly. This
also fixes the resize-recovery edge case for free (a direct paint never
consults the resize guard) without a throttle-bypass flag that could be
cargo-culted onto hot paths. Countdown refresh cadence tightened 5s->1s so the
timer stays visible while waiting, and a copy-pasted duplicate countdown block
in _clarify_callback is removed.

Tests: TestModalPaintNow drives all three wait-loop callbacks on a background
thread with BOTH gates active (_resize_recovery_pending=True + a recent
_last_invalidate in the throttle window) and asserts the panel paints on entry
AND repaints on teardown; plus a secret-teardown test, a direct
_paint_now-vs-_invalidate gate test, and a no-_app safety test. Each modal test
fails if its paint is reverted to _invalidate(). 17 in-file tests pass; full
tests/cli suite green (900).

Diagnosis credit: the throttle-drop root cause was identified by @sanidhyasin
in #41116; @islam666 independently reached the same direct-invalidate approach
in #41166; original report #41098 by @jodonnel.
2026-06-08 00:46:43 +05:30
Enes Aydın f3af489ec2 install.sh: hint at root-owned npm cache when desktop npm install fails (#39688)
When apps/desktop's `npm ci`/`npm install` fails, install_desktop printed a
single "Desktop workspace npm install failed" line and aborted, leaving the
user with a wall of raw npm output. A common trigger is a root-owned ~/.npm
cache left by an earlier `sudo npm`/`sudo npx`: the non-root install then
cannot write the shared cache, and npm reports it as EEXIST / "File exists"
while the real errno is EACCES (-13) -- so it reads like an installer bug.

Add a targeted remediation hint on that failure path pointing at:

    sudo chown -R "$(id -un)" ~/.npm && npm cache verify

followed by the manual rebuild command. The stage stays a hard failure by
design (a silent skip yields a "complete" install with no app); only the
failure output changes.
2026-06-07 17:55:58 +00:00
helix4u 333f01bc7f fix(tools): percent-encode non-ascii URL components 2026-06-07 11:42:26 -06:00
Teknium 1892e22acb fix(skills): browse shows full catalog, not first 5000 (#41413)
hermes skills browse capped the hermes-index source at 5000, so it
surfaced ~5.4k of the ~90.7k skills the index actually carries. Raise
the per-source ceiling above catalog size; browse already paginates
client-side and the index is disk-cached, so no extra fetch cost.
2026-06-07 10:15:31 -07:00
16786f3bb3 feat(desktop+gateway): remote media relay — attach images/PDFs and display gateway images over the network
Desktop connected to a remote gateway can now attach images and PDFs and
display agent-written images. Previously the desktop passed a LOCAL file path
to image.attach; on a remote gateway that path doesn't exist, so the image was
silently dropped ("skipped unreadable path") and the vision model never saw it.
The reverse direction was also broken — images the agent wrote on the gateway
rendered as dead links in the remote client.

Gateway (tui_gateway/server.py):
- image.attach_bytes: base64 byte upload written into the gateway's own images
  dir and queued via the existing native-image-attach pipeline. Magic-byte
  extension sniffing, data-URL prefix + whitespace tolerance, 25 MB cap,
  structured error codes. Accepts content_base64/filename (canonical) and
  data/ext (older-desktop aliases).
- pdf.attach: renders each page to PNG via pdftoppm (poppler-utils) at 150 DPI
  and queues the pages as images; 50 MB / 25-page caps. Accepts host path or
  base64 upload.
- Shared helpers (_decode_attach_base64, _sniff_image_ext, _queue_attached_image)
  so the two methods and the existing image.attach don't duplicate logic.

Gateway (hermes_cli/web_server.py):
- GET /api/media: returns a gateway-local image as a base64 data URL so remote
  clients can display it. Auth-gated like every /api route, extension
  allowlist + size cap, AND confined to the gateway's own media roots
  (images/screenshots/cache, resolved symlink-safe) so an authed caller can't
  read image-extension files anywhere on disk.

Desktop (apps/desktop):
- syncImageAttachmentsForSubmit uploads bytes via image.attach_bytes when the
  connection mode is 'remote'; the local fast path is unchanged.
- media.ts gains isRemoteGateway() + gatewayMediaDataUrl(); directive-text and
  markdown-text fetch images over /api/media in remote mode.

Consolidates the competing remote-media PRs (#38876, #40317, #21908, #39437)
into one coherent implementation, taking the strongest parts of each and adding
shared-helper cleanup plus the /api/media root-confinement hardening on top.
The per-profile gateway switching from #38876 is intentionally left out as a
separable feature. TUI file uploads (#40492) remain a separate surface.

Tested: 11 new tui_gateway tests + 5 /api/media endpoint tests + desktop
media.remote unit tests; full tui_gateway + web_server suites green (472
passed); tsc -b clean; E2E verified the full attach→disk→queue and
gateway-path→data-URL display round-trip plus the out-of-root security block.

Co-authored-by: Max Mitcham <maxmitcham@mac.home>
Co-authored-by: Justlrnal4 <Justlrnal4@users.noreply.github.com>
Co-authored-by: Chris Cook <ccook@nvms.com>
Co-authored-by: Thomas Paquette <thomas.paquette@gmail.com>
2026-06-07 10:05:53 -07:00
Teknium 20fd0bde5d feat(desktop): full tool-backend config (pickers + per-backend settings) in Settings (#41232)
* feat(desktop): surface TTS/STT/terminal backends as Settings dropdowns

Every native tool backend that the agent supports now shows up as a
clickable picker in the desktop Settings UI instead of a free-text box.

Desktop Settings renders a config field as a <Select> only if its dotpath
is a key in ENUM_OPTIONS (helpers.ts::enumOptionsFor returns undefined ->
free-text <Input> otherwise). Three backend-selector fields were surfaced
in their sections but missing from the map, so users had to hand-type the
provider name and could reasonably assume it was unsupported:

- tts.provider — now lists all built-in TTS backends incl. xai (Grok)
- stt.provider — local/groq/openai/mistral/elevenlabs
- terminal.backend — local/docker/singularity/modal/daytona/ssh

Each list is kept in sync with its backend source of truth (TTS:
agent/tts_registry.py::_BUILTIN_NAMES + tools/tts_tool.py; STT + terminal:
hermes_cli/config.py / tools/terminal_tool.py). The existing
enumOptionsFor current-value-append keeps any hand-typed/legacy value
selected, and command-type TTS providers still work.

Reported for Grok/xAI TTS, which was already a fully-wired built-in
provider (tts.provider: xai + XAI_API_KEY) with no picker entry.

* feat(desktop): expose per-backend TTS/STT/terminal config fields in Settings

Completes the backend-coverage pass: not just the provider PICKER but every
backend's own config fields are now tunable from desktop Settings, so a user
who picks (e.g.) Grok TTS can also set its voice/language without hand-editing
config.yaml.

Also fixes the STT provider dropdown: added 'xai' (Grok STT), which the
transcription dispatcher (tools/transcription_tools.py) handles but the
config.py comment had omitted — the dispatch ladder is the source of truth.

New Settings fields (Voice section):
- TTS xai (voice_id, language), minimax (model, voice_id), mistral
  (model, voice_id), gemini (model, voice), neutts (model, device),
  kittentts (model, voice), piper (voice)
- STT openai (model), groq (model), mistral (model)

New Settings fields (Advanced section):
- terminal docker_image / singularity_image / modal_image / daytona_image

New ENUM_OPTIONS dropdowns: stt.provider (+xai), stt.openai.model,
stt.mistral.model, tts.openai.model, tts.elevenlabs.model_id,
tts.neutts.device. Each list mirrors the backend generator's accepted values
(tools/tts_tool.py, tools/transcription_tools.py, hermes_cli/config.py).

i18n: FIELD_LABELS/FIELD_DESCRIPTIONS cover all locales via the English
fallback in config-settings.tsx; added native translations to ja/zh/zh-hant.

Secrets (provider API keys, modal/daytona tokens, ssh host/key) intentionally
stay in Settings -> Keys as env vars, not duplicated as config fields.
2026-06-07 10:05:47 -07:00
Teknium 0c48b7165d hardening(api-server): scan cron prompts on REST create/update for parity with the agent tool
The agent-facing cronjob tool scans the user prompt with _scan_cron_prompt()
before creating/updating a job (tools/cronjob_tools.py); the REST cron
endpoints (POST /api/jobs, PATCH /api/jobs/{id}) validated length but not
content. This adds the same scan to both handlers so an exfiltration/injection
prompt is rejected the same way regardless of which surface created the job.

NOT a security boundary, defense-in-depth / parity only: the REST cron
endpoints are authenticated (every handler runs _check_auth, and connect()
refuses to start without API_SERVER_KEY), and _scan_cron_prompt is a documented
in-process heuristic, not a containment boundary (SECURITY.md 3.2).

Raised externally via GHSA-fr3q-rjg3-x6mf (DNS-rebinding pre-auth RCE). The
report's load-bearing 'no auth by default' premise was already closed three
weeks after it was filed by the API_SERVER_KEY-required guard (commit
1a9ef8314); this lands the create/update prompt-validation parity the report
also pointed at. Scanner imported defensively so a missing scanner cannot
disable the cron REST API.
2026-06-07 10:04:57 -07:00
Tekniumandmohamedorigami-jpg af08c43f3e fix: skip MCP preflight content-type probe on reconnect when already ready (#40604)
Closes #40366.

Salvaged from #40548; re-verified on main, tightened, tested.

Co-authored-by: mohamedorigami-jpg <mohamedorigami-jpg@users.noreply.github.com>
2026-06-07 09:51:11 -07:00
teknium1 76f01780f0 fix(kanban): sweep deferred scratch parent on non-scratch child completion + tests
Follow-up on the deferred-cleanup salvage (#33774): _cleanup_workspace
returned early for a non-scratch ('dir'/'worktree') task and never ran the
parent sweep, so a scratch parent waiting on a 'dir' child would leak its
deferred workspace forever. Run the parent sweep before the early return.

Adds regression tests: deferred-while-child-active, swept-after-last-child,
and dir-child-unblocks-scratch-parent.
2026-06-07 09:50:44 -07:00
annguyenNous 9405cd0812 fix: defer scratch workspace cleanup when task has active children (#33774)
When a Kanban task with workspace_kind=scratch completes, the
_cleanup_workspace() function immediately deletes the workspace
directory. If the task has children linked via task_links, those
children find the workspace deleted when they start.

This fix adds two checks:
1. Before deleting, check if any children are still active
   (todo/ready/running). If so, defer cleanup.
2. After a child completes, check if parent workspace can now
   be cleaned up (all children terminal).

Fixes NousResearch/hermes-agent#33774
2026-06-07 09:50:44 -07:00
Teknium cb3e41e2fd feat(onboarding): opt-in structured profile-build path on first contact (#41114)
* feat(onboarding): opt-in structured profile-build path on first contact

On a user's very first gateway message, Hermes now optionally offers to
build a short profile of them — then, only with consent, gathers durable
facts and persists them to the user-profile memory store (memory tool,
target="user") so future sessions start already knowing who they are.

Inspired by Poke's zero-input onboarding, but consent-first by design:
- The agent OFFERS, never assumes. Declining stops it immediately.
- Before ANY external lookup it states what it will look up and asks.
- It never reads connected accounts (email/calendar) silently — the
  exact privacy concern that made naive implementations feel invasive.

Wiring reuses existing infrastructure end-to-end:
- gateway/run.py first-message hook (was a plain self-intro) now swaps in
  the profile-build directive when enabled and not yet offered.
- agent/onboarding.py gains profile_build_mode()/profile_build_directive()
  + PROFILE_BUILD_FLAG, latched once via the existing onboarding.seen
  mechanism so the offer fires at most once per install.
- config default onboarding.profile_build: "ask" (set "off" to disable).
  Added to an existing section, so no _config_version bump needed.

No new storage layer, no new injection path, no prompt-cache impact.

* fix(dashboard): fold onboarding into agent tab to avoid 1-field category

onboarding.profile_build is the only schema-surfaced onboarding field
(onboarding.seen is an internal latch dict), so the dashboard CONFIG_SCHEMA
single-field-category invariant rejected it. Merge onboarding -> agent like
the other small categories.
2026-06-07 08:36:48 -07:00
Teknium d87f293972 feat(compression): temporal anchoring in compaction summaries (#41102)
Compaction summaries now receive the current date and instruct the
summarizer to rewrite completed actions as absolute, dated, past-tense
facts (e.g. "email John about the proposal" -> "Sent the proposal email
to John on 2026-06-07"). A resumed conversation no longer re-issues work
that already happened or treats a finished action as still pending.

The date is resolved via hermes_time.now() (date-only, user-configured
timezone) inside _generate_summary. The compaction summary is a
mid-conversation message that is never part of the cached prefix, so the
date does not affect prompt-cache stability. Date resolution is
best-effort: a clock failure omits the rule rather than blocking
compaction. The rule rides the shared template, so both first-compaction
and iterative-update prompts carry it.

Inspired by Poke's summarization (temporal anchoring + semantic
preservation).
2026-06-07 08:36:45 -07:00
Teknium 9dbad1990b test(discord): align clarify/model-picker tests with fail-closed component auth (#41338)
Three gateway tests broke on main after the component-auth security
hardening (test_discord_component_auth.py) made empty Discord component
allowlists fail-closed: a view built with allowed_user_ids=set() now
rejects every click instead of allowing anyone.

The clarify and model-picker BEHAVIOR tests still constructed their views
with an empty allowlist and expected the click to succeed — a stale
assumption from before the hardening. Fixed by giving each view an
allowlist containing the clicking user (the interaction's own id), which
is the realistic shape and what the security model requires.

Production code unchanged — this only updates the test fixtures to match
the intended (and separately pinned) fail-closed contract. The security
regression suite and these behavior suites now both pass.

Fixes:
- test_discord_clarify_buttons.py: test_choice_falls_back_to_label_text_when_entry_missing, test_other_flips_entry_to_awaiting_text
- test_discord_model_picker.py: test_model_picker_clears_controls_before_running_switch_callback
2026-06-07 08:27:40 -07:00
Teknium a317e54935 chore(release): map Dusk1e and LaPhilosophie for approval fail-closed salvage (#33844, #33866, #30964) 2026-06-07 06:21:37 -07:00
LaPhilosophie f6f363662e fix(discord): fail closed for component button auth when no allowlist set
Salvage of the Discord half of PR #30964 by @LaPhilosophie. Discord
component button callbacks (ExecApprovalView, SlashConfirmView,
UpdatePromptView, ModelPickerView) bypass the normal message dispatch
authorization path. _component_check_auth previously returned True when
both the user and role allowlists were empty, so any guild member who
could see an approval prompt could click Approve on a dangerous command.

Fail closed instead: require DISCORD_ALLOWED_USERS / DISCORD_ALLOWED_ROLES
/ GATEWAY_ALLOWED_USERS membership, or an explicit DISCORD_ALLOW_ALL_USERS
/ GATEWAY_ALLOW_ALL_USERS opt-in for deliberately-open deployments.

Mirrors the Telegram (#24457) and Matrix fail-closed precedent.
The Slack half of #30964 is superseded by PR #33844's helper.

Reported via GHSA-mc26-p6fw-7pp6 (@whyiug).

Co-authored-by: LaPhilosophie <804436395@qq.com>
2026-06-07 06:21:37 -07:00
Dusk1e 3fa15b33dd fix(feishu): fail closed for update prompt card actions 2026-06-07 06:21:37 -07:00
Dusk1e 410cb743bf fix(slack): re-check gateway auth on approval and slash-confirm buttons 2026-06-07 06:21:37 -07:00
TekniumandannguyenNous 2912d94370 fix: guard int(os.getenv()) casts against malformed env vars (#40598)
A non-numeric value in env vars like HERMES_STREAM_RETRIES,
HERMES_KANBAN_SPECIFY_MAX_TOKENS, GOOGLE_CHAT_MAX_BYTES, IRC_PORT, etc.
raised ValueError at import/init and crashed startup. Parse them safely,
falling back to the default.

Unified onto the existing utils.env_int(key, default) helper for core/
hermes_cli/tools modules instead of the original PR's three duplicate
local helpers; plugins keep minimal inline guards (no core-utils import).
All existing max()/min()/`or extra.get()` wrappers preserved.

Co-authored-by: annguyenNous <annguyenNous@users.noreply.github.com>
2026-06-07 06:14:24 -07:00
oxngon e2cc24e331 fix: respect Honcho env var fallback in doctor and honcho status
hermes doctor and hermes honcho status warned 'Honcho config not found'
whenever ~/.honcho/config.json was absent, even though HONCHO_API_KEY in
.env resolves a working config via HonchoClientConfig.from_global_config()
-> from_env(). Both now check hcfg.api_key/base_url before warning.

Co-authored-by: oxngon <98992931+oxngon@users.noreply.github.com>
2026-06-07 05:37:02 -07:00
teknium1 fa8fd513ea chore(release): add synapsesx to AUTHOR_MAP for #40495 salvage 2026-06-07 05:01:27 -07:00
synapsesx f10a330aee fix(research): keep tool_call/tool_response pairs intact when compressing trajectories
## What does this PR do?

The trajectory compressor could corrupt training trajectories by cutting a
conversation in the middle of a tool-call/tool-response pair. In the from/value
trajectory format a `tool` turn (carrying `<tool_response>` markers) is always
emitted immediately after the `gpt` turn whose `<tool_call>` it answers, so the
two turns must stay together. The compressible region's end boundary, however,
was chosen purely by token accumulation: the loop stopped at the first turn where
the accumulated tokens met the savings target, with no regard for turn roles. For
any over-budget trajectory whose savings boundary happened to land between a `gpt`
turn and its `tool` turn, the `gpt` (with its `<tool_call>`) was summarised away
into the replacement `human` message while the now-orphaned `tool` turn (with its
`<tool_response>`) was kept verbatim in the tail — producing an unmatched marker
and silently corrupting the training signal. The head boundary had the mirror
problem when the first tool turn was not protected.

This change snaps both compression boundaries to a clean turn boundary before the
region is extracted and replaced, so the summary always covers whole gpt+tool
blocks and a `tool` turn is never separated from the `gpt` turn that precedes it.
The boundary is moved forward when possible (folding an orphaned tool turn into
the region that already holds its gpt) and falls back to moving backward when no
clean boundary exists ahead, such as when the protected tail itself begins on a
tool turn.

## Related Issue

N/A

## Type of Change

- [x] 🐛 Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `trajectory_compressor.py`: added `_is_boundary_clean()` and `_snap_boundary()`
  helpers on `TrajectoryCompressor`, and applied them to both the head and tail
  compression boundaries in `compress_trajectory()` and
  `compress_trajectory_async()`. When snapping collapses the region to nothing
  safe to compress, the trajectory is returned unchanged and flagged as still
  over the limit rather than being corrupted.
- `tests/test_trajectory_compressor.py`: added `TestCompressionToolPairIntegrity`
  covering the sync and async paths plus direct unit tests for the boundary
  snapping (forward skip and backward fallback).

## How to Test

1. Run the focused tests: `pytest tests/test_trajectory_compressor.py -q`.
2. The new sync/async cases build a trajectory of gpt/tool pairs with an oversized
   middle gpt turn and choose a token target that forces the accumulation
   boundary to stop between a `<tool_call>` and its `<tool_response>`. They assert
   that `<tool_call>` and `<tool_response>` markers stay balanced after
   compression and that every kept `tool` turn is immediately preceded by a `gpt`
   turn (never the inserted summary or another tool turn).

## Checklist

### Code

- [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md)
- [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
- [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix/feature (no unrelated commits)
- [x] I've run `pytest tests/ -q` and all tests pass
- [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)

### Documentation & Housekeeping

- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
2026-06-07 05:01:27 -07:00
manishbyatroy 490c486ff6 fix(simplex): accept display name in SIMPLEX_ALLOWED_USERS
SIMPLEX_ALLOWED_USERS silently denied every contact when operators
listed display names instead of numeric contactIds. The SimpleX UI
never surfaces the numeric id, so display names are what operators
naturally put in the env var. _is_user_authorized only compared
source.user_id (the contactId), so the allowlist never matched.

Expand check_ids to include source.user_name for the simplex platform,
mirroring the existing WhatsApp phone-LID aliasing pattern. Adds doc +
setup-prompt clarification and three regression tests.

Salvaged from PR #40393. Adds manishbyatroy to release.py AUTHOR_MAP.
2026-06-07 04:53:22 -07:00
Teknium 9d72680ca3 fix(desktop): make the running-turn timer per-session (#41182)
The desktop statusbar turn timer read a single process-global $turnStartedAt,
set/cleared only for the active session. With multiple same-profile sessions
running at once, switching to session B reset the one shared clock, so
session A's still-running turn "restarted from zero" the moment you left it —
exactly the behaviour @Da7_Tech reported after the profile-scoped session work.

Move turnStartedAt onto ClientSessionState so each session owns its own turn
clock. The global atom now just mirrors whichever session is focused, written
on view-sync (the flush that already stages the active session's state). A
backgrounded turn keeps counting in its own cache entry, and focusing it
restores its real elapsed time instead of zeroing it.

Set/clear sites: message.start (seed), message.complete + error + interrupted
bail (clear), and the session.info running-state path (seed if missing / clear
on stop) so a turn that goes busy via session.info — e.g. resuming a session
that's already running — also gets a clock.

Note: the agent loop itself never froze — every same-profile session runs in
its own backend thread and background deltas are buffered per-session. This
fixes the timer-reset symptom; the "no live progress until you return" is
inherent to a single-view transcript and is out of scope here.
2026-06-07 04:29:05 -07:00
teknium1 1a4010edf5 test(approval): regression for shell-escape denylist bypass (#36846, #36847) 2026-06-07 03:57:21 -07:00
ashishpatel26 621bf3a873 fix(security): strip shell escapes in denylist normalizer; fail-closed on missing approval module
DANGEROUS_PATTERNS and HARDLINE_PATTERNS are matched on the raw command string,
so backslash-escape (r\m) and empty-quote split (r''m) bypass both lists.
_normalize_command_for_detection now strips these before pattern matching.

tui_gateway shell.exec had a bare 'except ImportError: pass' that silently
disabled the entire safety gate if tools.approval wasn't importable. Changed
to fail-closed (return 5001 error). Added detect_hardline_command check.

Fixes #36846, #36847.
2026-06-07 03:57:21 -07:00
Tekniumandashishpatel26 1fb99b1f22 fix(stream+output-cap): guard empty streams and parse OpenRouter output-cap errors (#40589)
Two isolated reliability fixes:
- chat_completion_helpers: raise on a zero-chunk stream (no finish_reason,
  no content/reasoning/tool_calls) so retry handles it instead of
  fabricating a successful empty turn.
- model_metadata: parse the OpenRouter/Nous output-cap error phrasing
  ("maximum context length is N ... (A of text input, B of tool input,
  C in the output)") so parse_available_output_tokens_from_error returns
  a real cap and the caller stops looping on it.

Salvaged from #40405 (@ashishpatel26) — took the two stream/error-parsing
fixes. The PR also bundled compression-state changes (on_session_start
clearing _previous_summary; cron session-id prefix preservation, #38788);
those touch the compression hot path and are split out for separate review.

Co-authored-by: ashishpatel26 <ashishpatel26@users.noreply.github.com>
2026-06-07 03:52:09 -07:00
teknium1andTamaz-sujashvili 02aad08acf fix(desktop): bootstrap falls back to installed agent install.sh on GitHub 404
Packaged Desktop first-launch bootstrap no longer dies with a fatal HTTP
404 when install-stamp.json pins a commit that isn't fetchable from GitHub.

This only happens for locally-built desktop apps: write-build-stamp.cjs's
fromLocalGit() pins `git rev-parse HEAD`, which can be an unpushed commit
or dirty tree. CI builds stamp $GITHUB_SHA and are unaffected. The fix
unblocks the dev / self-builder workflow.

resolveInstallScript() now wraps the GitHub download in try/catch; on
failure it resolves ~/.hermes/hermes-agent/scripts/install.sh (the
already-installed agent checkout), copies it into bootstrap-cache, and
returns it as source 'installed-agent'. If the cache copy fails (read-only
FS), it uses the source path directly. With no installed checkout to fall
back to, the original error rethrows unchanged.

Download is now injectable via an optional _download param so the fallback
path is tested hermetically (no network).

Reported with a precise repro and suggested fix by @Tamaz-sujashvili (#40815).

Co-authored-by: Tamaz-sujashvili <56168197+Tamaz-sujashvili@users.noreply.github.com>
2026-06-07 03:46:12 -07:00
Teknium 9e63109522 feat(dashboard): change UI font from the theme picker, independent of theme (#41145)
The dashboard font is now selectable from the UI, not just YAML. A new Font
section in the header theme picker overrides the UI font of whatever theme is
active; the choice is orthogonal to the theme and survives theme switches.
Each theme keeps its own font as the default — picking "Theme default" clears
the override.

- web/src/themes/fonts.ts: curated font catalog (system + Google Fonts across
  sans/serif/mono), each with a family stack and optional webfont URL. The
  catalog is the only injected-font surface — no free-text URL box, so the
  injected <link> origins stay fixed.
- web/src/themes/context.tsx: font-override state (localStorage + server),
  applied after theme typography so it wins; theme apply re-asserts it, and
  clearing re-runs theme apply to restore the theme's own font. Mono is left
  to the theme so code/terminal are untouched.
- web/src/components/ThemeSwitcher.tsx: Font section with grouped, self-
  previewing font rows and a "Theme default" clear option.
- hermes_cli/web_server.py: GET/PUT /api/dashboard/font persisting to
  config.yaml dashboard.font, with a server-side id allow-list (unknown ids
  coerce to the theme sentinel).
- i18n + types, api client methods, tests, and docs.

Validation: 6 new backend endpoint tests pass; tsc + vite build clean; live
browser test confirmed pick/persist/survive-theme-switch/clear all work.
2026-06-07 03:39:01 -07:00
Tekniumandyubingz 136dae779e fix(cli): return bool (not None) when a destructive-slash confirmation is cancelled (#40583)
process_command() is typed -> bool, but the /clear, /new, and /undo
cancel paths did a bare `return` (None) when _confirm_destructive_slash
was declined, leaking None through the bool contract. Return True
(command handled, keep the REPL alive) on cancel.

Co-authored-by: yubingz <yubingz@users.noreply.github.com>
2026-06-07 02:49:28 -07:00
Teknium 0507e4630d fix(desktop): preserve configured base_url on same-provider model switch (#41121)
The desktop model picker calls POST /api/model/set with provider+model only
(no base_url). _apply_main_model_assignment cleared model.base_url for every
non-custom provider, so re-picking a Xiaomi MiMo model wiped a Token Plan
endpoint (https://token-plan-*.xiaomimimo.com/v1) back to the registry default
api.xiaomimimo.com — breaking valid tp- keys with 401s.

Now base_url is cleared only when switching to a different provider (the stale
URL belonged to the old one); same-provider re-assignment preserves it, and an
explicitly supplied base_url is honored for any provider.
2026-06-07 02:48:21 -07:00
Teknium 349a3f601c fix(desktop): stop bare-URL autolinker swallowing trailing emphasis asterisks (#41093)
The desktop markdown preprocessor autolinks bare URLs by wrapping them in
<...>. RAW_URL_RE allowed '*' in its character classes, so a bold line with
a URL and no separating space — e.g. '**PR opened: https://.../pull/123**' —
greedily pulled the closing '**' into the href, producing a broken link and
an unterminated bold run. Exclude '*' from both URL character classes; '_'
and '~' (which can appear in real paths) are preserved.
2026-06-07 02:47:39 -07:00
Teknium ed81cfe3de fix(cron): bound the desktop run-history query to one job (#41088)
The cron run-history endpoint (GET /api/cron/jobs/{id}/runs, added in
#40684) reused list_sessions_rich's order_by_last_active path with a
leading-wildcard id_query. That routes through the recursive
compression-chain CTE, which seeds from EVERY source='cron' row in the DB
and runs per-row preview/last_active subqueries before filtering to one
job and applying LIMIT. Work scaled with the total cron history, so a
large pile made the run-history load time out before eventually
populating.

Cron runs are flat, never-compressed sessions with ids of the form
cron_{job_id}_{ts}, so the chain machinery is pure overhead and the
job binding is a true prefix, not a substring.

- New SessionDB.list_cron_job_runs(): bounded [prefix, hi) id-range scan
  on source='cron', ordered by started_at DESC, with the same
  preview/last_active enrichment. No CTE, no leading-wildcard LIKE.
- Add idx_sessions_source(source, id) so the range is an index scan;
  bump SCHEMA_VERSION 14 -> 15 (index reconciles onto existing DBs via
  CREATE INDEX IF NOT EXISTS on startup).
- Point the endpoint at the new method.

Measured on a real SessionDB with 30k cron rows: 5ms vs 85ms for the old
path (16x), and the new path stays flat as the pile grows while the old
one scaled with it. Verified the query plan uses idx_sessions_source_id
(range scan, no full table scan), runs are correctly scoped (substring
collisions like cron_xalpha_ excluded), newest-first, and paged.
2026-06-07 02:41:01 -07:00
Teknium 5a3092b601 fix(desktop): scope in-session /model switch per-session, stop process-env leak (#41120)
* fix(desktop): scope in-session /model switch per-session, stop process-env leak

The desktop/dashboard tui_gateway backend hosts every same-profile session
in ONE process. An in-session /model switch wrote process-global env vars
(HERMES_MODEL / HERMES_INFERENCE_MODEL / HERMES_TUI_PROVIDER /
HERMES_INFERENCE_PROVIDER), which _resolve_startup_runtime() reads when
building a fresh agent. So switching the model in one session leaked into
every other live session's next agent rebuild (/new, resume) — changing the
model in session B silently changed it in session A.

Fix: record the switch as a per-session model_override on the session dict
instead of mutating os.environ. _make_agent honors that override on rebuild
(carrying the concrete base_url/api_key/api_mode the switch resolved), and
falls back to global config when absent. Global persistence on the --global
flag is unchanged.

Also a cleaner fix for #16857 (/new after switching to a custom-provider
model): the override carries the resolved credentials, so the rebuild keeps
the right endpoint without relying on the leaky env vars.

Reported via Twitter (@Da7_Tech): MiniMax M3 in one session + GLM 5.1 in
another interfere when switching between them.

* test(tui_gateway): align /model switch tests with per-session override contract

The three test_config_set_model_syncs_* tests asserted the old leaky contract
(switch writes HERMES_MODEL / HERMES_TUI_PROVIDER / HERMES_INFERENCE_PROVIDER to
process env). That env-sync IS the cross-session contamination bug this PR
removes. Updated to assert the new contract: shared process env untouched, the
switch recorded as a per-session model_override carrying provider/model/base_url/
api_key/api_mode. #16857's intent (a custom-provider switch survives /new) is
still covered — now via the override _make_agent honors on rebuild.
2026-06-07 02:33:28 -07:00
Teknium 4b9862eb7f chore: map bmoore210 author email for PR #40550 salvage 2026-06-07 02:15:23 -07:00
bmoore210 b55ac45264 fix(desktop): scope session list to active profile + longer timeout
The desktop sidebar fetched the unified cross-profile session list as
profile='all' and filtered it client-side by the active profile. On a
large multi-profile install the active profile's rows could be windowed
out of the cross-profile recency page entirely, so switching to a profile
agent showed an empty history panel (and the 'all' fetch could exceed the
15s IPC timeout on startup). Scope the fetch to the active profile so its
own page comes back on its merits, and bump the session-list IPC timeout
to 60s. profileScope is now a refreshSessions dep, so the existing
gateway-open effect re-pulls on profile switch.
2026-06-07 02:15:23 -07:00
bmoore210 330ca4585b fix: harden gateway startup and turn persistence
Persist the inbound user turn before provider/tool execution so a crash
before run_conversation() (e.g. provider/httpx client init failure) keeps
the inbound message in the transcript. Repair stale/missing SSL_CERT_FILE
state on gateway startup, and avoid duplicate gateway fallback writes.
2026-06-07 02:15:23 -07:00
helix4u 591e6fb8f4 fix(computer_use): honor custom vision routing 2026-06-07 02:09:20 -07:00
kshitijk4poor ffe665277c fix(aux): honor model.default_headers on auxiliary client too (#40033)
The salvaged main-agent fix (sanidhyasin) applies model.default_headers
to the primary OpenAI client, but the auxiliary client (title generation,
context compression, vision routing) builds its own clients and did not
read the override. For a `provider: custom` endpoint behind a gateway/WAF
that rejects the OpenAI SDK's identifying headers, the main turn would
succeed while auxiliary calls to the same endpoint still failed with the
opaque 502/4xx from #40033.

Add agent.auxiliary_client._apply_user_default_headers() (user values win
over provider/SDK defaults; no-op when unconfigured) and apply it at every
OpenAI-wire client construction site:
- _try_custom_endpoint() — config-level `model.provider: custom`
- the named custom-provider branch (custom_providers/providers entries),
  including the anthropic-SDK-missing OpenAI-wire fallback
- the api-key-provider, async-conversion, and main resolve_provider_client
  fallback branches

To prevent the two clients ever drifting on precedence/value handling,
AIAgent._apply_user_default_headers (run_agent.py) now delegates the config
read + merge to this shared helper (run_agent already imports from
auxiliary_client). Native Anthropic/Bedrock branches are untouched (they
don't use the OpenAI wire).

8 new tests (helper semantics + config-level custom + named custom);
full aux + attribution header suites green (295).
2026-06-07 02:02:40 -07:00
Sanidhya Singh a216ff839b fix(agent): honor model.default_headers for custom OpenAI-compatible providers (#40033)
Custom OpenAI-compatible endpoints sitting behind a gateway/WAF can reject
the OpenAI Python SDK's default identifying headers (User-Agent: OpenAI/Python,
X-Stainless-*) and return an opaque 502/4xx even though the same request body
succeeds under curl. There was no supported way to override those headers.

Add a model.default_headers config key whose values are merged onto the
OpenAI client's default_headers, taking precedence over provider- and
SDK-supplied defaults. Applied at client construction and on every credential
swap / client rebuild so the override survives reconnects. No-op for native
Anthropic / Bedrock modes and when unconfigured.
2026-06-07 02:02:40 -07:00
Teknium f5c3fc319c docs(i18n): port deep-audit corrections to zh-Hans mirror (#41104)
Mirrors the EN deep-audit fixes (PR #40952) into the zh-Hans translation so the
two locales agree. zh-Hans is the only non-English locale; 26 translated pages
carried the same stale claims.

Corrections ported (code tokens identical across locales; prose re-translated
where the surrounding text was already Chinese):
- reference: /version slash command + dual-surface list; cli --provider adds
  openai-api + novita aliases; tool count 70->71 (+ removed phantom "10 RL tools"
  and fixed kanban 7->9); model_catalog ttl 24->1.
- user-guide: hermes -w -q -> -w -z; language list 8->16; aux slots 8->11;
  docker separate-dashboard claim; gateway-streaming per-platform note;
  computer-use frontmatter.
- features: curator prune_builtins truth; codex-runtime aux keys
  (context_compression->compression, vision_detect->vision); voice-mode STT/TTS
  enums; removed phantom rl toolset.
- integrations: StepFun step-3-mini->step-3.5-flash; web-search backends 4->8;
  nous-portal status subcommand.
- messaging: WeCom typing/streaming columns; telegram transport default
  edit->auto; sms host 0.0.0.0->127.0.0.1; simplex/ntfy gateway-setup + pairing
  approve; line smart-chunking; matrix MATRIX_DM_AUTO_THREAD; msgraph host note.
- developer-guide: entry-point group hermes.plugins->hermes_agent.plugins;
  PLUGIN.yaml->plugin.yaml.

Net-new EN sections (mcp mTLS, api-server run-approval, kanban CLI verbs) are
untranslated in zh-Hans and fall back to English source, consistent with the
mirror's existing partial-coverage state. Verified: docusaurus build --locale
zh-Hans succeeds; no new broken anchors from these edits.
2026-06-07 01:57:18 -07:00
Tekniumanddavidgut1982 3c8f1dee8d fix(compression): don't overwrite the -1 post-compression sentinel in preflight seed (#36718)
compress_context() sets last_prompt_tokens=-1 right after compression to
mark "no real API usage yet". The preflight display-seed used
`_preflight_tokens > (last_prompt_tokens or 0)`, and `(-1 or 0)` is -1
(truthy), so any positive rough estimate clobbered the sentinel with a
schema-inflated count — re-triggering compression on the next turn.
Treat any negative value as "no real data yet" and skip the seed.

Salvaged from #40246 as the minimal root-cause fix. The original also
added an `_awaiting_suppression_count` bounded-window state machine to
should_compress() across 3 files; left out here to keep blast radius
small — the sentinel guard alone fixes the re-fire. The suppression
window can be added separately if the usage=None-stub edge case warrants it.

Co-authored-by: davidgut1982 <davidgut1982@users.noreply.github.com>
2026-06-07 01:56:51 -07:00
kshitij 3763355f08 chore(release): map singhsanidhya741@gmail.com to sanidhyasin (#41094)
Adds the AUTHOR_MAP entry for the #40403 salvage (model.default_headers
for custom OpenAI-compatible providers, fixes #40033) so contributor_audit
passes when the salvage PR lands.
2026-06-07 01:55:24 -07:00
Teknium e18f14d928 test(kimi): align stale parity/profile tests with thinking-xor-effort contract (#41095)
* test(kimi): align stale parity/profile tests with thinking-xor-effort contract

ce4e74b3 (fix(kimi): send thinking xor reasoning_effort, never both)
changed the Kimi profile to emit at most one of extra_body.thinking or a
top-level reasoning_effort, and added tests/plugins/model_providers/test_kimi_profile.py
to pin it — but left two older test files still asserting the removed
'send both' behavior, turning main red for every PR branched after it.

Update the stale assertions to the xor contract:
- explicit recognized effort (low|medium|high) -> reasoning_effort only,
  no thinking
- enabled w/o effort, or no reasoning_config -> thinking:enabled only,
  no reasoning_effort
- disabled -> thinking:disabled only

No production change.

* test(kimi): cover remaining xor stale assertions (profile_wiring, run_agent)

Two more test files asserted the pre-ce4e74b3 'thinking + reasoning_effort
together' behavior — landed in a different CI shard so they surfaced only
after the first batch went green:
- tests/providers/test_profile_wiring.py::TestKimiProfileParity (2)
- tests/run_agent/test_run_agent.py::TestBuildApiKwargs (3: kimi-coding,
  moonshot, moonshot-cn)

Same realignment to the xor contract: default/enabled-without-effort emits
thinking:enabled and no reasoning_effort; explicit effort emits
reasoning_effort only. Verified by running the full provider +
TestBuildApiKwargs Kimi surface (202 passed) plus a codebase-wide grep for
any remaining paired thinking+effort assertion (none).
2026-06-07 01:52:49 -07:00
Teknium 0524c9b34e feat(compression): raise compaction trigger to 85% for gpt-5.5 on Codex OAuth (#40957)
The ChatGPT Codex OAuth backend hard-caps gpt-5.5 at a 272K context window
(verified live: a ~330K-token request to chatgpt.com/backend-api/codex/responses
is rejected with context_length_exceeded while ~250K succeeds; the same slug
exposes 1.05M on the direct OpenAI API / OpenRouter and 400K on Copilot). At the
default 50% trigger, auto-compaction fires at ~136K — half the usable window.

Raise the trigger to 85% (~231K) on this exact route only, gated by a new
compression.codex_gpt55_autoraise config flag (default true). When it fires,
emit a one-time notice (CLI inline print + gateway status_callback replay) with
the exact opt-back-out command. gpt-5.5 on any other provider keeps the user's
global threshold.

- _is_codex_gpt55() matches the 5.5 family only on provider=openai-codex
- _compression_threshold_for_model() now provider-aware + opt-out param
- config key + _config_version bump (27->28) for backfill
- docs + tests (40 cases in test_arcee_trinity_overrides.py)
2026-06-07 01:40:50 -07:00
Teknium 2d099fed1e docs: deep audit — registry drift, stale claims, 2-week PR coverage, dashboard screenshot (#40952)
Full-corpus correctness audit of the hand-written docs against the codebase,
plus a 2-week merged-PR coverage sweep and one live dashboard screenshot.

Correctness (verified against COMMAND_REGISTRY / PROVIDER_REGISTRY / TOOLSETS /
tools.registry / DEFAULT_CONFIG / source):
- reference: add /version slash command, context_engine toolset, openai-api +
  novita-ai to --provider; fix tool count 64->71; model_catalog ttl 24->1;
  add profile describe to summary table; add real provider env vars
  (LM_API_KEY/LM_BASE_URL, KIMI_CODING_API_KEY, ALIBABA_CODING_PLAN_*,
  ANTHROPIC_BASE_URL, COPILOT_API_BASE_URL); fix faq "Windows: not natively".
- user-guide: fix broken `hermes -w -q` (->-z) and `hermes logs --tail` (->-f);
  language list 8->16; aux slots 8->11; docker separate-dashboard claim;
  _SECURITY_ARGS -> _BASE_SECURITY_ARGS.
- features: curator prune_builtins truth + missing CLI verbs; codex-runtime aux
  keys (context_compression->compression, vision_detect->vision); kanban
  terminate endpoint + promote/reassign/schedule/diagnostics/edit + per-profile
  cap; mcp mTLS (client_cert/client_key); built-in-plugins nemo_relay +
  teams_pipeline; api-server run approval endpoint; computer-use frontmatter.
- features N-Z + integrations: StepFun step-3-mini->step-3.5-flash; web-search
  backends 4->8; tool-gateway image-model IDs; voice-mode STT/TTS enums; remove
  phantom `rl` toolset; nous-portal status subcommand.
- messaging: WeCom typing/streaming cols; telegram transport default edit->auto;
  sms host default; simplex/ntfy `gateway setup` + pairing approve; line
  smart-chunking; matrix MATRIX_DM_AUTO_THREAD.
- developer-guide: build-a-plugin code examples (register_command signature,
  ContextEngine/ImageGenProvider/MemoryProvider ABCs); model-provider-plugin
  entry-point group hermes.plugins->hermes_agent.plugins; PLUGIN.yaml->plugin.yaml;
  agent-loop stale LOC; web-search-provider phantom crawl().

PR coverage (2-week window, 149 feat PRs):
- desktop.md refreshed for ~15 shipped features (zh-Hans switcher, rebindable
  shortcuts + zoom + Cmd+K, status-bar model picker + YOLO toggle, session-by-id
  + archive, multi-profile concurrent + cross-profile @session, composer history,
  Providers pane, per-profile remote hosts, Grok OAuth, aux-pin warning).
- configuration.md gateway-streaming default corrected to per-platform.
- tool-gateway.md free tool pool entitlement note.

Media:
- New /img/dashboard/admin-config.png — live dashboard Config admin page
  (captured from a clean profile, no secrets/personalization).
2026-06-07 01:39:06 -07:00
Teknium 3289d4adf2 fix(transcription): handle ffmpeg TimeoutExpired in _prepare_local_audio
Follow-up to the subprocess timeout: _prepare_local_audio only caught
CalledProcessError, so a timeout would raise uncaught. Return a clean
error instead.
2026-06-07 01:26:33 -07:00
annguyenNous 7223f22d65 fix: add timeout to subprocess.run() and proc.wait() calls
subprocess.run() and proc.wait() without timeout can hang indefinitely
if the child process becomes unresponsive. This blocks the calling
thread forever.

Fixed locations:
- tools/transcription_tools.py: ffmpeg conversion (timeout=300) and
  user-configured STT commands with shell=True (timeout=300)
- gateway/run.py: helper script proc.wait() (timeout=3600)

Not fixed:
- agent/anthropic_adapter.py: interactive 'claude setup-token' —
  user-driven, timeout would be inappropriate
2026-06-07 01:26:33 -07:00
teknium1 ce4e74b350 fix(kimi): send thinking xor reasoning_effort, never both
The standalone Kimi/Moonshot profile (api.moonshot.ai/v1) sent both
extra_body.thinking AND a top-level reasoning_effort. With no reasoning
config it even defaulted to thinking:enabled + reasoning_effort:medium,
pairing them on every default call. Moonshot treats these as mutually
exclusive (cannot specify both 'thinking' and 'reasoning_effort').

Align with the kimi-k2 handling already shipped for the opencode-go relay:
send effort when a recognized low|medium|high is requested, otherwise fall
back to the extra_body.thinking toggle. Disabled sends thinking:disabled
only. Never both.

Reported by Cars29 (NOUS Discord). DeepSeek was deliberately left untouched:
its native endpoint accepts both (verified by the live guardrail in
test_deepseek_v4_thinking_live.py), so the report's DeepSeek claim does not
hold there.

Tests: tests/plugins/model_providers/test_kimi_profile.py pins the xor
contract across all config shapes.
2026-06-07 01:24:29 -07:00
teknium1andjimjsong 03392b67d6 fix(opencode-go): gate thinking when reasoning_effort set to avoid HTTP 400
Salvaged from #40429; re-verified on main, tightened, tested.

Co-authored-by: jimjsong <jimjsong@users.noreply.github.com>
2026-06-07 01:24:29 -07:00
Teknium fe0b3f2338 fix(windows): retry watcher Popen without breakaway when parent job denies it, plus regression tests for the breakaway bit (#40956)
#40909 added `CREATE_BREAKAWAY_FROM_JOB` to `windows_detach_flags()`,
which fixed the headline bug (gateway dies after Desktop GUI update
and never comes back). The flag's own docstring acknowledges that
restrictive parent job objects can still refuse breakaway with
`ERROR_ACCESS_DENIED`, surfacing as `OSError` on the `subprocess.Popen`
call:

  "Callers in this codebase already wrap detached spawns in
  try/except OSError and fall back to a cmd.exe wrapper, so the
  breakaway-denied case degrades gracefully rather than crashing."

That's true for `_spawn_detached` in `gateway_windows.py` (the
`hermes gateway start` path), which has both the breakaway bit AND a
retry-without-breakaway fallback. It's NOT true for the post-update
watcher path in `launch_detached_profile_gateway_restart`
(`hermes_cli/gateway.py`), which only has `except OSError: return
False` and gives up entirely. If a user's shell/terminal/container
wraps Hermes in a breakaway-denying job, the gateway-respawn watcher
silently fails to launch instead of trying again without breakaway.

This PR closes that gap and adds the regression tests that were
missing from the original fix.

## Changes

### `hermes_cli/_subprocess_compat.py`

Adds a sibling helper `windows_detach_flags_without_breakaway()` so
callers can express the fallback symbolically (via the helper) rather
than coding the magic `& ~0x01000000` mask at every site. Documented
on `windows_detach_flags` and `windows_detach_flags_without_breakaway`
with the recommended try/except pattern.

### `hermes_cli/gateway.py::launch_detached_profile_gateway_restart`

Two changes, both aligned with the canonical pattern in
`gateway_windows._spawn_detached`:

1. The outer watcher Popen now wraps in `try/except OSError`, and on
   failure retries with `windows_detach_flags_without_breakaway()`
   (POSIX never reaches this branch — `start_new_session=True` can't
   raise OSError).
2. The inlined respawn payload (the `python -c` watcher) also
   wraps its CreateProcess in try/except OSError and retries with
   `_flags & ~_CREATE_BREAKAWAY_FROM_JOB` on failure. This matters
   because the watcher's job-object inheritance is independent of the
   outer process's — even if the outer Popen succeeds with breakaway,
   the respawned gateway might inherit a job that doesn't.

### Regression tests in `tests/tools/test_windows_native_support.py`

#40909 shipped the fix without any test that the breakaway bit is
present (the existing `test_windows_detach_flags_has_expected_win32_bits`
asserted only the three legacy bits). Four new tests close that:

- `test_windows_detach_flags_includes_breakaway_from_job` — explicit
  assertion that the breakaway bit is in the default bundle, with the
  rationale spelled out in the docstring so a future maintainer
  staring at this test understands why removing it would resurrect
  the gateway-dies-after-GUI-update bug.
- `test_windows_detach_flags_without_breakaway_drops_only_that_bit`
  — fallback payload keeps the other three detach bits intact.
- `test_launch_detached_profile_gateway_restart_inlined_watcher_uses_breakaway`
  — static-text check on the stringified watcher payload. The inlined
  Python program isn't reachable via normal import-time inspection
  because it lives in a `textwrap.dedent("""...""")` literal that
  gets passed to a separate `python -c` interpreter. Asserting that
  both `_CREATE_BREAKAWAY_FROM_JOB` (symbolic) and `0x01000000` (hex
  literal) appear inside the dedent block is a sufficient regression
  guard against accidental refactors.
- `test_launch_detached_profile_gateway_restart_outer_popen_has_access_denied_fallback`
  — static check that this PR's fallback retry is wired up
  symbolically. Without standing up a real Windows job object that
  refuses breakaway, we can't trigger the OSError in a unit test;
  the text guard catches the case where a future refactor removes
  the helper import or the `& ~_CREATE_BREAKAWAY_FROM_JOB` retry.

Also extends `test_windows_detach_flags_has_expected_win32_bits` to
include the breakaway bit assertion and updates
`test_windows_flags_zero_on_posix` to cover the new helper.

## Tests

Locally on Windows: 8/8 in the `-k "detach or breakaway or
popen_kwargs or launch_detached or gateway_run_update or
hermes_cli_gateway"` slice pass.

Broader `tests/hermes_cli/test_gateway*.py + test_windows_native_support.py`:
172 passed, 10 failed. All 10 failures are pre-existing POSIX-only
tests running on a Windows host (os.geteuid, SIGKILL fallback,
is_linux fixture mismatches). Stashing this PR and re-running on bare
post-#40909 main reproduces all 10 identically — none are regressions.

POSIX paths unchanged: `windows_detach_flags()` and
`windows_detach_flags_without_breakaway()` both return 0 off Windows,
`windows_detach_popen_kwargs()` still yields `{"start_new_session": True}`.

## Out of scope

- The other detached-spawn site in `hermes_cli/gateway.py` (around
  line 3068) also uses `windows_detach_popen_kwargs()` + `except
  OSError`. It deserves the same fallback treatment but the codepath
  is different enough (not the update-flow watcher) that it warrants
  a separate PR with its own scrutiny.
- `gateway/run.py` has Windows branches with `windows_detach_popen_kwargs`
  too — same reasoning.

## Context

Follow-up to #40909 (merged). I had a parallel PR (#40934, closed)
that duplicated the core breakaway fix; the bits unique to that PR
that #40909 didn't cover are the contents of this one. Closing #40934
and opening this slimmed-down version as the focused follow-up.
2026-06-07 01:21:58 -07:00
kshitijk4poor 44c0c2d4ac refactor(inventory): make force_fresh_nous_tier keyword-only + pin contract
Follow-up to the salvaged perf fix. The new force_fresh_nous_tier param was
inserted into list_authenticated_providers between custom_providers and
max_models. Make it keyword-only (*) so a positional caller passing max_models
as the 5th arg can never silently mis-bind it to the tier-refresh flag, and
add a signature-contract test that fails if the keyword-only separator is
later dropped. All in-repo callers already use keyword args; verified no
caller breaks.
2026-06-07 00:41:13 -07:00
helix4u eb70ab894b fix(inventory): avoid fresh Nous tier checks in picker payloads 2026-06-07 00:41:13 -07:00
Cornna fec5ca71d8 fix: preserve telegram queue fifo during grace window 2026-06-03 20:30:59 +08:00
Cornna 4d0f2bd241 fix(gateway): use FIFO queue for busy_input_mode pending messages
Closes #28503
2026-06-03 20:25:17 +08:00
393 changed files with 27021 additions and 10514 deletions
+6 -5
View File
@@ -75,9 +75,10 @@ jobs:
run: |
set -euo pipefail
# Ensure only nix files were modified — prevents accidental
# self-triggering if fix-lockfiles ever touches package files.
unexpected="$(git diff --name-only | grep -Ev '^nix/(tui|web)\.nix$' || true)"
# Ensure only nix/lib.nix (home of the single npmDepsHash) was
# modified — prevents accidental self-triggering if fix-lockfiles
# ever touches package files.
unexpected="$(git diff --name-only | grep -Ev '^nix/lib\.nix$' || true)"
if [ -n "$unexpected" ]; then
echo "::error::Unexpected modified files: $unexpected"
exit 1
@@ -89,7 +90,7 @@ jobs:
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add nix/tui.nix nix/web.nix
git add nix/lib.nix
git commit -m "fix(nix): auto-refresh npm lockfile hashes" \
-m "Source: $GITHUB_SHA" \
-m "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
@@ -216,7 +217,7 @@ jobs:
set -euo pipefail
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add nix/tui.nix nix/web.nix
git add nix/lib.nix
git commit -m "fix(nix): refresh npm lockfile hashes"
git push
+1
View File
@@ -10,6 +10,7 @@
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
<a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Built%20by-Nous%20Research-blueviolet?style=for-the-badge" alt="Built by Nous Research"></a>
<a href="README.zh-CN.md"><img src="https://img.shields.io/badge/Lang-中文-red?style=for-the-badge" alt="中文"></a>
<a href="README.ur-pk.md"><img src="https://img.shields.io/badge/Lang-اردو-green?style=for-the-badge" alt="اردو"></a>
</p>
**The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM.
+261
View File
@@ -0,0 +1,261 @@
<div dir="rtl">
<p align="center">
<img src="assets/banner.png" alt="Hermes Agent" width="100%">
</p>
# ہرمیس ایجنٹ ☤ (Hermes Agent)
<p align="center">
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
<a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Built%20by-Nous%20Research-blueviolet?style=for-the-badge" alt="Built by Nous Research"></a>
<a href="README.md"><img src="https://img.shields.io/badge/Lang-English-lightgrey?style=for-the-badge" alt="English"></a>
<a href="README.zh-CN.md"><img src="https://img.shields.io/badge/Lang-中文-red?style=for-the-badge" alt="中文"></a>
</p>
**[نوس ریسرچ (Nous Research)](https://nousresearch.com) کا تیار کردہ خود کو بہتر بنانے والا اے آئی (AI) ایجنٹ۔** یہ واحد ایجنٹ ہے جس میں سیکھنے کا عمل (learning loop) پہلے سے موجود ہے — یہ اپنے تجربات سے نئی مہارتیں (skills) بناتا ہے، استعمال کے دوران ان کو بہتر کرتا ہے، معلومات کو محفوظ رکھنے کے لیے خود کو یاد دہانی کرواتا ہے، اپنی پرانی بات چیت کو تلاش کر سکتا ہے، اور مختلف سیشنز کے دوران آپ کے بارے میں ایک گہری سمجھ پیدا کرتا ہے۔ اسے $5 والے VPS پر چلائیں، GPU کلسٹر پر، یا سرور لیس (serverless) انفراسٹرکچر پر جس کی قیمت استعمال نہ ہونے پر تقریباً صفر ہے۔ یہ آپ کے لیپ ٹاپ تک محدود نہیں ہے — آپ ٹیلی گرام (Telegram) سے اس کے ساتھ بات چیت کر سکتے ہیں جبکہ یہ کلاؤڈ VM پر کام کر رہا ہو۔
آپ اپنی مرضی کا کوئی بھی ماڈل استعمال کر سکتے ہیں — [Nous Portal](https://portal.nousresearch.com)، [OpenRouter](https://openrouter.ai) (200 سے زائد ماڈلز)، [NovitaAI](https://novita.ai) (ماڈل API، ایجنٹ سینڈ باکس، اور GPU کلاؤڈ کے لیے اے آئی مقامی کلاؤڈ)، [NVIDIA NIM](https://build.nvidia.com) (Nemotron)، [Xiaomi MiMo](https://platform.xiaomimimo.com)، [z.ai/GLM](https://z.ai)، [Kimi/Moonshot](https://platform.moonshot.ai)، [MiniMax](https://www.minimax.io)، [Hugging Face](https://huggingface.co)، OpenAI، یا اپنا حسب ضرورت اینڈ پوائنٹ (endpoint) استعمال کریں۔ ماڈل تبدیل کرنے کے لیے صرف `hermes model` استعمال کریں — کسی کوڈ کو تبدیل کرنے کی ضرورت نہیں، کوئی پابندی نہیں۔
<table>
<tr><td><b>حقیقی ٹرمینل انٹرفیس</b></td><td>مکمل TUI جس میں ملٹی لائن ایڈیٹنگ، سلیش-کمانڈ آٹو کمپلیٹ، بات چیت کی ہسٹری، انٹرپٹ اور ری ڈائریکٹ، اور سٹریمنگ ٹول آؤٹ پٹ شامل ہے۔</td></tr>
<tr><td><b>یہ وہاں موجود ہے جہاں آپ ہیں</b></td><td>ٹیلی گرام، ڈسکارڈ (Discord)، سلیک (Slack)، واٹس ایپ (WhatsApp)، سگنل (Signal)، اور CLI — سب ایک ہی گیٹ وے پروسیس سے کام کرتے ہیں۔ وائس میمو (Voice memo) ٹرانسکرپشن، کراس پلیٹ فارم بات چیت کا تسلسل۔</td></tr>
<tr><td><b>سیکھنے کا ایک مکمل عمل</b></td><td>ایجنٹ کی اپنی ترتیب دی گئی میموری، جس میں وہ خود کو وقتاً فوقتاً یاد دہانی کرواتا ہے۔ پیچیدہ کاموں کے بعد خود کار طریقے سے مہارت (skill) کی تخلیق۔ استعمال کے دوران مہارتوں میں بہتری۔ LLM سمرائزیشن کے ساتھ FTS5 سیشن سرچ تاکہ پرانے سیشنز کی یاددہانی کی جا سکے۔ <a href="https://github.com/plastic-labs/honcho">Honcho</a> کے ذریعے صارف کی ماڈلنگ۔ <a href="https://agentskills.io">agentskills.io</a> اوپن سٹینڈرڈ کے ساتھ مکمل مطابقت۔</td></tr>
<tr><td><b>شیڈول کی گئی خودکار کارروائیاں</b></td><td>بلٹ ان (Built-in) کرون (cron) شیڈیولر جو کسی بھی پلیٹ فارم پر ڈیلیوری کے لیے استعمال ہو سکتا ہے۔ روزانہ کی رپورٹس، رات کے بیک اپس، ہفتہ وار آڈٹس — یہ سب کچھ قدرتی زبان (natural language) میں اور بغیر کسی نگرانی کے کام کرتا ہے۔</td></tr>
<tr><td><b>کام کی تقسیم اور متوازی عمل</b></td><td>متوازی (parallel) کاموں کے لیے الگ سے ذیلی ایجنٹس (subagents) بنائیں۔ پائتھون (Python) سکرپٹس لکھیں جو RPC کے ذریعے ٹولز کو استعمال کریں، تاکہ کئی مراحل پر مشتمل کاموں کو بغیر کسی سیاق و سباق (context) کے خرچ کے، ایک ہی باری میں انجام دیا جا سکے۔</td></tr>
<tr><td><b>کہیں بھی چلائیں، صرف اپنے لیپ ٹاپ پر نہیں</b></td><td>چھ (Six) ٹرمینل بیک اینڈز — لوکل، Docker، SSH، Singularity، Modal، اور Daytona۔ ڈیٹونا (Daytona) اور موڈل (Modal) سرور لیس (serverless) فعالیت پیش کرتے ہیں — جب آپ کا ایجنٹ فارغ ہوتا ہے تو اس کا ماحول سلیپ (hibernate) ہو جاتا ہے اور ضرورت پڑنے پر خود بخود جاگ جاتا ہے، جس کی وجہ سے سیشنز کے درمیان لاگت تقریباً صفر رہتی ہے۔ اسے $5 والے VPS یا GPU کلسٹر پر چلائیں۔</td></tr>
<tr><td><b>تحقیق کے لیے تیار</b></td><td>بیچ (Batch) ٹریجیکٹری (trajectory) جنریشن، اگلی نسل کے ٹول کالنگ ماڈلز کی تربیت کے لیے ٹریجیکٹری کمپریشن۔</td></tr>
</table>
---
## فوری انسٹالیشن (Quick Install)
### لینکس (Linux)، میک او ایس (macOS)، ڈبلیو ایس ایل ٹو (WSL2)، ٹرمکس (Termux)
<div dir="ltr">
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
```
</div>
### ونڈوز (نیٹو، پاور شیل)
> **توجہ فرمائیں:** مقامی ونڈوز (Native Windows) پر ہرمیس بغیر WSL کے چلتا ہے — CLI، گیٹ وے، TUI، اور ٹولز سب مقامی طور پر کام کرتے ہیں۔ اگر آپ WSL2 استعمال کرنا پسند کرتے ہیں، تو اوپر دی گئی لینکس/میک او ایس کی کمانڈ وہاں بھی کام کرے گی۔ کوئی مسئلہ نظر آیا؟ براہ کرم [مسائل (issues) درج کریں](https://github.com/NousResearch/hermes-agent/issues)۔
اسے پاور شیل (PowerShell) میں چلائیں:
<div dir="ltr">
```powershell
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
```
</div>
انسٹالر سب کچھ خود سنبھالتا ہے: uv، Python 3.11، Node.js، ripgrep، ffmpeg، **اور ایک پورٹ ایبل (portable) گٹ بیش (Git Bash)** (یعنی MinGit، جو `%LOCALAPPDATA%\hermes\git` میں ان پیک ہوتا ہے — اس کے لیے ایڈمن کی اجازت درکار نہیں، اور یہ سسٹم کے کسی بھی گٹ انسٹال سے بالکل الگ ہے)۔ ہرمیس اس بنڈل شدہ گٹ بیش کو شیل کمانڈز چلانے کے لیے استعمال کرتا ہے۔
اگر آپ کے پاس پہلے سے گٹ (Git) انسٹال ہے، تو انسٹالر اسے شناخت کر لیتا ہے اور اسے ہی استعمال کرتا ہے۔ بصورت دیگر آپ کو صرف ~45MB کے MinGit ڈاؤنلوڈ کی ضرورت ہوگی — یہ آپ کے سسٹم کے گٹ پر کوئی اثر نہیں ڈالے گا۔
> **اینڈرائیڈ (Android) / ٹرمکس (Termux):** ٹیسٹ کیا گیا مینوئل طریقہ [Termux گائیڈ](https://hermes-agent.nousresearch.com/docs/getting-started/termux) میں موجود ہے۔ ٹرمکس پر ہرمیس ایک مخصوص `.[termux]` ایکسٹرا انسٹال کرتا ہے کیونکہ مکمل `.[all]` ایکسٹرا میں ایسی وائس ڈیپینڈینسیز شامل ہیں جو اینڈرائیڈ کے ساتھ مطابقت نہیں رکھتیں۔
>
> **ونڈوز (Windows):** مقامی ونڈوز کی مکمل سپورٹ موجود ہے — اوپر دی گئی پاور شیل کی کمانڈ سب کچھ انسٹال کر دیتی ہے۔ اگر آپ WSL2 استعمال کرنا چاہتے ہیں، تو لینکس کی کمانڈ وہاں کام کرتی ہے۔ مقامی ونڈوز میں انسٹالیشن `%LOCALAPPDATA%\hermes` میں ہوتی ہے؛ جبکہ WSL2 میں لینکس کی طرح `~/.hermes` میں ہوتی ہے۔ ہرمیس کا وہ واحد فیچر جسے فی الحال خاص طور پر WSL2 کی ضرورت ہے وہ براؤزر پر مبنی ڈیش بورڈ چیٹ پین ہے (یہ POSIX PTY استعمال کرتا ہے — کلاسک CLI اور گیٹ وے دونوں مقامی طور پر چلتے ہیں)۔
انسٹالیشن کے بعد:
<div dir="ltr">
```bash
source ~/.bashrc # شیل کو ری لوڈ کریں (یا: source ~/.zshrc)
hermes # بات چیت شروع کریں!
```
</div>
---
## آغاز کریں (Getting Started)
<div dir="ltr">
```bash
hermes # انٹرایکٹو CLI — بات چیت شروع کریں
hermes model # اپنا LLM پرووائیڈر اور ماڈل منتخب کریں
hermes tools # کنفیگر کریں کہ کون سے ٹولز ایکٹو ہیں
hermes config set # انفرادی کنفگ (config) ویلیوز سیٹ کریں
hermes gateway # میسجنگ گیٹ وے شروع کریں (ٹیلی گرام، ڈسکارڈ، وغیرہ)
hermes setup # مکمل سیٹ اپ وزرڈ چلائیں (یہ سب کچھ ایک ساتھ کنفیگر کر دے گا)
hermes claw migrate # OpenClaw سے مائیگریٹ کریں (اگر آپ OpenClaw سے آ رہے ہیں)
hermes update # لیٹسٹ ورژن پر اپ ڈیٹ کریں
hermes doctor # کسی بھی مسئلے کی تشخیص کریں
```
</div>
📖 **[مکمل دستاویزات →](https://hermes-agent.nousresearch.com/docs/)**
---
## API-کیز اکٹھی کرنے سے بچیں — Nous Portal
ہرمیس آپ کے پسندیدہ پرووائیڈر کے ساتھ کام کرتا ہے — یہ چیز تبدیل نہیں ہو رہی۔ لیکن اگر آپ ماڈل، ویب سرچ، امیج جنریشن، TTS، اور کلاؤڈ براؤزر کے لیے پانچ الگ الگ API کیز جمع نہیں کرنا چاہتے، تو **[Nous Portal](https://portal.nousresearch.com)** ان سب کو ایک ہی سبسکرپشن کے تحت کور کرتا ہے:
- **300+ ماڈلز** — ان میں سے کوئی بھی ماڈل `/model <name>` کے ذریعے منتخب کریں
- **ٹول گیٹ وے (Tool Gateway)** — ویب سرچ (Firecrawl)، امیج جنریشن (FAL)، ٹیکسٹ ٹو سپیچ (OpenAI)، کلاؤڈ براؤزر (Browser Use)، یہ سب آپ کی سبسکرپشن کے ذریعے چلتے ہیں۔ کسی اضافی اکاؤنٹ کی ضرورت نہیں۔
نئی انسٹالیشن کے بعد بس ایک کمانڈ کی ضرورت ہے:
<div dir="ltr">
```bash
hermes setup --portal
```
</div>
یہ آپ کو OAuth کے ذریعے لاگ ان کرواتا ہے، Nous کو آپ کا پرووائیڈر مقرر کرتا ہے، اور ٹول گیٹ وے کو آن کر دیتا ہے۔ `hermes portal info` کمانڈ استعمال کر کے آپ کسی بھی وقت چیک کر سکتے ہیں کہ کون کون سی سروسز منسلک ہیں۔ مکمل تفصیلات [Tool Gateway دستاویزات کے صفحے](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway) پر موجود ہیں۔
آپ اب بھی کسی بھی ٹول کے لیے اپنی مرضی کی API کیز استعمال کر سکتے ہیں — گیٹ وے ہر سروس کے لیے الگ الگ کام کرتا ہے، ایسا نہیں کہ یا تو سب کچھ استعمال کریں یا کچھ بھی نہیں۔
---
## CLI بمقابلہ میسجنگ فوری حوالہ
ہرمیس کے دو بنیادی انٹر فیس ہیں: آپ ٹرمینل UI کو `hermes` کے ساتھ شروع کریں، یا گیٹ وے چلا کر اس کے ساتھ ٹیلی گرام، ڈسکارڈ، سلیک، واٹس ایپ، سگنل، یا ای میل کے ذریعے بات کریں۔ جب آپ کسی بات چیت میں ہوتے ہیں، تو بہت سی سلیش (slash) کمانڈز دونوں انٹرفیسز میں ایک جیسی ہوتی ہیں۔
<div dir="ltr">
| کارروائی (Action) | سی ایل آئی (CLI) | میسجنگ پلیٹ فارمز (Messaging platforms) |
| --------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------- |
| بات چیت شروع کریں | `hermes` | `hermes gateway setup` اور `hermes gateway start` چلائیں، پھر بوٹ کو میسج بھیجیں |
| نئی بات چیت شروع کریں | `/new` یا `/reset` | `/new` یا `/reset` |
| ماڈل تبدیل کریں | `/model [provider:model]` | `/model [provider:model]` |
| پرسنلٹی (Personality) سیٹ کریں | `/personality [name]` | `/personality [name]` |
| پچھلی باری کو دوبارہ یا منسوخ (undo) کریں | `/retry`، `/undo` | `/retry`، `/undo` |
| کانٹیکسٹ (context) کمپریس کریں / استعمال چیک کریں | `/compress`، `/usage`، `/insights [--days N]` | `/compress`، `/usage`، `/insights [days]` |
| مہارتیں (Skills) براؤز کریں | `/skills` یا `/<skill-name>` | `/<skill-name>` |
| موجودہ کام کو روکیں | `Ctrl+C` دبائیں یا نیا میسج بھیجیں | `/stop` یا نیا میسج بھیجیں |
| پلیٹ فارم کے لحاظ سے سٹیٹس | `/platforms` | `/status`، `/sethome` |
</div>
مکمل کمانڈ لسٹ کے لیے، [CLI گائیڈ](https://hermes-agent.nousresearch.com/docs/user-guide/cli) اور [میسجنگ گیٹ وے گائیڈ](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) دیکھیں۔
---
## دستاویزات (Documentation)
تمام دستاویزات **[hermes-agent.nousresearch.com/docs](https://hermes-agent.nousresearch.com/docs/)** پر موجود ہیں:
<div dir="ltr">
| سیکشن (Section) | تفصیل (What's Covered) |
| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| [فوری آغاز (Quickstart)](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | انسٹالیشن → سیٹ اپ → 2 منٹ میں پہلی بات چیت شروع کریں |
| [CLI کا استعمال](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | کمانڈز، کی بائنڈنگز (keybindings)، پرسنلٹیز (personalities)، سیشنز |
| [کنفیگریشن (Configuration)](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | کنفگ فائل، پرووائیڈرز، ماڈلز، اور تمام آپشنز |
| [میسجنگ گیٹ وے](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | ٹیلی گرام، ڈسکارڈ، سلیک، واٹس ایپ، سگنل، ہوم اسسٹنٹ |
| [سیکیورٹی (Security)](https://hermes-agent.nousresearch.com/docs/user-guide/security) | کمانڈ کی منظوری، DM پیئرنگ (pairing)، کنٹینر آئسولیشن |
| [ٹولز اور ٹول سیٹس](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | 40 سے زائد ٹولز، ٹول سیٹ سسٹم، ٹرمینل بیک اینڈز |
| [مہارتوں کا سسٹم (Skills System)](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills)| پروسیجرل (Procedural) میموری، سکلز ہب، نئی مہارتیں بنانا |
| [میموری (Memory)](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | مستقل میموری، یوزر پروفائلز، بہترین طریقہ کار |
| [MCP انضمام (Integration)](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | صلاحیتوں کو بڑھانے کے لیے کسی بھی MCP سرور کو جوڑیں |
| [کرون (Cron) شیڈیولنگ](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | پلیٹ فارم ڈیلیوری کے ساتھ شیڈول کیے گئے کام |
| [کانٹیکسٹ (Context) فائلز](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files)| پروجیکٹ کا سیاق و سباق (context) جو ہر بات چیت پر اثر انداز ہوتا ہے |
| [آرکیٹیکچر (Architecture)](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | پروجیکٹ کا ڈھانچہ، ایجنٹ لوپ، اہم کلاسز |
| [تعاون (Contributing)](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | ڈیویلپمنٹ سیٹ اپ، PR کا طریقہ کار، کوڈنگ کا انداز |
| [CLI حوالہ جات (Reference)](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | تمام کمانڈز اور فلیگز (flags) |
| [انوائرمنٹ ویری ایبلز](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | مکمل انوائرمنٹ ویری ایبل حوالہ جات |
</div>
---
## OpenClaw سے منتقلی
اگر آپ OpenClaw سے منتقل ہو رہے ہیں، تو ہرمیس آپ کی سیٹنگز، یادیں (memories)، مہارتیں (skills)، اور API کیز کو خود بخود امپورٹ کر سکتا ہے۔
**پہلی بار سیٹ اپ کے دوران:** سیٹ اپ وزرڈ (`hermes setup`) خود بخود `~/.openclaw` کو پہچان لیتا ہے اور کنفیگریشن شروع ہونے سے پہلے مائیگریٹ (migrate) کرنے کا آپشن دیتا ہے۔
**انسٹالیشن کے بعد کسی بھی وقت:**
<div dir="ltr">
```bash
hermes claw migrate # انٹرایکٹو مائیگریشن (مکمل پری سیٹ)
hermes claw migrate --dry-run # جائزہ لیں کہ کیا کیا مائیگریٹ ہوگا
hermes claw migrate --preset user-data # حساس معلومات (secrets) کے بغیر مائیگریٹ کریں
hermes claw migrate --overwrite # موجودہ متصادم فائلوں کو اوور رائٹ کریں
```
</div>
جو چیزیں امپورٹ ہوتی ہیں:
- **SOUL.md** — پرسونا (persona) فائل
- **میموریز (Memories)** — MEMORY.md اور USER.md کی اندراجات
- **مہارتیں (Skills)** — صارف کی بنائی گئی مہارتیں → `~/.hermes/skills/openclaw-imports/`
- **کمانڈ الاؤ لسٹ (allowlist)** — منظوری کے پیٹرنز (approval patterns)
- **میسجنگ سیٹنگز** — پلیٹ فارم کنفیگریشنز، اجازت یافتہ صارفین، ورکنگ ڈائریکٹری
- **API کیز** — الاؤ لسٹ شدہ حساس معلومات (ٹیلی گرام، OpenRouter، OpenAI، Anthropic، ElevenLabs)
- **TTS اثاثے** — ورک اسپیس کی آڈیو فائلیں
- **ورک اسپیس کی ہدایات** — AGENTS.md (`--workspace-target` کے ساتھ)
تمام آپشنز دیکھنے کے لیے `hermes claw migrate --help` استعمال کریں، یا انٹرایکٹو ایجنٹ کی مدد سے مائیگریٹ کرنے کے لیے `openclaw-migration` سکل کا استعمال کریں (جس میں ڈرائی رن (dry-run) پریویوز شامل ہیں)۔
---
## تعاون کریں (Contributing)
ہم آپ کے تعاون کا خیرمقدم کرتے ہیں! ڈیویلپمنٹ سیٹ اپ، کوڈ کے انداز اور PR کے طریقہ کار کے لیے براہ کرم ہماری [Contributing گائیڈ](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) دیکھیں۔
معاونین (contributors) کے لیے فوری آغاز — کلون (clone) کریں اور `setup-hermes.sh` چلائیں:
<div dir="ltr">
```bash
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
./setup-hermes.sh # uv کو انسٹال کرتا ہے، venv بناتا ہے، .[all] کو انسٹال کرتا ہے، اور ~/.local/bin/hermes کا سیم لنک (symlink) بناتا ہے
./hermes # خود بخود venv کی شناخت کرتا ہے، پہلے `source` کرنے کی ضرورت نہیں
```
</div>
مینوئل طریقہ (اوپر والے طریقے کے مساوی):
<div dir="ltr">
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
scripts/run_tests.sh
```
</div>
---
## کمیونٹی (Community)
- 💬 [ڈسکارڈ (Discord)](https://discord.gg/NousResearch)
- 📚 [سکلز ہب (Skills Hub)](https://agentskills.io)
- 🐛 [مسائل (Issues)](https://github.com/NousResearch/hermes-agent/issues)
- 🔌 [computer-use-linux](https://github.com/avifenesh/computer-use-linux) — ہرمیس اور دیگر MCP ہوسٹس کے لیے لینکس (Linux) ڈیسک ٹاپ کنٹرول MCP سرور، جس میں AT-SPI ایکسیسیبلٹی ٹریز، Wayland/X11 ان پٹ، سکرین شاٹس، اور کمپوزیٹر ونڈو ٹارگیٹنگ شامل ہے۔
- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — کمیونٹی وی چیٹ (WeChat) برج: ہرمیس ایجنٹ اور OpenClaw کو ایک ہی وی چیٹ اکاؤنٹ پر چلائیں۔
---
## لائسنس (License)
MIT — تفصیلات کے لیے [LICENSE](LICENSE) دیکھیں۔
[نوس ریسرچ (Nous Research)](https://nousresearch.com) کی جانب سے تیار کردہ۔
</div>
+1
View File
@@ -10,6 +10,7 @@
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
<a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Built%20by-Nous%20Research-blueviolet?style=for-the-badge" alt="Built by Nous Research"></a>
<a href="README.md"><img src="https://img.shields.io/badge/Lang-English-lightgrey?style=for-the-badge" alt="English"></a>
<a href="README.ur-pk.md"><img src="https://img.shields.io/badge/Lang-اردو-green?style=for-the-badge" alt="اردو"></a>
</p>
**由 [Nous Research](https://nousresearch.com) 构建的自进化 AI 代理。** 它是唯一内置学习闭环的智能代理——从经验中创建技能,在使用中改进技能,主动持久化知识,搜索过往对话,并在跨会话中逐步构建对你的深度理解。可以在 $5 的 VPS 上运行,也可以在 GPU 集群上运行,或者使用几乎零成本的 Serverless 基础设施。它不绑定你的笔记本——你可以在 Telegram 上与它对话,而它在云端 VM 上工作。
+127
View File
@@ -0,0 +1,127 @@
"""Derive ACP session-provenance metadata from the existing compression chain.
This is an additive Hermes extension surfaced under ACP ``_meta.hermes`` so
existing ACP clients ignore it. It carries no new persisted state: everything
is derived on demand from the ``sessions`` table (``parent_session_id`` /
``end_reason``), which already models compression-continuation chains.
The ACP/editor ``session_id`` stays the stable public handle. When context
compression rotates the internal Hermes head, ``build_session_provenance`` lets
a client see the previous/current internal ids and the lineage root without
parsing status text, guessing from token drops, or reading ``state.db``.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
# Bound defensive walks; compression chains this deep are pathological.
_MAX_WALK = 100
def build_session_provenance(
db: Any,
acp_session_id: str,
current_hermes_session_id: str,
*,
previous_hermes_session_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Build ``_meta.hermes.sessionProvenance`` for an ACP session.
Args:
db: A ``SessionDB`` (must expose ``get_session``).
acp_session_id: The stable ACP/editor-facing session handle.
current_hermes_session_id: The live internal Hermes DB session id
(``state.agent.session_id``).
previous_hermes_session_id: The internal id from before the most recent
turn, when known. Supplied by ``prompt()`` to flag a rotation.
Returns:
A dict suitable for ``{"hermes": {"sessionProvenance": <dict>}}`` under
ACP ``_meta``, or ``None`` if the session can't be read.
"""
try:
row = db.get_session(current_hermes_session_id)
except Exception:
return None
if not row:
return None
parent_id = row.get("parent_session_id")
end_reason = row.get("end_reason")
# Walk parents to the lineage root and count compression depth. Only
# compression-split parents (parent.end_reason == 'compression') count
# toward depth — delegate/branch children share the parent_session_id
# column but are not compaction boundaries.
root_id = current_hermes_session_id
compression_depth = 0
cursor_parent = parent_id
seen = {current_hermes_session_id}
for _ in range(_MAX_WALK):
if not cursor_parent or cursor_parent in seen:
break
seen.add(cursor_parent)
try:
prow = db.get_session(cursor_parent)
except Exception:
prow = None
if not prow:
break
root_id = cursor_parent
if prow.get("end_reason") == "compression":
compression_depth += 1
cursor_parent = prow.get("parent_session_id")
# A session is a compression continuation when its parent was ended with
# end_reason='compression'. Determine that from the immediate parent.
is_continuation = False
if parent_id:
try:
immediate_parent = db.get_session(parent_id)
except Exception:
immediate_parent = None
if immediate_parent and immediate_parent.get("end_reason") == "compression":
is_continuation = True
rotated = bool(
previous_hermes_session_id
and previous_hermes_session_id != current_hermes_session_id
)
provenance: Dict[str, Any] = {
"acpSessionId": acp_session_id,
"currentHermesSessionId": current_hermes_session_id,
"rootHermesSessionId": root_id,
"parentHermesSessionId": parent_id,
"sessionKind": "continuation" if is_continuation else "root",
"compressionDepth": compression_depth,
}
if previous_hermes_session_id:
provenance["previousHermesSessionId"] = previous_hermes_session_id
if rotated:
# The head moved during the last turn. The only mechanism that rotates
# the internal id mid-turn is compression-driven session splitting.
provenance["reason"] = "compression"
provenance["creatorKind"] = "compression"
return provenance
def session_provenance_meta(
db: Any,
acp_session_id: str,
current_hermes_session_id: str,
*,
previous_hermes_session_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Return a ready ``_meta`` payload: ``{"hermes": {"sessionProvenance": ...}}``."""
prov = build_session_provenance(
db,
acp_session_id,
current_hermes_session_id,
previous_hermes_session_id=previous_hermes_session_id,
)
if prov is None:
return None
return {"hermes": {"sessionProvenance": prov}}
+95 -5
View File
@@ -71,6 +71,7 @@ from acp_adapter.events import (
make_tool_progress_cb,
)
from acp_adapter.permissions import make_approval_callback
from acp_adapter.provenance import session_provenance_meta
from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets
from acp_adapter.tools import build_tool_complete, build_tool_start
@@ -709,8 +710,39 @@ class HermesACPAgent(acp.Agent):
exc_info=True,
)
async def _send_session_info_update(self, session_id: str) -> None:
"""Send ACP native session metadata after Hermes changes it."""
def _provenance_meta(
self,
acp_session_id: str,
current_hermes_session_id: str,
previous_hermes_session_id: Optional[str] = None,
) -> Optional[dict]:
"""Best-effort ``_meta.hermes.sessionProvenance`` for an ACP session."""
try:
return session_provenance_meta(
self.session_manager._get_db(),
acp_session_id,
current_hermes_session_id,
previous_hermes_session_id=previous_hermes_session_id,
)
except Exception:
logger.debug(
"Could not build ACP session provenance for %s", acp_session_id, exc_info=True
)
return None
async def _send_session_info_update(
self,
session_id: str,
*,
current_hermes_session_id: Optional[str] = None,
previous_hermes_session_id: Optional[str] = None,
) -> None:
"""Send ACP native session metadata after Hermes changes it.
When the internal Hermes head rotated (e.g. compression-driven session
split during a turn), pass ``previous_hermes_session_id`` so the
attached ``_meta.hermes.sessionProvenance`` flags the rotation reason.
"""
if not self._conn:
return
try:
@@ -727,10 +759,16 @@ class HermesACPAgent(acp.Agent):
# the updated_at since we're emitting this notification precisely
# because the title was just refreshed.
updated_at = datetime.now(timezone.utc).isoformat()
meta = self._provenance_meta(
session_id,
current_hermes_session_id or session_id,
previous_hermes_session_id,
)
update = SessionInfoUpdate(
session_update="session_info_update",
title=title if isinstance(title, str) and title.strip() else None,
updated_at=updated_at,
field_meta=meta,
)
try:
await self._conn.session_update(
@@ -1081,6 +1119,9 @@ class HermesACPAgent(acp.Agent):
session_id=state.session_id,
models=self._build_model_state(state),
modes=self._session_modes(state),
field_meta=self._provenance_meta(
state.session_id, getattr(state.agent, "session_id", state.session_id)
),
)
async def load_session(
@@ -1125,6 +1166,9 @@ class HermesACPAgent(acp.Agent):
return LoadSessionResponse(
models=self._build_model_state(state),
modes=self._session_modes(state),
field_meta=self._provenance_meta(
session_id, getattr(state.agent, "session_id", session_id)
),
)
async def resume_session(
@@ -1157,6 +1201,9 @@ class HermesACPAgent(acp.Agent):
return ResumeSessionResponse(
models=self._build_model_state(state),
modes=self._session_modes(state),
field_meta=self._provenance_meta(
state.session_id, getattr(state.agent, "session_id", state.session_id)
),
)
async def cancel(self, session_id: str, **kwargs: Any) -> None:
@@ -1494,6 +1541,11 @@ class HermesACPAgent(acp.Agent):
logger.debug("Could not clear ACP session context", exc_info=True)
try:
# Snapshot the internal Hermes DB session id before the turn so we
# can detect a compression-driven session rotation afterwards. The
# ACP `session_id` stays the stable client handle; agent.session_id
# is the live internal head that compression may rotate.
pre_turn_hermes_id = getattr(state.agent, "session_id", None)
# Wrap the executor call in a fresh copy of the current context so
# concurrent ACP sessions on the shared ThreadPoolExecutor don't
# stomp on each other's ContextVar writes (HERMES_SESSION_KEY in
@@ -1512,8 +1564,41 @@ class HermesACPAgent(acp.Agent):
# Persist updated history so sessions survive process restarts.
self.session_manager.save_session(session_id)
# Detect a compression-driven internal session rotation. If the agent's
# DB head moved during the turn, emit a session_info_update carrying
# _meta.hermes.sessionProvenance so ACP clients can render the boundary
# and keep old/new ids in lineage. The ACP session_id is unchanged.
post_turn_hermes_id = getattr(state.agent, "session_id", None)
if (
conn
and post_turn_hermes_id
and pre_turn_hermes_id
and post_turn_hermes_id != pre_turn_hermes_id
):
try:
await self._send_session_info_update(
session_id,
current_hermes_session_id=post_turn_hermes_id,
previous_hermes_session_id=pre_turn_hermes_id,
)
except Exception:
logger.debug(
"Could not emit ACP provenance update after rotation for %s",
session_id,
exc_info=True,
)
final_response = result.get("final_response", "")
if final_response:
cancelled = bool(state.cancel_event and state.cancel_event.is_set())
interrupted = bool(result.get("interrupted")) or cancelled
# Hermes' local "waiting for model response" interrupt status is metadata,
# not assistant prose — clients get cancellation from stop_reason instead.
from agent.conversation_loop import INTERRUPT_WAITING_FOR_MODEL_PREFIX
suppress_interrupt_response = interrupted and final_response.startswith(
INTERRUPT_WAITING_FOR_MODEL_PREFIX
)
if final_response and not suppress_interrupt_response:
try:
from agent.title_generator import maybe_auto_title
@@ -1534,7 +1619,12 @@ class HermesACPAgent(acp.Agent):
)
except Exception:
logger.debug("Failed to auto-title ACP session %s", session_id, exc_info=True)
if final_response and conn and (not streamed_message or result.get("response_transformed")):
if (
final_response
and conn
and not suppress_interrupt_response
and (not streamed_message or result.get("response_transformed"))
):
# Deliver the final response when streaming did not already send it,
# or when a plugin hook transformed the response after streaming
# finished (e.g. transform_llm_output) — otherwise the appended /
@@ -1576,7 +1666,7 @@ class HermesACPAgent(acp.Agent):
await self._send_usage_update(state)
stop_reason = "cancelled" if state.cancel_event and state.cancel_event.is_set() else "end_turn"
stop_reason = "cancelled" if cancelled else "end_turn"
return PromptResponse(stop_reason=stop_reason, usage=usage)
# ---- Slash commands (headless) -------------------------------------------
+71 -2
View File
@@ -68,6 +68,24 @@ def _ra():
return run_agent
def _build_codex_gpt55_autoraise_notice(autoraise: Dict[str, float]) -> str:
"""Build the one-time notice shown when Codex gpt-5.5 raises compaction.
``autoraise`` is ``{"from": <old_ratio>, "to": <new_ratio>}``. The same
text is printed inline for CLI users and replayed via ``status_callback``
for gateway users, so it must be self-contained and include the exact
opt-back-out command.
"""
from_pct = int(round(autoraise["from"] * 100))
to_pct = int(round(autoraise["to"] * 100))
return (
f" Codex gpt-5.5 caps context at 272K, so auto-compaction was raised "
f"to {to_pct}% (from {from_pct}%) to use more of the window before "
f"summarizing.\n"
f" Opt back out: hermes config set compression.codex_gpt55_autoraise false"
)
def _normalized_custom_base_url(value: Any) -> str:
if not isinstance(value, str):
return ""
@@ -867,6 +885,14 @@ def init_agent(
headers["x-anthropic-beta"] = _FINE_GRAINED
client_kwargs["default_headers"] = headers
# User-configured request headers (model.default_headers in
# config.yaml) override provider/SDK defaults. Lets custom
# OpenAI-compatible endpoints behind a gateway/WAF that rejects the
# OpenAI SDK's identifying headers swap in a plain User-Agent. (#40033)
# client_kwargs is the same dict object as agent._client_kwargs, so
# this mutation is reflected in the client built just below.
agent._apply_user_default_headers()
agent.api_key = client_kwargs.get("api_key", "")
agent.base_url = client_kwargs.get("base_url", agent.base_url)
try:
@@ -1240,11 +1266,41 @@ def init_agent(
if not isinstance(_compression_cfg, dict):
_compression_cfg = {}
compression_threshold = float(_compression_cfg.get("threshold", 0.50))
# Per-model/route compaction-threshold override. Codex gpt-5.5 raises to
# 85% (the Codex backend caps the window at 272K, so the default 50% would
# compact at ~136K — half the usable context). Gated by an opt-out config
# flag so the user can fall back to the global threshold; when the override
# fires we stash a one-time notification (replayed on the first turn) that
# tells the user what changed and how to revert.
_codex_gpt55_autoraise = str(
_compression_cfg.get("codex_gpt55_autoraise", True)
).lower() in {"true", "1", "yes"}
agent._compression_threshold_autoraised = None
try:
from agent.auxiliary_client import _compression_threshold_for_model as _cthresh_fn
_model_cthresh = _cthresh_fn(agent.model)
from agent.auxiliary_client import (
_compression_threshold_for_model as _cthresh_fn,
_is_codex_gpt55 as _is_codex_gpt55_fn,
)
_model_cthresh = _cthresh_fn(
agent.model,
agent.provider,
allow_codex_gpt55_autoraise=_codex_gpt55_autoraise,
)
if _model_cthresh is not None:
_prev_threshold = compression_threshold
compression_threshold = _model_cthresh
# Notify only for the Codex gpt-5.5 autoraise (the Arcee Trinity
# override is a long-standing silent default). Skip the notice when
# the user's global threshold already meets/exceeds the raised
# value, since nothing actually changed for them.
if (
_is_codex_gpt55_fn(agent.model, agent.provider)
and _model_cthresh > _prev_threshold + 1e-9
):
agent._compression_threshold_autoraised = {
"from": _prev_threshold,
"to": _model_cthresh,
}
except Exception:
pass
compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"}
@@ -1621,11 +1677,24 @@ def init_agent(
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(compression_threshold*100)}% = {agent.context_compressor.threshold_tokens:,})")
else:
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)")
# One-time notice when the Codex gpt-5.5 autoraise kicked in, with the
# exact opt-back-out command. Printed inline at startup for CLI users;
# gateway users get the same text replayed via _compression_warning on
# turn 1 (set below, after the warning slot is initialized).
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
if _autoraise and compression_enabled:
print(_build_codex_gpt55_autoraise_notice(_autoraise))
# Check immediately so CLI users see the warning at startup.
# Gateway status_callback is not yet wired, so any warning is stored
# in _compression_warning and replayed in the first run_conversation().
agent._compression_warning = None
# Gateway parity for the Codex gpt-5.5 autoraise notice: the startup print
# above only reaches the CLI, so stash the same text here to be replayed
# through status_callback on the first turn (Telegram/Discord/Slack/etc.).
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
if _autoraise and compression_enabled:
agent._compression_warning = _build_codex_gpt55_autoraise_notice(_autoraise)
# Lazy feasibility check: deferred to the first turn that approaches the
# compression threshold. Running it eagerly here costs ~400ms cold (network
# probe of the auxiliary provider chain + /models lookup) on every agent
+21
View File
@@ -1846,6 +1846,27 @@ def repair_tool_call(agent, tool_name: str) -> str | None:
if not tool_name:
return None
# VolcEngine api/plan workaround (issue #33007): the endpoint's
# protocol-translation layer occasionally leaks raw XML attribute
# fragments into tool_use.name, e.g.
# `terminal" parameter="command" string="true`
# `execute_code" parameter="code" string="true`
# `session_search" parameter="session_id" string="true`
# We trim at the first unambiguous XML/quote character so the rest
# of the repair pipeline (lowercase / snake_case / fuzzy match)
# can resolve the cleaned name to a real tool.
#
# Crucially we DO NOT split on whitespace: legitimate inputs like
# "write file" must keep flowing through ``_norm`` -> ``write_file``
# (covered by test_space_to_underscore in
# tests/run_agent/test_repair_tool_call_name.py).
for _xml_sep in ('"', "'", "<", ">"):
_idx = tool_name.find(_xml_sep)
if _idx > 0:
tool_name = tool_name[:_idx]
if not tool_name:
return None
def _norm(s: str) -> str:
return s.lower().replace("-", "_").replace(" ", "_")
+178 -62
View File
@@ -202,6 +202,35 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool:
return bare == "trinity-large-thinking"
# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.5.
# The raw OpenAI API and OpenRouter expose 1.05M for the same slug, but the
# Codex backend hard-caps at 272K (verified live: a ~330K-token request to
# chatgpt.com/backend-api/codex/responses is rejected with
# ``context_length_exceeded`` while ~250K succeeds). With a 272K ceiling the
# default 50% compaction trigger fires at ~136K — wasteful, since the model
# can hold far more raw context before summarization actually buys anything.
# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.5
# sessions use the window they actually have.
_CODEX_GPT55_COMPACTION_THRESHOLD = 0.85
def _is_codex_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:
"""True for gpt-5.5 accessed through the ChatGPT Codex OAuth backend.
Matches only the Codex OAuth route (provider ``openai-codex``), not the
direct OpenAI API, OpenRouter, or GitHub Copilot paths those expose a
larger context window for the same slug and must keep the user's default
compaction threshold. ``gpt-5.5-pro`` and dated snapshots
(``gpt-5.5-2026-04-23``) are matched via prefix so the override tracks the
family without re-listing every variant.
"""
prov = (provider or "").strip().lower()
if prov != "openai-codex":
return False
bare = (model or "").strip().lower().rsplit("/", 1)[-1]
return bare == "gpt-5.5" or bare.startswith("gpt-5.5-") or bare.startswith("gpt-5.5.")
def _fixed_temperature_for_model(
model: Optional[str],
base_url: Optional[str] = None,
@@ -224,18 +253,32 @@ def _fixed_temperature_for_model(
return None
def _compression_threshold_for_model(model: Optional[str]) -> Optional[float]:
def _compression_threshold_for_model(
model: Optional[str],
provider: Optional[str] = None,
*,
allow_codex_gpt55_autoraise: bool = True,
) -> Optional[float]:
"""Return a context-compression threshold override for specific models.
The threshold is the fraction of the model's context window that must be
consumed before Hermes triggers summarization. Higher values delay
compression and preserve more raw context.
Per-model/route overrides:
- Arcee Trinity Large Thinking 0.75 (preserve reasoning context).
- gpt-5.5 on the Codex OAuth route 0.85, because Codex caps the window
at 272K and the default 50% trigger would compact at ~136K. Gated by
``allow_codex_gpt55_autoraise`` so the user can opt back down to the
global default (the caller passes the config flag through here).
Returns a float in (0, 1] to override the global ``compression.threshold``
config value, or ``None`` to leave the user's config value unchanged.
"""
if _is_arcee_trinity_thinking(model):
return 0.75
if allow_codex_gpt55_autoraise and _is_codex_gpt55(model, provider):
return _CODEX_GPT55_COMPACTION_THRESHOLD
return None
# Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
@@ -314,6 +357,35 @@ _OR_HEADERS_BASE = {
_TRUTHY_ENV_VALUES = frozenset({"1", "true", "yes", "on"})
def _apply_user_default_headers(headers: dict | None) -> dict | None:
"""Merge user-configured ``model.default_headers`` onto resolved headers.
User values take precedence over provider/SDK defaults, mirroring the main
agent client (``AIAgent._apply_user_default_headers``). This lets a
``custom`` OpenAI-compatible endpoint behind a gateway/WAF that rejects the
OpenAI SDK's identifying headers (``User-Agent: OpenAI/Python ...``,
``X-Stainless-*``) override them for auxiliary calls too otherwise the
main turn would succeed but title/compression/vision calls to the same
endpoint would still fail. (#40033)
Returns the merged dict, or the original ``headers`` (possibly ``None``)
when nothing is configured. No allocation when there are no overrides.
"""
try:
from hermes_cli.config import cfg_get, load_config
user_headers = cfg_get(load_config(), "model", "default_headers")
except Exception:
return headers
if not isinstance(user_headers, dict) or not user_headers:
return headers
merged = dict(headers or {})
for key, value in user_headers.items():
if value is None:
continue
merged[str(key)] = str(value)
return merged or headers
def build_or_headers(or_config: dict | None = None) -> dict:
"""Build OpenRouter headers, optionally including response-cache headers.
@@ -565,54 +637,6 @@ def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str:
# calls to the Codex Responses API so callers don't need any changes.
def _convert_content_for_responses(content: Any) -> Any:
"""Convert chat.completions content to Responses API format.
chat.completions uses:
{"type": "text", "text": "..."}
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
Responses API uses:
{"type": "input_text", "text": "..."}
{"type": "input_image", "image_url": "data:image/png;base64,..."}
If content is a plain string, it's returned as-is (the Responses API
accepts strings directly for text-only messages).
"""
if isinstance(content, str):
return content
if not isinstance(content, list):
return str(content) if content else ""
converted: List[Dict[str, Any]] = []
for part in content:
if not isinstance(part, dict):
continue
ptype = part.get("type", "")
if ptype == "text":
converted.append({"type": "input_text", "text": part.get("text", "")})
elif ptype == "image_url":
# chat.completions nests the URL: {"image_url": {"url": "..."}}
image_data = part.get("image_url", {})
url = image_data.get("url", "") if isinstance(image_data, dict) else str(image_data)
entry: Dict[str, Any] = {"type": "input_image", "image_url": url}
# Preserve detail if specified
detail = image_data.get("detail") if isinstance(image_data, dict) else None
if detail:
entry["detail"] = detail
converted.append(entry)
elif ptype in {"input_text", "input_image"}:
# Already in Responses format — pass through
converted.append(part)
else:
# Unknown content type — try to preserve as text
text = part.get("text", "")
if text:
converted.append({"type": "input_text", "text": text})
return converted or ""
class _CodexCompletionsAdapter:
"""Drop-in shim that accepts chat.completions.create() kwargs and
routes them through the Codex Responses streaming API."""
@@ -625,26 +649,37 @@ class _CodexCompletionsAdapter:
messages = kwargs.get("messages", [])
model = kwargs.get("model", self._model)
# Separate system/instructions from conversation messages.
# Convert chat.completions multimodal content blocks to Responses
# API format (input_text / input_image instead of text / image_url).
# Separate system/instructions from replayable conversation messages,
# then route the rest through the SINGLE shared chat->Responses
# converter used by the main agent transport
# (agent/transports/codex.py). Maintaining a private conversion loop
# here let chat-style messages with role="tool" leak straight into
# Responses input[] — which the Responses API rejects with
# "Invalid value: 'tool'. Supported values are: 'assistant', 'system',
# 'developer', and 'user'." (issue #5709, hit hard by flush_memories()
# / compression replaying real session history that includes assistant
# tool_calls + role="tool" results). The shared converter encodes
# assistant tool calls as `function_call` items and tool results as
# `function_call_output` items with a valid call_id, so every
# Responses path normalizes tool history identically and cannot drift.
from agent.codex_responses_adapter import _chat_messages_to_responses_input
instructions = "You are a helpful assistant."
input_msgs: List[Dict[str, Any]] = []
replay_messages: List[Dict[str, Any]] = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content") or ""
if role == "system":
instructions = content if isinstance(content, str) else str(content)
else:
input_msgs.append({
"role": role,
"content": _convert_content_for_responses(content),
})
replay_messages.append(msg)
input_items = _chat_messages_to_responses_input(replay_messages)
resp_kwargs: Dict[str, Any] = {
"model": model,
"instructions": instructions,
"input": input_msgs or [{"role": "user", "content": ""}],
"input": input_items or [{"role": "user", "content": ""}],
"store": False,
}
@@ -1452,6 +1487,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
extra["default_headers"] = dict(_ph_aux.default_headers)
except Exception:
pass
_merged_aux = _apply_user_default_headers(extra.get("default_headers"))
if _merged_aux:
extra["default_headers"] = _merged_aux
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
return _client, model
@@ -1489,6 +1527,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
extra["default_headers"] = dict(_ph_aux2.default_headers)
except Exception:
pass
_merged_aux2 = _apply_user_default_headers(extra.get("default_headers"))
if _merged_aux2:
extra["default_headers"] = _merged_aux2
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
return _client, model
@@ -1879,6 +1920,13 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
logger.debug("Auxiliary client: custom endpoint (%s, api_mode=%s)", model, custom_mode or "chat_completions")
_clean_base, _dq = _extract_url_query_params(custom_base)
_extra = {"default_query": _dq} if _dq else {}
# User-configured model.default_headers override the SDK's identifying
# headers (User-Agent: OpenAI/Python ..., X-Stainless-*) on this custom
# endpoint's auxiliary calls too — matching the main agent client so the
# whole session reaches a gateway/WAF that rejects the SDK fingerprint. (#40033)
_custom_headers = _apply_user_default_headers(None)
if _custom_headers:
_extra["default_headers"] = _custom_headers
if custom_mode == "codex_responses":
real_client = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra)
return CodexAuxiliaryClient(real_client, model), model
@@ -2428,6 +2476,25 @@ def _is_connection_error(exc: Exception) -> bool:
return False
def _is_transient_transport_error(exc: Exception) -> bool:
"""Return True for a one-off transport blip worth retrying ONCE on the
same provider before any provider/model fallback.
Covers connection/streaming-close errors (via the canonical
``_is_connection_error`` detector, shared so the two cannot drift) plus a
pure 5xx/408 HTTP status. Deliberately narrow: this is the "retry the
same target once" gate, distinct from ``_is_payment_error`` /
``_is_auth_error`` / ``_is_rate_limit_error`` which the except-chain
handles by switching provider, refreshing creds, or rotating the pool.
"""
if _is_connection_error(exc):
return True
status = getattr(exc, "status_code", None) or getattr(
getattr(exc, "response", None), "status_code", None
)
return isinstance(status, int) and (status == 408 or 500 <= status < 600)
def _is_auth_error(exc: Exception) -> bool:
"""Detect auth failures that should trigger provider-specific refresh."""
status = getattr(exc, "status_code", None)
@@ -3248,6 +3315,9 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
async_kwargs["default_headers"] = dict(_ph_async.default_headers)
except Exception:
pass
_merged_async = _apply_user_default_headers(async_kwargs.get("default_headers"))
if _merged_async:
async_kwargs["default_headers"] = _merged_async
return AsyncOpenAI(**async_kwargs), model
@@ -3535,6 +3605,9 @@ def resolve_provider_client(
extra["default_headers"] = dict(_ph_custom.default_headers)
except Exception:
pass
_merged_custom = _apply_user_default_headers(extra.get("default_headers"))
if _merged_custom:
extra["default_headers"] = _merged_custom
client = OpenAI(api_key=custom_key, base_url=_clean_base, **extra)
client = _wrap_if_needed(client, final_model, custom_base, custom_key)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
@@ -3611,6 +3684,9 @@ def resolve_provider_client(
raw_base_for_wrap = custom_base
_clean_base2, _dq2 = _extract_url_query_params(openai_base)
_extra2 = {"default_query": _dq2} if _dq2 else {}
_headers2 = _apply_user_default_headers(_extra2.get("default_headers"))
if _headers2:
_extra2["default_headers"] = _headers2
logger.debug(
"resolve_provider_client: named custom provider %r (%s, api_mode=%s)",
provider, final_model, entry_api_mode or "chat_completions")
@@ -3633,6 +3709,9 @@ def resolve_provider_client(
_fallback_base = _to_openai_base_url(custom_base)
_fb_clean, _fb_dq = _extract_url_query_params(_fallback_base)
_fb_extra = {"default_query": _fb_dq} if _fb_dq else {}
_fb_headers = _apply_user_default_headers(_fb_extra.get("default_headers"))
if _fb_headers:
_fb_extra["default_headers"] = _fb_headers
client = OpenAI(api_key=custom_key, base_url=_fb_clean, **_fb_extra)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
@@ -3781,6 +3860,9 @@ def resolve_provider_client(
headers.update(_ph_main.default_headers)
except Exception:
pass
_merged_main = _apply_user_default_headers(headers)
if _merged_main:
headers = _merged_main
client = OpenAI(api_key=api_key, base_url=base_url,
**({"default_headers": headers} if headers else {}))
@@ -5084,8 +5166,28 @@ def call_llm(
# Handle unsupported temperature, max_tokens vs max_completion_tokens retry,
# then payment fallback.
try:
return _validate_llm_response(
client.chat.completions.create(**kwargs), task)
# Retry ONCE on the same provider for a one-off transient transport
# blip (streaming-close / incomplete chunked read / 5xx / 408) before
# the except-chain below escalates to provider/model fallback. A
# single dropped connection shouldn't abandon an otherwise-healthy
# provider. A second failure (or any non-transient error) falls
# through to ``first_err`` and the existing fallback handling
# unchanged. This is the unified home for the transient retry that
# every auxiliary task (compression, memory flush, title-gen,
# session-search, vision) shares. (PR #16587)
try:
return _validate_llm_response(
client.chat.completions.create(**kwargs), task)
except Exception as transient_err:
if not _is_transient_transport_error(transient_err):
raise
logger.info(
"Auxiliary %s: transient transport error; retrying once on "
"the same provider before fallback: %s",
task or "call", transient_err,
)
return _validate_llm_response(
client.chat.completions.create(**kwargs), task)
except Exception as first_err:
if "temperature" in kwargs and _is_unsupported_temperature_error(first_err):
retry_kwargs = dict(kwargs)
@@ -5551,8 +5653,22 @@ async def async_call_llm(
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])
try:
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task)
# Retry ONCE on the same provider for a transient transport blip
# before the except-chain escalates to fallback — see call_llm()
# for the rationale. (PR #16587)
try:
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task)
except Exception as transient_err:
if not _is_transient_transport_error(transient_err):
raise
logger.info(
"Auxiliary %s (async): transient transport error; retrying "
"once on the same provider before fallback: %s",
task or "call", transient_err,
)
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task)
except Exception as first_err:
if "temperature" in kwargs and _is_unsupported_temperature_error(first_err):
retry_kwargs = dict(kwargs)
+11
View File
@@ -449,6 +449,17 @@ def _run_review_in_thread(
# if a future code path bypasses the cache.
review_agent.session_start = agent.session_start
review_agent.session_id = agent.session_id
# Never let the review fork compress. It shares the parent's
# session_id, so if it won a compression race it would rotate the
# parent into a NEW child that the gateway never adopts (the fork
# is single-lifecycle and dies right after this run_conversation).
# The foreground turn would then start from the stale parent and
# compress it again, leaving the same parent with two sibling
# children (issue #38727). Review also needs full context to
# produce a good memory/skill summary — compressing would strip
# detail. Both compression triggers in conversation_loop.py gate on
# agent.compression_enabled, so this short-circuits both paths.
review_agent.compression_enabled = False
from model_tools import get_tool_definitions
from hermes_cli.plugins import (
+76 -2
View File
@@ -34,7 +34,7 @@ from agent.message_sanitization import (
_repair_tool_call_arguments,
)
from tools.terminal_tool import is_persistent_env
from utils import base_url_host_matches, base_url_hostname
from utils import base_url_host_matches, base_url_hostname, env_int
logger = logging.getLogger(__name__)
@@ -139,6 +139,15 @@ def interruptible_api_call(agent, api_kwargs: dict):
result = {"response": None, "error": None}
request_client_holder = {"client": None, "owner_tid": None}
request_client_lock = threading.Lock()
# Request-local cancellation flag. Distinct from agent._interrupt_requested
# because that flag is cleared at run_conversation() turn boundaries, but
# this daemon worker thread can outlive the turn (the gateway caches
# AIAgent instances per session). Tracks whether THIS specific request was
# cancelled by the main thread's interrupt handler, so the transport error
# that is the expected consequence of our own force-close isn't misread as
# a network bug and surfaced to the caller. (PR #6600 — cascading interrupt
# hang.)
_request_cancelled = {"value": False}
def _set_request_client(client):
with request_client_lock:
@@ -229,6 +238,17 @@ def interruptible_api_call(agent, api_kwargs: dict):
)
result["response"] = request_client.chat.completions.create(**api_kwargs)
except Exception as e:
# If the request was cancelled by the main thread's interrupt
# handler, the transport error is the expected consequence of our
# own force-close, NOT a network bug. Swallow it instead of
# surfacing — the main thread raises InterruptedError. (#6600)
if _request_cancelled["value"]:
logger.debug(
"Non-streaming worker caught %s after request cancellation — "
"exiting without surfacing a network error.",
type(e).__name__,
)
return
result["error"] = e
finally:
_close_request_client_once("request_complete")
@@ -506,6 +526,14 @@ def interruptible_api_call(agent, api_kwargs: dict):
break
if agent._interrupt_requested:
# Mark THIS request cancelled before force-closing so the worker's
# exception handler recognizes the forced transport error as a
# cancel and exits cleanly instead of surfacing a network error or
# (in the streaming path) burning full retry cycles. (#6600)
_request_cancelled["value"] = True
logger.debug(
"Force-closing httpx client due to interrupt (not a network error)."
)
# Force-close the in-flight worker-local HTTP connection to stop
# token generation without poisoning the shared client used to
# seed future retries.
@@ -1625,6 +1653,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
result = {"response": None, "error": None, "partial_tool_names": []}
request_client_holder = {"client": None, "diag": None, "owner_tid": None}
request_client_lock = threading.Lock()
# Request-local cancellation flag — see interruptible_api_call for the full
# rationale. The streaming retry loop is where the 7-minute cascading-
# interrupt hang originated: a force-close raised RemoteProtocolError, the
# loop classified it as a transient network error, and burned full retry
# cycles (and emitted "reconnecting" noise) on a request the user already
# cancelled. The token lets the worker recognize its own forced close and
# exit immediately instead of retrying. (PR #6600.)
_request_cancelled = {"value": False}
def _set_request_client(client):
with request_client_lock:
@@ -1936,6 +1972,20 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
),
))
# Zero-chunk guard: stream yielded nothing usable — a provider/upstream
# error or malformed SSE, not a legitimate empty completion. Raise so the
# retry machinery handles it instead of fabricating a successful turn.
if (
finish_reason is None
and not content_parts
and not reasoning_parts
and not tool_calls_acc
):
raise RuntimeError(
"Provider returned an empty stream with no finish_reason "
"(possible upstream error or malformed SSE response)."
)
effective_finish_reason = finish_reason or "stop"
if has_truncated_tool_args:
effective_finish_reason = "length"
@@ -2044,7 +2094,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
def _call():
import httpx as _httpx
_max_stream_retries = int(os.getenv("HERMES_STREAM_RETRIES", 2))
_max_stream_retries = env_int("HERMES_STREAM_RETRIES", 2)
try:
for _stream_attempt in range(_max_stream_retries + 1):
@@ -2064,6 +2114,21 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
result["response"] = _call_chat_completions()
return # success
except Exception as e:
# If the main poll loop force-closed this request because
# of an interrupt, the resulting transport error is the
# expected consequence of our own close — NOT a transient
# network error. Exit immediately: no retry, no fallback,
# no "reconnecting" status. The outer poll loop raises
# InterruptedError. This is the fix for the cascading-
# interrupt hang where doomed retries burned full
# stream-stale-timeout cycles. (#6600)
if _request_cancelled["value"]:
logger.debug(
"Streaming worker caught %s after request "
"cancellation — exiting without retry.",
type(e).__name__,
)
return
_is_timeout = isinstance(
e, (_httpx.ReadTimeout, _httpx.ConnectTimeout, _httpx.PoolTimeout)
)
@@ -2373,6 +2438,15 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
)
if agent._interrupt_requested:
# Mark THIS request cancelled before force-closing so the worker's
# exception handler recognizes the forced transport error as a
# cancel and exits without retrying or surfacing a network error.
# (#6600)
_request_cancelled["value"] = True
logger.debug(
"Force-closing streaming httpx client due to interrupt "
"(not a network error)."
)
try:
if agent.api_mode == "anthropic_messages":
agent._anthropic_client.close()
+105 -1
View File
@@ -553,6 +553,22 @@ class ContextCompressor(ContextEngine):
self.last_rough_tokens_when_real_prompt_fit = 0
self.awaiting_real_usage_after_compression = False
def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
"""Clear per-session compaction state at a real session boundary.
``_previous_summary`` is per-session iterative-summary state. It is
cleared on ``on_session_reset()`` (/new, /reset), but session *end*
(CLI exit, gateway expiry, session-id rotation) goes through
``on_session_end()`` instead which inherited a no-op from
``ContextEngine``. Without clearing here, a cron/background session's
summary could survive on a reused compressor instance and leak into the
next live session via the ``_generate_summary()`` iterative-update path
(#38788). ``compress()`` already guards the leak at the point of use;
this is defense-in-depth that drops the stale summary the moment the
owning session ends.
"""
self._previous_summary = None
def update_model(
self,
model: str,
@@ -1247,6 +1263,19 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
summary_budget = self._compute_summary_budget(turns_to_summarize)
content_to_summarize = self._serialize_for_summary(turns_to_summarize)
# Current date for temporal anchoring (see ## Temporal Anchoring below).
# Date-only granularity matches system_prompt.py:337 (PR #20451) and the
# user's configured timezone via hermes_time.now(). The compaction summary
# is a mid-conversation message that is NOT part of the cached prefix, so a
# date here never affects prompt-cache stability. Resolved defensively —
# a clock failure must never block compaction.
try:
from hermes_time import now as _hermes_now
_today_str = _hermes_now().strftime("%Y-%m-%d")
except Exception: # pragma: no cover - clock resolution is best-effort
_today_str = ""
# Preamble shared by both first-compaction and iterative-update prompts.
# Keep the wording deliberately plain: Azure/OpenAI-compatible content
# filters have flagged stronger "injection" / "do not respond" framing.
@@ -1264,6 +1293,24 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
"do not preserve their values."
)
# Temporal anchoring directive. Rewrites relative / still-pending-sounding
# references into absolute, dated, past-tense facts so a resumed
# conversation does not re-issue completed actions. Only emitted when the
# current date resolved successfully; otherwise the rule is omitted so the
# summarizer is never handed an empty date placeholder.
if _today_str:
_temporal_anchoring_rule = (
f"\nTEMPORAL ANCHORING: The current date is {_today_str}. When an "
"action has already been carried out, phrase it as a completed, "
"dated, past-tense fact rather than an open instruction. For "
'example, rewrite "email John about the proposal" as "Sent the '
f'proposal email to John on {_today_str}." Never leave a finished '
"action worded as if it still needs doing, and never invent a date "
"for work that has not happened yet.\n"
)
else:
_temporal_anchoring_rule = ""
# Shared structured template (used by both paths).
_template_sections = f"""## Active Task
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
@@ -1337,7 +1384,7 @@ Be specific with file paths, commands, line numbers, and results.]
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials write [REDACTED] instead.]
Target ~{summary_budget} tokens. Be CONCRETE include file paths, command outputs, error messages, line numbers, and specific values. Avoid vague descriptions like "made some changes" say exactly what changed.
{_temporal_anchoring_rule}
Write only the summary body. Do not include any preamble or prefix."""
if self._previous_summary:
@@ -1787,6 +1834,41 @@ The user has requested that this compaction PRIORITISE preserving all informatio
accumulated += msg_tokens
cut_idx = i
# If the backward walk never broke early because the entire transcript
# fits within soft_ceiling, accumulated now holds the total transcript
# size. Without intervention _ensure_last_user_message_in_tail pushes
# cut_idx forward to include the last user message, and the caller's
# compress_start >= compress_end guard either returns unchanged (no-op)
# or compresses a single message — both of which trigger the infinite
# compaction loop described in #40803.
#
# Fix: when the whole transcript fits in soft_ceiling, compute a
# meaningful cut point using the raw (non-inflated) budget so that
# compression actually summarizes a worthwhile middle section.
if cut_idx <= head_end and accumulated <= soft_ceiling and accumulated > 0:
# The entire compressable region fits in the soft ceiling.
# Re-walk with the raw budget (no 1.5x multiplier) to find a
# split that gives the summarizer something useful.
raw_budget = token_budget
raw_accumulated = 0
for j in range(n - 1, head_end - 1, -1):
raw_msg = messages[j]
raw_content = raw_msg.get("content") or ""
raw_len = _content_length_for_budget(raw_content)
raw_tok = raw_len // _CHARS_PER_TOKEN + 10
for tc in raw_msg.get("tool_calls") or []:
if isinstance(tc, dict):
args = tc.get("function", {}).get("arguments", "")
raw_tok += len(args) // _CHARS_PER_TOKEN
if raw_accumulated + raw_tok > raw_budget and (n - j) >= min_tail:
cut_idx = j
break
raw_accumulated += raw_tok
cut_idx = j
# If the raw-budget walk also consumed everything (very small
# transcript), fall through — the existing fallback logic below
# will still force a minimal cut after head_end.
# Ensure we protect at least min_tail messages
fallback_cut = n - min_tail
cut_idx = min(cut_idx, fallback_cut)
@@ -1889,6 +1971,21 @@ The user has requested that this compaction PRIORITISE preserving all informatio
compress_end = self._find_tail_cut_by_tokens(messages, compress_start)
if compress_start >= compress_end:
# No compressable window — the entire transcript fits within
# the tail budget (soft_ceiling). Without recording this as
# an ineffective compression the anti-thrashing guard in
# should_compress() never fires and every subsequent turn
# re-triggers a no-op compression loop. (#40803)
self._ineffective_compression_count += 1
self._last_compression_savings_pct = 0.0
if not self.quiet_mode:
logger.warning(
"Compression skipped: compress_start (%d) >= compress_end (%d) "
"— transcript fits within tail budget, nothing to compress. "
"ineffective_compression_count=%d",
compress_start, compress_end,
self._ineffective_compression_count,
)
return messages
turns_to_summarize = messages[compress_start:compress_end]
@@ -1909,6 +2006,13 @@ The user has requested that this compaction PRIORITISE preserving all informatio
if summary_body and not self._previous_summary:
self._previous_summary = summary_body
turns_to_summarize = messages[max(compress_start, summary_idx + 1):compress_end]
elif self._previous_summary:
# No handoff summary found in the current messages, but
# _previous_summary is non-empty — it was set by a different
# (now-ended) session (e.g., a cron job, a prior /new). Discard
# it so _generate_summary() does not inject cross-session content
# into the summarizer prompt via the iterative-update path.
self._previous_summary = None
if not self.quiet_mode:
logger.info(
+17
View File
@@ -507,12 +507,29 @@ def compress_context(
agent._session_db.end_session(agent.session_id, "compression")
old_session_id = agent.session_id
agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
# Ordering contract: the agent thread updates the contextvar here;
# the gateway propagates to SessionEntry after run_in_executor returns.
try:
from gateway.session_context import set_current_session_id
set_current_session_id(agent.session_id)
except Exception:
os.environ["HERMES_SESSION_ID"] = agent.session_id
# The gateway/tools session context (ContextVar + env) and the
# logging session context are SEPARATE mechanisms. The call above
# moves the former; the ``[session_id]`` tag on log lines comes
# from ``hermes_logging._session_context`` (set once per turn in
# conversation_loop.py). Without this, post-rotation log lines in
# the same turn keep the STALE old id while the message/DB/gateway
# state carry the new one — breaking log correlation exactly at the
# compaction boundary (see #34089). Guarded separately so a logging
# failure can never regress the routing update above.
try:
from hermes_logging import set_session_context
set_session_context(agent.session_id)
except Exception:
pass
agent._session_db_created = False
agent._session_db.create_session(
session_id=agent.session_id,
+85 -452
View File
@@ -31,6 +31,8 @@ from agent.codex_responses_adapter import _summarize_user_message_for_log
from agent.display import KawaiiSpinner
from agent.error_classifier import FailoverReason, classify_api_error
from agent.iteration_budget import IterationBudget
from agent.turn_context import build_turn_context
from agent.turn_retry_state import TurnRetryState
from agent.memory_manager import build_memory_context_block
from agent.message_sanitization import (
_repair_tool_call_arguments,
@@ -63,6 +65,11 @@ from utils import base_url_host_matches, env_var_enabled
logger = logging.getLogger(__name__)
# Stable prefix of the local interrupt status string emitted when a turn is
# cancelled while waiting on the provider. Surfaces (ACP, TUI) match on this
# to treat it as cancellation metadata rather than assistant prose.
INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model response ("
def _ollama_context_limit_error(agent: Any, request_tokens: int) -> Optional[str]:
"""Return a user-facing error when Ollama is loaded with too little context."""
@@ -389,356 +396,43 @@ def run_conversation(
Returns:
Dict: Complete conversation result with final response and message history
"""
# Guard stdio against OSError from broken pipes (systemd/headless/daemon).
# Installed once, transparent when streams are healthy, prevents crash on write.
_install_safe_stdio()
agent._ensure_db_session()
# Tell auxiliary_client what the live main provider/model are for
# this turn. Used by tools whose behaviour depends on the active
# main model (e.g. vision_analyze's native fast path) so they see
# the CLI/gateway override instead of the stale config.yaml
# default. Idempotent — fine to call every turn.
try:
from agent.auxiliary_client import set_runtime_main
set_runtime_main(
getattr(agent, "provider", "") or "",
getattr(agent, "model", "") or "",
base_url=getattr(agent, "base_url", "") or "",
api_key=getattr(agent, "api_key", "") or "",
api_mode=getattr(agent, "api_mode", "") or "",
)
except Exception:
pass
# Tag all log records on this thread with the session ID so
# ``hermes logs --session <id>`` can filter a single conversation.
set_session_context(agent.session_id)
# Bind the skill write-origin ContextVar for this thread so tool
# handlers (e.g. skill_manage create) can tell whether they are
# running inside the background agent-improvement review fork vs.
# a foreground user-directed turn. Set at the top of each call;
# the review fork runs on its own thread with a fresh context,
# so the foreground value here does not leak into it.
set_current_write_origin(getattr(agent, "_memory_write_origin", "assistant_tool"))
# If the previous turn activated fallback, restore the primary
# runtime so this turn gets a fresh attempt with the preferred model.
# No-op when _fallback_activated is False (gateway, first turn, etc.).
agent._restore_primary_runtime()
# Sanitize surrogate characters from user input. Clipboard paste from
# rich-text editors (Google Docs, Word, etc.) can inject lone surrogates
# that are invalid UTF-8 and crash JSON serialization in the OpenAI SDK.
if isinstance(user_message, str):
user_message = _sanitize_surrogates(user_message)
if isinstance(persist_user_message, str):
persist_user_message = _sanitize_surrogates(persist_user_message)
# Store stream callback for _interruptible_api_call to pick up
agent._stream_callback = stream_callback
agent._persist_user_message_idx = None
agent._persist_user_message_override = persist_user_message
# Generate unique task_id if not provided to isolate VMs between concurrent tasks
effective_task_id = task_id or str(uuid.uuid4())
# Expose the active task_id so tools running mid-turn (e.g. delegate_task
# in delegate_tool.py) can identify this agent for the cross-agent file
# state registry. Set BEFORE any tool dispatch so snapshots taken at
# child-launch time see the parent's real id, not None.
agent._current_task_id = effective_task_id
turn_id = f"{agent.session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
agent._current_turn_id = turn_id
agent._current_api_request_id = ""
# Reset retry counters and iteration budget at the start of each turn
# so subagent usage from a previous turn doesn't eat into the next one.
agent._invalid_tool_retries = 0
agent._invalid_json_retries = 0
agent._empty_content_retries = 0
agent._incomplete_scratchpad_retries = 0
agent._codex_incomplete_retries = 0
agent._thinking_prefill_retries = 0
agent._post_tool_empty_retried = False
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
agent._mute_post_response = False
agent._unicode_sanitization_passes = 0
agent._tool_guardrails.reset_for_turn()
agent._tool_guardrail_halt_decision = None
# True until the server rejects an image_url content part with an error
# like "Only 'text' content type is supported." Set to False on first
# rejection and kept False for the rest of the session so we never re-send
# images to a text-only endpoint. Scoped per `_run()` call, not per instance.
agent._vision_supported = True
# Pre-turn connection health check: detect and clean up dead TCP
# connections left over from provider outages or dropped streams.
# This prevents the next API call from hanging on a zombie socket.
if agent.api_mode != "anthropic_messages":
try:
if agent._cleanup_dead_connections():
agent._emit_status(
"🔌 Detected stale connections from a previous provider "
"issue — cleaned up automatically. Proceeding with fresh "
"connection."
)
except Exception:
pass
# Replay compression warning through status_callback for gateway
# platforms (the callback was not wired during __init__).
if agent._compression_warning:
agent._replay_compression_warning()
agent._compression_warning = None # send once
# NOTE: _turns_since_memory and _iters_since_skill are NOT reset here.
# They are initialized in __init__ and must persist across run_conversation
# calls so that nudge logic accumulates correctly in CLI mode.
agent.iteration_budget = IterationBudget(agent.max_iterations)
# Log conversation turn start for debugging/observability
_preview_text = _summarize_user_message_for_log(user_message)
_msg_preview = (_preview_text[:80] + "...") if len(_preview_text) > 80 else _preview_text
_msg_preview = _msg_preview.replace("\n", " ")
logger.info(
"conversation turn: session=%s model=%s provider=%s platform=%s history=%d msg=%r",
agent.session_id or "none", agent.model, agent.provider or "unknown",
agent.platform or "unknown", len(conversation_history or []),
_msg_preview,
# ── 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-
# build, crash-resilience persistence, preflight compression, the
# ``pre_llm_call`` plugin hook, and external-memory prefetch — lives in
# ``build_turn_context``. It mutates ``agent`` exactly as the inline code
# did and returns the locals the loop below reads back. See
# ``agent/turn_context.py``.
_ctx = build_turn_context(
agent,
user_message,
system_message,
conversation_history,
task_id,
stream_callback,
persist_user_message,
restore_or_build_system_prompt=_restore_or_build_system_prompt,
install_safe_stdio=_install_safe_stdio,
sanitize_surrogates=_sanitize_surrogates,
summarize_user_message_for_log=_summarize_user_message_for_log,
set_session_context=set_session_context,
set_current_write_origin=set_current_write_origin,
ra=_ra,
)
user_message = _ctx.user_message
original_user_message = _ctx.original_user_message
messages = _ctx.messages
conversation_history = _ctx.conversation_history
active_system_prompt = _ctx.active_system_prompt
effective_task_id = _ctx.effective_task_id
turn_id = _ctx.turn_id
current_turn_user_idx = _ctx.current_turn_user_idx
_should_review_memory = _ctx.should_review_memory
_plugin_user_context = _ctx.plugin_user_context
_ext_prefetch_cache = _ctx.ext_prefetch_cache
# Initialize conversation (copy to avoid mutating the caller's list)
messages = list(conversation_history) if conversation_history else []
# Hydrate todo store from conversation history (gateway creates a fresh
# AIAgent per message, so the in-memory store is empty -- we need to
# recover the todo state from the most recent todo tool response in history)
if conversation_history and not agent._todo_store.has_items():
agent._hydrate_todo_store(conversation_history)
# Hydrate per-session nudge counters from persisted history.
# Gateway creates a fresh AIAgent per inbound message (cache miss /
# 1h idle eviction / config-signature mismatch / process restart), so
# _turns_since_memory and _user_turn_count start at 0 every turn and
# the memory.nudge_interval trigger may never be reached. Reconstruct
# an effective count from prior user turns in conversation_history.
# Idempotent: a cached agent that already accumulated counters keeps
# them; only a freshly-built agent with empty in-memory state hydrates.
# See issue #22357.
if conversation_history and agent._user_turn_count == 0:
prior_user_turns = sum(
1 for m in conversation_history if m.get("role") == "user"
)
if prior_user_turns > 0:
agent._user_turn_count = prior_user_turns
if agent._memory_nudge_interval > 0 and agent._turns_since_memory == 0:
# % preserves original 1-in-N cadence rather than firing a
# review immediately on resume (which would surprise users
# whose session happened to land just past a multiple of N).
agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval
# Prefill messages (few-shot priming) are injected at API-call time only,
# never stored in the messages list. This keeps them ephemeral: they won't
# be saved to session DB, session logs, or batch trajectories, but they're
# automatically re-applied on every API call (including session continuations).
# Track user turns for memory flush and periodic nudge logic
agent._user_turn_count += 1
# Reset the streaming context scrubber at the top of each turn so a
# hung span from a prior interrupted stream can't taint this turn's
# output.
scrubber = getattr(agent, "_stream_context_scrubber", None)
if scrubber is not None:
scrubber.reset()
# Reset the think scrubber for the same reason — an interrupted
# prior stream may have left us inside an unterminated block.
think_scrubber = getattr(agent, "_stream_think_scrubber", None)
if think_scrubber is not None:
think_scrubber.reset()
# Preserve the original user message (no nudge injection).
original_user_message = persist_user_message if persist_user_message is not None else user_message
# Track memory nudge trigger (turn-based, checked here).
# Skill trigger is checked AFTER the agent loop completes, based on
# how many tool iterations THIS turn used.
_should_review_memory = False
if (agent._memory_nudge_interval > 0
and "memory" in agent.valid_tool_names
and agent._memory_store):
agent._turns_since_memory += 1
if agent._turns_since_memory >= agent._memory_nudge_interval:
_should_review_memory = True
agent._turns_since_memory = 0
# Add user message
user_msg = {"role": "user", "content": user_message}
messages.append(user_msg)
current_turn_user_idx = len(messages) - 1
agent._persist_user_message_idx = current_turn_user_idx
if not agent.quiet_mode:
_print_preview = _summarize_user_message_for_log(user_message)
agent._safe_print(f"💬 Starting conversation: '{_print_preview[:60]}{'...' if len(_print_preview) > 60 else ''}'")
# ── System prompt (cached per session for prefix caching) ──
# Built once on first call, reused for all subsequent calls.
# Only rebuilt after context compression events (which invalidate
# the cache and reload memory from disk).
#
# For continuing sessions (gateway creates a fresh AIAgent per
# message), we load the stored system prompt from the session DB
# instead of rebuilding. Rebuilding would pick up memory changes
# from disk that the model already knows about (it wrote them!),
# producing a different system prompt and breaking the Anthropic
# prefix cache.
if agent._cached_system_prompt is None:
_restore_or_build_system_prompt(agent, system_message, conversation_history)
active_system_prompt = agent._cached_system_prompt
# ── Preflight context compression ──
# Before entering the main loop, check if the loaded conversation
# history already exceeds the model's context threshold. This handles
# cases where a user switches to a model with a smaller context window
# while having a large existing session — compress proactively rather
# than waiting for an API error (which might be caught as a non-retryable
# 4xx and abort the request entirely).
if (
agent.compression_enabled
and len(messages) > agent.context_compressor.protect_first_n
+ agent.context_compressor.protect_last_n + 1
):
# Include tool schema tokens — with many tools these can add
# 20-30K+ tokens that the old sys+msg estimate missed entirely.
_preflight_tokens = estimate_request_tokens_rough(
messages,
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
_compressor = agent.context_compressor
_defer_preflight = getattr(
_compressor,
"should_defer_preflight_to_real_usage",
lambda _tokens: False,
)
_preflight_deferred = _defer_preflight(_preflight_tokens)
if not _preflight_deferred:
# Keep the CLI/ACP context display in sync with what preflight
# actually measured. The status bar reads
# ``compressor.last_prompt_tokens``, which otherwise only updates
# from a *successful* API response. When the conversation has grown
# since the last successful call — or when compression then fails
# (e.g. the auxiliary summary model times out) and no fresh usage
# arrives — the bar stays stuck at the old, smaller value while
# preflight reports a much larger number, looking out of sync.
# Seed it with the fresh estimate (only ever revising upward; a real
# ``update_from_response`` will correct it after the next API call).
# Skipped when deferring — a deferred estimate is known to over-count
# vs the last real provider prompt, so trusting it for the display
# would re-introduce the very desync we're avoiding.
if _preflight_tokens > (_compressor.last_prompt_tokens or 0):
_compressor.last_prompt_tokens = _preflight_tokens
if _preflight_deferred:
logger.info(
"Skipping preflight compression: rough estimate ~%s >= %s, "
"but last real provider prompt was %s after compression",
f"{_preflight_tokens:,}",
f"{_compressor.threshold_tokens:,}",
f"{_compressor.last_real_prompt_tokens:,}",
)
elif _compressor.should_compress(_preflight_tokens):
logger.info(
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
f"{_preflight_tokens:,}",
f"{_compressor.threshold_tokens:,}",
agent.model,
f"{_compressor.context_length:,}",
)
agent._emit_status(
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
f">= {_compressor.threshold_tokens:,} threshold. "
"This may take a moment."
)
# May need multiple passes for very large sessions with small
# context windows (each pass summarises the middle N turns).
for _pass in range(3):
_orig_len = len(messages)
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=_preflight_tokens,
task_id=effective_task_id,
)
if len(messages) >= _orig_len:
break # Cannot compress further
# Compression created a new session — clear the history
# reference so _flush_messages_to_session_db writes ALL
# compressed messages to the new session's SQLite, not
# skipping them because conversation_history is still the
# pre-compression length.
conversation_history = None
# Fix: reset retry counters after compression so the model
# gets a fresh budget on the compressed context. Without
# this, pre-compression retries carry over and the model
# hits "(empty)" immediately after compression-induced
# context loss.
agent._empty_content_retries = 0
agent._thinking_prefill_retries = 0
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
agent._mute_post_response = False
# Re-estimate after compression
_preflight_tokens = estimate_request_tokens_rough(
messages,
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
if not _compressor.should_compress(_preflight_tokens):
break # Under threshold or anti-thrash guard stopped it
# Plugin hook: pre_llm_call
# Fired once per turn before the tool-calling loop. Plugins can
# return a dict with a ``context`` key (or a plain string) whose
# value is appended to the current turn's user message.
#
# Context is ALWAYS injected into the user message, never the
# system prompt. This preserves the prompt cache prefix — the
# system prompt stays identical across turns so cached tokens
# are reused. The system prompt is Hermes's territory; plugins
# contribute context alongside the user's input.
#
# All injected context is ephemeral (not persisted to session DB).
_plugin_user_context = ""
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_pre_results = _invoke_hook(
"pre_llm_call",
session_id=agent.session_id,
task_id=effective_task_id,
turn_id=turn_id,
user_message=original_user_message,
conversation_history=list(messages),
is_first_turn=(not bool(conversation_history)),
model=agent.model,
platform=getattr(agent, "platform", None) or "",
sender_id=getattr(agent, "_user_id", None) or "",
)
_ctx_parts: list[str] = []
for r in _pre_results:
if isinstance(r, dict) and r.get("context"):
_ctx_parts.append(str(r["context"]))
elif isinstance(r, str) and r.strip():
_ctx_parts.append(r)
if _ctx_parts:
_plugin_user_context = "\n\n".join(_ctx_parts)
except Exception as exc:
logger.warning("pre_llm_call hook failed: %s", exc)
# Main conversation loop
# Main conversation loop counters (pure locals consumed by the loop below).
api_call_count = 0
final_response = None
interrupted = False
@@ -750,53 +444,6 @@ def run_conversation(
compression_attempts = 0
_turn_exit_reason = "unknown" # Diagnostic: why the loop ended
# Per-turn file-mutation verifier state. Keyed by resolved path;
# each failed ``write_file`` / ``patch`` call records the error
# preview. Later successful writes to the same path remove the
# entry (the model recovered). At end-of-turn, any entries still
# present are surfaced in an advisory footer so the model cannot
# over-claim success while the file is actually unchanged on disk.
agent._turn_failed_file_mutations: Dict[str, Dict[str, Any]] = {}
# Record the execution thread so interrupt()/clear_interrupt() can
# scope the tool-level interrupt signal to THIS agent's thread only.
# Must be set before any thread-scoped interrupt syncing.
agent._execution_thread_id = threading.current_thread().ident
# Always clear stale per-thread state from a previous turn. If an
# interrupt arrived before startup finished, preserve it and bind it
# to this execution thread now instead of dropping it on the floor.
_ra()._set_interrupt(False, agent._execution_thread_id)
if agent._interrupt_requested:
_ra()._set_interrupt(True, agent._execution_thread_id)
agent._interrupt_thread_signal_pending = False
else:
agent._interrupt_message = None
agent._interrupt_thread_signal_pending = False
# Notify memory providers of the new turn so cadence tracking works.
# Must happen BEFORE prefetch_all() so providers know which turn it is
# and can gate context/dialectic refresh via contextCadence/dialecticCadence.
if agent._memory_manager:
try:
_turn_msg = original_user_message if isinstance(original_user_message, str) else ""
agent._memory_manager.on_turn_start(agent._user_turn_count, _turn_msg)
except Exception:
pass
# External memory provider: prefetch once before the tool loop.
# Reuse the cached result on every iteration to avoid re-calling
# prefetch_all() on each tool call (10 tool calls = 10x latency + cost).
# Use original_user_message (clean input) — user_message may contain
# injected skill content that bloats / breaks provider queries.
_ext_prefetch_cache = ""
if agent._memory_manager:
try:
_query = original_user_message if isinstance(original_user_message, str) else ""
_ext_prefetch_cache = agent._memory_manager.prefetch_all(_query) or ""
except Exception:
pass
# 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.
@@ -1152,22 +799,8 @@ def run_conversation(
api_start_time = time.time()
retry_count = 0
max_retries = agent._api_max_retries
primary_recovery_attempted = False
_retry = TurnRetryState()
max_compression_attempts = 3
codex_auth_retry_attempted=False
anthropic_auth_retry_attempted=False
nous_auth_retry_attempted=False
nous_paid_entitlement_refresh_attempted=False
copilot_auth_retry_attempted=False
thinking_sig_retry_attempted = False
invalid_encrypted_content_retry_attempted = False
image_shrink_retry_attempted = False
multimodal_tool_content_retry_attempted = False
oauth_1m_beta_retry_attempted = False
llama_cpp_grammar_retry_attempted = False
has_retried_429 = False
restart_with_compressed_messages = False
restart_with_length_continuation = False
finish_reason = "stop"
response = None # Guard against UnboundLocalError if all retries fail
@@ -1200,7 +833,7 @@ def run_conversation(
if agent._try_activate_fallback():
retry_count = 0
compression_attempts = 0
primary_recovery_attempted = False
_retry.primary_recovery_attempted = False
continue
# No fallback available — surface buffered context
# so user sees the rate-limit message that led here.
@@ -1525,7 +1158,7 @@ def run_conversation(
if agent._try_activate_fallback():
retry_count = 0
compression_attempts = 0
primary_recovery_attempted = False
_retry.primary_recovery_attempted = False
continue
# Check for error field in response (some providers include this)
@@ -1596,7 +1229,7 @@ def run_conversation(
if agent._try_activate_fallback():
retry_count = 0
compression_attempts = 0
primary_recovery_attempted = False
_retry.primary_recovery_attempted = False
continue
# Terminal — flush buffered retry trace so user sees what happened.
agent._flush_status_buffer()
@@ -1820,7 +1453,7 @@ def run_conversation(
}
messages.append(continue_msg)
agent._session_messages = messages
restart_with_length_continuation = True
_retry.restart_with_length_continuation = True
break
partial_response = agent._strip_think_blocks("".join(truncated_response_parts)).strip()
@@ -2069,7 +1702,7 @@ def run_conversation(
f"({hit_pct:.0f}% hit, {written:,} written)"
)
has_retried_429 = False # Reset on success
_retry.has_retried_429 = False # Reset on success
# Note: don't clear the retry buffer here — an "API call
# success" only means we got bytes back, not that we got
# usable content. Empty responses still loop through the
@@ -2097,7 +1730,7 @@ def run_conversation(
agent._vprint(f"{agent.log_prefix}⚡ Interrupted during API call.", force=True)
agent._persist_session(messages, conversation_history)
interrupted = True
final_response = f"Operation interrupted: waiting for model response ({api_elapsed:.1f}s elapsed)."
final_response = f"{INTERRUPT_WAITING_FOR_MODEL_PREFIX}{api_elapsed:.1f}s elapsed)."
break
except Exception as api_error:
@@ -2399,9 +2032,9 @@ def run_conversation(
getattr(agent, "provider", "") or "",
getattr(agent, "base_url", "") or "",
)
and not nous_paid_entitlement_refresh_attempted
and not _retry.nous_paid_entitlement_refresh_attempted
):
nous_paid_entitlement_refresh_attempted = True
_retry.nous_paid_entitlement_refresh_attempted = True
if _try_refresh_nous_paid_entitlement_credentials(agent):
agent._vprint(
f"{agent.log_prefix}🔐 Nous paid access verified — "
@@ -2410,9 +2043,9 @@ def run_conversation(
)
continue
recovered_with_pool, has_retried_429 = agent._recover_with_credential_pool(
recovered_with_pool, _retry.has_retried_429 = agent._recover_with_credential_pool(
status_code=status_code,
has_retried_429=has_retried_429,
has_retried_429=_retry.has_retried_429,
classified_reason=classified.reason,
error_context=error_context,
)
@@ -2427,9 +2060,9 @@ def run_conversation(
# fails, fall through to normal error handling.
if (
classified.reason == FailoverReason.image_too_large
and not image_shrink_retry_attempted
and not _retry.image_shrink_retry_attempted
):
image_shrink_retry_attempted = True
_retry.image_shrink_retry_attempted = True
if agent._try_shrink_image_parts_in_messages(api_messages):
agent._vprint(
f"{agent.log_prefix}📐 Image(s) exceeded provider size limit — "
@@ -2452,9 +2085,9 @@ def run_conversation(
# downgrade, and retry once. See issue #27344.
if (
classified.reason == FailoverReason.multimodal_tool_content_unsupported
and not multimodal_tool_content_retry_attempted
and not _retry.multimodal_tool_content_retry_attempted
):
multimodal_tool_content_retry_attempted = True
_retry.multimodal_tool_content_retry_attempted = True
if agent._try_strip_image_parts_from_tool_messages(api_messages):
agent._vprint(
f"{agent.log_prefix}📐 Provider rejected list-type tool content — "
@@ -2481,9 +2114,9 @@ def run_conversation(
classified.reason == FailoverReason.oauth_long_context_beta_forbidden
and agent.api_mode == "anthropic_messages"
and agent._is_anthropic_oauth
and not oauth_1m_beta_retry_attempted
and not _retry.oauth_1m_beta_retry_attempted
):
oauth_1m_beta_retry_attempted = True
_retry.oauth_1m_beta_retry_attempted = True
if not getattr(agent, "_oauth_1m_beta_disabled", False):
agent._oauth_1m_beta_disabled = True
try:
@@ -2502,9 +2135,9 @@ def run_conversation(
agent.api_mode == "codex_responses"
and agent.provider in {"openai-codex", "xai-oauth"}
and status_code == 401
and not codex_auth_retry_attempted
and not _retry.codex_auth_retry_attempted
):
codex_auth_retry_attempted = True
_retry.codex_auth_retry_attempted = True
if agent._try_refresh_codex_client_credentials(force=True):
_label = "xAI OAuth" if agent.provider == "xai-oauth" else "Codex"
agent._buffer_vprint(f"🔐 {_label} auth refreshed after 401. Retrying request...")
@@ -2513,9 +2146,9 @@ def run_conversation(
agent.api_mode == "chat_completions"
and agent.provider == "nous"
and status_code == 401
and not nous_auth_retry_attempted
and not _retry.nous_auth_retry_attempted
):
nous_auth_retry_attempted = True
_retry.nous_auth_retry_attempted = True
if agent._try_refresh_nous_client_credentials(force=True):
print(f"{agent.log_prefix}🔐 Nous agent key refreshed after 401. Retrying request...")
continue
@@ -2544,9 +2177,9 @@ def run_conversation(
if (
agent.provider == "copilot"
and status_code == 401
and not copilot_auth_retry_attempted
and not _retry.copilot_auth_retry_attempted
):
copilot_auth_retry_attempted = True
_retry.copilot_auth_retry_attempted = True
if agent._try_refresh_copilot_client_credentials():
agent._buffer_vprint(f"🔐 Copilot credentials refreshed after 401. Retrying request...")
continue
@@ -2554,9 +2187,9 @@ def run_conversation(
agent.api_mode == "anthropic_messages"
and status_code == 401
and hasattr(agent, '_anthropic_api_key')
and not anthropic_auth_retry_attempted
and not _retry.anthropic_auth_retry_attempted
):
anthropic_auth_retry_attempted = True
_retry.anthropic_auth_retry_attempted = True
from agent.anthropic_adapter import _is_oauth_token
from agent.azure_identity_adapter import is_token_provider
if agent._try_refresh_anthropic_client_credentials():
@@ -2597,9 +2230,9 @@ def run_conversation(
# blocks at all. One-shot — don't retry infinitely.
if (
classified.reason == FailoverReason.thinking_signature
and not thinking_sig_retry_attempted
and not _retry.thinking_sig_retry_attempted
):
thinking_sig_retry_attempted = True
_retry.thinking_sig_retry_attempted = True
for _m in messages:
if isinstance(_m, dict):
_m.pop("reasoning_details", None)
@@ -2631,7 +2264,7 @@ def run_conversation(
# handles it (the provider is rejecting something else).
if (
classified.reason == FailoverReason.invalid_encrypted_content
and not invalid_encrypted_content_retry_attempted
and not _retry.invalid_encrypted_content_retry_attempted
and agent.api_mode == "codex_responses"
and bool(getattr(agent, "_codex_reasoning_replay_enabled", True))
and any(
@@ -2642,7 +2275,7 @@ def run_conversation(
for _m in messages
)
):
invalid_encrypted_content_retry_attempted = True
_retry.invalid_encrypted_content_retry_attempted = True
replay_stats = agent._disable_codex_reasoning_replay(messages)
agent._vprint(
f"{agent.log_prefix}⚠️ Encrypted reasoning replay was rejected by the provider — "
@@ -2669,9 +2302,9 @@ def run_conversation(
# fires only for users on llama.cpp's OAI server.
if (
classified.reason == FailoverReason.llama_cpp_grammar_pattern
and not llama_cpp_grammar_retry_attempted
and not _retry.llama_cpp_grammar_retry_attempted
):
llama_cpp_grammar_retry_attempted = True
_retry.llama_cpp_grammar_retry_attempted = True
try:
from tools.schema_sanitizer import strip_pattern_and_format
_, _stripped = strip_pattern_and_format(agent.tools)
@@ -2882,7 +2515,7 @@ def run_conversation(
f"(was {old_ctx:,}), retrying..."
)
time.sleep(2)
restart_with_compressed_messages = True
_retry.restart_with_compressed_messages = True
break
# Fall through to normal error handling if compression
# is exhausted or didn't help.
@@ -2915,7 +2548,7 @@ def run_conversation(
if agent._try_activate_fallback(reason=classified.reason):
retry_count = 0
compression_attempts = 0
primary_recovery_attempted = False
_retry.primary_recovery_attempted = False
continue
# ── Nous Portal: record rate limit & skip retries ─────
@@ -3053,7 +2686,7 @@ def run_conversation(
if len(messages) < original_len:
agent._buffer_status(f"🗜️ Compressed {original_len}{len(messages)} messages, retrying...")
time.sleep(2) # Brief pause between compression retries
restart_with_compressed_messages = True
_retry.restart_with_compressed_messages = True
break
else:
# Terminal — surface buffered context so the user
@@ -3125,7 +2758,7 @@ def run_conversation(
"failed": True,
"compression_exhausted": True,
}
restart_with_compressed_messages = True
_retry.restart_with_compressed_messages = True
break
# Error is about the INPUT being too large. Only reduce
@@ -3210,7 +2843,7 @@ def run_conversation(
if len(messages) < original_len:
agent._buffer_status(f"🗜️ Compressed {original_len}{len(messages)} messages, retrying...")
time.sleep(2) # Brief pause between compression retries
restart_with_compressed_messages = True
_retry.restart_with_compressed_messages = True
break
else:
# Can't compress further and already at minimum tier
@@ -3315,7 +2948,7 @@ def run_conversation(
if agent._try_activate_fallback():
retry_count = 0
compression_attempts = 0
primary_recovery_attempted = False
_retry.primary_recovery_attempted = False
continue
if api_kwargs is not None:
agent._dump_api_request_debug(
@@ -3447,10 +3080,10 @@ def run_conversation(
# client once for transient transport errors (stale
# connection pool, TCP reset). Only attempted once
# per API call block.
if not primary_recovery_attempted and agent._try_recover_primary_transport(
if not _retry.primary_recovery_attempted and agent._try_recover_primary_transport(
api_error, retry_count=retry_count, max_retries=max_retries,
):
primary_recovery_attempted = True
_retry.primary_recovery_attempted = True
retry_count = 0
continue
# Try fallback before giving up entirely
@@ -3459,7 +3092,7 @@ def run_conversation(
if agent._try_activate_fallback():
retry_count = 0
compression_attempts = 0
primary_recovery_attempted = False
_retry.primary_recovery_attempted = False
continue
# Terminal — flush buffered retry/fallback trace.
agent._flush_status_buffer()
@@ -3610,17 +3243,17 @@ def run_conversation(
_turn_exit_reason = "interrupted_during_api_call"
break
if restart_with_compressed_messages:
if _retry.restart_with_compressed_messages:
api_call_count -= 1
agent.iteration_budget.refund()
# Count compression restarts toward the retry limit to prevent
# infinite loops when compression reduces messages but not enough
# to fit the context window.
retry_count += 1
restart_with_compressed_messages = False
_retry.restart_with_compressed_messages = False
continue
if restart_with_length_continuation:
if _retry.restart_with_length_continuation:
# Progressively boost the output token budget on each retry.
# Retry 1 → 2× base, retry 2 → 3× base, capped at 32 768.
# Applies to all providers via _ephemeral_max_output_tokens.
+1 -1
View File
@@ -374,7 +374,7 @@ def _iter_custom_providers(config: Optional[dict] = None):
yield _normalize_custom_pool_name(name), entry
def get_custom_provider_pool_key(base_url: str, provider_name: Optional[str] = None) -> Optional[str]:
def get_custom_provider_pool_key(base_url: Optional[str], provider_name: Optional[str] = None) -> Optional[str]:
"""Look up the custom_providers list in config.yaml and return 'custom:<name>' for a matching base_url.
When provider_name is given, prefer matching by name first (solving the case where
+5
View File
@@ -375,6 +375,11 @@ CURATOR_REVIEW_PROMPT = (
"into ~/.hermes/skills/.archive/) is the maximum destructive action. "
"Archives are recoverable; deletion is not.\n"
"3. DO NOT touch skills shown as pinned=yes. Skip them entirely.\n"
"3b. DO NOT archive, delete, consolidate, move, or otherwise modify any "
"skill named in the protected built-ins list (currently: plan). These "
"back load-bearing UX (slash-command entry points referenced in docs and "
"tips) and are filtered out of the candidate list below — never resurrect "
"one as an archive or absorb target.\n"
"4. DO NOT use usage counters as a reason to skip consolidation. The "
"counters are new and often mostly zero. Judge overlap on CONTENT, "
"not on use_count. 'use=0' is not evidence a skill is valuable; it's "
+29
View File
@@ -219,6 +219,35 @@ def _supports_vision_override(
coerced = _coerce_capability_bool(per_model.get("supports_vision"))
if coerced is not None:
return coerced
# 2b. Legacy list-style custom_providers. Entries are dicts with a
# "name" key and a nested "models" dict. Match by provider name (which
# may appear as the raw name or "custom:<name>" at runtime).
custom_providers = cfg.get("custom_providers")
if isinstance(custom_providers, list):
# Build candidate names: the provider value and the config provider
# value, both raw and with "custom:" prefix stripped/added.
candidate_names: set = set()
for p in filter(None, (provider, config_provider)):
candidate_names.add(p)
if p.startswith("custom:"):
candidate_names.add(p[len("custom:"):])
else:
candidate_names.add(f"custom:{p}")
for entry_raw in custom_providers:
if not isinstance(entry_raw, dict):
continue
entry_name = str(entry_raw.get("name") or "").strip()
if entry_name not in candidate_names:
continue
models_raw = entry_raw.get("models")
models_cfg = models_raw if isinstance(models_raw, dict) else {}
per_model_raw = models_cfg.get(model)
per_model = per_model_raw if isinstance(per_model_raw, dict) else {}
coerced = _coerce_capability_bool(per_model.get("supports_vision"))
if coerced is not None:
return coerced
return None
+8 -17
View File
@@ -20,23 +20,17 @@ import json
import time
from collections import Counter, defaultdict
from datetime import datetime
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from agent.usage_pricing import (
CanonicalUsage,
DEFAULT_PRICING,
estimate_usage_cost,
format_duration_compact,
has_known_pricing,
)
_DEFAULT_PRICING = DEFAULT_PRICING
def _has_known_pricing(model_name: str, provider: str = None, base_url: str = None) -> bool:
"""Check if a model has known pricing (vs unknown/custom endpoint)."""
return has_known_pricing(model_name, provider=provider, base_url=base_url)
def _estimate_cost(
session_or_model: Dict[str, Any] | str,
@@ -45,8 +39,8 @@ def _estimate_cost(
*,
cache_read_tokens: int = 0,
cache_write_tokens: int = 0,
provider: str = None,
base_url: str = None,
provider: Optional[str] = None,
base_url: Optional[str] = None,
) -> tuple[float, str]:
"""Estimate the USD cost for a session row or a model/token tuple."""
if isinstance(session_or_model, dict):
@@ -77,9 +71,6 @@ def _estimate_cost(
return float(result.amount_usd or 0.0), result.status
def _format_duration(seconds: float) -> str:
"""Format seconds into a human-readable duration string."""
return format_duration_compact(seconds)
def _bar_chart(values: List[int], max_width: int = 20) -> List[str]:
@@ -435,7 +426,7 @@ class InsightsEngine:
included_cost_sessions += 1
elif status == "unknown":
unknown_cost_sessions += 1
if _has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url")):
if has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url")):
models_with_pricing.add(display)
else:
models_without_pricing.add(display)
@@ -508,7 +499,7 @@ class InsightsEngine:
d["tool_calls"] += s.get("tool_call_count") or 0
estimate, status = _estimate_cost(s)
d["cost"] += estimate
d["has_pricing"] = _has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url"))
d["has_pricing"] = has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url"))
d["cost_status"] = status
result = [
@@ -679,7 +670,7 @@ class InsightsEngine:
top.append({
"label": "Longest session",
"session_id": longest["id"][:16],
"value": _format_duration(dur),
"value": format_duration_compact(dur),
"date": datetime.fromtimestamp(longest["started_at"]).strftime("%b %d"),
})
@@ -764,7 +755,7 @@ class InsightsEngine:
lines.append(f" Input tokens: {o['total_input_tokens']:<12,} Output tokens: {o['total_output_tokens']:,}")
lines.append(f" Total tokens: {o['total_tokens']:,}")
if o["total_hours"] > 0:
lines.append(f" Active time: ~{_format_duration(o['total_hours'] * 3600):<11} Avg session: ~{_format_duration(o['avg_session_duration'])}")
lines.append(f" Active time: ~{format_duration_compact(o['total_hours'] * 3600):<11} Avg session: ~{format_duration_compact(o['avg_session_duration'])}")
lines.append(f" Avg msgs/session: {o['avg_messages_per_session']:.1f}")
lines.append("")
@@ -879,7 +870,7 @@ class InsightsEngine:
lines.append(f"**Sessions:** {o['total_sessions']} | **Messages:** {o['total_messages']:,} | **Tool calls:** {o['total_tool_calls']:,}")
lines.append(f"**Tokens:** {o['total_tokens']:,} (in: {o['total_input_tokens']:,} / out: {o['total_output_tokens']:,})")
if o["total_hours"] > 0:
lines.append(f"**Active time:** ~{_format_duration(o['total_hours'] * 3600)} | **Avg session:** ~{_format_duration(o['avg_session_duration'])}")
lines.append(f"**Active time:** ~{format_duration_compact(o['total_hours'] * 3600)} | **Avg session:** ~{format_duration_compact(o['avg_session_duration'])}")
lines.append("")
# Models (top 5)
+203 -29
View File
@@ -28,6 +28,8 @@ from __future__ import annotations
import logging
import re
import inspect
import threading
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider
@@ -35,6 +37,12 @@ from tools.registry import tool_error
logger = logging.getLogger(__name__)
# How long shutdown_all() waits for in-flight background sync/prefetch work
# to drain before abandoning it. A wedged provider must never block process
# teardown indefinitely — the worker threads are daemon, so anything still
# running past this window dies with the interpreter.
_SYNC_DRAIN_TIMEOUT_S = 5.0
# ---------------------------------------------------------------------------
# Context fencing helpers
@@ -252,6 +260,13 @@ class MemoryManager:
self._providers: List[MemoryProvider] = []
self._tool_to_provider: Dict[str, MemoryProvider] = {}
self._has_external: bool = False # True once a non-builtin provider is added
# Background executor for end-of-turn sync/prefetch. Lazily created on
# first use so the common builtin-only path spawns no extra threads.
# A single worker serializes a provider's writes (turn N must land
# before turn N+1) and caps thread growth at one per manager. See
# _submit_background() and the sync_all/queue_prefetch_all rationale.
self._sync_executor: Optional[ThreadPoolExecutor] = None
self._sync_executor_lock = threading.Lock()
# -- Registration --------------------------------------------------------
@@ -375,15 +390,27 @@ class MemoryManager:
return "\n\n".join(parts)
def queue_prefetch_all(self, query: str, *, session_id: str = "") -> None:
"""Queue background prefetch on all providers for the next turn."""
for provider in self._providers:
try:
provider.queue_prefetch(query, session_id=session_id)
except Exception as e:
logger.debug(
"Memory provider '%s' queue_prefetch failed (non-fatal): %s",
provider.name, e,
)
"""Queue background prefetch on all providers for the next turn.
Provider work is dispatched to a background worker so a slow or
wedged provider can never block the caller. See ``sync_all`` for
the full rationale (agent stuck "running" minutes after a turn).
"""
providers = list(self._providers)
if not providers:
return
def _run() -> None:
for provider in providers:
try:
provider.queue_prefetch(query, session_id=session_id)
except Exception as e:
logger.debug(
"Memory provider '%s' queue_prefetch failed (non-fatal): %s",
provider.name, e,
)
self._submit_background(_run)
# -- Sync ----------------------------------------------------------------
@@ -407,27 +434,120 @@ class MemoryManager:
session_id: str = "",
messages: Optional[List[Dict[str, Any]]] = None,
) -> None:
"""Sync a completed turn to all providers."""
for provider in self._providers:
"""Sync a completed turn to all providers.
Runs on a background worker thread, NOT inline on the
turn-completion path. A provider's ``sync_turn`` may make a
blocking network/daemon call (a misconfigured Hindsight daemon
was observed blocking ~298s before failing); doing that inline
held ``run_conversation`` open long after the user saw their
response, so every interface (CLI, TUI, gateway) kept the agent
marked "running" for minutes and any follow-up message triggered
an aggressive interrupt. Dispatching off-thread means a slow or
broken provider can never stall the turn the sync simply
completes (or fails, logged) in the background.
Writes are serialized through a single worker so turn N lands
before turn N+1; provider implementations don't need their own
ordering guarantees.
"""
providers = list(self._providers)
if not providers:
return
def _run() -> None:
for provider in providers:
try:
if messages is not None and self._provider_sync_accepts_messages(provider):
provider.sync_turn(
user_content,
assistant_content,
session_id=session_id,
messages=messages,
)
else:
provider.sync_turn(
user_content,
assistant_content,
session_id=session_id,
)
except Exception as e:
logger.warning(
"Memory provider '%s' sync_turn failed: %s",
provider.name, e,
)
self._submit_background(_run)
# -- Background dispatch -------------------------------------------------
def _submit_background(self, fn) -> None:
"""Run ``fn`` on the manager's background worker.
The executor is created lazily and shared across calls. If the
executor can't be created or has already been shut down, ``fn``
runs inline as a last-resort fallback losing the async benefit
but never losing the write itself. ``fn`` must do its own
per-provider error handling; this wrapper only guards executor
plumbing.
"""
executor = self._get_sync_executor()
if executor is None:
# Executor unavailable (shut down / creation failed) — run
# inline rather than drop the work. Slow, but correct.
try:
if messages is not None and self._provider_sync_accepts_messages(provider):
provider.sync_turn(
user_content,
assistant_content,
session_id=session_id,
messages=messages,
fn()
except Exception as e: # pragma: no cover - fn guards internally
logger.debug("Inline memory background task failed: %s", e)
return
try:
executor.submit(fn)
except RuntimeError:
# Executor was shut down between the get and the submit
# (teardown race). Fall back to inline.
try:
fn()
except Exception as e: # pragma: no cover - fn guards internally
logger.debug("Inline memory background task failed: %s", e)
def _get_sync_executor(self) -> Optional[ThreadPoolExecutor]:
"""Lazily create the single-worker background executor."""
if self._sync_executor is not None:
return self._sync_executor
with self._sync_executor_lock:
if self._sync_executor is None:
try:
self._sync_executor = ThreadPoolExecutor(
max_workers=1,
thread_name_prefix="mem-sync",
)
else:
provider.sync_turn(
user_content,
assistant_content,
session_id=session_id,
)
except Exception as e:
logger.warning(
"Memory provider '%s' sync_turn failed: %s",
provider.name, e,
)
except Exception as e: # pragma: no cover - resource exhaustion
logger.warning("Failed to create memory sync executor: %s", e)
return None
return self._sync_executor
def flush_pending(self, timeout: Optional[float] = None) -> bool:
"""Block until queued sync/prefetch work has drained.
Single-worker executor means submitting a sentinel and waiting on
it guarantees every previously-submitted task has run. Returns
True if the barrier completed within ``timeout`` (or no executor
exists), False on timeout. Used at real session boundaries and by
tests that need to assert provider state deterministically.
"""
executor = self._sync_executor
if executor is None:
return True
try:
fut = executor.submit(lambda: None)
except RuntimeError:
# Executor already shut down — nothing pending.
return True
try:
fut.result(timeout=timeout)
return True
except Exception:
return False
# -- Tools ---------------------------------------------------------------
@@ -653,7 +773,15 @@ class MemoryManager:
)
def shutdown_all(self) -> None:
"""Shut down all providers (reverse order for clean teardown)."""
"""Shut down all providers (reverse order for clean teardown).
Drains the background sync/prefetch executor first (bounded by
``_SYNC_DRAIN_TIMEOUT_S``) so a turn's final sync has a chance to
land before providers are torn down. The worker threads are
daemon, so anything still wedged past the drain window dies with
the interpreter rather than blocking exit.
"""
self._drain_sync_executor()
for provider in reversed(self._providers):
try:
provider.shutdown()
@@ -663,6 +791,52 @@ class MemoryManager:
provider.name, e,
)
def _drain_sync_executor(self) -> None:
"""Shut down the background executor, waiting briefly for drain.
Bounded by ``_SYNC_DRAIN_TIMEOUT_S``: a wedged provider must never
hang process/session teardown. We stop accepting new work and
cancel anything still queued, then wait at most the drain timeout
for the currently-running task on a watcher thread. The worker is
daemon, so an over-running task dies with the interpreter.
"""
with self._sync_executor_lock:
executor = self._sync_executor
self._sync_executor = None
if executor is None:
return
try:
# Stop accepting new work and drop anything still queued, but
# do NOT block here — cancel_futures cancels not-yet-started
# tasks; the in-flight one keeps running on its daemon thread.
executor.shutdown(wait=False, cancel_futures=True)
except TypeError:
# Older Python without cancel_futures kwarg.
try:
executor.shutdown(wait=False)
except Exception as e: # pragma: no cover
logger.debug("Memory sync executor shutdown failed: %s", e)
return
except Exception as e: # pragma: no cover
logger.debug("Memory sync executor shutdown failed: %s", e)
return
# Give an in-flight sync a bounded chance to finish on a watcher
# thread so we don't block the caller past the drain timeout.
drainer = threading.Thread(
target=lambda: self._bounded_executor_wait(executor),
daemon=True,
name="mem-sync-drain",
)
drainer.start()
drainer.join(timeout=_SYNC_DRAIN_TIMEOUT_S)
@staticmethod
def _bounded_executor_wait(executor: ThreadPoolExecutor) -> None:
try:
executor.shutdown(wait=True)
except Exception as e: # pragma: no cover
logger.debug("Memory sync executor drain wait failed: %s", e)
def initialize_all(self, session_id: str, **kwargs) -> None:
"""Initialize all providers.
+37
View File
@@ -964,6 +964,10 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
is_output_cap_error = (
"max_tokens" in error_lower
and ("available_tokens" in error_lower or "available tokens" in error_lower)
) or (
# OpenRouter/Nous phrasing of the same condition.
"in the output" in error_lower
and "maximum context length" in error_lower
)
if not is_output_cap_error:
return None
@@ -982,6 +986,19 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
tokens = int(match.group(1))
if tokens >= 1:
return tokens
# OpenRouter/Nous format: "maximum context length is N … (A of text input,
# B of tool input, C in the output)". Available output = ctx - text - tool.
_m_ctx = re.search(r'maximum context length is (\d+)', error_lower)
_m_parts = re.search(
r'\((\d+)\s+of text input,\s*(\d+)\s+of tool input,\s*(\d+)\s+in the output\)',
error_lower,
)
if _m_ctx and _m_parts:
_available = int(_m_ctx.group(1)) - int(_m_parts.group(1)) - int(_m_parts.group(2))
if _available >= 1:
return _available
return None
@@ -1667,6 +1684,26 @@ def get_model_context_length(
"in config.yaml to override.",
model, base_url, f"{DEFAULT_FALLBACK_CONTEXT:,}",
)
# 3b. Before falling back to the hard 256K default, consult the
# hardcoded catalog as a last resort. A proxied/custom Anthropic
# gateway (e.g. corporate proxy) fails the Ollama/local probes
# above, but the model name may still match an entry in
# DEFAULT_CONTEXT_LENGTHS (e.g. "claude-opus-4-8" → 1M).
# Without this, the early return here short-circuits the catalog
# lookup at step 8 and silently caps context at 256K.
model_lower = model.lower()
for default_model, length in sorted(
DEFAULT_CONTEXT_LENGTHS.items(),
key=lambda x: len(x[0]),
reverse=True,
):
if default_model in model_lower:
logger.info(
"Using hardcoded context length %s for model %r "
"(custom endpoint, catalog match on %r)",
f"{length:,}", model, default_model,
)
return length
return DEFAULT_FALLBACK_CONTEXT
# 4. Anthropic /v1/models API (only for regular API keys, not OAuth)
+60
View File
@@ -26,6 +26,7 @@ logger = logging.getLogger(__name__)
BUSY_INPUT_FLAG = "busy_input_prompt"
TOOL_PROGRESS_FLAG = "tool_progress_prompt"
OPENCLAW_RESIDUE_FLAG = "openclaw_residue_cleanup"
PROFILE_BUILD_FLAG = "profile_build_offered"
# -------------------------------------------------------------------------
@@ -126,6 +127,62 @@ def detect_openclaw_residue(home: Optional[Path] = None) -> bool:
return False
# -------------------------------------------------------------------------
# Onboarding profile-build path (opt-in, consent-gated)
# -------------------------------------------------------------------------
def profile_build_mode(config: Mapping[str, Any]) -> str:
"""Resolve the onboarding profile-build mode from config.
Returns one of:
``"ask"`` on first contact, OFFER to build a profile (default).
``"off"`` never offer; the first-message note stays a plain intro.
Read from ``config.onboarding.profile_build``. Unknown / missing values
fall back to ``"ask"`` so the default experience offers the flow. Any
network/account lookups inside the flow are separately consented to in
conversation this setting only governs whether the offer is made.
"""
if not isinstance(config, Mapping):
return "ask"
onboarding = config.get("onboarding")
if not isinstance(onboarding, Mapping):
return "ask"
mode = onboarding.get("profile_build")
if isinstance(mode, str) and mode.strip().lower() == "off":
return "off"
return "ask"
def profile_build_directive() -> str:
"""System-note directive appended to the very first message ever.
Instructs the agent to run a short, opt-in, consent-gated profile-build
flow and persist confirmed facts to the user-profile memory store
(``memory`` tool, ``target="user"``). Phrased so the agent ASKS before any
lookup and never silently reads connected accounts directly addressing
the privacy concern that reading email/accounts unprompted feels invasive.
"""
return (
"\n\n[System note: This is the user's very first message ever. "
"After a one-sentence introduction (mention /help shows commands), "
"OFFER — do not assume — to build a short profile of them so you can "
"be more useful, and explain they can decline or do it later. If and "
"ONLY IF they accept:\n"
" 1. Ask for whatever they're comfortable sharing (name, what they "
"do, how they like you to work). Volunteered facts come first.\n"
" 2. Before ANY external lookup, say what you intend to look up and "
"get explicit consent for that step. Never read their connected "
"accounts (email, calendar, etc.) silently — ask each time.\n"
" 3. With consent, you may use web_search to confirm public details "
"(e.g. employer, public profiles) from the data points they gave.\n"
" 4. Save each confirmed, durable fact with the memory tool using "
"target=\"user\" — keep entries compact and high-signal.\n"
"If they decline at any point, stop immediately and continue normally. "
"Keep the whole exchange light and conversational, not an interrogation.]"
)
# -------------------------------------------------------------------------
# State read / write
# -------------------------------------------------------------------------
@@ -182,12 +239,15 @@ __all__ = [
"BUSY_INPUT_FLAG",
"TOOL_PROGRESS_FLAG",
"OPENCLAW_RESIDUE_FLAG",
"PROFILE_BUILD_FLAG",
"busy_input_hint_gateway",
"busy_input_hint_cli",
"tool_progress_hint_gateway",
"tool_progress_hint_cli",
"openclaw_residue_hint_cli",
"detect_openclaw_residue",
"profile_build_mode",
"profile_build_directive",
"is_seen",
"mark_seen",
]
+388
View File
@@ -0,0 +1,388 @@
"""Per-turn setup for ``run_conversation`` (the turn prologue).
``run_conversation`` opened with ~470 lines of straight-line setup before the
tool-calling loop ever started: stdio guarding, runtime-main wiring, retry-counter
resets, user-message sanitization, todo/nudge-counter hydration, system-prompt
restore-or-build, crash-resilience persistence, preflight context compression, the
``pre_llm_call`` plugin hook, and external-memory prefetch.
All of that is *prologue* it runs once per turn, has no back-references into the
loop, and produces a fixed set of values the loop then consumes. ``TurnContext``
captures those produced values; ``build_turn_context`` performs the setup work and
returns one. ``run_conversation`` is left to unpack the context and run the loop,
shrinking the orchestrator by the full prologue.
The builder still mutates ``agent`` heavily (counters, thread id, cached prompt,
session DB) exactly as the inline code did those side effects are the point. The
``TurnContext`` it returns carries only the *locals* the loop reads back.
Behavior is identical to the original inline prologue; this is a pure
move-and-name refactor with no semantic change.
"""
from __future__ import annotations
import logging
import threading
import uuid
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from agent.iteration_budget import IterationBudget
from agent.model_metadata import estimate_request_tokens_rough
logger = logging.getLogger(__name__)
@dataclass
class TurnContext:
"""Values produced by the turn prologue and consumed by the turn loop."""
# Sanitized inbound message (surrogates stripped).
user_message: str
# Clean message preserved for transcripts / memory queries (no nudge injection).
original_user_message: Any
# Working message list for this turn (loop appends to it).
messages: List[Dict[str, Any]]
# May be reset to None by preflight compression (new session created).
conversation_history: Optional[List[Dict[str, Any]]]
# Cached system prompt active for this turn (may be rebuilt by compression).
active_system_prompt: Optional[str]
# Task / turn identifiers.
effective_task_id: str
turn_id: str
# Index of the current user turn within ``messages``.
current_turn_user_idx: int
# Whether the post-turn memory review should fire.
should_review_memory: bool = False
# Context contributed by ``pre_llm_call`` plugins (appended to user message).
plugin_user_context: str = ""
# External-memory prefetch result, reused across loop iterations.
ext_prefetch_cache: str = ""
def build_turn_context(
agent,
user_message: str,
system_message: Optional[str],
conversation_history: Optional[List[Dict[str, Any]]],
task_id: Optional[str],
stream_callback,
persist_user_message: Optional[str],
*,
restore_or_build_system_prompt,
install_safe_stdio,
sanitize_surrogates,
summarize_user_message_for_log,
set_session_context,
set_current_write_origin,
ra,
) -> TurnContext:
"""Run the once-per-turn setup and return the loop's input context.
The callables/helpers the original prologue referenced from the
``conversation_loop`` module are passed in explicitly to keep this module
free of an import cycle with ``agent.conversation_loop``.
"""
# Guard stdio against OSError from broken pipes (systemd/headless/daemon).
install_safe_stdio()
agent._ensure_db_session()
# Tell auxiliary_client what the live main provider/model are for this turn.
try:
from agent.auxiliary_client import set_runtime_main
set_runtime_main(
getattr(agent, "provider", "") or "",
getattr(agent, "model", "") or "",
base_url=getattr(agent, "base_url", "") or "",
api_key=getattr(agent, "api_key", "") or "",
api_mode=getattr(agent, "api_mode", "") or "",
)
except Exception:
pass
# Tag log records on this thread with the session ID for ``hermes logs``.
set_session_context(agent.session_id)
# Bind the skill write-origin ContextVar for this thread.
set_current_write_origin(getattr(agent, "_memory_write_origin", "assistant_tool"))
# Restore the primary runtime if the previous turn activated fallback.
agent._restore_primary_runtime()
# Sanitize surrogate characters from user input.
if isinstance(user_message, str):
user_message = sanitize_surrogates(user_message)
if isinstance(persist_user_message, str):
persist_user_message = sanitize_surrogates(persist_user_message)
# Store stream callback for _interruptible_api_call to pick up.
agent._stream_callback = stream_callback
agent._persist_user_message_idx = None
agent._persist_user_message_override = persist_user_message
# Generate unique task_id if not provided to isolate VMs between tasks.
effective_task_id = task_id or str(uuid.uuid4())
agent._current_task_id = effective_task_id
turn_id = f"{agent.session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
agent._current_turn_id = turn_id
agent._current_api_request_id = ""
# Reset retry counters and iteration budget at the start of each turn.
agent._invalid_tool_retries = 0
agent._invalid_json_retries = 0
agent._empty_content_retries = 0
agent._incomplete_scratchpad_retries = 0
agent._codex_incomplete_retries = 0
agent._thinking_prefill_retries = 0
agent._post_tool_empty_retried = False
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
agent._mute_post_response = False
agent._unicode_sanitization_passes = 0
agent._tool_guardrails.reset_for_turn()
agent._tool_guardrail_halt_decision = None
agent._vision_supported = True
# Pre-turn connection health check: clean up dead TCP connections.
if agent.api_mode != "anthropic_messages":
try:
if agent._cleanup_dead_connections():
agent._emit_status(
"🔌 Detected stale connections from a previous provider "
"issue — cleaned up automatically. Proceeding with fresh "
"connection."
)
except Exception:
pass
# Replay compression warning through status_callback for gateway platforms.
if agent._compression_warning:
agent._replay_compression_warning()
agent._compression_warning = None # send once
# NOTE: _turns_since_memory and _iters_since_skill are NOT reset here.
agent.iteration_budget = IterationBudget(agent.max_iterations)
# Log conversation turn start for debugging/observability.
_preview_text = summarize_user_message_for_log(user_message)
_msg_preview = (_preview_text[:80] + "...") if len(_preview_text) > 80 else _preview_text
_msg_preview = _msg_preview.replace("\n", " ")
logger.info(
"conversation turn: session=%s model=%s provider=%s platform=%s history=%d msg=%r",
agent.session_id or "none", agent.model, agent.provider or "unknown",
agent.platform or "unknown", len(conversation_history or []),
_msg_preview,
)
# Initialize conversation (copy to avoid mutating the caller's list).
messages = list(conversation_history) if conversation_history else []
# Hydrate todo store from conversation history.
if conversation_history and not agent._todo_store.has_items():
agent._hydrate_todo_store(conversation_history)
# Hydrate per-session nudge counters from persisted history (issue #22357).
if conversation_history and agent._user_turn_count == 0:
prior_user_turns = sum(
1 for m in conversation_history if m.get("role") == "user"
)
if prior_user_turns > 0:
agent._user_turn_count = prior_user_turns
if agent._memory_nudge_interval > 0 and agent._turns_since_memory == 0:
agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval
# Track user turns for memory flush and periodic nudge logic.
agent._user_turn_count += 1
# Reset the streaming context scrubber at the top of each turn.
scrubber = getattr(agent, "_stream_context_scrubber", None)
if scrubber is not None:
scrubber.reset()
# Reset the think scrubber for the same reason.
think_scrubber = getattr(agent, "_stream_think_scrubber", None)
if think_scrubber is not None:
think_scrubber.reset()
# Preserve the original user message (no nudge injection).
original_user_message = persist_user_message if persist_user_message is not None else user_message
# Track memory nudge trigger (turn-based, checked here).
should_review_memory = False
if (agent._memory_nudge_interval > 0
and "memory" in agent.valid_tool_names
and agent._memory_store):
agent._turns_since_memory += 1
if agent._turns_since_memory >= agent._memory_nudge_interval:
should_review_memory = True
agent._turns_since_memory = 0
# Add user message.
user_msg = {"role": "user", "content": user_message}
messages.append(user_msg)
current_turn_user_idx = len(messages) - 1
agent._persist_user_message_idx = current_turn_user_idx
if not agent.quiet_mode:
_print_preview = summarize_user_message_for_log(user_message)
agent._safe_print(
f"💬 Starting conversation: '{_print_preview[:60]}"
f"{'...' if len(_print_preview) > 60 else ''}'"
)
# ── System prompt (cached per session for prefix caching) ──
if agent._cached_system_prompt is None:
restore_or_build_system_prompt(agent, system_message, conversation_history)
active_system_prompt = agent._cached_system_prompt
# Crash-resilience: persist the inbound user turn as soon as the session row exists.
try:
agent._persist_session(messages, conversation_history)
except Exception:
logger.warning(
"Early turn-start session persistence failed for session=%s",
agent.session_id or "none",
exc_info=True,
)
# ── Preflight context compression ──
if (
agent.compression_enabled
and len(messages) > agent.context_compressor.protect_first_n
+ agent.context_compressor.protect_last_n + 1
):
_preflight_tokens = estimate_request_tokens_rough(
messages,
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
_compressor = agent.context_compressor
_defer_preflight = getattr(
_compressor,
"should_defer_preflight_to_real_usage",
lambda _tokens: False,
)
_preflight_deferred = _defer_preflight(_preflight_tokens)
if not _preflight_deferred:
_last = _compressor.last_prompt_tokens
# Do NOT overwrite the -1 sentinel (#36718).
if _last >= 0 and _preflight_tokens > _last:
_compressor.last_prompt_tokens = _preflight_tokens
if _preflight_deferred:
logger.info(
"Skipping preflight compression: rough estimate ~%s >= %s, "
"but last real provider prompt was %s after compression",
f"{_preflight_tokens:,}",
f"{_compressor.threshold_tokens:,}",
f"{_compressor.last_real_prompt_tokens:,}",
)
elif _compressor.should_compress(_preflight_tokens):
logger.info(
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
f"{_preflight_tokens:,}",
f"{_compressor.threshold_tokens:,}",
agent.model,
f"{_compressor.context_length:,}",
)
agent._emit_status(
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
f">= {_compressor.threshold_tokens:,} threshold. "
"This may take a moment."
)
for _pass in range(3):
_orig_len = len(messages)
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=_preflight_tokens,
task_id=effective_task_id,
)
if len(messages) >= _orig_len:
break # Cannot compress further
conversation_history = None
agent._empty_content_retries = 0
agent._thinking_prefill_retries = 0
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
agent._mute_post_response = False
_preflight_tokens = estimate_request_tokens_rough(
messages,
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
if not _compressor.should_compress(_preflight_tokens):
break
# Plugin hook: pre_llm_call (context injected into user message, not system prompt).
plugin_user_context = ""
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_pre_results = _invoke_hook(
"pre_llm_call",
session_id=agent.session_id,
task_id=effective_task_id,
turn_id=turn_id,
user_message=original_user_message,
conversation_history=list(messages),
is_first_turn=(not bool(conversation_history)),
model=agent.model,
platform=getattr(agent, "platform", None) or "",
sender_id=getattr(agent, "_user_id", None) or "",
)
_ctx_parts: list[str] = []
for r in _pre_results:
if isinstance(r, dict) and r.get("context"):
_ctx_parts.append(str(r["context"]))
elif isinstance(r, str) and r.strip():
_ctx_parts.append(r)
if _ctx_parts:
plugin_user_context = "\n\n".join(_ctx_parts)
except Exception as exc:
logger.warning("pre_llm_call hook failed: %s", exc)
# Per-turn file-mutation verifier state.
agent._turn_failed_file_mutations = {}
# Record the execution thread so interrupt()/clear_interrupt() can scope
# the tool-level interrupt signal to THIS agent's thread only.
agent._execution_thread_id = threading.current_thread().ident
# Clear stale per-thread interrupt state, preserving a pending interrupt.
ra()._set_interrupt(False, agent._execution_thread_id)
if agent._interrupt_requested:
ra()._set_interrupt(True, agent._execution_thread_id)
agent._interrupt_thread_signal_pending = False
else:
agent._interrupt_message = None
agent._interrupt_thread_signal_pending = False
# Notify memory providers of the new turn (BEFORE prefetch_all).
if agent._memory_manager:
try:
_turn_msg = original_user_message if isinstance(original_user_message, str) else ""
agent._memory_manager.on_turn_start(agent._user_turn_count, _turn_msg)
except Exception:
pass
# External memory provider: prefetch once before the tool loop.
ext_prefetch_cache = ""
if agent._memory_manager:
try:
_query = original_user_message if isinstance(original_user_message, str) else ""
ext_prefetch_cache = agent._memory_manager.prefetch_all(_query) or ""
except Exception:
pass
return TurnContext(
user_message=user_message,
original_user_message=original_user_message,
messages=messages,
conversation_history=conversation_history,
active_system_prompt=active_system_prompt,
effective_task_id=effective_task_id,
turn_id=turn_id,
current_turn_user_idx=current_turn_user_idx,
should_review_memory=should_review_memory,
plugin_user_context=plugin_user_context,
ext_prefetch_cache=ext_prefetch_cache,
)
+68
View File
@@ -0,0 +1,68 @@
"""Per-attempt recovery bookkeeping for the conversation turn loop.
The inner retry loop in ``run_conversation`` (``while retry_count <
max_retries``) makes several distinct recovery attempts on a single model API
call: a credential-pool 429 retry, a per-provider OAuth refresh (codex,
anthropic, nous, copilot), a long-context compression restart, a length-
continuation restart, and a handful of format-recovery branches (thinking-
signature stripping, multimodal-tool-content stripping, llama.cpp grammar
fallback, image shrink, invalid-encrypted-content, 1M-beta header).
Each of those branches is guarded by a one-shot boolean so it fires at most
once per attempt. They used to be ~16 bare ``*_attempted`` / ``has_retried_*``
/ ``restart_with_*`` locals declared inline before the loop and threaded
through its 2,400-line body. ``TurnRetryState`` collapses them into one object
the loop mutates in place (``state.codex_auth_retry_attempted = True``), giving
the recovery bookkeeping a single named, testable home.
Loop-control variables (``retry_count``, ``max_retries``,
``max_compression_attempts``) intentionally stay as plain locals they are the
``while`` mechanics, not recovery bookkeeping, and putting them on the object
would add indirection without clarifying anything.
This module is dependency-free so it can be unit-tested in isolation and
imported by the turn loop without an import cycle.
"""
from __future__ import annotations
from dataclasses import dataclass, fields
@dataclass
class TurnRetryState:
"""One-shot recovery guards + restart signals for a single API-call attempt.
A fresh instance is created for each iteration of the outer turn loop
(once per ``api_call_count``). Each guard fires its recovery branch at most
once; the ``restart_with_*`` signals are read by the loop after the attempt
to decide whether to rebuild the request and retry.
"""
# ── Per-provider OAuth / credential refresh guards ───────────────────
codex_auth_retry_attempted: bool = False
anthropic_auth_retry_attempted: bool = False
nous_auth_retry_attempted: bool = False
nous_paid_entitlement_refresh_attempted: bool = False
copilot_auth_retry_attempted: bool = False
# ── Format / payload recovery guards ─────────────────────────────────
thinking_sig_retry_attempted: bool = False
invalid_encrypted_content_retry_attempted: bool = False
image_shrink_retry_attempted: bool = False
multimodal_tool_content_retry_attempted: bool = False
oauth_1m_beta_retry_attempted: bool = False
llama_cpp_grammar_retry_attempted: bool = False
# ── Transport / rate-limit recovery ──────────────────────────────────
primary_recovery_attempted: bool = False
has_retried_429: bool = False
# ── Restart signals (read by the outer loop after the attempt) ───────
restart_with_compressed_messages: bool = False
restart_with_length_continuation: bool = False
def __iter__(self):
# Convenience for debugging / tests: iterate (name, value) pairs.
for f in fields(self):
yield f.name, getattr(self, f.name)
+47 -4
View File
@@ -76,6 +76,21 @@ function bootstrapCacheDir(hermesHome) {
return path.join(hermesHome, 'bootstrap-cache')
}
// The install.sh / install.ps1 that ships inside the already-installed agent
// checkout under ~/.hermes/hermes-agent. Used as a last-resort fallback when
// the pinned commit can't be fetched from GitHub (e.g. a locally-built desktop
// app stamped to an unpushed HEAD).
function installedAgentInstallScript(hermesHome) {
if (!hermesHome) return null
const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName())
try {
fs.accessSync(candidate, fs.constants.R_OK)
return candidate
} catch {
return null
}
}
function cachedScriptPath(hermesHome, commit) {
return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.${process.platform === 'win32' ? 'ps1' : 'sh'}`)
}
@@ -155,7 +170,7 @@ function downloadInstallScript(commit, destPath) {
})
}
async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit }) {
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/../..).
@@ -189,9 +204,35 @@ async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome,
type: 'log',
line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub`
})
await downloadInstallScript(installStamp.commit, cached)
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
try {
await _download(installStamp.commit, cached)
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
} catch (err) {
// The pinned commit may not be fetchable from GitHub -- most commonly a
// locally-built desktop app stamped to an unpushed HEAD (see
// write-build-stamp.cjs fromLocalGit). Fall back to the installer that
// ships inside the already-installed agent checkout so dev/self-builds can
// still bootstrap instead of dying with a fatal 404.
const installed = installedAgentInstallScript(hermesHome)
if (installed) {
emit({
type: 'log',
line:
`[bootstrap] GitHub fetch failed (${err.message}); ` +
`falling back to installed agent ${installScriptName()} at ${installed}`
})
try {
fs.mkdirSync(path.dirname(cached), { recursive: true })
fs.copyFileSync(installed, cached)
return { path: cached, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
} catch {
// Cache copy failed (read-only FS, etc.) -- use the source path directly.
return { path: installed, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
}
}
throw err
}
}
// ---------------------------------------------------------------------------
@@ -673,5 +714,7 @@ module.exports = {
// Exposed for testability
parseStageResult,
resolveLocalInstallScript,
resolveInstallScript,
installedAgentInstallScript,
cachedScriptPath
}
+112 -1
View File
@@ -1,7 +1,21 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { runBootstrap } = require('./bootstrap-runner.cjs')
const {
runBootstrap,
resolveInstallScript,
installedAgentInstallScript,
cachedScriptPath
} = require('./bootstrap-runner.cjs')
const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh'
function mkTmpHome() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-bootstrap-test-'))
}
test('runBootstrap bails immediately when the signal is already aborted', async () => {
const controller = new AbortController()
@@ -25,3 +39,100 @@ test('runBootstrap bails immediately when the signal is already aborted', async
'should emit a cancelled failure event'
)
})
test('installedAgentInstallScript resolves the installer in the agent checkout', () => {
const home = mkTmpHome()
try {
assert.equal(installedAgentInstallScript(home), null, 'absent before the checkout exists')
const scriptsDir = path.join(home, 'hermes-agent', 'scripts')
fs.mkdirSync(scriptsDir, { recursive: true })
const scriptPath = path.join(scriptsDir, SCRIPT_NAME)
fs.writeFileSync(scriptPath, '#!/bin/sh\necho hi\n')
assert.equal(installedAgentInstallScript(home), scriptPath)
assert.equal(installedAgentInstallScript(null), null, 'null home -> null')
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})
test('resolveInstallScript prefers a cached script without touching the network', async () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
const cached = cachedScriptPath(home, commit)
fs.mkdirSync(path.dirname(cached), { recursive: true })
fs.writeFileSync(cached, '#!/bin/sh\necho cached\n')
const logs = []
const result = await resolveInstallScript({
installStamp: { commit },
sourceRepoRoot: null,
hermesHome: home,
emit: ev => logs.push(ev)
})
assert.equal(result.source, 'cache')
assert.equal(result.path, cached)
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})
test('resolveInstallScript falls back to the installed agent checkout on a 404', async () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
// Seed the installed agent checkout so the fallback has something to resolve.
const scriptsDir = path.join(home, 'hermes-agent', 'scripts')
fs.mkdirSync(scriptsDir, { recursive: true })
const installed = path.join(scriptsDir, SCRIPT_NAME)
fs.writeFileSync(installed, '#!/bin/sh\necho fallback\n')
const logs = []
const result = await resolveInstallScript({
installStamp: { commit },
sourceRepoRoot: null,
hermesHome: home,
emit: ev => logs.push(ev),
// Simulate GitHub returning a 404 for the pinned commit.
_download: async () => {
throw new Error('Failed to download install.sh: HTTP 404')
}
})
assert.equal(result.source, 'installed-agent')
// It should have copied the installer into the bootstrap cache.
assert.equal(result.path, cachedScriptPath(home, commit))
assert.ok(fs.existsSync(result.path), 'fallback script copied into cache')
assert.ok(
logs.some(ev => /falling back to installed agent/.test(ev.line || '')),
'emits a fallback log line'
)
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})
test('resolveInstallScript rethrows when the 404 fallback is unavailable', async () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
// No installed agent checkout seeded -> nothing to fall back to.
await assert.rejects(
resolveInstallScript({
installStamp: { commit },
sourceRepoRoot: null,
hermesHome: home,
emit: () => {},
_download: async () => {
throw new Error('Failed to download install.sh: HTTP 404')
}
}),
/HTTP 404|Failed to download/
)
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})
+105 -13
View File
@@ -1902,12 +1902,36 @@ function resolveWebDist() {
const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist')
if (directoryExists(unpackedDist)) return unpackedDist
return path.join(APP_ROOT, 'dist')
// Final fallback: APP_ROOT/dist. When packaged with asar:true this lives
// INSIDE app.asar — not a servable filesystem directory — so the embedded
// dashboard backend 404s on static routes (see #41327, #39472). The durable
// fix is unpacking dist/ (PR #41411 adds dist/** to asarUnpack so the tier-2
// unpackedDist above resolves). If we still land here while packaged, log it
// so the cause isn't silent.
const fallback = path.join(APP_ROOT, 'dist')
if (IS_PACKAGED && /app\.asar(?=$|[\\/])/.test(fallback) && !directoryExists(fallback)) {
rememberLog(
`[web-dist] dashboard frontend dir resolved to an asar-internal path that ` +
`is not a real directory: ${fallback}. Static routes will 404. ` +
`Ensure dist/** is unpacked (asarUnpack) or set HERMES_DESKTOP_WEB_DIST.`
)
}
return fallback
}
function resolveRendererIndex() {
const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')]
return candidates.find(fileExists) || candidates[0]
const found = candidates.find(fileExists)
if (found) return found
// Nothing on disk. A packaged build with no renderer bundle blank-pages with
// a bare ERR_FILE_NOT_FOUND and no clue why (see #39484). Surface the cause
// and the fix before Electron loads the missing file.
rememberLog(
`[renderer] index.html not found — the desktop app was packaged without a ` +
`renderer bundle. Tried: ${candidates.join(', ')}. ` +
`Rebuild with: hermes desktop --force-build`
)
return candidates[0]
}
function resolveHermesCwd() {
@@ -3137,7 +3161,7 @@ function buildApplicationMenu() {
label: 'Actual Size',
accelerator: 'CommandOrControl+0',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.setZoomLevel(0)
setAndPersistZoomLevel(mainWindow, 0)
}
},
{
@@ -3145,8 +3169,7 @@ function buildApplicationMenu() {
accelerator: 'CommandOrControl+Plus',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
const next = Math.min(mainWindow.webContents.getZoomLevel() + 0.1, 9)
mainWindow.webContents.setZoomLevel(next)
setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() + 0.1)
}
}
},
@@ -3155,8 +3178,7 @@ function buildApplicationMenu() {
accelerator: 'CommandOrControl+-',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
const next = Math.max(mainWindow.webContents.getZoomLevel() - 0.1, -9)
mainWindow.webContents.setZoomLevel(next)
setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() - 0.1)
}
}
},
@@ -3218,6 +3240,38 @@ function installPreviewShortcut(window) {
})
}
// Zoom level is persisted in the renderer's own localStorage (per-origin,
// survives reloads/restarts) rather than a main-process JSON file. The main
// process owns setZoomLevel, so we mirror each change into localStorage and
// read it back on did-finish-load to re-apply after reloads or crash recovery.
const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel'
function clampZoomLevel(value) {
if (!Number.isFinite(value)) return 0
return Math.min(Math.max(value, -9), 9)
}
function setAndPersistZoomLevel(window, zoomLevel) {
if (!window || window.isDestroyed()) return
const next = clampZoomLevel(zoomLevel)
window.webContents.setZoomLevel(next)
window.webContents
.executeJavaScript(`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`)
.catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`))
}
function restorePersistedZoomLevel(window) {
if (!window || window.isDestroyed()) return
window.webContents
.executeJavaScript(`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`)
.then(stored => {
if (stored == null || !window || window.isDestroyed()) return
const level = clampZoomLevel(Number(stored))
window.webContents.setZoomLevel(level)
})
.catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`))
}
function installZoomShortcuts(window) {
// Override Ctrl/Cmd + +/-/0 with half the default zoom step (0.1 vs 0.2).
// The menu items handle this on macOS (where the menu is always present),
@@ -3231,15 +3285,13 @@ function installZoomShortcuts(window) {
const key = input.key
if (key === '0') {
event.preventDefault()
window.webContents.setZoomLevel(0)
setAndPersistZoomLevel(window, 0)
} else if (key === '=' || key === '+') {
event.preventDefault()
const next = Math.min(window.webContents.getZoomLevel() + ZOOM_STEP, 9)
window.webContents.setZoomLevel(next)
setAndPersistZoomLevel(window, window.webContents.getZoomLevel() + ZOOM_STEP)
} else if (key === '-') {
event.preventDefault()
const next = Math.max(window.webContents.getZoomLevel() - ZOOM_STEP, -9)
window.webContents.setZoomLevel(next)
setAndPersistZoomLevel(window, window.webContents.getZoomLevel() - ZOOM_STEP)
}
})
}
@@ -4614,7 +4666,7 @@ function createWindow() {
mainWindow = new BrowserWindow({
width: 1220,
height: 800,
minWidth: 900,
minWidth: 400,
minHeight: 620,
title: 'Hermes',
// Frameless title bar on every platform so the renderer can paint the
@@ -4730,6 +4782,7 @@ function createWindow() {
}
mainWindow.webContents.once('did-finish-load', () => {
restorePersistedZoomLevel(mainWindow)
broadcastBootProgress()
sendWindowStateChanged()
startHermes().catch(error => rememberLog(error.stack || error.message))
@@ -4737,6 +4790,45 @@ function createWindow() {
}
ipcMain.handle('hermes:connection', async (_event, profile) => ensureBackend(profile))
// Reconnect-after-wake recovery. A REMOTE primary backend has no child process,
// so the 'exit'/'error' handlers that would clear a dead connectionPromise never
// fire — once the remote becomes unreachable across a sleep/wake the renderer
// re-dials the same dead descriptor forever and the composer stays stuck on
// "Starting Hermes…". Before the renderer's backoff loop reconnects, it asks us
// to confirm the cached PRIMARY backend is still reachable; if a remote one is
// not, we drop the cache so the next getConnection() rebuilds it. Local backends
// self-heal via their child 'exit' handler, so we never touch them here.
ipcMain.handle('hermes:connection:revalidate', async () => {
if (!connectionPromise) {
return { ok: true, rebuilt: false }
}
let conn = null
try {
conn = await connectionPromise
} catch {
// The cached boot already rejected (its own catch nulls connectionPromise);
// nothing to revalidate — the next getConnection() builds fresh.
return { ok: true, rebuilt: false }
}
if (!conn || conn.mode !== 'remote' || !conn.baseUrl) {
return { ok: true, rebuilt: false }
}
const base = conn.baseUrl.replace(/\/+$/, '')
try {
await fetchPublicJson(`${base}/api/status`, { timeoutMs: 2_500 })
return { ok: true, rebuilt: false }
} catch {
// Unreachable remote: drop the stale cache so the renderer's next reconnect
// tick rebuilds a fresh, reachable descriptor. resetHermesConnection only
// nulls connectionPromise for a remote (no child to SIGTERM).
rememberLog('Cached remote Hermes backend failed liveness probe; dropping stale connection.')
resetHermesConnection()
return { ok: true, rebuilt: true }
}
})
ipcMain.handle('hermes:backend:touch', async (_event, profile) => {
touchPoolBackend(profile)
return { ok: true }
+1
View File
@@ -2,6 +2,7 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron')
contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
+3 -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",
"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/assert-dist-built.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder",
@@ -166,7 +166,8 @@
"afterSign": "scripts/notarize.cjs",
"asarUnpack": [
"**/*.node",
"**/prebuilds/**"
"**/prebuilds/**",
"dist/**"
],
"mac": {
"category": "public.app-category.developer-tools",
Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

@@ -0,0 +1,70 @@
"use strict"
// Build-time guard: refuse to hand a half-built renderer to electron-builder.
//
// `npm run pack` / `npm run dist*` are `npm run build && npm run builder`.
// If the `build` step (tsc -b && vite build) fails but packaging proceeds
// anyway — a stale checkout that fails typecheck, an interrupted vite build,
// or npm not short-circuiting `&&` in some shells — electron-builder happily
// packages an app with an empty or missing `dist/`. The result launches but
// blank-pages with `ERR_FILE_NOT_FOUND` for dist/index.html, with no clue why.
//
// This runs at the tail of `build`, after vite build, so any packaging path
// inherits it. It fails loud and early instead of shipping a broken bundle.
// See issues #39484 (renderer blank page) and #41327 / #39472 (dashboard 404).
const fs = require("fs")
const path = require("path")
// Pure check — returns { ok: true } or { ok: false, error: "..." }.
// Kept side-effect-free so it can be unit tested without spawning a process.
function checkDistBuilt(distDir) {
if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) {
return { ok: false, error: `no dist directory at ${distDir}` }
}
const indexHtml = path.join(distDir, "index.html")
if (!fs.existsSync(indexHtml) || !fs.statSync(indexHtml).isFile()) {
return { ok: false, error: `dist/index.html is missing at ${indexHtml}` }
}
if (fs.statSync(indexHtml).size === 0) {
return { ok: false, error: `dist/index.html is empty at ${indexHtml}` }
}
// index.html alone isn't enough — vite emits hashed JS into dist/assets.
// An index.html with no script bundle still blank-pages.
const assetsDir = path.join(distDir, "assets")
const hasAssets =
fs.existsSync(assetsDir) &&
fs.statSync(assetsDir).isDirectory() &&
fs.readdirSync(assetsDir).some(name => name.endsWith(".js"))
if (!hasAssets) {
return { ok: false, error: `dist/assets has no built JS bundle (expected vite output under ${assetsDir})` }
}
return { ok: true }
}
function main() {
const desktopRoot = path.resolve(__dirname, "..")
const distDir = path.join(desktopRoot, "dist")
const result = checkDistBuilt(distDir)
if (!result.ok) {
console.error(`\n✗ assert-dist-built: ${result.error}`)
console.error(" The renderer bundle is missing or incomplete, so packaging")
console.error(" would produce an app that launches to a blank page.")
console.error(" Re-run the build and check the tsc/vite output above for the")
console.error(" real failure, then package again:")
console.error(` cd ${desktopRoot} && npm run build\n`)
process.exit(1)
}
console.log("✓ assert-dist-built: dist/index.html + assets present")
}
if (require.main === module) {
main()
}
module.exports = { checkDistBuilt }
@@ -0,0 +1,84 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { checkDistBuilt } = require('../scripts/assert-dist-built.cjs')
function makeDist(extra) {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-'))
const distDir = path.join(tempRoot, 'dist')
fs.mkdirSync(distDir, { recursive: true })
if (extra) extra(distDir)
return { tempRoot, distDir }
}
test('checkDistBuilt passes when index.html + an assets JS bundle exist', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html><div id=root></div>', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
})
try {
assert.deepEqual(checkDistBuilt(distDir), { ok: true })
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when the dist directory is absent', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-'))
try {
const result = checkDistBuilt(path.join(tempRoot, 'dist'))
assert.equal(result.ok, false)
assert.match(result.error, /no dist directory/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when index.html is missing', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.mkdirSync(path.join(d, 'assets'))
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
})
try {
const result = checkDistBuilt(distDir)
assert.equal(result.ok, false)
assert.match(result.error, /index\.html is missing/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when index.html is empty', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
})
try {
const result = checkDistBuilt(distDir)
assert.equal(result.ok, false)
assert.match(result.error, /index\.html is empty/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when assets/ has no JS bundle', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
// CSS only, no JS — still a blank page at runtime.
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.css'), 'body{}', 'utf8')
})
try {
const result = checkDistBuilt(distDir)
assert.equal(result.ok, false)
assert.match(result.error, /no built JS bundle/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
+6 -3
View File
@@ -124,7 +124,10 @@ function ChatHeader({
return (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div className="min-w-0 flex-1">
<div
className="min-w-0 flex-1"
style={{ maxWidth: 'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)' }}
>
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
@@ -135,11 +138,11 @@ function ChatHeader({
title={title}
>
<Button
className="pointer-events-auto h-6 min-w-0 gap-1 border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>
<h2 className="max-w-[52vw] truncate text-[0.75rem] font-medium leading-none">{title}</h2>
<h2 className="min-w-0 flex-1 truncate text-[0.75rem] font-medium leading-none">{title}</h2>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.8125rem" />
</Button>
</SessionActionsMenu>
+275 -67
View File
@@ -19,6 +19,7 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
@@ -39,6 +40,7 @@ import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/he
import { useI18n } from '@/i18n'
import { profileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search'
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
import { $cronJobs } from '@/store/cron'
import {
@@ -47,8 +49,11 @@ import {
$sidebarAgentsGrouped,
$sidebarCronOpen,
$sidebarOpen,
$sidebarOverlayMounted,
$sidebarPinsOpen,
$sidebarRecentsOpen,
$sidebarSessionOrderIds,
$sidebarWorkspaceOrderIds,
pinSession,
reorderPinnedSession,
SESSION_SEARCH_FOCUS_EVENT,
@@ -56,6 +61,8 @@ import {
setSidebarCronOpen,
setSidebarPinsOpen,
setSidebarRecentsOpen,
setSidebarSessionOrderIds,
setSidebarWorkspaceOrderIds,
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '@/store/layout'
@@ -116,10 +123,14 @@ const WORKSPACE_PAGE = 5
// ALL-profiles view: show only the latest N per profile up front to keep the
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
const PROFILE_INITIAL_PAGE = 5
const WS_ID_PREFIX = 'workspace:'
const GROUP_DND_ID_PREFIX = 'group:'
const LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui'])
const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
const parseGroupDndId = (id: string) =>
id.startsWith(GROUP_DND_ID_PREFIX) ? id.slice(GROUP_DND_ID_PREFIX.length) : null
const wsId = (id: string) => `${WS_ID_PREFIX}${id}`
const parseWsId = (id: string) => (id.startsWith(WS_ID_PREFIX) ? id.slice(WS_ID_PREFIX.length) : null)
const countLabel = (loaded: number, total: number) => (total > loaded ? `${loaded}/${total}` : String(loaded))
const sessionTime = (s: SessionInfo) => s.last_active || s.started_at || 0
@@ -150,6 +161,33 @@ function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[
return out
}
function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
if (!currentIds.length) {
return []
}
if (!orderIds.length) {
return currentIds
}
const current = new Set(currentIds)
const next = orderIds.filter(id => current.has(id))
const known = new Set(next)
for (const id of currentIds) {
if (!known.has(id)) {
next.push(id)
known.add(id)
}
}
return next
}
function sameIds(left: string[], right: string[]) {
return left.length === right.length && left.every((item, index) => item === right[index])
}
const baseName = (path: string) =>
path
.replace(/[/\\]+$/, '')
@@ -183,7 +221,11 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo {
}
}
function workspaceGroupsFor(sessions: SessionInfo[], noWorkspaceLabel: string): SidebarSessionGroup[] {
function workspaceGroupsFor(
sessions: SessionInfo[],
noWorkspaceLabel: string,
options: { preserveSessionOrder?: boolean } = {}
): SidebarSessionGroup[] {
const groups = new Map<string, SidebarSessionGroup>()
for (const session of sessions) {
@@ -196,17 +238,56 @@ function workspaceGroupsFor(sessions: SessionInfo[], noWorkspaceLabel: string):
groups.set(id, group)
}
// Groups keep recency order (Map insertion = first-seen in the recency-sorted
// input, so an active project floats up), but rows *within* a group sort by
// creation time so they don't reshuffle every time a message lands — keeps
// muscle memory intact.
for (const group of groups.values()) {
group.sessions.sort((a, b) => b.started_at - a.started_at)
if (!options.preserveSessionOrder) {
// Groups keep recency order (Map insertion = first-seen in the recency-sorted
// input, so an active project floats up), but rows *within* a group sort by
// creation time so they don't reshuffle every time a message lands — keeps
// muscle memory intact.
for (const group of groups.values()) {
group.sessions.sort((a, b) => b.started_at - a.started_at)
}
}
return [...groups.values()]
}
function sourceSessionGroupsFor(sessions: SessionInfo[]): {
localSessions: SessionInfo[]
sourceGroups: SidebarSessionGroup[]
} {
const groups = new Map<string, SidebarSessionGroup>()
const localSessions: SessionInfo[] = []
for (const session of sessions) {
const sourceId = normalizeSessionSource(session.source)
if (!sourceId || LOCAL_SESSION_SOURCES.has(sourceId)) {
localSessions.push(session)
continue
}
const label = sessionSourceLabel(sourceId) ?? sourceId
const group = groups.get(sourceId) ?? {
id: `source:${sourceId}`,
label,
mode: 'source',
path: null,
sessions: [],
sourceId
}
group.sessions.push(session)
groups.set(sourceId, group)
}
return {
localSessions,
sourceGroups: [...groups.values()].sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}
}
function useSortableBindings(id: string) {
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
@@ -215,7 +296,11 @@ function useSortableBindings(id: string) {
dragHandleProps: { ...attributes, ...listeners },
ref: setNodeRef,
reorderable: true as const,
style: { transform: CSS.Transform.toString(transform), transition }
style: {
transform: CSS.Transform.toString(transform),
transition: isDragging ? undefined : transition,
willChange: isDragging ? 'transform' : undefined
}
}
}
@@ -247,6 +332,9 @@ export function ChatSidebar({
const { t } = useI18n()
const s = t.sidebar
const sidebarOpen = useStore($sidebarOpen)
// Collapsed-but-overlay-mounted → render the full sidebar, not just the nav rail.
const overlayMounted = useStore($sidebarOverlayMounted)
const contentVisible = sidebarOpen || overlayMounted
const panesFlipped = useStore($panesFlipped)
const agentsGrouped = useStore($sidebarAgentsGrouped)
const pinnedSessionIds = useStore($pinnedSessionIds)
@@ -270,8 +358,8 @@ export function ChatSidebar({
// profile while scope is still ALL (persisted), the rail is hidden and they'd
// otherwise be stuck in the grouped view with no way out.
const showAllProfiles = multiProfile && profileScope === ALL_PROFILES
const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
const agentOrderIds = useStore($sidebarSessionOrderIds)
const workspaceOrderIds = useStore($sidebarWorkspaceOrderIds)
const [searchQuery, setSearchQuery] = useState('')
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
@@ -425,14 +513,40 @@ export function ChatSidebar({
[sortedSessions, pinnedRealIdSet]
)
useEffect(() => {
const next = reconcileOrderIds(
unpinnedAgentSessions.map(s => s.id),
agentOrderIds
)
if (!sameIds(next, agentOrderIds)) {
setSidebarSessionOrderIds(next)
}
}, [agentOrderIds, unpinnedAgentSessions])
const agentSessions = useMemo(
() => orderByIds(unpinnedAgentSessions, s => s.id, agentOrderIds),
[unpinnedAgentSessions, agentOrderIds]
)
const { localSessions: localAgentSessions, sourceGroups } = useMemo(
() => sourceSessionGroupsFor(agentSessions),
[agentSessions]
)
const orderedSourceGroups = useMemo(
() => orderByIds(sourceGroups, g => g.id, workspaceOrderIds),
[sourceGroups, workspaceOrderIds]
)
const agentGroups = useMemo(
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
[agentSessions, s.noWorkspace, workspaceOrderIds]
() =>
orderByIds(
workspaceGroupsFor(localAgentSessions, s.noWorkspace, { preserveSessionOrder: sourceGroups.length > 0 }),
g => g.id,
workspaceOrderIds
),
[localAgentSessions, s.noWorkspace, sourceGroups.length, workspaceOrderIds]
)
const loadMoreForProfileGroup = useCallback(
@@ -445,9 +559,7 @@ export function ChatSidebar({
void Promise.resolve(onLoadMoreProfileSessions(profile))
.catch(() => undefined)
.finally(() =>
setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest)
)
.finally(() => setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest))
},
[onLoadMoreProfileSessions]
)
@@ -478,15 +590,17 @@ export function ChatSidebar({
groups.set(key, group)
}
return [...groups.values()]
.map(group => ({
...group,
loadingMore: Boolean(profileLoadMorePending[group.id]),
onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined,
totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0)
}))
// default (root) first, then the rest alphabetically.
.sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)))
return (
[...groups.values()]
.map(group => ({
...group,
loadingMore: Boolean(profileLoadMorePending[group.id]),
onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined,
totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0)
}))
// default (root) first, then the rest alphabetically.
.sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)))
)
}, [
showAllProfiles,
agentSessions,
@@ -496,6 +610,53 @@ export function ChatSidebar({
sessionProfileTotals
])
const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions
const displayAgentGroups = useMemo(() => {
if (orderedSourceGroups.length) {
const localGroups = agentsGrouped
? agentGroups
: localAgentSessions.length
? [
{
id: 'local-sessions',
label: 'Local',
mode: 'workspace' as const,
path: null,
sessions: localAgentSessions
}
]
: []
return orderByIds([...orderedSourceGroups, ...localGroups], g => g.id, workspaceOrderIds)
}
return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
}, [
agentGroups,
agentsGrouped,
localAgentSessions,
orderedSourceGroups,
profileGroups,
showAllProfiles,
workspaceOrderIds
])
useEffect(() => {
if (!displayAgentGroups?.length || showAllProfiles) {
return
}
const next = reconcileOrderIds(
displayAgentGroups.map(g => g.id),
workspaceOrderIds
)
if (!sameIds(next, workspaceOrderIds)) {
setSidebarWorkspaceOrderIds(next)
}
}, [displayAgentGroups, showAllProfiles, workspaceOrderIds])
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
@@ -543,23 +704,24 @@ export function ChatSidebar({
const activeId = String(active.id)
const overId = String(over.id)
const activeWs = parseWsId(activeId)
const overWs = parseWsId(overId)
const activeGroup = parseGroupDndId(activeId)
const overGroup = parseGroupDndId(overId)
if (activeWs && overWs) {
const oldIdx = agentGroups.findIndex(g => g.id === activeWs)
const newIdx = agentGroups.findIndex(g => g.id === overWs)
if (activeGroup && overGroup) {
const groups = displayAgentGroups ?? []
const oldIdx = groups.findIndex(g => g.id === activeGroup)
const newIdx = groups.findIndex(g => g.id === overGroup)
if (oldIdx < 0 || newIdx < 0) {
return
}
setWorkspaceOrderIds(arrayMove(agentGroups, oldIdx, newIdx).map(g => g.id))
setSidebarWorkspaceOrderIds(arrayMove(groups, oldIdx, newIdx).map(g => g.id))
return
}
if (activeWs || overWs) {
if (activeGroup || overGroup) {
return
}
@@ -570,7 +732,7 @@ export function ChatSidebar({
return
}
setAgentOrderIds(arrayMove(agentSessions, oldIdx, newIdx).map(s => s.id))
setSidebarSessionOrderIds(arrayMove(agentSessions, oldIdx, newIdx).map(s => s.id))
}
return (
@@ -580,7 +742,11 @@ export function ChatSidebar({
panesFlipped ? 'border-l border-r-0' : 'border-r border-l-0',
sidebarOpen
? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
: 'pointer-events-none border-transparent bg-transparent opacity-0'
: 'pointer-events-none border-transparent bg-transparent opacity-0',
// While floated by PaneShell's hover-reveal, force visible + interactive
// — on hover (group-hover/reveal) or when keyboard-pinned (data-forced).
'in-data-[pane-hover-reveal=open]:pointer-events-auto in-data-[pane-hover-reveal=open]:border-(--sidebar-edge-border) in-data-[pane-hover-reveal=open]:bg-(--ui-sidebar-surface-background) in-data-[pane-hover-reveal=open]:opacity-100',
'group-hover/reveal:pointer-events-auto group-hover/reveal:border-(--sidebar-edge-border) group-hover/reveal:bg-(--ui-sidebar-surface-background) group-hover/reveal:opacity-100'
)}
collapsible="none"
>
@@ -624,14 +790,14 @@ export function ChatSidebar({
type="button"
>
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
{sidebarOpen && (
{contentVisible && (
<>
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">
<span className="min-w-0 flex-1 truncate">
{s.nav[item.id] ?? item.label}
</span>
{isNewSession && (
<KbdGroup
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
className={cn('ml-auto', newSessionKbdFlash && 'opacity-100!')}
keys={[...NEW_SESSION_KBD]}
/>
)}
@@ -645,7 +811,7 @@ export function ChatSidebar({
</SidebarGroupContent>
</SidebarGroup>
{sidebarOpen && showSessionSections && (
{contentVisible && showSessionSections && (
<div className="shrink-0 px-2 pb-1 pt-1">
<SearchField
aria-label={s.searchAria}
@@ -657,7 +823,7 @@ export function ChatSidebar({
</div>
)}
{sidebarOpen && showSessionSections && trimmedQuery && (
{contentVisible && showSessionSections && trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
@@ -681,7 +847,7 @@ export function ChatSidebar({
/>
)}
{sidebarOpen && showSessionSections && !trimmedQuery && (
{contentVisible && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
@@ -703,7 +869,7 @@ export function ChatSidebar({
/>
)}
{sidebarOpen && showSessionSections && !trimmedQuery && (
{contentVisible && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn(
@@ -727,7 +893,7 @@ export function ChatSidebar({
) : null
}
forceEmptyState={showSessionSkeletons}
groups={showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined}
groups={displayAgentGroups}
headerAction={
// Always reserve the icon-xs (size-6) slot so the header keeps the
// same height whether or not the toggle renders — otherwise the
@@ -736,7 +902,7 @@ export function ChatSidebar({
// the toggle does nothing, and it's irrelevant in the ALL-profiles
// view (always grouped by profile), so hide the button (not the slot).
<div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && agentSessions.length > 0 ? (
{!showAllProfiles && localAgentSessions.length > 0 ? (
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
<Button
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
@@ -770,13 +936,13 @@ export function ChatSidebar({
open={agentsOpen}
pinned={false}
rootClassName="min-h-0 flex-1 p-0"
sessions={agentSessions}
sessions={displayAgentSessions}
sortable={!showAllProfiles && agentSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{sidebarOpen && !trimmedQuery && cronJobs.length > 0 && (
{contentVisible && !trimmedQuery && cronJobs.length > 0 && (
<SidebarCronJobsSection
jobs={cronJobs}
label={s.cronJobs}
@@ -788,9 +954,9 @@ export function ChatSidebar({
/>
)}
{sidebarOpen && !showSessionSections && <div className="min-h-0 flex-1" />}
{contentVisible && !showSessionSections && <div className="min-h-0 flex-1" />}
{sidebarOpen && (
{contentVisible && (
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
<ProfileRail />
</div>
@@ -872,8 +1038,9 @@ interface SidebarSessionGroup {
// Profile color for the ALL-profiles view; absent for workspace groups.
color?: null | string
loadingMore?: boolean
mode?: 'profile' | 'workspace'
mode?: 'profile' | 'source' | 'workspace'
onLoadMore?: () => void
sourceId?: string
totalCount?: number
}
@@ -928,7 +1095,8 @@ function SidebarSessionsSection({
onReorder,
dndSensors
}: SidebarSessionsSectionProps) {
const showEmptyState = forceEmptyState || sessions.length === 0
const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0))
const showEmptyState = forceEmptyState || (!hasGroupedSessions && sessions.length === 0)
const dndActive = sortable && !!onReorder
const renderRow = (session: SessionInfo) => {
@@ -961,12 +1129,25 @@ function SidebarSessionsSection({
renderRows(items)
)
const renderNestedSessionList = (items: SessionInfo[]) =>
dndActive ? (
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
<SortableContext items={items.map(s => s.id)} strategy={verticalListSortingStrategy}>
{renderRows(items)}
</SortableContext>
</DndContext>
) : (
renderRows(items)
)
const flatVirtualized = !showEmptyState && !groups?.length && sessions.length >= VIRTUALIZE_THRESHOLD
let inner: React.ReactNode
let bodyOwnsDndContext = dndActive && !showEmptyState
if (showEmptyState) {
inner = emptyState
bodyOwnsDndContext = false
} else if (groups?.length) {
const groupNodes = groups.map(group =>
dndActive ? (
@@ -974,7 +1155,7 @@ function SidebarSessionsSection({
group={group}
key={group.id}
onNewSession={onNewSessionInWorkspace}
renderRows={renderSessionList}
renderRows={renderNestedSessionList}
/>
) : (
<SidebarWorkspaceGroup
@@ -987,12 +1168,15 @@ function SidebarSessionsSection({
)
inner = dndActive ? (
<SortableContext items={groups.map(g => wsId(g.id))} strategy={verticalListSortingStrategy}>
{groupNodes}
</SortableContext>
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
<SortableContext items={groups.map(g => groupDndId(g.id))} strategy={verticalListSortingStrategy}>
{groupNodes}
</SortableContext>
</DndContext>
) : (
groupNodes
)
bodyOwnsDndContext = false
} else if (flatVirtualized) {
inner = (
<VirtualSessionList
@@ -1011,14 +1195,13 @@ function SidebarSessionsSection({
inner = renderSessionList(sessions)
}
const body =
dndActive && !showEmptyState ? (
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
{inner}
</DndContext>
) : (
inner
)
const body = bodyOwnsDndContext ? (
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
{inner}
</DndContext>
) : (
inner
)
// The virtualizer owns its own scroller, so suppress the wrapper's overflow
// to avoid a double scroll container.
@@ -1061,6 +1244,7 @@ function SidebarWorkspaceGroup({
const { t } = useI18n()
const s = t.sidebar
const isProfileGroup = group.mode === 'profile'
const isSourceGroup = group.mode === 'source'
const pageStep = isProfileGroup ? PROFILE_INITIAL_PAGE : WORKSPACE_PAGE
const [open, setOpen] = useState(true)
const [visibleCount, setVisibleCount] = useState(pageStep)
@@ -1086,7 +1270,16 @@ function SidebarWorkspaceGroup({
}
return (
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
<div
className={cn(
'grid gap-px data-[dragging=true]:z-10 data-[dragging=true]:opacity-70 data-[dragging=true]:will-change-transform',
className
)}
data-dragging={dragging ? 'true' : undefined}
ref={ref}
style={style}
{...rest}
>
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
<button
className="flex min-w-0 items-center gap-1.5 bg-transparent text-left hover:text-(--ui-text-secondary)"
@@ -1094,7 +1287,18 @@ function SidebarWorkspaceGroup({
type="button"
>
{group.color ? (
<span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: group.color }} />
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: group.color }}
/>
) : null}
{isSourceGroup && group.sourceId ? (
<PlatformAvatar
className="size-4 rounded-[4px] text-[0.5625rem] [&_svg]:size-3"
platformId={group.sourceId}
platformName={group.label}
/>
) : null}
<span className="truncate">{group.label}</span>
<SidebarCount>
@@ -1143,7 +1347,11 @@ function SidebarWorkspaceGroup({
{renderRows(visibleSessions)}
{hiddenCount > 0 &&
(isProfileGroup ? (
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
<SidebarLoadMoreRow
loading={Boolean(group.loadingMore)}
onClick={handleProfileLoadMore}
step={nextCount}
/>
) : (
<Tip label={s.showMoreIn(nextCount, group.label)}>
<button
@@ -1169,7 +1377,7 @@ interface SortableWorkspaceProps {
}
function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
return <SidebarWorkspaceGroup {...props} {...useSortableBindings(wsId(props.group.id))} />
return <SidebarWorkspaceGroup {...props} {...useSortableBindings(groupDndId(props.group.id))} />
}
function SidebarCount({ children }: { children: React.ReactNode }) {
@@ -176,8 +176,8 @@ export function SidebarSessionRow({
needsInput ? 'overflow-visible' : 'overflow-hidden'
)}
>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
)}
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
+28 -3
View File
@@ -8,6 +8,7 @@ import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
import { Pane, PaneMain } from '@/components/pane-shell'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
@@ -23,13 +24,21 @@ import {
FILE_BROWSER_MAX_WIDTH,
FILE_BROWSER_MIN_WIDTH,
pinSession,
setSidebarOverlayMounted,
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH,
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '../store/layout'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import { $activeGatewayProfile, $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
import {
$activeGatewayProfile,
$freshSessionRequest,
$profileScope,
ALL_PROFILES,
normalizeProfileKey,
refreshActiveProfile
} from '../store/profile'
import {
$activeSessionId,
$currentCwd,
@@ -69,6 +78,7 @@ import { CommandPalette } from './command-palette'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { useKeybinds } from './hooks/use-keybinds'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
import { RightSidebarPane } from './right-sidebar'
@@ -157,6 +167,11 @@ export function DesktopController() {
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const terminalTakeover = useStore($terminalTakeover)
const panesFlipped = useStore($panesFlipped)
const profileScope = useStore($profileScope)
// Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail —
// collapse both sidebars (without touching their stored open state) so the
// hover-reveal overlay becomes the way in. Restores once it's wide again.
const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)
const routedSessionId = routeSessionId(location.pathname)
const routeToken = `${location.pathname}:${location.search}:${location.hash}`
@@ -288,7 +303,12 @@ export function DesktopController() {
// the same rows tagged profile="default". Cron sessions are excluded here
// and fetched separately (refreshCronSessions) so the scheduler's
// always-newest rows can't consume the recents page budget.
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', 'all', {
// Scope the fetch to the active profile (not always 'all') so a profile
// with few recent sessions isn't windowed out of the cross-profile
// recency page — the empty-history-on-profile-switch bug.
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
excludeSources: ['cron']
})
@@ -305,7 +325,7 @@ export function DesktopController() {
void refreshCronSessions()
void refreshCronJobs()
}, [refreshCronSessions, refreshCronJobs])
}, [profileScope, refreshCronSessions, refreshCronJobs])
const loadMoreSessions = useCallback(() => {
bumpSessionsLimit()
@@ -834,6 +854,8 @@ export function DesktopController() {
<Pane
defaultOpen={false}
disabled={!chatOpen}
forceCollapsed={narrowViewport}
hoverReveal
id="file-browser"
key="file-browser"
maxWidth={FILE_BROWSER_MAX_WIDTH}
@@ -861,9 +883,12 @@ export function DesktopController() {
>
<Pane
disabled={terminalTakeoverActive}
forceCollapsed={narrowViewport}
hoverReveal
id="chat-sidebar"
maxWidth={SIDEBAR_MAX_WIDTH}
minWidth={SIDEBAR_DEFAULT_WIDTH}
onOverlayActiveChange={setSidebarOverlayMounted}
resizable
side={sidebarSide}
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
@@ -120,6 +120,13 @@ export function useGatewayBoot({
reconnecting = true
try {
// Drop a stale REMOTE backend cache before re-dialing. After sleep/wake a
// remote backend can become unreachable, but it has no child process
// whose 'exit' would clear the main process's cached descriptor — without
// this the renderer re-dials the same dead endpoint forever and stays on
// "Starting Hermes…". The probe is a no-op for a healthy or local backend.
await desktop.revalidateConnection?.().catch(() => undefined)
const conn = await desktop.getConnection($activeGatewayProfile.get())
if (cancelled) {
@@ -218,6 +225,15 @@ export function useGatewayBoot({
reconnectAttempt = 0
reauthNotified = false
clearReconnectTimer()
// A revalidate-driven reconnect can rebuild the backend in place when the
// cached remote was found dead, which re-drives the boot-progress overlay.
// Unlike the initial boot, nothing calls completeDesktopBoot() afterwards,
// so dismiss it here once we're open again — otherwise the overlay sticks
// at ~94%. A no-op on a normal (non-rebuild) reconnect.
if (bootCompleted) {
completeDesktopBoot()
}
} else if (bootCompleted && (st === 'closed' || st === 'error')) {
// The socket dropped after a healthy boot (typically sleep/wake). Try
// to bring it back instead of leaving the composer stuck disabled.
+19 -2
View File
@@ -2,11 +2,15 @@ import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { setRightSidebarTab } from '@/app/right-sidebar/store'
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
import { matchesQuery } from '@/hooks/use-media-query'
import { PROFILE_SLOT_COUNT } from '@/lib/keybinds/actions'
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
import { toggleCommandPalette } from '@/store/command-palette'
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
import {
CHAT_SIDEBAR_PANE_ID,
FILE_BROWSER_PANE_ID,
requestSessionSearchFocus,
setFileBrowserOpen,
toggleFileBrowserOpen,
@@ -24,6 +28,7 @@ import { $activeSessionId, $sessions, setModelPickerOpen } from '@/store/session
import { useTheme } from '@/themes/context'
import { requestComposerFocus } from '../chat/composer/focus'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
import {
AGENTS_ROUTE,
ARTIFACTS_ROUTE,
@@ -109,8 +114,20 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'session.focusSearch': requestSessionSearchFocus,
'session.togglePin': deps.toggleSelectedPin,
'view.toggleSidebar': toggleSidebarOpen,
'view.toggleRightSidebar': toggleFileBrowserOpen,
'view.toggleSidebar': () => {
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: CHAT_SIDEBAR_PANE_ID } }))
} else {
toggleSidebarOpen()
}
},
'view.toggleRightSidebar': () => {
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: FILE_BROWSER_PANE_ID } }))
} else {
toggleFileBrowserOpen()
}
},
'view.showFiles': () => showRightSidebarTab('files'),
'view.showTerminal': () => showRightSidebarTab('terminal'),
'view.flipPanes': togglePanesFlipped,
+6
View File
@@ -11,3 +11,9 @@ export const PAGE_INSET_X = 'px-[clamp(1.25rem,4vw,4rem)]'
// Matching negative inline-margin to bleed an element (e.g. a sticky header bar)
// out to the gutter edges before re-applying PAGE_INSET_X.
export const PAGE_INSET_NEG_X = '-mx-[clamp(1.25rem,4vw,4rem)]'
// Below this viewport width a docked sidebar leaves no room for content, so both
// rails auto-collapse into the hover-reveal overlay. Single source of truth for
// the responsive collapse point.
export const SIDEBAR_COLLAPSE_BREAKPOINT_PX = 768
export const SIDEBAR_COLLAPSE_MEDIA_QUERY = `(max-width: ${SIDEBAR_COLLAPSE_BREAKPOINT_PX}px)`
@@ -28,15 +28,17 @@ import { cn } from '@/lib/utils'
type IconKind = 'brand' | 'generic'
interface PlatformIconSpec {
Icon: ComponentType<SVGProps<SVGSVGElement>>
Icon?: ComponentType<SVGProps<SVGSVGElement>>
color: string
kind: IconKind
monogram?: string
}
const PLATFORM_ICONS: Record<string, PlatformIconSpec> = {
telegram: { Icon: SiTelegram, color: '#26A5E4', kind: 'brand' },
discord: { Icon: SiDiscord, color: '#5865F2', kind: 'brand' },
// Slack removed from Simple Icons by Salesforce request — letter monogram.
slack: { color: '#4A154B', kind: 'brand', monogram: 'S' },
mattermost: { Icon: SiMattermost, color: '#0058CC', kind: 'brand' },
matrix: { Icon: SiMatrix, color: '#000000', kind: 'brand' },
signal: { Icon: SiSignal, color: '#3A76F0', kind: 'brand' },
@@ -87,7 +89,7 @@ export function PlatformAvatar({ className, platformId, platformName }: Platform
color
}}
>
<Icon className="size-3.5" />
{Icon ? <Icon className="size-3.5" /> : spec.monogram || platformName.charAt(0).toUpperCase()}
</span>
)
}
@@ -451,7 +451,8 @@ export function useMessageStream({
busy: false,
needsInput: false,
pendingBranchGroup: null,
streamId: null
streamId: null,
turnStartedAt: null
}
}
@@ -541,7 +542,8 @@ export function useMessageStream({
pendingBranchGroup: null,
awaitingResponse: false,
busy: false,
needsInput: false
needsInput: false,
turnStartedAt: null
}
})
@@ -599,7 +601,8 @@ export function useMessageStream({
sawAssistantPayload: true,
awaitingResponse: false,
busy: false,
needsInput: false
needsInput: false,
turnStartedAt: null
}
})
},
@@ -683,7 +686,8 @@ export function useMessageStream({
if (busy) {
return {
...state,
busy
busy,
turnStartedAt: state.turnStartedAt ?? Date.now()
}
}
@@ -696,7 +700,8 @@ export function useMessageStream({
awaitingResponse: false,
busy,
pendingBranchGroup: null,
streamId: null
streamId: null,
turnStartedAt: null
}
})
}
@@ -735,7 +740,8 @@ export function useMessageStream({
busy: true,
awaitingResponse: true,
sawAssistantPayload: false,
interrupted: false
interrupted: false,
turnStartedAt: Date.now()
}))
if (isActiveEvent) {
@@ -34,6 +34,7 @@ import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$busy,
$connection,
$messages,
$yoloActive,
setAwaitingResponse,
@@ -80,6 +81,28 @@ function inlineErrorMessage(error: unknown, fallback: string): string {
return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim()
}
function base64FromDataUrl(dataUrl: string): string {
const comma = dataUrl.indexOf(',')
return comma >= 0 ? dataUrl.slice(comma + 1) : ''
}
function imageFilenameFromPath(filePath: string): string {
return filePath.split(/[\\/]/).filter(Boolean).pop() || 'image.png'
}
// Remote gateway: the local composer-image file lives on THIS machine's disk,
// not the gateway's, so read the bytes here and upload them via
// image.attach_bytes. Returns null when the file can't be read.
async function readImageForRemoteAttach(
filePath: string
): Promise<{ contentBase64: string; filename: string } | null> {
const dataUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
const contentBase64 = dataUrl ? base64FromDataUrl(dataUrl) : ''
return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
}
interface PromptActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
@@ -197,16 +220,36 @@ export function usePromptActions({
) => {
const updateComposerAttachments = options.updateComposerAttachments ?? true
const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path)
const remote = $connection.get()?.mode === 'remote'
for (const attachment of images) {
if (attachment.attachedSessionId === sessionId) {
continue
}
const result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
let result: ImageAttachResponse
if (remote) {
// The gateway is on another machine — it can't read attachment.path
// (a path on THIS disk). Upload the bytes via image.attach_bytes.
const payload = attachment.path ? await readImageForRemoteAttach(attachment.path) : null
if (!payload) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
throw new Error(`Could not read ${label}`)
}
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
session_id: sessionId,
content_base64: payload.contentBase64,
filename: payload.filename
})
} else {
result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
}
if (!result.attached) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
@@ -0,0 +1,118 @@
import { act, cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $turnStartedAt, setTurnStartedAt } from '@/store/session'
import { useSessionStateCache } from './use-session-state-cache'
type Cache = ReturnType<typeof useSessionStateCache>
interface HarnessProps {
activeSessionId: string | null
onReady: (cache: Cache) => void
selectedStoredSessionId: string | null
}
function Harness({ activeSessionId, onReady, selectedStoredSessionId }: HarnessProps) {
const busyRef: MutableRefObject<boolean> = { current: false }
const cache = useSessionStateCache({
activeSessionId,
busyRef,
selectedStoredSessionId,
setAwaitingResponse: () => undefined,
setBusy: () => undefined,
setMessages: () => undefined
})
onReady(cache)
return null
}
describe('useSessionStateCache — per-session turn timer', () => {
beforeEach(() => {
// The view-sync flush runs on a real rAF in the browser path; in jsdom we
// want it synchronous so the global mirror is observable immediately. The
// hook closes over `window.requestAnimationFrame`, so stub that exact ref.
// Return null (not a handle) so the hook's `viewSyncRafRef.current = rAF(...)`
// assignment doesn't overwrite the null the synchronous callback just set —
// otherwise the ref reads truthy and the NEXT sync is suppressed (a real
// browser returns a handle but runs the callback async, so this race is a
// test-only artifact of firing synchronously).
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
cb(0)
return null as unknown as number
})
setTurnStartedAt(null)
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
setTurnStartedAt(null)
})
it("keeps a background session's running turn clock and never mirrors it to the view", () => {
let cache!: Cache
// Active session is "fg-runtime"; the turn starts on the BACKGROUND session.
render(
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
)
const startedAt = 1_700_000_000_000
act(() => {
cache.updateSessionState(
'bg-runtime',
state => ({ ...state, busy: true, turnStartedAt: startedAt }),
'bg-stored'
)
})
// The background session's own cache entry holds the clock...
expect(cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')?.turnStartedAt).toBe(startedAt)
// ...but the global atom (statusbar timer) is untouched — a background turn
// must not drive the foreground timer.
expect($turnStartedAt.get()).toBeNull()
})
it("mirrors the focused session's turn clock into the global atom on view-sync", () => {
let cache!: Cache
render(<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />)
const startedAt = 1_700_000_111_000
// A turn on the ACTIVE session stages into the view; the flush mirrors its
// turnStartedAt into the global atom the statusbar reads.
act(() => {
cache.updateSessionState(
'fg-runtime',
state => ({ ...state, busy: true, turnStartedAt: startedAt }),
'fg-stored'
)
})
expect($turnStartedAt.get()).toBe(startedAt)
})
it('clears the global clock when the focused turn ends', () => {
let cache!: Cache
render(<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />)
act(() => {
cache.updateSessionState(
'fg-runtime',
state => ({ ...state, busy: true, turnStartedAt: 1_700_000_222_000 }),
'fg-stored'
)
})
expect($turnStartedAt.get()).toBe(1_700_000_222_000)
act(() => {
cache.updateSessionState('fg-runtime', state => ({ ...state, busy: false, turnStartedAt: null }))
})
expect($turnStartedAt.get()).toBeNull()
})
})
@@ -5,10 +5,32 @@ import type { ChatMessage } from '@/lib/chat-messages'
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { setMutableRef } from '@/lib/mutable-ref'
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking } from '@/store/session'
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking, setTurnStartedAt } from '@/store/session'
import type { ClientSessionState } from '../../types'
// Shallow per-message identity check. When a flush carries no transcript
// changes, `preserveLocalAssistantErrors` returns the same message objects in
// the same order, so reference equality per slot is enough to detect "nothing
// to publish" and avoid a needless `$messages` churn.
function sameMessageList(a: ChatMessage[], b: ChatMessage[]): boolean {
if (a === b) {
return true
}
if (a.length !== b.length) {
return false
}
for (let index = 0; index < a.length; index += 1) {
if (a[index] !== b[index]) {
return false
}
}
return true
}
interface SessionStateCacheOptions {
activeSessionId: string | null
busyRef: MutableRefObject<boolean>
@@ -88,10 +110,27 @@ export function useSessionStateCache({
return
}
setMessages(preserveLocalAssistantErrors(pending.state.messages, $messages.get()))
// `preserveLocalAssistantErrors` always returns a fresh array, so publishing
// it unconditionally puts a new `$messages` reference on the store every
// flush — including the periodic `session.info` heartbeats that don't touch
// the transcript. That churns ChatView → runtimeMessageRepository → the
// assistant-ui runtime → the virtualizer, which re-measures and visibly
// jerks the scroll position while the user is reading. Skip the publish when
// the merged result is content-identical to what's already on screen.
const currentMessages = $messages.get()
const nextMessages = preserveLocalAssistantErrors(pending.state.messages, currentMessages)
if (!sameMessageList(nextMessages, currentMessages)) {
setMessages(nextMessages)
}
setBusy(pending.state.busy)
setMutableRef(busyRef, pending.state.busy)
setAwaitingResponse(pending.state.awaitingResponse)
// Mirror the focused session's per-session turn clock into the global
// atom the statusbar timer reads. Keeps a backgrounded turn's elapsed
// time intact on focus instead of zeroing it (the "timer restarts" bug).
setTurnStartedAt(pending.state.turnStartedAt)
}, [busyRef, setAwaitingResponse, setBusy, setMessages])
const syncSessionStateToView = useCallback(
+103 -2
View File
@@ -240,9 +240,37 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'context.engine': ['compressor', 'default', 'custom'],
'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh'],
'memory.provider': ['', 'builtin', 'honcho'],
// Terminal execution backends — kept in sync with the dispatch ladder in
// tools/terminal_tool.py::_create_environment (local/docker/singularity/
// modal/daytona/ssh). Remote backends need extra env (image, tokens, host).
'terminal.backend': ['local', 'docker', 'singularity', 'modal', 'daytona', 'ssh'],
'stt.elevenlabs.model_id': ['scribe_v2', 'scribe_v1'],
'stt.local.model': ['tiny', 'base', 'small', 'medium', 'large-v3'],
// Speech-to-text backends — kept in sync with the stt block in
// hermes_cli/config.py (local/groq/openai/mistral/elevenlabs).
'stt.provider': ['local', 'groq', 'openai', 'mistral', 'xai', 'elevenlabs'],
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'],
// Text-to-speech backends — kept in sync with the built-in source of truth
// (agent/tts_registry.py::_BUILTIN_NAMES / tools/tts_tool.py::
// BUILTIN_TTS_PROVIDERS). 'xai' is Grok TTS.
'tts.provider': [
'edge',
'elevenlabs',
'openai',
'xai',
'minimax',
'mistral',
'gemini',
'neutts',
'kittentts',
'piper'
],
'stt.openai.model': ['whisper-1', 'gpt-4o-mini-transcribe', 'gpt-4o-transcribe'],
'stt.mistral.model': ['voxtral-mini-latest', 'voxtral-mini-2602'],
'tts.openai.model': ['gpt-4o-mini-tts', 'tts-1', 'tts-1-hd'],
'tts.elevenlabs.model_id': ['eleven_multilingual_v2', 'eleven_turbo_v2_5', 'eleven_flash_v2_5'],
// NeuTTS local inference device.
'tts.neutts.device': ['cpu', 'cuda', 'mps'],
'updates.non_interactive_local_changes': ['stash', 'discard']
}
@@ -268,7 +296,11 @@ export const FIELD_LABELS: Record<string, string> = defineFieldCopy({
backend: 'Execution Backend',
timeout: 'Command Timeout',
persistentShell: 'Persistent Shell',
envPassthrough: 'Environment Passthrough'
envPassthrough: 'Environment Passthrough',
dockerImage: 'Docker Image',
singularityImage: 'Singularity Image',
modalImage: 'Modal Image',
daytonaImage: 'Daytona Image'
},
fileReadMaxChars: 'File Read Limit',
toolOutput: {
@@ -309,6 +341,15 @@ export const FIELD_LABELS: Record<string, string> = defineFieldCopy({
model: 'Local Transcription Model',
language: 'Transcription Language'
},
openai: {
model: 'OpenAI STT Model'
},
groq: {
model: 'Groq STT Model'
},
mistral: {
model: 'Mistral STT Model'
},
elevenlabs: {
modelId: 'ElevenLabs STT Model',
languageCode: 'ElevenLabs Language',
@@ -328,6 +369,33 @@ export const FIELD_LABELS: Record<string, string> = defineFieldCopy({
elevenlabs: {
voiceId: 'ElevenLabs Voice',
modelId: 'ElevenLabs Model'
},
xai: {
voiceId: 'xAI (Grok) Voice',
language: 'xAI Language'
},
minimax: {
model: 'MiniMax TTS Model',
voiceId: 'MiniMax Voice'
},
mistral: {
model: 'Mistral TTS Model',
voiceId: 'Mistral Voice'
},
gemini: {
model: 'Gemini TTS Model',
voice: 'Gemini Voice'
},
neutts: {
model: 'NeuTTS Model',
device: 'NeuTTS Device'
},
kittentts: {
model: 'KittenTTS Model',
voice: 'KittenTTS Voice'
},
piper: {
voice: 'Piper Voice'
}
},
memory: {
@@ -375,7 +443,11 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = defineFieldCopy({
terminal: {
cwd: 'Default project folder for tool and terminal work.',
persistentShell: 'Keep shell state between commands when the backend supports it.',
envPassthrough: 'Environment variables to pass into tool execution.'
envPassthrough: 'Environment variables to pass into tool execution.',
dockerImage: 'Container image used when the execution backend is Docker.',
singularityImage: 'Image used when the execution backend is Singularity.',
modalImage: 'Image used when the execution backend is Modal.',
daytonaImage: 'Image used when the execution backend is Daytona.'
},
codeExecution: {
mode: 'How strictly code execution is scoped to the current project.'
@@ -404,6 +476,15 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = defineFieldCopy({
voice: {
autoTts: 'Automatically speak assistant responses.'
},
tts: {
xai: {
voiceId: 'xAI voice ID (e.g. eve) or a custom voice ID.',
language: 'Spoken language code, e.g. en.'
},
neutts: {
device: 'Local inference device for NeuTTS.'
}
},
stt: {
enabled: 'Enable local or provider-backed speech transcription.',
elevenlabs: {
@@ -495,8 +576,24 @@ export const SECTIONS: DesktopConfigSection[] = [
'tts.openai.voice',
'tts.elevenlabs.voice_id',
'tts.elevenlabs.model_id',
'tts.xai.voice_id',
'tts.xai.language',
'tts.minimax.model',
'tts.minimax.voice_id',
'tts.mistral.model',
'tts.mistral.voice_id',
'tts.gemini.model',
'tts.gemini.voice',
'tts.neutts.model',
'tts.neutts.device',
'tts.kittentts.model',
'tts.kittentts.voice',
'tts.piper.voice',
'stt.local.model',
'stt.local.language',
'stt.openai.model',
'stt.groq.model',
'stt.mistral.model',
'stt.elevenlabs.model_id',
'stt.elevenlabs.language_code',
'stt.elevenlabs.tag_audio_events',
@@ -513,6 +610,10 @@ export const SECTIONS: DesktopConfigSection[] = [
'toolsets',
'terminal.backend',
'terminal.timeout',
'terminal.docker_image',
'terminal.singularity_image',
'terminal.modal_image',
'terminal.daytona_image',
'tool_output.max_bytes',
'tool_output.max_lines',
'tool_output.max_line_length',
+35 -1
View File
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import type { HermesConfigRecord } from '@/types/hermes'
import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from './field-copy'
import { getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
import { enumOptionsFor, getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
describe('settings helpers', () => {
describe('defineFieldCopy', () => {
@@ -135,4 +135,38 @@ describe('settings helpers', () => {
expect(providerGroup('SOMETHING_RANDOM')).toBe('Other')
})
})
describe('enumOptionsFor — backend selector dropdowns', () => {
const config: HermesConfigRecord = {}
it('renders a dropdown for the TTS provider including xAI (Grok)', () => {
const opts = enumOptionsFor('tts.provider', 'edge', config)
expect(opts).toBeDefined()
expect(opts).toContain('xai')
expect(opts).toContain('edge')
expect(opts).toContain('elevenlabs')
})
it('renders a dropdown for the STT provider including xAI (Grok)', () => {
const opts = enumOptionsFor('stt.provider', 'local', config)
expect(opts).toEqual(['local', 'groq', 'openai', 'mistral', 'xai', 'elevenlabs'])
})
it('renders dropdowns for per-backend model/device sub-fields', () => {
expect(enumOptionsFor('stt.openai.model', 'whisper-1', config)).toContain('gpt-4o-transcribe')
expect(enumOptionsFor('tts.openai.model', 'gpt-4o-mini-tts', config)).toContain('tts-1-hd')
expect(enumOptionsFor('tts.neutts.device', 'cpu', config)).toEqual(['cpu', 'cuda', 'mps'])
})
it('renders a dropdown for the terminal execution backend', () => {
const opts = enumOptionsFor('terminal.backend', 'local', config)
expect(opts).toEqual(['local', 'docker', 'singularity', 'modal', 'daytona', 'ssh'])
})
it('appends a hand-typed value not in the known list so it stays selected', () => {
const opts = enumOptionsFor('tts.provider', 'my-custom-command-tts', config)
expect(opts).toContain('my-custom-command-tts')
expect(opts).toContain('xai')
})
})
})
+8 -2
View File
@@ -5,6 +5,7 @@ import { useSyncExternalStore } from 'react'
import { NotificationStack } from '@/components/notifications'
import { PaneShell } from '@/components/pane-shell'
import { SidebarProvider } from '@/components/ui/sidebar'
import { useMediaQuery } from '@/hooks/use-media-query'
import {
$fileBrowserOpen,
$panesFlipped,
@@ -16,6 +17,8 @@ import {
import { $paneWidthOverride } from '@/store/panes'
import { $connection } from '@/store/session'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
import { KeybindPanel } from './keybind-panel'
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
@@ -58,6 +61,7 @@ export function AppShell({
const sidebarOpen = useStore($sidebarOpen)
const fileBrowserOpen = useStore($fileBrowserOpen)
const panesFlipped = useStore($panesFlipped)
const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)
const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID))
const connection = useStore($connection)
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
@@ -71,8 +75,10 @@ export function AppShell({
// The inset clears the top-left titlebar buttons when nothing covers the
// window's left edge. Default layout: the sessions sidebar sits there.
// Flipped layout: the file browser does instead.
const leftEdgePaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
// Flipped layout: the file browser does instead. Below the collapse
// breakpoint both rails are force-collapsed (hover-reveal overlay), so the
// edge is uncovered regardless of their stored open state.
const leftEdgePaneOpen = !narrowViewport && (panesFlipped ? fileBrowserOpen : sidebarOpen)
const titlebarContentInset = leftEdgePaneOpen
? 0
@@ -4,6 +4,7 @@ import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { useI18n } from '@/i18n'
import {
Activity,
AlertCircle,
@@ -16,12 +17,11 @@ import {
Zap,
ZapFilled
} from '@/lib/icons'
import { useI18n } from '@/i18n'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils'
import { setSessionYolo } from '@/lib/yolo-session'
import { setGlobalYolo, setSessionYolo } from '@/lib/yolo-session'
import { $desktopActionTasks } from '@/store/activity'
import { $previewServerRestartStatus } from '@/store/preview'
import {
@@ -44,7 +44,7 @@ import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } fr
import type { StatusResponse } from '@/types/hermes'
import { CRON_ROUTE } from '../../routes'
import type { StatusbarItem } from '../statusbar-controls'
import type { StatusbarItem, StatusbarSelectModifiers } from '../statusbar-controls'
interface StatusbarItemsOptions {
agentsOpen: boolean
@@ -105,22 +105,39 @@ export function useStatusbarItems({
// Per-session approval bypass (same scope as the TUI's Shift+Tab). On a
// new-chat draft (no runtime session yet) we arm locally; the session-create
// path applies it once the backend session exists.
const toggleYolo = useCallback(async () => {
const next = !$yoloActive.get()
const sid = $activeSessionId.get()
//
// Shift+click flips the GLOBAL approvals.mode instead — a persistent,
// all-sessions/CLI/TUI/cron bypass that survives restarts.
const toggleYolo = useCallback(
async (modifiers?: StatusbarSelectModifiers) => {
const next = !$yoloActive.get()
setYoloActive(next)
setYoloActive(next)
if (!sid) {
return
}
if (modifiers?.shiftKey) {
try {
await setGlobalYolo(requestGateway, next)
} catch {
setYoloActive(!next)
}
try {
await setSessionYolo(requestGateway, sid, next)
} catch {
setYoloActive(!next)
}
}, [requestGateway])
return
}
const sid = $activeSessionId.get()
if (!sid) {
return
}
try {
await setSessionYolo(requestGateway, sid, next)
} catch {
setYoloActive(!next)
}
},
[requestGateway]
)
const showYoloToggle = gatewayState === 'open' && (!!activeSessionId || freshDraftReady)
@@ -333,7 +350,7 @@ export function useStatusbarItems({
<Zap className="size-3.5 shrink-0 opacity-70" />
),
id: 'yolo',
onSelect: () => void toggleYolo(),
onSelect: modifiers => void toggleYolo(modifiers),
title: yoloActive ? copy.yoloOn : copy.yoloOff,
variant: 'action'
},
@@ -24,6 +24,7 @@ import {
$visibleModels,
collapseModelFamilies,
DEFAULT_VISIBLE_PER_PROVIDER,
effectiveVisibleKeys,
type ModelFamily,
modelVisibilityKey,
setModelVisibilityOpen
@@ -86,13 +87,17 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
: null
const providers = modelOptions.data?.providers
const effectiveVisibleModels = useMemo(
() => effectiveVisibleKeys(visibleModels, providers ?? []),
[visibleModels, providers]
)
const switchTo = (model: string, provider: string) =>
onSelectModel({ model, persistGlobal: !activeSessionId, provider })
const groups = useMemo(
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, visibleModels),
[providers, search, optionsModel, optionsProvider, visibleModels]
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels),
[providers, search, optionsModel, optionsProvider, effectiveVisibleModels]
)
return (
@@ -35,12 +35,16 @@ export interface StatusbarItem {
menuClassName?: string
menuContent?: ReactNode
menuItems?: readonly StatusbarMenuItem[]
onSelect?: () => void
onSelect?: (modifiers: StatusbarSelectModifiers) => void
title?: string
to?: string
variant?: 'action' | 'link' | 'menu' | 'text'
}
export interface StatusbarSelectModifiers {
shiftKey: boolean
}
export type StatusbarItemSide = 'left' | 'right'
export type SetStatusbarItemGroup = (id: string, items: readonly StatusbarItem[], side?: StatusbarItemSide) => void
@@ -170,12 +174,12 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
<button
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
disabled={item.disabled}
onClick={() => {
onClick={event => {
if (item.to) {
navigate(item.to)
}
item.onSelect?.()
item.onSelect?.({ shiftKey: event.shiftKey })
}}
type="button"
>
+12
View File
@@ -13,6 +13,13 @@ export interface ImageAttachResponse {
path?: string
text?: string
message?: string
// Returned by the byte-upload variant (image.attach_bytes) used in remote mode.
count?: number
bytes?: number
name?: string
width?: number
height?: number
token_estimate?: number
}
export interface ImageDetachResponse {
@@ -91,4 +98,9 @@ export interface ClientSessionState {
/** A blocking clarify prompt is waiting on the user for this session. Drives
* the sidebar "needs input" indicator; cleared when the turn resumes/ends. */
needsInput: boolean
/** Epoch ms the current turn started, or null when idle. Per-session so a
* background turn's elapsed timer keeps counting while another session is
* focused, and switching sessions doesn't zero a still-running turn's clock.
* The global $turnStartedAt mirrors whichever session is currently viewed. */
turnStartedAt: number | null
}
@@ -7,6 +7,7 @@ import { Fragment, useEffect, useMemo, useState } from 'react'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { extractEmbeddedImages } from '@/lib/embedded-images'
import { gatewayMediaDataUrl, isRemoteGateway } from '@/lib/media'
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
@@ -327,25 +328,32 @@ export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePar
* messages render after the backend embeds the data URL, so the UX is stable
* across initial send and refresh. */
const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => {
const remote = /^(?:https?|data):/i.test(id)
const [src, setSrc] = useState<string | null>(remote ? id : null)
const isUrl = /^(?:https?|data):/i.test(id)
const [src, setSrc] = useState<string | null>(isUrl ? id : null)
const [failed, setFailed] = useState(false)
useEffect(() => {
if (remote || !id) {
if (isUrl || !id) {
return
}
let alive = true
void window.hermesDesktop
?.readFileDataUrl(id)
.then(url => alive && setSrc(url))
// Remote gateway: the image lives on the gateway's disk, not ours — fetch
// it over the authenticated API. Local: read it straight off this disk.
const load =
window.hermesDesktop && isRemoteGateway()
? gatewayMediaDataUrl(id)
: window.hermesDesktop?.readFileDataUrl(id)
void Promise.resolve(load)
.then(url => alive && url && setSrc(url))
.catch(() => alive && setFailed(true))
return () => {
alive = false
}
}, [id, remote])
}, [id, isUrl])
if (failed) {
return <DirectiveChip id={id} label={label} type="image" />
@@ -172,4 +172,33 @@ describe('preprocessMarkdown', () => {
'<https://www.getyourguide.com/en-gb/san-juan-puerto-rico-l355/san-juan-old-san-juan-sunset-cruise-with-drinks-transfer-t405191/>'
)
})
it('does not swallow trailing emphasis asterisks into an autolinked url', () => {
const input = '**PR opened: https://github.com/NousResearch/hermes-agent/pull/12345**'
const output = preprocessMarkdown(input)
// The URL is autolinked WITHOUT the trailing `**` glued into the href,
// and the bold emphasis run stays intact so it renders as bold + a link.
expect(output).toContain('<https://github.com/NousResearch/hermes-agent/pull/12345>')
expect(output).not.toContain('pull/12345**>')
expect(output).not.toContain('12345*')
})
it('stops an autolinked url at mid-string bold markers', () => {
const input = 'See https://github.com/foo/bar**bold** for details.'
const output = preprocessMarkdown(input)
expect(output).toContain('<https://github.com/foo/bar>')
expect(output).toContain('**bold**')
})
it('keeps underscores and tildes inside autolinked url paths', () => {
const input = 'Docs at https://example.com/a_b/c~d/page'
const output = preprocessMarkdown(input)
expect(output).toContain('<https://example.com/a_b/c~d/page>')
})
})
@@ -17,6 +17,8 @@ import { createMemoizedMathPlugin } from '@/lib/katex-memo'
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
import {
filePathFromMediaPath,
gatewayMediaDataUrl,
isRemoteGateway,
mediaExternalUrl,
mediaKind,
mediaName,
@@ -51,6 +53,12 @@ async function mediaSrc(path: string): Promise<string> {
return mediaStreamUrl(path)
}
// Remote gateway: the image lives on the gateway machine, so read it over the
// authenticated API rather than this machine's disk.
if (window.hermesDesktop && isRemoteGateway()) {
return gatewayMediaDataUrl(path)
}
if (!window.hermesDesktop?.readFileDataUrl) {
return mediaExternalUrl(path)
}
@@ -417,7 +425,7 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
<div className="aui-md-table my-2 max-w-full overflow-x-auto rounded-[0.375rem] border border-border">
<table
className={cn(
'm-0 w-full border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
'm-0 w-full min-w-[18rem] border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
className
)}
{...props}
@@ -430,7 +438,7 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
th: ({ className, ...props }: ComponentProps<'th'>) => (
<th
className={cn(
'px-2.5 py-1.5 text-left align-middle text-[0.75rem] font-medium text-muted-foreground',
'whitespace-nowrap px-2.5 py-1.5 text-left align-middle text-[0.75rem] font-medium text-muted-foreground',
className
)}
{...props}
@@ -489,7 +489,7 @@ describe('assistant-ui streaming renderer', () => {
expect(viewport.scrollTop).toBe(420)
})
it('keeps sticky-bottom armed through viewport height changes during streaming', async () => {
it('does not follow streaming content growth even while parked at the bottom', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
@@ -508,6 +508,7 @@ describe('assistant-ui streaming renderer', () => {
await wait(80)
// Park the user at the bottom of the current content.
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
@@ -520,6 +521,9 @@ describe('assistant-ui streaming renderer', () => {
fireEvent.scroll(viewport)
})
// Content grows as tokens stream in. Streaming auto-follow is removed, so
// the viewport must NOT chase the new bottom — it stays where the user
// last left it.
scrollHeight = 1_200
await act(async () => {
@@ -529,7 +533,7 @@ describe('assistant-ui streaming renderer', () => {
})
await wait(0)
expect(viewport.scrollTop).toBe(1_200)
expect(viewport.scrollTop).toBe(760)
})
it('honors the first upward wheel scroll even when a programmatic bottom-pin scroll event is still pending', async () => {
@@ -566,7 +570,7 @@ describe('assistant-ui streaming renderer', () => {
expect(viewport.scrollTop).toBe(420)
})
it('keeps following final code-highlight growth when a run completes at bottom', async () => {
it('does not snap to the bottom on final code-highlight growth after a run completes', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
@@ -588,10 +592,13 @@ describe('assistant-ui streaming renderer', () => {
await wait(650)
// Completion re-measures (Shiki highlight) and grows the content. The
// post-run bottom lock is removed, so the viewport stays put instead of
// snapping to the new bottom.
scrollHeight = 1_700
await wait(0)
expect(viewport.scrollTop).toBe(1_700)
expect(viewport.scrollTop).toBe(800)
})
it('does not restart bottom-follow after completion when the user scrolled up', async () => {
@@ -19,7 +19,6 @@ import { setThreadScrolledUp } from '@/store/thread-scroll'
const ESTIMATED_ITEM_HEIGHT = 220
const OVERSCAN = 4
const AT_BOTTOM_THRESHOLD = 4
const POST_RUN_BOTTOM_LOCK_MS = 1_200
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
@@ -265,8 +264,27 @@ function useThreadScrollAnchor({
return
}
// Already parked at the bottom: writing `scrollTop` is a no-op and the
// browser fires NO scroll event, so arming the programmatic gate here would
// leave it permanently set. Repeated pins (streaming heartbeats, the
// post-run lock loop) then accumulate the gate, and the next genuine user
// scroll-up is misread as one of our programmatic scrolls — re-arming
// sticky-bottom and yanking the viewport back down. Refresh trackers, bail.
const distFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight)
if (distFromBottom <= AT_BOTTOM_THRESHOLD) {
lastTopRef.current = el.scrollTop
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
return
}
// Hold the disarm gate across the scroll event the next line will fire.
programmaticScrollPendingRef.current += 1
// Set to 1 rather than incrementing: coalesced writes within a frame fire a
// single scroll event, so a counter > 1 can never drain and would swallow a
// later real user scroll.
programmaticScrollPendingRef.current = 1
scrollElementToBottom(el)
lastTopRef.current = el.scrollTop
lastHeightRef.current = el.scrollHeight
@@ -369,51 +387,15 @@ function useThreadScrollAnchor({
}
}, [scrollerRef, stickyBottomRef])
// Follow content growth (streaming, item measurements, loading indicator)
// while armed. During fast streaming the ResizeObserver can fire many
// times per frame as Streamdown re-tokenizes; coalesce to one pin per
// animation frame so we don't run the scroll-event/re-pin chain
// (~20+ ms self in `Virtualizer.getMaxScrollOffset`) several times per
// token.
useEffect(() => {
if (!enabled || !isRunning) {
return undefined
}
const el = scrollerRef.current
if (!el) {
return undefined
}
let pinRafScheduled = false
const schedulePin = () => {
if (pinRafScheduled || !stickyBottomRef.current) {
return
}
pinRafScheduled = true
requestAnimationFrame(() => {
pinRafScheduled = false
if (stickyBottomRef.current) {
pinToBottom()
}
})
}
const observer = new ResizeObserver(schedulePin)
// Observe ONLY the content (firstElementChild), not the scroller `el`
// itself. Resizes of the viewport/scroller (window resize, devtools
// panel toggle) shouldn't trigger a pin — only content growth should.
if (el.firstElementChild) {
observer.observe(el.firstElementChild)
}
return () => observer.disconnect()
}, [enabled, isRunning, pinToBottom, scrollerRef, stickyBottomRef])
// Intentionally NO streaming auto-follow. Earlier builds ran a
// ResizeObserver here that re-pinned the viewport to the bottom on every
// content growth while a turn was running, so the chat tracked tokens as
// they streamed. That behavior is removed by request: once a turn is in
// flight the viewport stays exactly where the user left it. The viewport
// is still moved to the bottom ONCE per user submit / new turn / session
// change (see the layout effect and the session-change effect below) so a
// freshly submitted message lands in view — but it does not chase the
// stream afterward.
// Jump to bottom on session change OR when an empty thread first gets
// content. Both share the same intent and the same effect.
@@ -429,22 +411,21 @@ function useThreadScrollAnchor({
}
}, [enabled, groupCount, jumpToBottom, sessionKey])
// Pre-paint pin: when groupCount increases while armed (optimistic user
// message insert, streaming assistant turn arriving, etc.), pin BEFORE
// the browser commits the layout to screen. Using useLayoutEffect rather
// than useEffect so this runs synchronously after React commits the DOM
// mutation but before the browser paints. Without this, there's a ~50ms
// visual window where the new message sits below the fold while we wait
// for the ResizeObserver / scroll event chain to fire and re-pin.
// Pre-paint pin: when groupCount increases while armed (a new turn arriving
// from the user submit or assistant turn start), pin BEFORE the browser
// commits the layout to screen. Using useLayoutEffect rather than useEffect
// so this runs synchronously after React commits the DOM mutation but before
// the browser paints. Without this, there's a ~50ms visual window where the
// new message sits below the fold.
//
// We pin TWICE in this critical path — once synchronously, then once on
// the next rAF. The second pin catches the case where React mounts the
// new message in the second commit (after our layout effect ran), which
// grows scrollHeight again; without the rAF pin the user briefly sees a
// ~15 px gap below the new message until the RO catches up. Streaming
// tokens use the rate-limited RO path only; only the group-count change
// (which fires once per user submit / new turn arrival) pays for the
// extra pin.
// ~15 px gap below the new message. This fires once per user submit / new
// turn arrival — it is NOT streaming-token follow (that path is removed
// above), so a turn that streams a long response after this initial jump
// will not chase the bottom.
const prevGroupCountForLayoutRef = useRef(groupCount)
useLayoutEffect(() => {
if (!enabled) {
@@ -468,45 +449,17 @@ function useThreadScrollAnchor({
prevGroupCountForLayoutRef.current = groupCount
}, [enabled, groupCount, pinToBottom, stickyBottomRef])
// Completion swaps streaming placeholders/plain code for final rendered DOM
// (notably Shiki-highlighted code). Keep following the bottom briefly after
// `isRunning` flips false so that final measurement pass cannot strand the
// viewport near the top of a large code block.
// Intentionally NO post-run bottom lock. Earlier builds kept pinning to
// the bottom for POST_RUN_BOTTOM_LOCK_MS after `isRunning` flipped false to
// chase final Shiki re-highlight measurement. With streaming follow gone,
// re-pinning at completion would yank the viewport back to the bottom even
// though the user is reading earlier content — the opposite of what's
// wanted. The one-time submit / new-turn jump already covers landing a
// fresh message in view.
const prevIsRunningForLayoutRef = useRef(isRunning)
useLayoutEffect(() => {
const finishedRun = prevIsRunningForLayoutRef.current && !isRunning
prevIsRunningForLayoutRef.current = isRunning
if (!enabled || !finishedRun || !stickyBottomRef.current) {
return undefined
}
const lockUntil = performance.now() + POST_RUN_BOTTOM_LOCK_MS
let lockRaf: number | null = null
const lockFrame = () => {
lockRaf = null
if (!stickyBottomRef.current) {
return
}
pinToBottom()
if (performance.now() < lockUntil) {
lockRaf = requestAnimationFrame(lockFrame)
}
}
pinToBottom()
lockRaf = requestAnimationFrame(lockFrame)
return () => {
if (lockRaf !== null) {
cancelAnimationFrame(lockRaf)
}
}
}, [enabled, isRunning, pinToBottom, stickyBottomRef])
}, [isRunning])
useAuiEvent('thread.runStart', jumpToBottom)
}
@@ -150,10 +150,7 @@ export const Thread: FC<{
)
const emptyPlaceholder = intro ? (
<div
className="flex min-h-0 w-full flex-col items-center justify-center"
style={{ paddingBottom: 'var(--composer-measured-height)' }}
>
<div className="flex min-h-0 w-full flex-col items-center justify-center pt-[var(--composer-measured-height)]">
<Intro {...intro} />
</div>
) : undefined
@@ -470,9 +467,7 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
s =>
s.thread.isRunning &&
s.message.status?.type === 'running' &&
s.message.parts
.slice(Math.max(0, startIndex))
.some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
s.message.parts.slice(Math.max(0, startIndex)).some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
)
// A reasoning group with no actual text is pure noise — drop the whole
+3 -3
View File
@@ -160,14 +160,14 @@ export function Intro({ personality, seed }: IntroProps) {
return (
<div
className="pointer-events-none flex w-full min-w-0 flex-col items-center justify-center px-3 py-6 text-center text-muted-foreground sm:px-6 lg:px-8"
className="pointer-events-none flex w-full min-w-0 flex-col items-center justify-center px-0.5 py-6 text-center text-muted-foreground sm:px-6 lg:px-8"
data-slot="aui_intro"
>
<div className="w-full min-w-0">
<p
aria-label={WORDMARK}
className="fit-text mx-auto mb-3 w-[88%] font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
style={{ '--fit-text-line-height': '0.9', '--fit-text-min': '2.75rem' } as CSSProperties}
className="fit-text mx-auto mb-1 w-[calc(100%-1rem)] font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
style={{ '--fit-min': '2.75rem' } as CSSProperties}
>
<span>
<span>{WORDMARK}</span>
@@ -1,4 +1,4 @@
export type { PaneShellContextValue, PaneSlot } from './context'
export { PaneShellContext } from './context'
export { Pane, PaneMain, PaneShell } from './pane-shell'
export { Pane, PANE_TOGGLE_REVEAL_EVENT, PaneMain, PaneShell } from './pane-shell'
export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell'
@@ -10,7 +10,8 @@ import {
useContext,
useEffect,
useMemo,
useRef
useRef,
useState
} from 'react'
import { cn } from '@/lib/utils'
@@ -31,6 +32,12 @@ export interface PaneProps {
defaultOpen?: boolean
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
disabled?: boolean
/** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */
forceCollapsed?: boolean
/** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */
hoverReveal?: boolean
/** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */
onOverlayActiveChange?: (overlayActive: boolean) => void
id: string
maxWidth?: WidthValue
minWidth?: WidthValue
@@ -53,6 +60,7 @@ export interface PaneShellProps {
interface CollectedPane {
defaultOpen: boolean
disabled: boolean
forceCollapsed: boolean
id: string
resizable: boolean
side: PaneSide
@@ -62,6 +70,22 @@ interface CollectedPane {
const DEFAULT_WIDTH = '16rem'
const DEFAULT_RESIZE_MIN_WIDTH = 160
// Hover-reveal slide. The enter delay is a pure-CSS hover-intent gate: a fast
// pass-by doesn't dwell on the trigger long enough for the delay to elapse.
const HOVER_REVEAL_SLIDE_MS = 220
const HOVER_REVEAL_ENTER_DELAY_MS = 130
const HOVER_REVEAL_EASE = 'cubic-bezier(0.32,0.72,0,1)'
// Offset shadow lifting the revealed panel off the content (same both sides;
// the mirror axis is offset-x, which is 0). Same color on light + dark.
const HOVER_REVEAL_SHADOW = '0px -18px 18px -5px #00000012'
// Edge trigger strip, inset past the OS window-resize grab area.
const HOVER_REVEAL_TRIGGER_WIDTH = 14
const HOVER_REVEAL_EDGE_GUTTER = 6
// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal
// from the keyboard, since its store-open toggle is a no-op while collapsed.
export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal'
const widthToCss = (value: WidthValue | undefined, fallback: string) =>
value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value
@@ -110,6 +134,7 @@ function collectPanes(children: ReactNode) {
const entry: CollectedPane = {
defaultOpen: props.defaultOpen ?? true,
disabled: props.disabled ?? false,
forceCollapsed: props.forceCollapsed ?? false,
id: props.id,
resizable: props.resizable ?? false,
side: props.side,
@@ -124,7 +149,7 @@ function collectPanes(children: ReactNode) {
function trackForPane(pane: CollectedPane, states: Record<string, { open: boolean; widthOverride?: number }>) {
const stateOpen = states[pane.id]?.open ?? pane.defaultOpen
const open = !pane.disabled && stateOpen
const open = !pane.disabled && !pane.forceCollapsed && stateOpen
if (!open) {
return { open: false, track: '0px' }
@@ -193,14 +218,29 @@ export function Pane({
className,
defaultOpen = true,
disabled = false,
hoverReveal = false,
id,
maxWidth,
minWidth,
resizable = false
onOverlayActiveChange,
resizable = false,
width
}: PaneProps) {
const ctx = useContext(PaneShellContext)
const paneStates = useStore($paneStates)
const registered = useRef(false)
const paneRef = useRef<HTMLDivElement | null>(null)
// Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS.
const [forced, setForced] = useState(false)
const slot = ctx?.paneById.get(id)
const open = Boolean(slot?.open && !disabled)
const side = slot?.side ?? 'left'
// Collapsed + hoverReveal: float the pane contents over the main column on
// hover/focus instead of hiding them. Honors any persisted resize width.
const overlayActive = !open && hoverReveal && !disabled
const override = resizable ? paneStates[id]?.widthOverride : undefined
const overlayWidth = override !== undefined ? `${override}px` : widthToCss(width, DEFAULT_WIDTH)
useEffect(() => {
if (registered.current) {
@@ -211,12 +251,34 @@ export function Pane({
ensurePaneRegistered(id, { open: defaultOpen })
}, [defaultOpen, id])
const slot = ctx?.paneById.get(id)
const open = Boolean(slot?.open && !disabled)
// Keyboard toggle pins/unpins the reveal while collapsed; clear when no longer
// a collapsed overlay (reopened / widened).
useEffect(() => {
if (typeof window === 'undefined' || !overlayActive) {
setForced(false)
return
}
const onToggle = (e: Event) => {
if ((e as CustomEvent<{ id: string }>).detail?.id === id) {
setForced(v => !v)
}
}
window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
return () => window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
}, [id, overlayActive])
// Keep contents mounted while collapsed so reveal is a pure CSS transform.
useEffect(() => {
onOverlayActiveChange?.(overlayActive)
}, [onOverlayActiveChange, overlayActive])
const canResize = open && resizable
const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH
const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY
const side = slot?.side ?? 'left'
const startResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
@@ -273,6 +335,58 @@ export function Pane({
return null
}
// Collapsed hover-reveal track: a 0px, pointer-transparent grid cell holding a
// thin edge trigger + the floating panel (both absolute, escaping the zero
// box). group-hover (or data-forced from the keyboard) drives the slide; the
// enter-delay is the hover-intent gate. No JS pointer math.
if (overlayActive) {
const edge = side === 'left' ? 'left' : 'right'
const offscreen = side === 'left' ? '-translate-x-[calc(100%+1rem)]' : 'translate-x-[calc(100%+1rem)]'
return (
<div
className={cn('group/reveal pointer-events-none relative row-start-1 min-w-0', className)}
data-forced={forced ? '' : undefined}
data-pane-hover-reveal={forced ? 'open' : 'closed'}
data-pane-id={id}
data-pane-open="false"
data-pane-side={side}
ref={paneRef}
style={{ gridColumn: `${slot.column} / ${slot.column + 1}` }}
>
<div
aria-hidden="true"
className="pointer-events-auto absolute inset-y-0 z-30 [-webkit-app-region:no-drag]"
style={{ [edge]: HOVER_REVEAL_EDGE_GUTTER, width: HOVER_REVEAL_TRIGGER_WIDTH }}
/>
{/* Keyed on side so flipping panes remounts off-screen on the new edge
instead of transitioning the transform across the viewport. */}
<div
className={cn(
'pointer-events-none absolute inset-y-0 z-30 overflow-hidden transition-transform delay-0',
offscreen,
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
'group-data-[forced]/reveal:pointer-events-auto group-data-[forced]/reveal:translate-x-0 group-data-[forced]/reveal:delay-0 group-data-[forced]/reveal:shadow-[var(--reveal-shadow)]'
)}
key={edge}
style={
{
[edge]: 0,
width: overlayWidth,
'--reveal-shadow': HOVER_REVEAL_SHADOW,
transitionDuration: `${HOVER_REVEAL_SLIDE_MS}ms`,
transitionTimingFunction: HOVER_REVEAL_EASE,
'--reveal-enter-delay': `${HOVER_REVEAL_ENTER_DELAY_MS}ms`
} as CSSProperties
}
>
<div className="flex h-full w-full flex-col">{children}</div>
</div>
</div>
)
}
return (
<div
aria-hidden={!open}
+7
View File
@@ -7,6 +7,13 @@ declare global {
// the window's backend; pass a named profile to lazily spawn/reuse that
// profile's backend from the pool.
getConnection: (profile?: string | null) => Promise<HermesConnection>
// Reconnect-after-wake recovery: liveness-probe the cached PRIMARY backend
// and drop it if a remote one has gone unreachable, so the next
// getConnection() rebuilds a reachable descriptor instead of the renderer
// re-dialing a dead remote forever. No-op for local backends (they
// self-heal via the child 'exit' handler). `rebuilt` is true when a stale
// remote cache was dropped.
revalidateConnection: () => Promise<{ ok: boolean; rebuilt: boolean }>
// Keepalive: mark a pool profile backend as recently used so the idle
// reaper spares it while its chat is active.
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
+49
View File
@@ -0,0 +1,49 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listAllProfileSessions, listSessions } from './hermes'
const emptySessionsResponse = {
limit: 0,
offset: 0,
sessions: [],
total: 0
}
describe('Hermes REST session helpers', () => {
let api: ReturnType<typeof vi.fn>
beforeEach(() => {
api = vi.fn().mockResolvedValue(emptySessionsResponse)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { api }
})
})
afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(window, 'hermesDesktop')
})
it('uses a longer timeout for the single-profile session list', async () => {
await listSessions(50, 1)
expect(api).toHaveBeenCalledWith(
expect.objectContaining({
path: '/api/sessions?limit=50&offset=0&min_messages=1&archived=exclude&order=recent',
timeoutMs: 60_000
})
)
})
it('uses a longer timeout for the all-profile session list', async () => {
await listAllProfileSessions(50, 1)
expect(api).toHaveBeenCalledWith(
expect.objectContaining({
path: '/api/profiles/sessions?limit=50&offset=0&min_messages=1&archived=exclude&order=recent&profile=all',
timeoutMs: 60_000
})
)
})
})
+5 -2
View File
@@ -42,6 +42,7 @@ import type {
} from '@/types/hermes'
const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000
const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000
export type {
ActionResponse,
@@ -136,7 +137,8 @@ export async function listSessions(
order: 'created' | 'recent' = 'recent'
): Promise<PaginatedSessions> {
const result = await window.hermesDesktop.api<PaginatedSessions>({
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}&order=${order}`
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}&order=${order}`,
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
})
return {
@@ -176,7 +178,8 @@ export async function listAllProfileSessions(
const result = await window.hermesDesktop.api<PaginatedSessions>({
path:
`/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`,
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
})
return {
+2 -2
View File
@@ -1463,8 +1463,8 @@ export const en: Translations = {
contextUsage: 'Context usage',
session: 'Session',
runtimeSessionElapsed: 'Runtime session elapsed',
yoloOn: 'YOLO on — auto-approving dangerous commands. Click to turn off.',
yoloOff: 'YOLO off — click to auto-approve dangerous commands.',
yoloOn: 'YOLO on — auto-approving dangerous commands. Click to turn off. Shift+click toggles it globally.',
yoloOff: 'YOLO off — click to auto-approve dangerous commands. Shift+click toggles it globally.',
modelNone: 'none',
noModel: 'no model',
switchModel: 'Switch model',
+43 -3
View File
@@ -239,7 +239,11 @@ export const ja = defineLocale({
backend: '実行バックエンド',
timeout: 'コマンドタイムアウト',
persistentShell: '永続シェル',
envPassthrough: '環境変数の引き継ぎ'
envPassthrough: '環境変数の引き継ぎ',
dockerImage: 'Docker イメージ',
singularityImage: 'Singularity イメージ',
modalImage: 'Modal イメージ',
daytonaImage: 'Daytona イメージ'
},
fileReadMaxChars: 'ファイル読み取り上限',
toolOutput: {
@@ -280,6 +284,15 @@ export const ja = defineLocale({
model: 'ローカル文字起こしモデル',
language: '文字起こし言語'
},
openai: {
model: 'OpenAI STT モデル'
},
groq: {
model: 'Groq STT モデル'
},
mistral: {
model: 'Mistral STT モデル'
},
elevenlabs: {
modelId: 'ElevenLabs STT モデル',
languageCode: 'ElevenLabs 言語',
@@ -299,6 +312,33 @@ export const ja = defineLocale({
elevenlabs: {
voiceId: 'ElevenLabs 音声',
modelId: 'ElevenLabs モデル'
},
xai: {
voiceId: 'xAI (Grok) 音声',
language: 'xAI 言語'
},
minimax: {
model: 'MiniMax TTS モデル',
voiceId: 'MiniMax 音声'
},
mistral: {
model: 'Mistral TTS モデル',
voiceId: 'Mistral 音声'
},
gemini: {
model: 'Gemini TTS モデル',
voice: 'Gemini 音声'
},
neutts: {
model: 'NeuTTS モデル',
device: 'NeuTTS デバイス'
},
kittentts: {
model: 'KittenTTS モデル',
voice: 'KittenTTS 音声'
},
piper: {
voice: 'Piper 音声'
}
},
memory: {
@@ -1566,8 +1606,8 @@ export const ja = defineLocale({
contextUsage: 'コンテキスト使用状況',
session: 'セッション',
runtimeSessionElapsed: 'ランタイムセッション経過時間',
yoloOn: 'YOLO オン — 危険なコマンドを自動承認中。クリックでオフに。',
yoloOff: 'YOLO オフ — クリックで危険なコマンドを自動承認。',
yoloOn: 'YOLO オン — 危険なコマンドを自動承認中。クリックでオフに。Shift+クリックで全体に切り替え。',
yoloOff: 'YOLO オフ — クリックで危険なコマンドを自動承認。Shift+クリックで全体に切り替え。',
modelNone: 'なし',
noModel: 'モデルなし',
switchModel: 'モデルを切り替え',
+43 -3
View File
@@ -233,7 +233,11 @@ export const zhHant = defineLocale({
backend: '執行後端',
timeout: '指令逾時',
persistentShell: '持久化 Shell',
envPassthrough: '環境變數傳遞'
envPassthrough: '環境變數傳遞',
dockerImage: 'Docker 映像',
singularityImage: 'Singularity 映像',
modalImage: 'Modal 映像',
daytonaImage: 'Daytona 映像'
},
fileReadMaxChars: '檔案讀取上限',
toolOutput: {
@@ -274,6 +278,15 @@ export const zhHant = defineLocale({
model: '本機轉寫模型',
language: '轉寫語言'
},
openai: {
model: 'OpenAI STT 模型'
},
groq: {
model: 'Groq STT 模型'
},
mistral: {
model: 'Mistral STT 模型'
},
elevenlabs: {
modelId: 'ElevenLabs STT 模型',
languageCode: 'ElevenLabs 語言',
@@ -293,6 +306,33 @@ export const zhHant = defineLocale({
elevenlabs: {
voiceId: 'ElevenLabs 語音',
modelId: 'ElevenLabs 模型'
},
xai: {
voiceId: 'xAI (Grok) 語音',
language: 'xAI 語言'
},
minimax: {
model: 'MiniMax TTS 模型',
voiceId: 'MiniMax 語音'
},
mistral: {
model: 'Mistral TTS 模型',
voiceId: 'Mistral 語音'
},
gemini: {
model: 'Gemini TTS 模型',
voice: 'Gemini 語音'
},
neutts: {
model: 'NeuTTS 模型',
device: 'NeuTTS 裝置'
},
kittentts: {
model: 'KittenTTS 模型',
voice: 'KittenTTS 語音'
},
piper: {
voice: 'Piper 語音'
}
},
memory: {
@@ -1527,8 +1567,8 @@ export const zhHant = defineLocale({
contextUsage: '上下文使用量',
session: '工作階段',
runtimeSessionElapsed: '執行時工作階段已用時間',
yoloOn: 'YOLO 已開啟 — 自動核准危險指令。點擊關閉。',
yoloOff: 'YOLO 已關閉 — 點擊自動核准危險指令。',
yoloOn: 'YOLO 已開啟 — 自動核准危險指令。點擊關閉。Shift+點擊可全域切換。',
yoloOff: 'YOLO 已關閉 — 點擊自動核准危險指令。Shift+點擊可全域切換。',
modelNone: '無',
noModel: '無模型',
switchModel: '切換模型',
+43 -3
View File
@@ -311,7 +311,11 @@ export const zh: Translations = {
backend: '执行后端',
timeout: '命令超时',
persistentShell: '持久化 Shell',
envPassthrough: '环境变量透传'
envPassthrough: '环境变量透传',
dockerImage: 'Docker 镜像',
singularityImage: 'Singularity 镜像',
modalImage: 'Modal 镜像',
daytonaImage: 'Daytona 镜像'
},
fileReadMaxChars: '文件读取上限',
toolOutput: {
@@ -352,6 +356,15 @@ export const zh: Translations = {
model: '本地转写模型',
language: '转写语言'
},
openai: {
model: 'OpenAI STT 模型'
},
groq: {
model: 'Groq STT 模型'
},
mistral: {
model: 'Mistral STT 模型'
},
elevenlabs: {
modelId: 'ElevenLabs STT 模型',
languageCode: 'ElevenLabs 语言',
@@ -371,6 +384,33 @@ export const zh: Translations = {
elevenlabs: {
voiceId: 'ElevenLabs 语音',
modelId: 'ElevenLabs 模型'
},
xai: {
voiceId: 'xAI (Grok) 语音',
language: 'xAI 语言'
},
minimax: {
model: 'MiniMax TTS 模型',
voiceId: 'MiniMax 语音'
},
mistral: {
model: 'Mistral TTS 模型',
voiceId: 'Mistral 语音'
},
gemini: {
model: 'Gemini TTS 模型',
voice: 'Gemini 语音'
},
neutts: {
model: 'NeuTTS 模型',
device: 'NeuTTS 设备'
},
kittentts: {
model: 'KittenTTS 模型',
voice: 'KittenTTS 语音'
},
piper: {
voice: 'Piper 语音'
}
},
memory: {
@@ -1604,8 +1644,8 @@ export const zh: Translations = {
contextUsage: '上下文用量',
session: '会话',
runtimeSessionElapsed: '运行时会话已用时间',
yoloOn: 'YOLO 已开启 - 自动批准危险命令。点击关闭。',
yoloOff: 'YOLO 已关闭 - 点击自动批准危险命令。',
yoloOn: 'YOLO 已开启 - 自动批准危险命令。点击关闭。Shift+点击可全局切换。',
yoloOff: 'YOLO 已关闭 - 点击自动批准危险命令。Shift+点击可全局切换。',
modelNone: '无',
noModel: '无模型',
switchModel: '切换模型',
+2 -1
View File
@@ -46,7 +46,8 @@ export function createClientSessionState(
sawAssistantPayload: false,
pendingBranchGroup: null,
interrupted: false,
needsInput: false
needsInput: false,
turnStartedAt: null
}
}
+8 -1
View File
@@ -8,7 +8,14 @@ const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/
const EMPTY_FENCE_BLOCK_RE = /(^|\n)[ \t]*(?:`{3,}|~{3,})[^\n]*\n[ \t]*(?:`{3,}|~{3,})[ \t]*(?=\n|$)/g
const CODE_FENCE_SPLIT_RE = /((?:```|~~~)[\s\S]*?(?:```|~~~))/g
const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g
const RAW_URL_RE = /https?:\/\/[^\s<>"'`]+[^\s<>"'`.,;:!?]/g
// Bare-URL autolink matcher. The character classes EXCLUDE `*` so a URL that
// abuts markdown emphasis with no separating space (e.g. `**label: https://x**`,
// a very common LLM pattern) doesn't swallow the trailing `**` into the href.
// `*` is never meaningful in a real URL path, and GFM's own autolink extension
// likewise strips trailing emphasis/punctuation — so dropping it here is safe
// and keeps the emphasis run intact. Other trailing punctuation is still peeled
// off by the final `[^\s<>"'`*.,;:!?]` class.
const RAW_URL_RE = /https?:\/\/[^\s<>"'`*]+[^\s<>"'`*.,;:!?]/g
const LOCAL_PREVIEW_URL_RE = /(^|\s)https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?[^\s<>"'`]*/gi
const LOCAL_PREVIEW_ONLY_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?$/i
const URL_ONLY_LINE_RE = /^\s*https?:\/\/\S+\s*$/i
+58
View File
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway } from './media'
describe('isRemoteGateway', () => {
afterEach(() => {
$connection.set(null)
})
it('is false with no connection', () => {
$connection.set(null)
expect(isRemoteGateway()).toBe(false)
})
it('is false in local mode', () => {
$connection.set({ mode: 'local' } as never)
expect(isRemoteGateway()).toBe(false)
})
it('is true in remote mode', () => {
$connection.set({ mode: 'remote' } as never)
expect(isRemoteGateway()).toBe(true)
})
})
describe('filePathFromMediaPath', () => {
it('passes through a plain path', () => {
expect(filePathFromMediaPath('/home/u/.hermes/images/a.png')).toBe('/home/u/.hermes/images/a.png')
})
it('decodes a file:// URL with encoded characters', () => {
expect(filePathFromMediaPath('file:///tmp/a%20b.png')).toBe('/tmp/a b.png')
})
})
describe('gatewayMediaDataUrl', () => {
const api = vi.fn(async () => ({ data_url: 'data:image/png;base64,ZHVtbXk=' }))
beforeEach(() => {
api.mockClear()
vi.stubGlobal('window', { hermesDesktop: { api } })
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('requests the encoded gateway path and returns the data URL', async () => {
const url = await gatewayMediaDataUrl('/home/u/.hermes/images/a b.png')
expect(url).toBe('data:image/png;base64,ZHVtbXk=')
expect(api).toHaveBeenCalledWith({
path: '/api/media?path=%2Fhome%2Fu%2F.hermes%2Fimages%2Fa%20b.png'
})
})
})
+22
View File
@@ -1,3 +1,5 @@
import { $connection } from '@/store/session'
export type MediaKind = 'audio' | 'image' | 'video' | 'file'
interface MediaInfo {
@@ -89,6 +91,26 @@ export function filePathFromMediaPath(path: string): string {
}
}
// True when this desktop shell is wired to a remote gateway. Local media paths
// then live on the gateway machine, not this disk, so we fetch them over the API.
export function isRemoteGateway(): boolean {
return $connection.get()?.mode === 'remote'
}
// Fetch a gateway-local image as a data URL via the authenticated REST bridge.
// Used in remote mode where readFileDataUrl (which reads THIS machine's disk)
// can't see files the agent wrote on the gateway. Requires the gateway to
// expose GET /api/media (hermes_cli/web_server.py).
export async function gatewayMediaDataUrl(path: string): Promise<string> {
const file = filePathFromMediaPath(path)
const result = await window.hermesDesktop!.api<{ data_url: string }>({
path: `/api/media?path=${encodeURIComponent(file)}`
})
return result.data_url
}
export function mediaDisplayLabel(path: string): string {
const escaped = mediaName(path).replace(/[[\]\\]/g, '\\$&')
const kind = mediaKind(path)
@@ -52,6 +52,14 @@ describe('sessionMatchesSearch', () => {
expect(sessionMatchesSearch(session, 'hermes-agent')).toBe(true)
})
it('matches sessions by source platform and aliases', () => {
expect(sessionMatchesSearch(makeSession({ source: 'telegram' }), 'Telegram')).toBe(true)
expect(sessionMatchesSearch(makeSession({ source: 'whatsapp' }), 'WhatsApp')).toBe(true)
expect(sessionMatchesSearch(makeSession({ source: 'whatsapp' }), 'wa')).toBe(true)
expect(sessionMatchesSearch(makeSession({ source: 'slack' }), 'slack')).toBe(true)
expect(sessionMatchesSearch(makeSession({ source: 'bluebubbles' }), 'imessage')).toBe(true)
})
it('does not match unrelated queries', () => {
expect(sessionMatchesSearch(makeSession(), 'totally-unrelated')).toBe(false)
})
+3 -1
View File
@@ -1,6 +1,7 @@
import type { SessionInfo } from '@/types/hermes'
import { sessionTitle } from './chat-runtime'
import { sessionSourceSearchTerms } from './session-source'
export function sessionMatchesSearch(session: SessionInfo, query: string): boolean {
const needle = query.trim().toLowerCase()
@@ -14,6 +15,7 @@ export function sessionMatchesSearch(session: SessionInfo, query: string): boole
session._lineage_root_id ?? '',
sessionTitle(session),
session.preview ?? '',
session.cwd ?? ''
session.cwd ?? '',
...sessionSourceSearchTerms(session.source)
].some(value => value.toLowerCase().includes(needle))
}
+62
View File
@@ -0,0 +1,62 @@
const SOURCE_LABELS: Record<string, string> = {
api_server: 'API',
bluebubbles: 'iMessage',
cli: 'CLI',
codex: 'Codex',
desktop: 'Desktop',
discord: 'Discord',
email: 'Email',
gateway: 'Gateway',
local: 'Local',
matrix: 'Matrix',
mattermost: 'Mattermost',
qqbot: 'QQ',
signal: 'Signal',
slack: 'Slack',
sms: 'SMS',
telegram: 'Telegram',
tui: 'TUI',
webhook: 'Webhook',
weixin: 'WeChat',
whatsapp: 'WhatsApp',
yuanbao: 'Yuanbao'
}
const SOURCE_ALIASES: Record<string, string[]> = {
bluebubbles: ['apple messages', 'imessage'],
cli: ['terminal'],
desktop: ['app', 'gui'],
local: ['machine'],
qqbot: ['qq'],
telegram: ['tg'],
tui: ['terminal'],
weixin: ['wechat'],
whatsapp: ['wa']
}
export function normalizeSessionSource(source: null | string | undefined): string | null {
const id = source?.trim().toLowerCase()
return id || null
}
export function sessionSourceLabel(source: null | string | undefined): string | null {
const id = normalizeSessionSource(source)
if (!id) {
return null
}
return SOURCE_LABELS[id] || id.replace(/[_-]+/g, ' ').replace(/\b\w/g, char => char.toUpperCase())
}
export function sessionSourceSearchTerms(source: null | string | undefined): string[] {
const id = normalizeSessionSource(source)
const label = sessionSourceLabel(id)
if (!id) {
return []
}
return [id, label ?? '', ...(SOURCE_ALIASES[id] ?? [])].filter(Boolean)
}
+24
View File
@@ -24,3 +24,27 @@ export async function setSessionYolo(
return active
}
/**
* Toggle GLOBAL YOLO (approval bypass) via gateway `config.set` with
* `scope: 'global'`. This flips the persistent `approvals.mode` in config.yaml
* between `off` (bypass on) and `manual` (bypass off), affecting every session,
* the CLI, the TUI, and cron and it survives restarts. Triggered by
* Shift+clicking the status-bar zap.
*/
export async function setGlobalYolo(
requestGateway: GatewayRequester,
enabled: boolean
): Promise<boolean> {
const result = await requestGateway<{ value?: string }>('config.set', {
key: 'yolo',
scope: 'global',
value: enabled ? '1' : '0'
})
const active = result?.value === '1'
setYoloActive(active)
return active
}
+27
View File
@@ -23,6 +23,8 @@ export const SIDEBAR_SESSIONS_PAGE_SIZE = 50
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
const SIDEBAR_AGENTS_GROUPED_STORAGE_KEY = 'hermes.desktop.agentsGroupedByWorkspace'
const SIDEBAR_CRON_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarCronOpen'
const SIDEBAR_SESSION_ORDER_STORAGE_KEY = 'hermes.desktop.sessionOrder'
const SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceOrder'
const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped'
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
@@ -53,7 +55,14 @@ export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states
})
export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_KEY))
export const $sidebarSessionOrderIds = atom(storedStringArray(SIDEBAR_SESSION_ORDER_STORAGE_KEY))
export const $sidebarWorkspaceOrderIds = atom(storedStringArray(SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY))
export const $sidebarPinsOpen = atom(true)
// Set by the PaneShell hover-reveal overlay while the sidebar is collapsed; kept
// true the whole time it's a floating overlay (not just while shown) so the
// consumer mounts contents off-screen, ready to slide. ChatSidebar mounts its
// rows on `sidebarOpen || this`.
export const $sidebarOverlayMounted = atom(false)
export const $sidebarRecentsOpen = atom(true)
// Cron-job sessions live in their own section below recents, collapsed by
// default (it only renders at all when cron sessions exist) so the
@@ -68,6 +77,8 @@ export const $sessionsLimit = atom(SIDEBAR_SESSIONS_PAGE_SIZE)
$pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY, [...ids]))
$sidebarCronOpen.subscribe(open => persistBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, open))
$sidebarSessionOrderIds.subscribe(ids => persistStringArray(SIDEBAR_SESSION_ORDER_STORAGE_KEY, [...ids]))
$sidebarWorkspaceOrderIds.subscribe(ids => persistStringArray(SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY, [...ids]))
$sidebarAgentsGrouped.subscribe(grouped => persistBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, grouped))
$panesFlipped.subscribe(flipped => persistBoolean(PANES_FLIPPED_STORAGE_KEY, flipped))
@@ -116,6 +127,10 @@ export function setSidebarPinsOpen(open: boolean) {
$sidebarPinsOpen.set(open)
}
export function setSidebarOverlayMounted(mounted: boolean) {
$sidebarOverlayMounted.set(mounted)
}
export function setSidebarRecentsOpen(open: boolean) {
$sidebarRecentsOpen.set(open)
}
@@ -128,6 +143,18 @@ export function setSidebarAgentsGrouped(grouped: boolean) {
$sidebarAgentsGrouped.set(grouped)
}
export function setSidebarSessionOrderIds(ids: string[]) {
if (!arraysEqual($sidebarSessionOrderIds.get(), ids)) {
$sidebarSessionOrderIds.set(ids)
}
}
export function setSidebarWorkspaceOrderIds(ids: string[]) {
if (!arraysEqual($sidebarWorkspaceOrderIds.get(), ids)) {
$sidebarWorkspaceOrderIds.set(ids)
}
}
export function setSidebarResizing(resizing: boolean) {
$isSidebarResizing.set(resizing)
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import type { ModelOptionProvider } from '@/types/hermes'
import { effectiveVisibleKeys, modelVisibilityKey } from './model-visibility'
const provider = (slug: string, models: string[]): ModelOptionProvider => ({
models,
name: slug,
slug
})
describe('model visibility', () => {
it('keeps newly configured providers visible when stored choices are stale', () => {
const stored = new Set([modelVisibilityKey('copilot', 'claude-sonnet-4.6')])
const visible = effectiveVisibleKeys(stored, [
provider('copilot', ['claude-sonnet-4.6']),
provider('local-ollama', ['qwen3:latest', 'llama3.2:latest'])
])
expect(visible.has(modelVisibilityKey('copilot', 'claude-sonnet-4.6'))).toBe(true)
expect(visible.has(modelVisibilityKey('local-ollama', 'qwen3:latest'))).toBe(true)
expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(true)
})
it('does not re-add models from a provider that already has stored choices', () => {
const stored = new Set([modelVisibilityKey('local-ollama', 'qwen3:latest')])
const visible = effectiveVisibleKeys(stored, [
provider('local-ollama', ['qwen3:latest', 'llama3.2:latest'])
])
expect(visible.has(modelVisibilityKey('local-ollama', 'qwen3:latest'))).toBe(true)
expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(false)
})
})
+26 -1
View File
@@ -104,5 +104,30 @@ export function effectiveVisibleKeys(
stored: Set<string> | null,
providers: readonly ModelOptionProvider[]
): Set<string> {
return stored ?? defaultVisibleKeys(providers)
if (!stored) {
return defaultVisibleKeys(providers)
}
if (stored.size === 0) {
return new Set()
}
const next = new Set(stored)
for (const provider of providers) {
const providerPrefix = `${provider.slug}::`
const hasStoredProvider = [...stored].some(key => key.startsWith(providerPrefix))
if (hasStoredProvider) {
continue
}
const families = collapseModelFamilies(provider.models ?? [])
for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) {
next.add(modelVisibilityKey(provider.slug, family.id))
}
}
return next
}
+17 -27
View File
@@ -888,52 +888,42 @@ canvas {
}
.fit-text {
--fit-captured-length: initial;
--fit-support-sentinel: var(--fit-captured-length, 9999px);
display: flex;
font-size: var(--fit-text-min, 1rem);
container-type: inline-size;
--captured-length: initial;
--support-sentinel: var(--captured-length, 9999px);
}
.fit-text > [aria-hidden='true'] {
.fit-text > [aria-hidden] {
visibility: hidden;
}
.fit-text > :not([aria-hidden='true']) {
.fit-text > :not([aria-hidden]) {
flex-grow: 1;
container-type: inline-size;
--captured-length: 100cqi;
--available-space: var(--captured-length);
--fit-captured-length: 100cqi;
--fit-available-space: var(--fit-captured-length);
}
.fit-text > :not([aria-hidden='true']) > * {
.fit-text > :not([aria-hidden]) > * {
--fit-support-sentinel: inherit;
--fit-captured-length: 100cqi;
--fit-ratio: tan(atan2(var(--fit-available-space), var(--fit-available-space) - var(--fit-captured-length)));
display: block;
inline-size: var(--available-space);
line-height: var(--fit-text-line-height, 1);
--support-sentinel: inherit;
--captured-length: 100cqi;
--ratio: tan(atan2(var(--available-space), var(--available-space) - var(--captured-length)));
--font-size: clamp(
var(--fit-text-min, 1em),
1em * var(--ratio),
var(--fit-text-max, infinity * 1px) - var(--support-sentinel)
);
font-size: var(--font-size);
inline-size: var(--fit-available-space);
font-size: clamp(var(--fit-min, 1em), 1em * var(--fit-ratio), var(--fit-max, infinity * 1px) - var(--fit-support-sentinel));
}
@container (inline-size > 0) {
.fit-text > :not([aria-hidden='true']) > * {
.fit-text > :not([aria-hidden]) > * {
white-space: nowrap;
}
}
@property --captured-length {
syntax: '<length>';
initial-value: 0px;
inherits: true;
}
@property --captured-length2 {
@property --fit-captured-length {
syntax: '<length>';
initial-value: 0px;
inherits: true;
+13
View File
@@ -6,6 +6,19 @@ import path from 'path'
export default defineConfig({
base: './',
plugins: [react(), tailwindcss()],
css: {
// Pin an explicit (empty) PostCSS config. Tailwind is handled entirely by
// `@tailwindcss/vite`, so the renderer needs no PostCSS plugins — and
// without this, Vite's `postcss-load-config` walks UP the filesystem
// looking for a stray `postcss.config.*` / `tailwind.config.*`. The desktop
// build runs from inside the user's home tree (e.g.
// `C:\Users\<name>\AppData\Local\hermes\hermes-agent\apps\desktop`), so an
// unrelated Tailwind v3 config higher up the tree gets picked up and
// reprocesses our v4 stylesheet, failing the build with
// "`@layer base` is used but no matching `@tailwind base` directive is
// present." Pinning the config makes the build hermetic.
postcss: { plugins: [] }
},
build: {
// Keep desktop packaging stable: Shiki ships many dynamic chunks by
// default, and electron-builder can OOM scanning thousands of files.
+15 -1
View File
@@ -72,6 +72,20 @@ model:
#
# max_tokens: 8192
# ── Custom request headers (optional) ─────────────────────────────────────
#
# default_headers: extra HTTP headers sent on every request to an
# OpenAI-compatible endpoint. User values take precedence over the
# provider/SDK defaults, so this is the supported way to override the
# OpenAI Python SDK's identifying headers (User-Agent: OpenAI/Python ...,
# X-Stainless-*) when a custom provider sits behind a gateway/WAF that
# rejects them — e.g. an upstream that returns "502 Upstream access
# forbidden" for the SDK default User-Agent but accepts a plain one.
# Applies on the OpenAI wire only (not native Anthropic / Bedrock).
#
# default_headers:
# User-Agent: "curl/8.7.1"
# Named provider overrides (optional)
# Use this for per-provider request timeouts, non-stream stale timeouts,
# and per-model exceptions.
@@ -871,7 +885,7 @@ delegation:
max_iterations: 50 # Max tool-calling turns per child (default: 50)
# max_concurrent_children: 3 # Max parallel child agents per batch (default: 3, floor: 1, no ceiling).
# WARNING: values above 10 multiply API cost linearly.
# max_spawn_depth: 1 # Delegation tree depth (floor 1, no ceiling; default: 1 = flat).
# max_spawn_depth: 1 # Delegation tree depth cap (range: 1-3, default: 1 = flat).
# Raise to 2 to allow workers to spawn their own subagents.
# Requires role="orchestrator" on intermediate agents.
# orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true).
+69 -2110
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -17,6 +17,23 @@ Events:
- command:* -- Any slash command executed (wildcard match)
Errors in hooks are caught and logged but never block the main pipeline.
Context dict passed to ``agent:start`` / ``agent:end`` handlers:
platform -- source platform name (e.g. "telegram", "matrix", "slack")
user_id -- platform user id of the sender
chat_id -- platform chat id (group/DM identifier)
thread_id -- Telegram forum-topic id / thread root id (string; empty
when not in a thread / topic)
chat_type -- "dm" | "group" | "forum" (empty if unknown)
session_id -- Hermes session id
message -- inbound message text (truncated to 500 chars)
``agent:end`` adds:
response -- agent response text (truncated to 500 chars)
Handlers posting a follow-up into the same Telegram forum-topic should
include ``message_thread_id=int(thread_id)`` when ``chat_type == "forum"``
and ``thread_id`` is non-empty.
"""
import asyncio
File diff suppressed because it is too large Load Diff
+51 -3
View File
@@ -61,6 +61,29 @@ from gateway.platforms.base import (
logger = logging.getLogger(__name__)
def _hermes_version() -> str:
"""Return the hermes-agent version string, or "dev" if it can't be resolved.
Tries the installed package metadata first (authoritative for a pip/uv
install), then the in-tree ``hermes_cli.__version__`` (covers editable /
source checkouts where metadata may be stale or absent). Never raises
a version probe must not be able to break the health endpoint.
"""
try:
from importlib.metadata import version
return version("hermes-agent")
except Exception:
pass
try:
from hermes_cli import __version__
return __version__
except Exception:
return "dev"
# Default settings
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8642
@@ -688,6 +711,19 @@ except ImportError:
_cron_resume = None
_cron_trigger = None
# Defense-in-depth: mirror the agent-facing cronjob tool, which scans the
# user-supplied prompt for exfiltration/injection payloads at create/update
# time (tools/cronjob_tools.py). The REST cron endpoints are authenticated
# (every handler runs _check_auth, and connect() refuses to start without
# API_SERVER_KEY), so this is not the trust boundary — it's parity with the
# tool path so a malicious prompt is rejected the same way regardless of
# which surface created the job. Imported defensively: a missing scanner
# must not disable the cron REST API.
try:
from tools.cronjob_tools import _scan_cron_prompt as _scan_cron_prompt
except Exception: # pragma: no cover - scanner is optional hardening
_scan_cron_prompt = None
class APIServerAdapter(BasePlatformAdapter):
"""
@@ -1034,7 +1070,9 @@ class APIServerAdapter(BasePlatformAdapter):
async def _handle_health(self, request: "web.Request") -> "web.Response":
"""GET /health — simple health check."""
return web.json_response({"status": "ok", "platform": "hermes-agent"})
return web.json_response(
{"status": "ok", "platform": "hermes-agent", "version": _hermes_version()}
)
async def _handle_health_detailed(self, request: "web.Request") -> "web.Response":
"""GET /health/detailed — rich status for cross-container dashboard probing.
@@ -1049,6 +1087,7 @@ class APIServerAdapter(BasePlatformAdapter):
return web.json_response({
"status": "ok",
"platform": "hermes-agent",
"version": _hermes_version(),
"gateway_state": runtime.get("gateway_state"),
"platforms": runtime.get("platforms", {}),
"active_agents": runtime.get("active_agents", 0),
@@ -1441,10 +1480,11 @@ class APIServerAdapter(BasePlatformAdapter):
if err:
return err
db = self._ensure_session_db()
messages = db.get_messages(session_id)
resolved_id = db.resolve_resume_session_id(session_id)
messages = db.get_messages(resolved_id)
return web.json_response({
"object": "list",
"session_id": session_id,
"session_id": resolved_id,
"data": [self._message_response(m) for m in messages],
})
@@ -3140,6 +3180,10 @@ class APIServerAdapter(BasePlatformAdapter):
return web.json_response(
{"error": f"Prompt must be ≤ {self._MAX_PROMPT_LENGTH} characters"}, status=400,
)
if prompt and _scan_cron_prompt is not None:
scan_error = _scan_cron_prompt(prompt)
if scan_error:
return web.json_response({"error": scan_error}, status=400)
if repeat is not None and (not isinstance(repeat, int) or repeat < 1):
return web.json_response({"error": "Repeat must be a positive integer"}, status=400)
@@ -3205,6 +3249,10 @@ class APIServerAdapter(BasePlatformAdapter):
return web.json_response(
{"error": f"Prompt must be ≤ {self._MAX_PROMPT_LENGTH} characters"}, status=400,
)
if sanitized.get("prompt") and _scan_cron_prompt is not None:
scan_error = _scan_cron_prompt(sanitized["prompt"])
if scan_error:
return web.json_response({"error": scan_error}, status=400)
job = _cron_update(job_id, sanitized)
if not job:
return web.json_response({"error": "Job not found"}, status=404)
+8 -1
View File
@@ -1792,7 +1792,14 @@ class BasePlatformAdapter(ABC):
- Sending messages/responses
- Handling media
"""
# Whether this platform renders triple-backtick fenced code blocks (i.e.
# ``format_message`` translates/preserves markdown fences into a real code
# block). Drives presentation choices like rendering a ``terminal`` tool
# call's command as a ```bash block instead of a flat preview line.
# Default False (plain-text platforms); markdown-rendering adapters set True.
supports_code_blocks: bool = False
def __init__(self, config: PlatformConfig, platform: Platform):
self.config = config
self.platform = platform
+55 -5
View File
@@ -1409,6 +1409,8 @@ def check_feishu_requirements() -> bool:
class FeishuAdapter(BasePlatformAdapter):
"""Feishu/Lark bot adapter."""
supports_code_blocks = True # Feishu renders fenced code blocks
MAX_MESSAGE_LENGTH = 8000
# Max distinct chat IDs retained in _chat_locks before LRU eviction kicks in.
CHAT_LOCK_MAX_SIZE: int = 1000
@@ -2644,7 +2646,8 @@ class FeishuAdapter(BasePlatformAdapter):
if prompt_id is None:
logger.debug("[Feishu] Card action missing update_prompt_id, ignoring")
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
if prompt_id not in self._update_prompt_state:
state = self._update_prompt_state.get(prompt_id)
if not state:
logger.debug("[Feishu] Update prompt %s already resolved or unknown", prompt_id)
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
@@ -2655,12 +2658,33 @@ class FeishuAdapter(BasePlatformAdapter):
operator = getattr(event, "operator", None)
open_id = str(getattr(operator, "open_id", "") or "")
if not self._is_interactive_operator_authorized(open_id):
sender_id = SimpleNamespace(open_id=open_id, user_id=str(getattr(operator, "user_id", "") or ""))
if not self._allow_group_message(sender_id, state.get("chat_id", ""), is_bot=False):
logger.warning("[Feishu] Unauthorized update prompt click by %s", open_id or "<unknown>")
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
callback_chat_id = str(getattr(getattr(event, "context", None), "open_chat_id", "") or "")
expected_chat_id = str(state.get("chat_id", "") or "")
if callback_chat_id and expected_chat_id and callback_chat_id != expected_chat_id:
logger.warning(
"[Feishu] Update prompt callback chat mismatch for %s (expected=%s, got=%s)",
prompt_id,
expected_chat_id,
callback_chat_id,
)
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
user_name = self._get_cached_sender_name(open_id) or open_id
if not self._submit_on_loop(loop, self._resolve_update_prompt(prompt_id, answer, user_name)):
if not self._submit_on_loop(
loop,
self._resolve_update_prompt(
prompt_id,
answer,
user_name,
open_id=open_id,
chat_id=callback_chat_id,
),
):
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
if P2CardActionTriggerResponse is None:
@@ -2711,12 +2735,38 @@ class FeishuAdapter(BasePlatformAdapter):
except Exception as exc:
logger.error("Failed to resolve gateway approval from Feishu button: %s", exc)
async def _resolve_update_prompt(self, prompt_id: Any, answer: str, user_name: str) -> None:
async def _resolve_update_prompt(
self,
prompt_id: Any,
answer: str,
user_name: str,
*,
open_id: str = "",
chat_id: str = "",
) -> None:
"""Persist an update prompt answer for the detached update process."""
state = self._update_prompt_state.pop(prompt_id, None)
state = self._update_prompt_state.get(prompt_id)
if not state:
logger.debug("[Feishu] Update prompt %s already resolved or unknown", prompt_id)
return
if open_id:
sender_id = SimpleNamespace(open_id=open_id, user_id="")
if not self._allow_group_message(sender_id, state.get("chat_id", ""), is_bot=False):
logger.warning("[Feishu] Unauthorized update prompt click by %s for prompt %s", open_id, prompt_id)
return
expected_chat_id = str(state.get("chat_id", "") or "")
if expected_chat_id and chat_id and expected_chat_id != chat_id:
logger.warning(
"[Feishu] Update prompt %s chat mismatch (expected=%s, got=%s)",
prompt_id,
expected_chat_id,
chat_id,
)
return
state = self._update_prompt_state.pop(prompt_id, None)
if not state:
logger.debug("[Feishu] Update prompt %s already resolved while validating callback", prompt_id)
return
try:
self._write_update_prompt_response(answer)
logger.info(
+2
View File
@@ -420,6 +420,8 @@ class _CryptoStateStore:
class MatrixAdapter(BasePlatformAdapter):
"""Gateway adapter for Matrix (any homeserver)."""
supports_code_blocks = True # Matrix renders fenced code blocks (HTML/markdown)
# Threshold for detecting Matrix client-side message splits.
# When a chunk is near the ~4000-char practical limit, a continuation
# is almost certain.
+105 -3
View File
@@ -317,6 +317,7 @@ class SlackAdapter(BasePlatformAdapter):
"""
MAX_MESSAGE_LENGTH = 39000 # Slack API allows 40,000 chars; leave margin
supports_code_blocks = True # Slack mrkdwn renders fenced code blocks
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.SLACK)
@@ -2290,7 +2291,38 @@ class SlackAdapter(BasePlatformAdapter):
if not thread_ts and self._dm_top_level_threads_as_sessions():
thread_ts = ts
else:
thread_ts = event.get("thread_ts") or ts # ts fallback for channels
# Channel message session scoping.
#
# Three cases:
# (a) genuine thread reply → scope session per thread
# (b) top-level, reply_in_thread=true (the default) →
# legacy behaviour: each top-level message becomes its
# own thread, so the UX still "replies in a thread"
# and sessions are keyed per thread root
# (c) top-level, reply_in_thread=false → scope one session
# across the whole channel so context accumulates across
# messages (#15421 bug 1)
event_thread_ts_raw = event.get("thread_ts")
# Align with ``is_thread_reply`` below — a ``thread_ts ==
# ts`` payload (some thread-root shapes) is not a real reply
# and must not prevent the shared-session path from taking
# effect. Matching the same invariant here keeps the two
# branches in sync even if Slack introduces new payload
# variants (Copilot on #15464).
if event_thread_ts_raw and event_thread_ts_raw != ts:
thread_ts = event_thread_ts_raw
elif self.config.extra.get("reply_in_thread", True):
# Legacy default: treat ts as a synthetic thread root so
# this top-level message gets its own session.
thread_ts = ts
else:
# reply_in_thread=false: no thread key → session manager
# groups by (platform, channel_id, None) and the channel
# shares one conversation. reply_to_message_id at the
# outbound side is already gated on ``thread_ts != ts``
# so None here produces a non-threaded reply without
# further changes.
thread_ts = None
# In channels, respond if:
# 0. Channel is in free_response_channels, OR require_mention is
@@ -2797,6 +2829,55 @@ class SlackAdapter(BasePlatformAdapter):
logger.error("[Slack] send_slash_confirm failed: %s", e, exc_info=True)
return SendResult(success=False, error=str(e))
def _is_interactive_user_authorized(
self,
user_id: str,
*,
channel_id: str = "",
user_name: Optional[str] = None,
) -> bool:
"""Return whether a Slack interactive caller may perform gated actions."""
normalized_user_id = str(user_id or "").strip()
if not normalized_user_id:
return False
runner = getattr(getattr(self, "_message_handler", None), "__self__", None)
auth_fn = getattr(runner, "_is_user_authorized", None)
if callable(auth_fn):
try:
from gateway.session import SessionSource
source = SessionSource(
platform=Platform.SLACK,
chat_id=str(channel_id or normalized_user_id),
chat_type="dm" if str(channel_id or "").startswith("D") else "group",
user_id=normalized_user_id,
user_name=str(user_name).strip() if user_name else None,
)
return bool(auth_fn(source))
except Exception:
logger.debug(
"[Slack] Falling back to env-only interactive auth for user %s",
normalized_user_id,
exc_info=True,
)
if os.getenv("SLACK_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}:
return True
allowed_ids = set()
platform_allowlist = os.getenv("SLACK_ALLOWED_USERS", "").strip()
if platform_allowlist:
allowed_ids.update(uid.strip() for uid in platform_allowlist.split(",") if uid.strip())
global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip()
if global_allowlist:
allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip())
if allowed_ids:
return "*" in allowed_ids or normalized_user_id in allowed_ids
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
async def _handle_slash_confirm_action(self, ack, body, action) -> None:
"""Handle a slash-confirm button click from Block Kit."""
await ack()
@@ -2808,9 +2889,19 @@ class SlackAdapter(BasePlatformAdapter):
channel_id = body.get("channel", {}).get("id", "")
user_name = body.get("user", {}).get("name", "unknown")
user_id = body.get("user", {}).get("id", "")
if not self._is_interactive_user_authorized(
user_id,
channel_id=channel_id,
user_name=user_name,
):
logger.warning(
"[Slack] Unauthorized slash-confirm click by %s (%s) - ignoring",
user_name, user_id,
)
return
# Authorization — reuse the exec-approval allowlist.
allowed_csv = os.getenv("SLACK_ALLOWED_USERS", "").strip()
allowed_csv = "" # Interactive auth already ran above.
if allowed_csv:
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
if "*" not in allowed_ids and user_id not in allowed_ids:
@@ -2917,10 +3008,21 @@ class SlackAdapter(BasePlatformAdapter):
user_name = body.get("user", {}).get("name", "unknown")
user_id = body.get("user", {}).get("id", "")
if not self._is_interactive_user_authorized(
user_id,
channel_id=channel_id,
user_name=user_name,
):
logger.warning(
"[Slack] Unauthorized approval click by %s (%s) - ignoring",
user_name, user_id,
)
return
# Only authorized users may click approval buttons. Button clicks
# bypass the normal message auth flow in gateway/run.py, so we must
# check here as well.
allowed_csv = os.getenv("SLACK_ALLOWED_USERS", "").strip()
allowed_csv = "" # Interactive auth already ran above.
if allowed_csv:
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
if "*" not in allowed_ids and user_id not in allowed_ids:
+8 -1
View File
@@ -344,6 +344,7 @@ class TelegramAdapter(BasePlatformAdapter):
# Telegram message limits
MAX_MESSAGE_LENGTH = 4096
supports_code_blocks = True # Telegram MarkdownV2 renders fenced code blocks
# Threshold for detecting Telegram client-side message splits.
# When a chunk is near this limit, a continuation is almost certain.
_SPLIT_THRESHOLD = 4000
@@ -1142,7 +1143,13 @@ class TelegramAdapter(BasePlatformAdapter):
# gateway process is alive and reports "connected" but
# no messages are received or sent.
if self._polling_conflict_count < MAX_CONFLICT_RETRIES:
loop = asyncio.get_event_loop()
# We are inside a running coroutine, so the running loop is
# guaranteed to exist. asyncio.get_event_loop() is deprecated
# and raises "RuntimeError: There is no current event loop in
# thread 'MainThread'" on Python 3.10+ when invoked from a
# context without an attached loop (which can happen when PTB
# dispatches this error callback). Use get_running_loop().
loop = asyncio.get_running_loop()
self._polling_error_task = loop.create_task(
self._handle_polling_conflict(retry_err)
)
+113 -2
View File
@@ -1138,6 +1138,8 @@ async def qr_login(
class WeixinAdapter(BasePlatformAdapter):
"""Native Hermes adapter for Weixin personal accounts."""
supports_code_blocks = True # Weixin renders fenced code blocks
MAX_MESSAGE_LENGTH = 2000
# WeChat does not support editing sent messages — streaming must use the
@@ -1172,6 +1174,24 @@ class WeixinAdapter(BasePlatformAdapter):
extra.get("send_chunk_retry_delay_seconds")
or os.getenv("WEIXIN_SEND_CHUNK_RETRY_DELAY_SECONDS", "1.0")
)
self._send_text_gate = asyncio.Lock()
self._rate_limit_circuit_threshold = max(
1,
int(
extra.get("rate_limit_circuit_threshold")
or os.getenv("WEIXIN_RATE_LIMIT_CIRCUIT_THRESHOLD", "1")
),
)
self._rate_limit_circuit_window_seconds = float(
extra.get("rate_limit_circuit_window_seconds")
or os.getenv("WEIXIN_RATE_LIMIT_CIRCUIT_WINDOW_SECONDS", "30.0")
)
self._rate_limit_circuit_open_seconds = float(
extra.get("rate_limit_circuit_open_seconds")
or os.getenv("WEIXIN_RATE_LIMIT_CIRCUIT_OPEN_SECONDS", "30.0")
)
self._rate_limit_circuit_until = 0.0
self._rate_limit_events: List[float] = []
self._dm_policy = str(extra.get("dm_policy") or os.getenv("WEIXIN_DM_POLICY", "open")).strip().lower()
self._group_policy = str(extra.get("group_policy") or os.getenv("WEIXIN_GROUP_POLICY", "disabled")).strip().lower()
allow_from = extra.get("allow_from")
@@ -1645,6 +1665,37 @@ class WeixinAdapter(BasePlatformAdapter):
content, self.MAX_MESSAGE_LENGTH, self._split_multiline_messages,
)
def _rate_limit_cooldown_remaining(self) -> float:
return max(0.0, self._rate_limit_circuit_until - time.monotonic())
def _rate_limit_error(self) -> RuntimeError:
return RuntimeError(
f"iLink sendmessage rate limited; cooldown active for {self._rate_limit_cooldown_remaining():.1f}s"
)
def _open_rate_limit_circuit(self) -> None:
if self._rate_limit_circuit_open_seconds <= 0:
return
self._rate_limit_circuit_until = max(
self._rate_limit_circuit_until,
time.monotonic() + self._rate_limit_circuit_open_seconds,
)
def _record_rate_limit_event(self) -> bool:
"""Record a genuine iLink rate limit and return True if breaker opened."""
now = time.monotonic()
window_start = now - self._rate_limit_circuit_window_seconds
self._rate_limit_events = [ts for ts in self._rate_limit_events if ts >= window_start]
self._rate_limit_events.append(now)
if len(self._rate_limit_events) >= self._rate_limit_circuit_threshold:
self._open_rate_limit_circuit()
return self._rate_limit_cooldown_remaining() > 0
return False
def _reset_rate_limit_circuit(self) -> None:
self._rate_limit_events.clear()
self._rate_limit_circuit_until = 0.0
async def _send_text_chunk(
self,
*,
@@ -1660,9 +1711,28 @@ class WeixinAdapter(BasePlatformAdapter):
degraded fallback, which keeps cron-initiated push messages working
even when no user message has refreshed the session recently.
"""
async with self._send_text_gate:
await self._send_text_chunk_locked(
chat_id=chat_id,
chunk=chunk,
context_token=context_token,
client_id=client_id,
)
async def _send_text_chunk_locked(
self,
*,
chat_id: str,
chunk: str,
context_token: Optional[str],
client_id: str,
) -> None:
"""Send a text chunk while holding the adapter-wide outbound text gate."""
last_error: Optional[Exception] = None
retried_without_token = False
for attempt in range(self._send_chunk_retries + 1):
if self._rate_limit_cooldown_remaining() > 0:
raise self._rate_limit_error()
try:
resp = await _send_message(
self._send_session,
@@ -1708,6 +1778,9 @@ class WeixinAdapter(BasePlatformAdapter):
last_error = RuntimeError(
f"iLink sendmessage rate limited: ret={ret} errcode={errcode} errmsg={errmsg}"
)
if self._record_rate_limit_event():
last_error = self._rate_limit_error()
break
if attempt >= self._send_chunk_retries:
break
wait = self._send_chunk_retry_delay_seconds * 3 # 3x backoff for rate limit
@@ -1721,6 +1794,7 @@ class WeixinAdapter(BasePlatformAdapter):
raise RuntimeError(
f"iLink sendmessage error: ret={ret} errcode={errcode} errmsg={errmsg}"
)
self._reset_rate_limit_circuit()
return
except Exception as exc:
last_error = exc
@@ -1808,10 +1882,47 @@ class WeixinAdapter(BasePlatformAdapter):
logger.error("[%s] send failed to=%s: %s", self.name, _safe_id(chat_id), exc)
return SendResult(success=False, error=str(exc))
async def _ensure_typing_ticket(self, chat_id: str) -> Optional[str]:
"""Return a valid typing ticket, refreshing from getConfig if expired.
The iLink typing ticket has a 600-second TTL. When a long-running
session exceeds that window the cached ticket evicts, and both
``send_typing`` and ``stop_typing`` silently no-op leaving the
WeChat client stuck showing the typing indicator forever. This
method transparently refreshes the ticket so the stop signal can
always be delivered.
"""
ticket = self._typing_cache.get(chat_id)
if ticket:
return ticket
if not self._send_session or not self._token:
return None
# Ticket expired or never fetched — refresh via getConfig.
# Use the most recent context_token for this peer if available.
context_token = self._token_store.get(self._account_id, chat_id)
try:
response = await _get_config(
self._send_session,
base_url=self._base_url,
token=self._token,
user_id=chat_id,
context_token=context_token,
)
typing_ticket = str(response.get("typing_ticket") or "")
if typing_ticket:
self._typing_cache.set(chat_id, typing_ticket)
return typing_ticket
except Exception as exc:
logger.debug(
"[%s] typing ticket refresh failed for %s: %s",
self.name, _safe_id(chat_id), exc,
)
return None
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
if not self._send_session or not self._token:
return
typing_ticket = self._typing_cache.get(chat_id)
typing_ticket = await self._ensure_typing_ticket(chat_id)
if not typing_ticket:
return
try:
@@ -1829,7 +1940,7 @@ class WeixinAdapter(BasePlatformAdapter):
async def stop_typing(self, chat_id: str) -> None:
if not self._send_session or not self._token:
return
typing_ticket = self._typing_cache.get(chat_id)
typing_ticket = await self._ensure_typing_ticket(chat_id)
if not typing_ticket:
return
try:
+1
View File
@@ -242,6 +242,7 @@ class WhatsAppAdapter(BasePlatformAdapter):
# WhatsApp message limits — practical UX limit, not protocol max.
# WhatsApp allows ~65K but long messages are unreadable on mobile.
MAX_MESSAGE_LENGTH = 4096
supports_code_blocks = True # WhatsApp renders fenced code blocks (monospace)
DEFAULT_REPLY_PREFIX = "⚕ *Hermes Agent*\n────────────\n"
# Default bridge location relative to the hermes-agent install
+22 -2
View File
@@ -120,6 +120,16 @@ AUTH_TIMEOUT_SECONDS = 10.0
MAX_RECONNECT_ATTEMPTS = 100
DEFAULT_SEND_TIMEOUT = 30.0 # WS biz request timeout
# Upper bound on the WS close handshake during teardown (#40383). The
# websockets connection's own close_timeout (5s) blocks until the server
# echoes the close frame; an idle/unresponsive server never replies, stalling
# gateway shutdown by the full timeout. Bounding the close await here keeps
# teardown fast — a responsive server completes the handshake in well under a
# second, so this only caps the pathological hang. Also bounds the reconnect /
# connect-failure cleanup paths that reuse _cleanup_ws(), where a graceful
# close is unnecessary anyway (the socket is being discarded to redial).
WS_CLOSE_TIMEOUT_S = 1.0
# Close codes that indicate permanent errors — do NOT reconnect.
NO_RECONNECT_CLOSE_CODES = {4012, 4013, 4014, 4018, 4019, 4021}
@@ -3445,12 +3455,22 @@ class ConnectionManager:
return False
async def _cleanup_ws(self) -> None:
"""Close and clear the WebSocket connection."""
"""Close and clear the WebSocket connection, bounded by
``WS_CLOSE_TIMEOUT_S`` so an unresponsive server can't stall teardown
(see the constant's definition for the full rationale)."""
ws = self._ws
self._ws = None
if ws is not None:
try:
await ws.close()
await asyncio.wait_for(ws.close(), timeout=WS_CLOSE_TIMEOUT_S)
except asyncio.TimeoutError:
# Server never echoed the close frame within the bound; drop the
# connection. websockets force-closes the transport on cancel,
# and at shutdown the loop is tearing down anyway.
logger.debug(
"[%s] WS close handshake exceeded %.1fs — dropping connection",
self._adapter.name, WS_CLOSE_TIMEOUT_S,
)
except Exception:
pass
+244 -4334
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -635,6 +635,22 @@ def build_session_key(
if source.thread_id:
return f"agent:main:{platform}:dm:{dm_chat_id}:{source.thread_id}"
return f"agent:main:{platform}:dm:{dm_chat_id}"
# No chat_id — fall back to the sender's own identifier before the
# bare per-platform sink. Without this, every DM from every user that
# arrives without a chat_id (non-standard adapters / synthetic sources)
# collapses into one shared "agent:main:<platform>:dm" session, and a
# single cached agent ends up serving multiple people's conversations —
# cross-user history bleed. participant_id keeps DMs isolated per user.
dm_participant_id = source.user_id_alt or source.user_id
if dm_participant_id and source.platform == Platform.WHATSAPP:
dm_participant_id = (
canonical_whatsapp_identifier(str(dm_participant_id))
or dm_participant_id
)
if dm_participant_id:
if source.thread_id:
return f"agent:main:{platform}:dm:{dm_participant_id}:{source.thread_id}"
return f"agent:main:{platform}:dm:{dm_participant_id}"
if source.thread_id:
return f"agent:main:{platform}:dm:{source.thread_id}"
return f"agent:main:{platform}:dm"
File diff suppressed because it is too large Load Diff
+31
View File
@@ -35,6 +35,7 @@ __all__ = [
"IS_WINDOWS",
"resolve_node_command",
"windows_detach_flags",
"windows_detach_flags_without_breakaway",
"windows_hide_flags",
"windows_detach_popen_kwargs",
]
@@ -152,6 +153,36 @@ def windows_detach_flags() -> int:
)
def windows_detach_flags_without_breakaway() -> int:
"""Same as :func:`windows_detach_flags` minus ``CREATE_BREAKAWAY_FROM_JOB``.
The docstring on :func:`windows_detach_flags` notes that a process in
a job which disallows breakaway (no ``JOB_OBJECT_LIMIT_BREAKAWAY_OK``)
will see ``ERROR_ACCESS_DENIED`` from CreateProcess, surfacing as
``OSError`` (``PermissionError``) on the ``subprocess.Popen`` call.
Callers that want to recover by retrying without the breakaway
bit can pair the two helpers symbolically rather than coding the
``& ~0x01000000`` magic at every site:
.. code-block:: python
try:
subprocess.Popen(argv, creationflags=windows_detach_flags(), )
except OSError:
subprocess.Popen(
argv,
creationflags=windows_detach_flags_without_breakaway(),
,
)
See ``gateway_windows.py::_spawn_detached`` for the canonical
implementation of this pattern. Returns 0 on non-Windows.
"""
if not IS_WINDOWS:
return 0
return _CREATE_NEW_PROCESS_GROUP | _DETACHED_PROCESS | _CREATE_NO_WINDOW
def windows_hide_flags() -> int:
"""Return Win32 creationflags that merely hide the child's console
window without detaching the child. 0 on non-Windows.
File diff suppressed because it is too large Load Diff
+25 -1
View File
@@ -1139,6 +1139,16 @@ DEFAULT_CONFIG = {
# Default False matches historical behavior; set to
# True if you'd rather pause than silently lose
# context turns when your aux model is flaky.
"codex_gpt55_autoraise": True, # When True, gpt-5.5 on the ChatGPT Codex OAuth
# route raises its compaction trigger to 85% (vs the
# global `threshold` above). Codex hard-caps gpt-5.5
# at a 272K window, so the default 50% would compact
# at ~136K and waste half the usable context. Set to
# False to opt back down to the global threshold
# (e.g. 0.50) for Codex gpt-5.5 sessions. Only this
# exact route is affected — gpt-5.5 on OpenAI's
# direct API, OpenRouter, and Copilot keep the
# global threshold regardless.
},
# Anthropic prompt caching (Claude via OpenRouter or native Anthropic API).
@@ -2258,6 +2268,12 @@ DEFAULT_CONFIG = {
# never fires again. Users can wipe the section to re-see all hints.
"onboarding": {
"seen": {},
# Structured profile-build path offered on the very first gateway
# message ever. "ask" (default) -> offer to build a user profile
# (opt-in, consent-gated; the agent asks before any lookup and never
# reads connected accounts silently). "off" -> plain intro only.
# The offer fires at most once (latched under onboarding.seen).
"profile_build": "ask",
},
# ``hermes update`` behaviour.
@@ -2420,7 +2436,7 @@ DEFAULT_CONFIG = {
# Config schema version - bump this when adding new required fields
"_config_version": 27,
"_config_version": 28,
}
# =============================================================================
@@ -2916,6 +2932,14 @@ OPTIONAL_ENV_VARS = {
"password": True,
"category": "tool",
},
"APIFY_API_TOKEN": {
"description": "Apify API token for Actor execution tools (apify_discover, apify_start, apify_collect)",
"prompt": "Apify API token",
"url": "https://apify.com/account/integrations",
"tools": ["apify_discover", "apify_start", "apify_collect"],
"password": True,
"category": "tool",
},
"SEARXNG_URL": {
"description": "URL of your SearXNG instance for free self-hosted web search",
"prompt": "SearXNG URL (e.g. http://localhost:8080)",
+9 -1
View File
@@ -2040,7 +2040,15 @@ def run_doctor(args):
_honcho_cfg_path = resolve_config_path()
if not _honcho_cfg_path.exists():
check_warn("Honcho config not found", "run: hermes memory setup")
# Config file missing — but env var fallback may have resolved it.
# Only warn if the config didn't actually resolve from env vars.
if hcfg.api_key or hcfg.base_url:
check_ok(
"Honcho configured via environment variables",
f"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var",
)
else:
check_warn("Honcho config not found", "run: hermes memory setup")
elif not hcfg.enabled:
check_info(f"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)")
elif not (hcfg.api_key or hcfg.base_url):
+81 -21
View File
@@ -641,7 +641,10 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
#
# ``windows_detach_popen_kwargs()`` returns the right kwargs for the
# host platform and is a no-op on POSIX (just ``start_new_session=True``).
from hermes_cli._subprocess_compat import windows_detach_popen_kwargs
from hermes_cli._subprocess_compat import (
windows_detach_flags_without_breakaway,
windows_detach_popen_kwargs,
)
watcher = textwrap.dedent(
"""
@@ -677,35 +680,66 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
_DETACHED_PROCESS = 0x00000008
_CREATE_NO_WINDOW = 0x08000000
_CREATE_BREAKAWAY_FROM_JOB = 0x01000000
_popen_kwargs["creationflags"] = (
_flags = (
_CREATE_NEW_PROCESS_GROUP
| _DETACHED_PROCESS
| _CREATE_NO_WINDOW
| _CREATE_BREAKAWAY_FROM_JOB
)
try:
_popen_kwargs["creationflags"] = _flags
subprocess.Popen(cmd, **_popen_kwargs)
except OSError:
# CREATE_BREAKAWAY_FROM_JOB can be rejected with
# ERROR_ACCESS_DENIED when the parent's job object refuses
# breakaway. Retry without it — DETACHED_PROCESS et al.
# alone are enough in most setups. Mirrors the canonical
# fallback in gateway_windows._spawn_detached.
_popen_kwargs["creationflags"] = _flags & ~_CREATE_BREAKAWAY_FROM_JOB
subprocess.Popen(cmd, **_popen_kwargs)
else:
_popen_kwargs["start_new_session"] = True
subprocess.Popen(cmd, **_popen_kwargs)
subprocess.Popen(cmd, **_popen_kwargs)
"""
).strip()
watcher_argv = [
sys.executable,
"-c",
watcher,
str(old_pid),
*_gateway_run_args_for_profile(profile),
]
# Same platform-aware detach for the watcher process itself — so
# closing the user's terminal doesn't kill the watcher.
try:
# Same platform-aware detach for the watcher process itself — so
# closing the user's terminal doesn't kill the watcher.
subprocess.Popen(
[
sys.executable,
"-c",
watcher,
str(old_pid),
*_gateway_run_args_for_profile(profile),
],
watcher_argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
**windows_detach_popen_kwargs(),
)
except OSError:
return False
# CREATE_BREAKAWAY_FROM_JOB rejected by the parent job object
# (Electron, Windows Terminal with restrictive job settings, …).
# Retry without it. POSIX never reaches this branch — there
# ``start_new_session=True`` cannot raise OSError — so the
# fallback is only meaningful on Windows.
try:
fallback_kwargs: dict = (
{"creationflags": windows_detach_flags_without_breakaway()}
if sys.platform == "win32"
else {"start_new_session": True}
)
subprocess.Popen(
watcher_argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
**fallback_kwargs,
)
except OSError:
return False
return True
@@ -2375,7 +2409,7 @@ StartLimitIntervalSec=0
Type=simple
User={username}
Group={group_name}
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run
WorkingDirectory={working_dir}
Environment="HOME={home_dir}"
Environment="USER={username}"
@@ -2385,8 +2419,6 @@ Environment="VIRTUAL_ENV={venv_dir}"
Environment="HERMES_HOME={hermes_home}"
Restart=always
RestartSec=5
RestartMaxDelaySec=300
RestartSteps=5
RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}
KillMode=mixed
KillSignal=SIGTERM
@@ -2413,15 +2445,13 @@ StartLimitIntervalSec=0
[Service]
Type=simple
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace
ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run
WorkingDirectory={working_dir}
Environment="PATH={sane_path}"
Environment="VIRTUAL_ENV={venv_dir}"
Environment="HERMES_HOME={hermes_home}"
Restart=always
RestartSec=5
RestartMaxDelaySec=300
RestartSteps=5
RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}
KillMode=mixed
KillSignal=SIGTERM
@@ -2439,6 +2469,29 @@ def _normalize_service_definition(text: str) -> str:
return "\n".join(line.rstrip() for line in text.strip().splitlines())
# Directives that older systemd versions silently ignore/strip. Normalize
# them out of stale-check comparisons so a unit that differs only by these
# directives is not perpetually flagged as outdated.
_SYSTEMD_OPTIONAL_DIRECTIVES = (
"RestartMaxDelaySec",
"RestartSteps",
)
def _strip_optional_systemd_directives(text: str) -> str:
"""Remove systemd directives that older hosts silently drop."""
lines = text.splitlines()
filtered = []
for line in lines:
stripped = line.strip()
if stripped and not stripped.startswith("#"):
key = stripped.split("=", 1)[0].strip()
if key in _SYSTEMD_OPTIONAL_DIRECTIVES:
continue
filtered.append(line)
return "\n".join(filtered)
def _normalize_launchd_plist_for_comparison(text: str) -> str:
"""Normalize launchd plist text for staleness checks.
@@ -2466,9 +2519,16 @@ def systemd_unit_is_current(system: bool = False) -> bool:
installed = unit_path.read_text(encoding="utf-8")
expected_user = _read_systemd_user_from_unit(unit_path) if system else None
expected = generate_systemd_unit(system=system, run_as_user=expected_user)
return _normalize_service_definition(installed) == _normalize_service_definition(
expected
# Normalize out directives that older systemd versions silently drop
# (RestartMaxDelaySec, RestartSteps) so a unit that differs only by
# those directives is not perpetually flagged as outdated.
norm_installed = _normalize_service_definition(
_strip_optional_systemd_directives(installed)
)
norm_expected = _normalize_service_definition(
_strip_optional_systemd_directives(expected)
)
return norm_installed == norm_expected
def refresh_systemd_unit_if_needed(system: bool = False) -> bool:

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