Commit Graph

22937 Commits

Author SHA1 Message Date
Teknium 7a16840add fix(compression): couple pruned-skill reload instruction to the preserved todo snapshot
Compaction re-injects the todo list verbatim (TODO_INJECTION_HEADER +
TodoStore.format_for_injection) while skill instructions are pruned down
to [SKILL_PRUNED: ...] markers — the imperative crosses the boundary
without the policy that governed it, and the agent keeps executing
preserved tasks with the guidance deleted (#84718's T6 pattern).

Close the retention asymmetry at the injection site: when the compressed
transcript carries [SKILL_PRUNED: ...] markers AND a todo snapshot is
being re-injected, append a bounded reload notice to the snapshot naming
each pruned skill with its exact skill_view() reload call, plus a
one-line instruction to re-check that preserved tasks are still
justified. Skill guidance recovery now travels in the SAME boundary
artifact as the imperative — same message, same stale-snapshot strip
lifecycle, so repeated compactions refresh rather than accumulate.

Properties:
- deterministic: derived only from the compressed transcript (same input,
  same bytes) — no per-turn nondeterminism in the rebuilt prompt
- zero recurring cost when nothing was pruned (clean sessions unchanged)
- bounded: shares _MAX_PRUNED_SKILL_MARKERS with the summary re-injection
  cap; the notice text never contains the canonical marker prefix, so it
  can never feed the marker extractor at the next boundary
- rides after TODO_INJECTION_HEADER, so _strip_stale_todo_snapshot
  removes snapshot + notice together and the synthetic-row classifier
  (_is_synthetic_compression_user_turn) is unaffected

Tests: tests/agent/test_skill_todo_retention_parity.py — unit contract of
the notice builder (naming, dedup/order, cap, determinism, no marker
self-feed) and behavioral compaction runs through the real
_compress_context path (notice travels with the snapshot, absent when
nothing pruned, synthetic-row classification unbroken, strip lifecycle
across repeated boundaries). Sabotage-verified: disabling the append
flips the 3 behavioral tests red.

Part of #84718
2026-08-15 02:45:50 -07:00
Teknium 8fdfe04371 fix(update): probe gateway loop liveness before drain; bounded escalation for wedged gateways
A gateway whose asyncio event loop is stalled (e.g. an in-loop
compression pass, #72707) cannot process SIGTERM/SIGUSR1 shutdown.
The updater's drain wait then burned the full 180s budget, warned
"Gateway PID X still running after 180.0s — restart may fail", and
`hermes update` could deadlock behind the wedged process — the user
cannot update their way out of the stall.

Fix: before any drain wait, read the loop-liveness heartbeat file the
gateway rewrites every 30s (#66892). Classification:

- alive (fresh heartbeat): busy-but-alive loop — take the normal
  graceful drain, honoring the in-flight cron drain floor (#86684).
- wedged (heartbeat for this PID stale >90s = 3 missed beats): the
  loop is provably dead; drain is pointless. Bounded escalation:
  SIGTERM + 5s grace, then SIGKILL + 5s wait, then proceed (~10s
  worst case, far under the 180s drain budget).
- unknown (missing/corrupt file, PID mismatch): never escalate on
  ambiguity — full drain path.

Wired into launchd_restart, systemd_restart, and both updater
gateway-shutdown sites (systemd unit drain + manual profile
gateways). The probe is a local stat + JSON read (well inside the
10s query tier of the subprocess timeout tiering).

The cron drain floor from #86684 is bypassed ONLY when the loop is
provably dead — a merely busy gateway still refreshes its heartbeat
and keeps the full drain budget.

Root cause of the loop stall itself (compression blocking the loop)
is #72707 territory and deliberately out of scope here.

Fixes #81642
2026-08-15 02:45:27 -07:00
Teknium be083358b2 feat(tui_gateway): INFO 'prompt accepted / turn finished' records on the Desktop/TUI turn path
Part of #86647.

During the #79278 persistent-mute investigation the decisive evidence was
an absence: a Desktop request left no INFO record in agent.log OR
gateway.log ("832 platform=webhook, 194 platform=telegram, 0
platform=desktop"), so the muted 13:15-13:19 window — 12 non-idempotent
Qdrant snapshots, zero results returned — was structurally
indistinguishable from a request that never arrived. The issue calls out
fixing this observability gap as the first actionable step.

This adds the two INFO records to _run_prompt_submit, the single choke
point every Desktop/TUI turn passes through (user submits, queued
prompts, auto-continue, goal follow-ups, watch upgrades):

- "tui prompt accepted": emitted before the turn thread starts, carrying
  the UI session id, the gateway session_key, and the agent's live
  session_id — the id triple a rotation-mute trace needs, since
  compression rotates agent.session_id independently of the other two.
  No prompt content is logged (length only).
- "tui turn finished": emitted in the turn's finally on every path
  (success, returned error, exception, interrupt), re-reading
  agent.session_id so a mid-turn compression rotation shows up as an
  accepted/finished pair with different agent ids. A missing finished
  record now positively identifies a turn thread that died before its
  finally.

Placement follows @Adolanium's note on the issue: in _run_prompt_submit,
logging sid + session_key + agent.session_id, NOT another platform= line
in gateway.log (Desktop does not use the messaging gateway).

tui_gateway is under COMPONENT_PREFIXES["gui"], so the records land in
agent.log (root catch-all) and gui.log when running under the dashboard.

Tests (tests/tui_gateway/test_prompt_accept_logging.py): accepted+finished
pair on success with the full id triple and no prompt content leaked;
mid-turn rotation visible as differing agent ids across the pair;
finished record fires on the exception path and the returned-error path.
Sabotage-verified: removing the accepted record fails the suite.
2026-08-15 02:45:03 -07:00
Teknium e729055a56 test(delegate): regression for #86632 — cron sync fallback must return after child completes
End-to-end #86632 reproduction: a real AIAgent child (mocked LLM) with the
post-turn skill-review trigger armed, dispatched through
delegate_task(background=True) on a session runtime where async delivery is
unsupported and no origin session id is bound (cron, post-#66617) — forcing
the synchronous fallback. Asserts (1) delegate_task returns the child's
result, and (2) the automatic background-review fork never spawns inside the
delegated child (the wedge site: the fork replayed the conversation on the
child's finalize path, and _child_future.result(timeout=None) never returned;
heartbeat went stale after 15 idle cycles and the cron watchdog killed the
job).

Verified RED on pre-fix main (fork spawns and wedges), GREEN with the
_delegate_depth guard in AIAgent._spawn_background_review.

Fixes #86632
2026-08-15 02:44:38 -07:00
PRATHAMESH75 ece3bc7d52 fix(agent): skip the automatic background review inside delegation subagents
The post-turn background review fork (`agent/background_review.py`) inherits
the parent agent's live runtime by default. That is a cost win when the parent
IS the main chat model (warm prompt cache, cheap), but the fork also fires
inside delegation subagents, where it inherits the *subagent's* model. When a
subagent runs a premium delegation model, the review silently replays the whole
conversation and emits skill/memory-update output at premium rates, with
nothing in the log or config flagging it (#85859).

Subagents are already barred from writing shared MEMORY.md
(`DELEGATE_BLOCKED_TOOLS`) and are spawned with `skip_memory=True`, so an
automatic review here has little to persist. Guard `_spawn_background_review`
(the single choke point both the turn-finalizer and codex-runtime callers pass
through) to return early when `_delegate_depth > 0`. An explicit `/refine`
(`focus` set) is a deliberate user request and still runs; the top-level path
is unchanged.

Fixes #85859
2026-08-15 02:44:38 -07:00
Teknium 0fc2a10d82 fix(cron): stop one-shot CLI cron run from orphaning the job; reap dead-owner claims on tick
`hermes cron run <job_id>` from a one-shot CLI invocation could
background-dispatch the run onto a daemon thread of the calling process
(when the CLI inherited a gateway/desktop session env and resolved a
session key). The CLI printed "Triggered job: ..." and exited instantly,
killing the runner mid-LLM-call: the async delegation died with
state='unknown' and the job's row in cron/executions.db stayed
status='claimed' forever, blocking every subsequent run of that job.

Two-part fix:

1. hermes_cli/cron.py: `_job_action("run", ...)` declares the delivery
   channel stateless (scoped ContextVar set/reset around the call) before
   invoking the cron API, so `async_delivery_supported()` gates off
   `_try_dispatch_background_run` and the run executes synchronously to
   completion in the CLI process — the same behavior `hermes -z` already
   gets via declare_stateless_channel().

2. cron/scheduler.py: tick() now periodically invokes
   recover_interrupted_executions() (previously only run at scheduler
   startup), so execution rows whose exact owner process is provably dead
   (pid + process start time check in _owner_is_live) are reaped to
   'unknown' by the long-lived gateway ticker without a restart.
   Throttled to once per 300s so idle 60s ticks don't pay a ledger
   connection every cycle.

Tests: tests/cron/test_dead_owner_claim_reclaim.py covers the dead-owner
reap (real dead pid via a finished subprocess), live-owner rows surviving
the reap, throttle behavior, reap-failure isolation, the CLI stateless
gate (including restoration after the call), and the end-to-end refusal
of background dispatch under a stateless channel.

Fixes #86721
2026-08-15 02:44:14 -07:00
hermes-seaeye[bot] 5b05e51f2e fmt(js): npm run fix on merge (#86876)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-15 09:33:47 +00:00
Chen Jin 0d1828294a fix(desktop): re-target find scope on a keep-alive surface flip (#81726)
The scoped find captures the foreground chat surface at bar-open time and
marks it `data-find-root`. A keep-alive tab flip — clicking another session
tab in the same stack — hides that surface with `data-pane-hidden` and
activates an unmarked one, without a pathname change, so the FindBar (which
only closes on a route change) stays open. Every subsequent query then
resolved no visible `data-find-root`, reporting 0/0 while the surface on
screen contained the text; Cmd+G / Cmd+Shift+G became no-ops.

currentFindScope now re-targets when every marked root is hidden: it
re-resolves to the foreground chat surface, clears the old surface's
highlights and marker (so they do not resurface when its tab is revisited),
stamps `data-find-root` on the new surface, and re-arms the re-render
watcher there. The normal case is unchanged — the scope stays on the
captured surface while it remains visible. Also restores the missing
trailing newline in store/find-in-page.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:28:07 -07:00
Chen Jin 041cbb5e48 style(desktop): add missing trailing newlines to find-in-page files (#81726)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:28:07 -07:00
Chen Jin c856c5f6db fix(desktop): keep find-in-page highlights consistent across React re-renders (#81726)
The scoped find walker wraps transcript text nodes in <mark> elements that
React does not own. Assistant responses stream through markdown-text.tsx,
which rebuilds the markdown DOM on every delta, and a new message is
appended whenever the assistant answers — so a re-render of a changed
region detaches the marks we inserted, dropping the user's highlights while
the bar stays open.

Watch the captured scope with a MutationObserver and re-wrap only when an
unmarked occurrence of the active query actually reappears. The observer is
gated behind a re-entrancy flag while the walker is mutating, coalesced to
one re-apply per microtask, torn down when the bar closes or the query
clears, and restores the active ordinal so a mid-stream re-render doesn't
reset the user's place to match #1. An append that adds no matching text is
a no-op; re-wrapping only fires when highlights genuinely went stale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:28:07 -07:00
Chen Jin 4e86caa1e2 fix(desktop): re-wrap on findNext when stale marks no longer cover all matches (#81778)
The fast path trusted that marks matching the query case-insensitively
implied the set still covered every match. A stale mark from an earlier
query (raced re-wrap, external DOM writes) can pass that per-mark
comparison while live occurrences stay unwrapped — stepping then walks
old highlights and the new matches never light up. Gate the fast path on
a read-only coverage check (same skip rules as the walker) so any
unmarked occurrence forces a re-wrap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:28:07 -07:00
Chen Jin 0ea7349336 fix(desktop): case-insensitive fast-path in find-in-page stepping (#81778)
Triage found the findNext fast path compared each existing `<mark>`'s
textContent byte-for-byte against the typed query. Since highlightMatches
stores the ORIGINAL-CASE source slice, the first differently-cased match
('Hermes' for 'hermes', sentence-initial capitals, ALL-CAPS) failed the
check, forcing a full re-wrap on every Enter/⌘G: fresh `<mark>` elements
lose `data-find-active`, `previousActive` resolves null, and the active
ordinal stays 1 forever (backward steps land on the last match every
time).

Compare case-insensitively. Regression test pins the differently-cased
stepping behavior: same marks survive, count stays 2, ordinal advances
to 2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:28:07 -07:00
Chen Jin 7ebef79c65 fix(desktop): resume sibling traversal after a fully-consumed text node match
Team review (#81778) found the walker terminated sibling traversal when a
text node was entirely consumed by a match: `current = textNode ? ... :
null` — after replaceChild detaches the original node, its `.nextSibling`
reads null, so `<div>needle<span>needle</span></div>` searching "needle"
matched only the first occurrence. Any JSX bare-text + element sibling
pattern (tool rows, attachment rows) could silently drop later matches.

Capture the text node's own `nextSibling` BEFORE the first replaceChild
and resume the outer walker from it on full consumption. The `after`
split path is unaffected (the trailing text node is re-scanned).

Regression test pins `<div>needle<span>needle</span></div><p>needle</p>`
→ 3 matches. 17/17 scope tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:28:07 -07:00
Chen Jin ab3b7394b1 fix(desktop): scope Ctrl+F find to the current chat view (#81726)
Pressing Cmd/Ctrl+F in Hermes Desktop searched the entire webContents,
so every keep-alive chat surface matched — a user in chat A got hits from
chat B, every background tile, and every other pane rendering a transcript.
That's not what users expect from in-page find; it's a global search, and
it deserves its own shortcut.

Replace `webContents.findInPage` (whole-document, unscoped) with a
renderer-side DOM walker that captures the active chat surface at
bar-open time and only walks text nodes inside that subtree. The Electron
bridge is retained for secondary session windows (each window still
searches its own webContents); the primary window no longer drives the
bridge.

Scope decision. "Current view" is the foreground `[data-chat-surface]`
filtered through the existing pane-visibility helper, so an inactive
keep-alive tab can't accidentally answer the lookup. The scope is
captured once when the bar opens, not re-resolved on every keystroke,
so a mid-search route change can't silently re-home the highlights — the
FindBar's route-change cleanup closes the bar first.

Walker. Walks text nodes in document order, splits nodes that span a
match boundary, wraps each match in `<mark class="find-hit">`, and tracks
the active match by a single `data-find-active` attribute. Step uses the
existing marks when the query has not changed (no DOM churn) and re-
wraps when it has. Closing the bar unwraps every mark and normalizes the
parent text, restoring the original DOM byte-for-byte.

Tests. New `find-in-page-scope.test.ts` covers the walker in isolation:
multi-match wrapping, case-insensitivity, cross-node splits, script/style
filtering, no-self-match against the search overlay, scope retarget on
surface swap, and unwrap-on-release. Rewrote the FindBar store/component
tests to assert against the real DOM (marks + counts) instead of the
bridge mock — keeping the bridge mock only for the listener-refcount
cases that still cover the secondary-window path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:28:07 -07:00
Teknium 8711f2c005 fix(update): make the self-lock deferral honest — fire only when the swap is at risk (#86735, #86780, #86781)
The #86687 self-lock preflight fired on every Windows `hermes update`:
bitwarden.py's module-level cryptography import (fixed in #86782 /
#86826-class change) meant cryptography._rust was ALWAYS mapped by the
time the preflight ran, so the update exited 2 before even fetching and
looped forever — including the Desktop in-app update (#86780).

Two structural fixes so the guard can never re-brick the flow it protects:

1. Version-gated detection: _detect_self_loaded_native_modules() now
   consults _dependency_sync_would_rewrite(dist) — installed version vs
   the on-disk pyproject pins (base deps + all extras, env markers
   honored). A loaded module whose distribution the sync will not touch
   is no lock risk and is not reported. Unknown → fail closed.

2. Relocated deferral: the check no longer runs pre-fetch. It runs via
   _abort_dependency_sync_if_self_locked() immediately before each venv
   rewrite (git-path dep sync, ZIP-path dep sync, current-checkout venv
   repair) — AFTER the code swap. A deferral now leaves the user on NEW
   code with only the dependency install pending (completed by the next
   launch's marker recovery), instead of stranding them on the old
   checkout in an exit-2 loop.

PyYAML's _yaml extension (loaded by every CLI process) joins the
registry — with version gating it is now safe to list.

Tests: version-gate unit coverage (no-change skip, stale pin, missing
dist, extras, markers, fail-closed None), deferral wiring (marker +
gateway resume + exit 2), placement guards (no detector call pre-fetch;
guard present at git/ZIP sync), and subprocess-verified import hygiene
(import hermes_cli.main and the update --check dispatch never load
cryptography._rust).

Follow-up to #86687 (Halldrix's #83590 salvage — the preflight's intent
stands as defence-in-depth; this makes it fire only when true).

Fixes #86735
Fixes #86780
Fixes #86781
2026-08-15 02:26:38 -07:00
Teknium 0d00ebef79 fix(state): bound the state.db repair loop and stop dead-backup accumulation (#86747)
A corruption class the repair strategies cannot heal (b-tree page
damage) failed repair_state_db_schema on every process start, forever:
_claim_repair_attempt's in-memory set only bounds one process, so each
restart re-ran the full surgery AND took a fresh ~900MB forensic backup
of the same damaged bytes — 105 attempts / 89GB of dead
state.db.malformed-backup-* files over 11 days in the reporting install.

Three bounded behaviors, all sidecar-file based (no schema changes):

1. Persistent attempt ledger (<db>.repair-attempts.json): after 3 failed
   repair passes against the same file fingerprint (size + mtime_ns),
   repair_state_db_schema refuses with a terminal, actionable error
   (restore a backup / `sqlite3 state.db ".recover"` / delete the ledger
   to force a retry) instead of re-running surgery. Success clears the
   ledger; a replaced or restored file re-keys it and gets fresh
   attempts. Missing/corrupt ledger fails open (never blocks a first
   repair).

2. Backup dedupe: _backup_db_file reuses the newest existing forensic
   backup when it is byte-identical to the damaged file (size+mtime
   match, preserved by copy2) instead of copying another ~900MB.

3. Retention cap: only the 3 newest malformed-backup copies (plus
   sidecars) are kept; older ones are pruned after each new backup.
   Also fixes a same-second timestamp collision that silently
   overwrote an earlier forensic copy.

Tests cover ledger accumulation, terminal refusal (surgery not called,
no new backup), budget reset on file change, success-clears-ledger,
corrupt-ledger tolerance, dedupe, distinct-state backups, retention
prune incl. sidecars, and the end-to-end one-backup invariant.

Fixes #86747
2026-08-15 02:22:58 -07:00
Jony ca8a47e276 fix(desktop): keep find bar out of its results 2026-08-15 02:20:23 -07:00
Teknium 62d3ef683c fix(tests): subprocess-surviving isolation marker closes the #82770 fixture escape
The hermetic conftest now exports HERMES_TEST_ISOLATION (value = the tmp
isolation root) before any test module imports, and re-pins it per test in
_hermetic_environment. hermes_state._running_under_pytest() honors the
marker as a test-context signal alongside PYTEST_CURRENT_TEST /
PYTEST_VERSION.

Why a third layer: PYTEST_* belongs to pytest, and tests that spawn
children routinely rebuild the child env and strip it ("the subprocess
must look like a real CLI" — tests/cli/test_exit_watchdog_signal_arm.py,
tests/hermes_cli/test_config_loader_e2e.py do exactly this on purpose).
Such a child loses the HERMES_HOME redirect and the guard's arming signal
in one step, which is how 700+ zero-message fixture rows (dm:123, chat-1,
wx-chat, ...) landed in a developer's production state.db. The marker is
OURS: stripping it is never required to make a child "look real" (no
production code branches on it except the guard), it inherits by default,
and children that genuinely need a real DB use the sanctioned
HERMES_STATE_DB_GUARD_BYPASS=1 hatch instead.

The ancestry-walk layer (previous commits) stays: it covers children whose
env was rebuilt from a completely empty dict. The marker layer covers the
common **os.environ-derived rebuilds cheaply (one dict lookup, no psutil),
and — unlike ancestry — also covers detached/daemonized children that
escape the process tree.

tests/hermes_state/test_isolation_marker_env.py pins: the conftest export,
the marker-alone arming, the rebuilt-env child refusing the production
path, and the bypass hatch. Sabotage-verified: 3/6 fail without the fix.
test_live_db_guard_ancestry._scrubbed_env now strips the marker too, so
the ancestry tests keep proving ancestry rather than riding the marker.
2026-08-15 02:20:13 -07:00
joaomarcos c0684e4223 fix(state): make the pytest-launcher match platform-independent
`_process_looks_like_pytest` used `os.path.basename`, which under Linux is
POSIX-only: a Windows-style argv token such as
`C:\venv\Scripts\pytest.exe` came back unchanged and never matched, so the
matcher's verdict depended on the host it ran on. A guard should answer the
same question the same way everywhere.

Split on both separators explicitly instead. This keeps the deliberate
false-positive protection — `/tmp/pytest-of-dev/...` still reduces to its
last segment, not to `pytest` — while removing the platform dependency.

Caught by CI: test_recognises_pytest_invocations[cmdline2] passed on Windows
and failed on Linux.
2026-08-15 02:20:13 -07:00
joaomarcos f5163bf83b fix(state): import Set for the routing-cleanup annotation
`_delete_routing_entries_for_sessions(session_ids: Set[str])` referenced a
name `hermes_state` never imported, so importing the module raised
`NameError: name 'Set' is not defined` and took down 11 of 12 CI test slices.

It passed locally because Python 3.14 evaluates annotations lazily (PEP 649)
and never touches the expression; CI runs an older interpreter, where the
annotation is evaluated at class-body execution.
2026-08-15 02:20:13 -07:00
joaomarcos 3f026fe210 test(state): assert the live-DB guard against the real platform root
`REAL_ROOT` hardcoded `Path.home() / ".hermes"`, but the guard resolves
`%LOCALAPPDATA%\hermes` on Windows. The paths under test were therefore
*correctly* classified as non-production, the guard never fired, and all
five TestProductionPathRefused cases failed for the wrong reason — the
guard was effectively unasserted on Windows.

Derive the root from `_real_platform_state_root()` — the same function the
guard uses — so the tests follow the implementation across platforms, and
build the unnormalized-spelling case from it instead of a second hardcoded
`~/.hermes`. Skips at module level if no platform root resolves.

Refs #82770
2026-08-15 02:20:13 -07:00
joaomarcos 174ce8770d fix(state): arm the live-DB guard by process ancestry, not env alone
Production `state.db` files accumulate zero-message "open" gateway session
rows carrying test-fixture identities (`chat-1` / `user-1` / `wx-chat`), with
matching `gateway_routing` scopes pointing at `pytest-of-*` temp directories.

The escape is structural. Hermetic isolation rides entirely on the process
environment: `HERMES_HOME` says *where* to write, `PYTEST_CURRENT_TEST` /
`PYTEST_VERSION` say *whether the guard is armed*. Both travel in the same
carrier, so a child spawned with a rebuilt environment loses them together —
it resolves the developer's real `state.db` *and* silences the only check
that would have stopped it, in one step. The guard is a no-op in precisely
the situation it was written for.

Back the env probe with process ancestry, which survives an env rebuild:

* `_process_looks_like_pytest()` matches a pytest launcher by argv token
  basename, so `/tmp/pytest-of-dev/...` paths in real argv cannot
  false-positive, and an unreadable process is never assumed to be a test.
* `_has_pytest_ancestor()` walks parents via psutil, memoised, and fails
  open when psutil is unavailable — a real `hermes` run pays for at most
  one walk and keeps the previous behaviour if the walk errors.
* `_in_test_context()` checks env first (two dict lookups, covers the
  in-process case) and only then ancestry.

`_STATE_DB_GUARD_BYPASS` is a module global and cannot cross a process
boundary, so ancestry-armed children would have had no way to opt out at
all; `HERMES_STATE_DB_GUARD_BYPASS=1` is the env-carried twin.

Also sweeps the rows already written. Bulk prune/archive cannot reach them:
their shared selector is pinned to `ended_at IS NOT NULL` so a live session
is never picked, which permanently excludes every never-closed row. Adds a
narrower selector — keyed, still open, and with no messages, tokens, tool
calls, API calls, activity or title — behind
`hermes sessions prune --never-active` (default floor 30 days, honours
--dry-run/--yes). Routing entries naming a deleted row go with it, so the
gateway is never left resuming a session id that no longer exists; `pinned`
and `archived` rows are excluded as explicit user intent.

Closes #82770
2026-08-15 02:20:13 -07:00
Teknium e0c73bfece Merge pull request #86847 from NousResearch/salvage/72959-titlebar-tool-count
fix(desktop): count all five static titlebar buttons; find-bar overlay guard (salvage #72959)
2026-08-15 02:14:49 -07:00
Teknium d8536709b7 fix(desktop): re-measure find-bar offset when the files pane toggles or resizes
The salvaged measurement effect only listened to window `resize`, so
opening, closing, or drag-resizing the files pane while the find bar was
up left the bar in a stale position (either still covering the pane's
header or floating mid-window after the pane closed).

Harden the measurement path:

- ResizeObserver on the aside tracks drag-resizes of the pane rail.
- A body-scoped childList MutationObserver catches the pane mounting or
  unmounting mid-find-session and retargets the ResizeObserver when the
  aside's identity changes (pane reopened, panes flipped).
- All triggers coalesce into a single rAF-batched measure; everything
  (observers, listener, pending frame) tears down when the bar closes.

Also extends find-bar.test.tsx with three positioning tests: default
right-4 when the pane is closed, parked left of an open pane, and
re-measuring when the pane opens/closes while the bar is up.
2026-08-15 02:06:37 -07:00
songoten eaef39df18 fix(desktop): position find bar clear of the files pane when open
The find bar is fixed to the top-right of the window, which overlays the
right sidebar's header and first file rows whenever the Files pane is
open. Measure the pane's live rect and park the bar just left of it;
fall back to the previous right-4 position when the pane is closed.

Closes #0
2026-08-15 02:06:37 -07:00
Teknium 279ec2f75b style(desktop): blank line before statement in open-find-bar release (lint) 2026-08-15 02:05:42 -07:00
Chen Jin f5e531ded0 fix(desktop): gate the Ctrl/Cmd+F main-process hook to Linux only (#81727)
The before-input-event handler was registered on every platform, so on macOS
and Windows — where the renderer's rebindable view.findInPage keybind already
owns Ctrl/Cmd+F — the chord became un-rebindable and would double-open when a
user remapped or cleared it. Restrict the install to process.platform ===
'linux' (the only platform #81727 affects); mac/Windows keep the renderer's
own keybind registry path.

Also corrects the docstring: the prior claim that 'GNOME Files owns Ctrl+F at
the windowing layer' is not accurate (Nautilus does not install global grabs).
The interception layer varies by distro/desktop; the fix sidesteps it by
acting at before-input-event regardless of cause, which is what actually
matters. The handler function itself stays platform-agnostic and injectable
for tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:05:42 -07:00
Chen Jin 75f0602301 test(desktop): cover the real macOS Cmd+F chord in the main-process handler (#81727)
IS_MAC was baked from process.platform at import time, so the unit test
titled "Cmd+F (macOS primary accelerator)" could never hit the meta (Cmd)
branch — it actually sent Ctrl and only exercised the literal-Ctrl-on-macOS
fallback. Make the platform detector an injectable default parameter and:
  - assert meta+Cmd on macOS opens the FindBar (the real Cmd path),
  - pin the dual-channel design (literal Ctrl also accepted on macOS),
  - cross-check that meta alone does NOT open on Linux/Windows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:05:42 -07:00
Chen Jin 2d51f6d022 style(desktop): use top-level BrowserWindow type import in find-in-page tests
`as unknown as import('electron').BrowserWindow` trips
@typescript-eslint/consistent-type-imports ("import() type annotations are
forbidden"). Use the top-level `import type { BrowserWindow }` instead,
which the lint gate accepts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:05:42 -07:00
Chen Jin d3193da3b1 fix(desktop): claim Ctrl/Cmd+F in main process so Pop!_OS / GNOME doesn't eat it
On Pop!_OS / GNOME-based Linux distros the GTK compositor grabs Ctrl+F at
the windowing layer (GNOME Files owns it) before the renderer's keydown
listener can fire, so the renderer's own `view.findInPage` keybind is
silently dead even though the binding is registered (#81727).

Claim the chord in the main process via `before-input-event` — that runs
strictly before the compositor shortcut can grab it. The renderer's
find-in-page pipeline still owns the FindBar UI and the actual search; we
just guarantee the press reaches it. The pre-existing `view.findInPage`
keybind stays as the renderer-side fallback for environments where the
compositor doesn't intercept.

Accept the platform's primary accelerator (Cmd on macOS, Ctrl elsewhere)
AND literal Ctrl on macOS so a non-macOS layout still works.

27/27 unit tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:05:42 -07:00
Teknium ca5c04818b feat: bare /save shows a usage card instead of exporting
Per review: /save with no arguments prints usage (formats, filename,
redact, examples) and writes nothing; the export requires an explicit
format. Unknown formats print the error plus the same usage card. One
shared SAVE_USAGE string in hermes_cli/session_export.py serves both the
CLI and gateway handlers. args_hint updated to <json|md|html> to reflect
the now-required format. Tests: bare-save and bad-format usage cases
added; location tests pass an explicit format.
2026-08-15 02:04:49 -07:00
Teknium e5a2c80c0e fix: harden /save for test doubles, async session-store boundary, snapshot shape pin
Three CI-red follow-ups on the /save rework:

- cli.py save_conversation: getattr-guard _session_db/session_id so
  SimpleNamespace/object.__new__ test doubles (and any embedder passing a
  minimal stub) don't AttributeError (pitfall #17 pattern).
- gateway/slash_commands.py: route through the awaited
  async_session_store.get_or_create_session boundary — the architecture
  test forbids raw session_store calls in async gateway code.
- tests/cli/test_save_conversation_location.py: /save now emits the
  canonical export_session payload shape; the session id key is "id"
  (was "session_id" in the legacy snapshot format) — update the pin.

The slice-8 lost-and-found failure was pre-existing on main and is fixed
there by f2a30fa400 (test: derive lost-and-found synthetic width from the
live schema); picked up via rebase.
2026-08-15 02:04:49 -07:00
Teknium 26aa12337a feat: /save exports the current session as json, md, or html on all platforms
Rework of salvaged PR #6372 (@ag9920) onto current main:

- /save promoted from CLI-only JSON snapshot to a cross-platform session
  export: `/save [json|md|html] [filename] [redact]` on CLI and every
  gateway platform (sent as a document via adapter.send_document).
- Rendering routes through the existing shared renderers
  (hermes_cli/session_export.py + session_export_html.py) instead of the
  PR's new hermes_state formatter — new helpers normalize_save_format /
  render_session_for_save / default_save_filename are shared by both
  surfaces.
- `redact` arg runs the export through the force-mode secret redaction
  pass (session_export_md.redact_session_data) before writing.
- Gateway handler awaits AsyncSessionDB correctly, sanitizes user-supplied
  filenames with basename, and lands in gateway/slash_commands.py (the
  handlers moved out of gateway/run.py since the PR was authored).
- /export stays profile export (name collision resolved: session export
  lives on /save).
- Slack 50-slash cap curation: /platform moves to the /hermes-only set to
  free a native slot for /save (parity test updated rationale comment).
- Folds in PR #62268 (@briandevans): None title/model coalescing in the
  single-session HTML export.

Closes #4249. Closes #51200.
2026-08-15 02:04:49 -07:00
briandevans edf6d2a074 fix(cli): coalesce None title/model in single-session HTML export
Single-session HTML exports of an un-named session render
`<title>None</title>` and `Model: None`. An untitled session (title
`None`) is the default state until async title generation completes, so
this is the common case, not an edge case.

The browser-tab `<title>` (page_title) and the `Model:` meta line use
`dict.get(key, default)`, whose default only fires when the key is
absent — not when it is present with value `None`. `_escape_html(None)`
then stringifies to the literal "None". The on-page `<h1>` in the same
function already uses the None-safe `... or "Hermes Session"` idiom, so
the tab title and header were inconsistent for the same session.

Use `... or "<default>"` at both sites so the tab title and model meta
fall back consistently with the header.
2026-08-15 02:04:49 -07:00
ag9920 23ad35b7fa feat: Add /export command for session export (Markdown/JSON) to CLI and Gateway 2026-08-15 02:04:49 -07:00
Teknium 883b57cbd8 feat(desktop): route backends by (connection, profile) — registry-scoped pool keys
Phase 2 of the multi-connection campaign (#86679 shipped the registry).
The Electron backend pool can now serve agents from ANY registered
connection concurrently, keyed by composite (connection, profile) scopes.

- connection-registry.ts: backendScopeKey(connectionId, profile) — the
  single home of the composite-key rule. Local/empty connection ids keep
  the BARE profile key, so every legacy pool entry, reaper log line, and
  touch call is byte-identical for single-source users; non-local
  connections get `conn:<id>::<profile>`, which cannot collide with a
  plain profile name. backendScopePrefix() matches the keys a connection
  owns (teardown on remove).
- main.ts ensureRegistryBackend(connectionId, profile): resolves a backend
  against the v2 registry. local kind delegates to ensureBackend()
  untouched; remote/cloud dial the entry's own URL/auth (descriptor carries
  profile + connectionId + sharedRemote for per-request ?profile= scoping);
  ssh bootstraps a tunnel scoped to the composite key, with the served
  dashboard token persisted back onto the REGISTRY entry (not v1
  connection.json). Pool entries reuse the existing LRU/idle-reaper/touch
  lifecycle.
- hermes:connections:remove now stops every pooled backend + ssh scope the
  removed connection owns.
- New IPC hermes:connection:for + preload getConnectionFor + renderer
  types (connectionId/sharedRemote on HermesConnection). No renderer
  behavior change yet — the multi-source roster/socket switchover is PR 3.

Tests: +2 backendScopeKey contract cases (28 total in the registry suite);
electron + settings projects 1357 passed; both tsc configs and eslint clean.
2026-08-15 02:04:30 -07:00
Teknium 467ec9208a chore: map content@tyfpro.com -> tyfcontent for #85181 salvage 2026-08-15 02:04:10 -07:00
tyfpro d059239aa4 fix(desktop): space out find-bar buttons and anchor below titlebar 2026-08-15 02:04:10 -07:00
Teknium fc8ebff6d8 feat(mcp): unify the desktop MCP suggestion directory into the catalog
The desktop app carried its own hardcoded list of 17 vendor MCP endpoints
(apps/desktop/src/lib/mcp-directory.ts) powering the composer suggestion
pills — a second PR-reviewed vendor list, overlapping and drifting from the
Nous-approved MCP catalog (optional-mcps/).

This makes the catalog the single source of truth:

- manifest schema: optional `suggest:` block (keywords + hosts), parsed,
  validated, and normalized in mcp_catalog.py
- 15 new URL-only hosted-remote catalog entries (atlassian, sentry, datadog,
  notion, stripe, vercel, supabase, netlify, hugging_face, asana, intercom,
  airtable, webflow, paypal, square); figma + linear manifests gain suggest
  blocks
- GET /api/mcp/catalog now serves the suggest metadata
- desktop suggestion provider builds its match index from the catalog;
  the static directory remains only as a compatibility rung for older
  backends without suggest metadata
- setup card source line prefers the catalog entry's transport URL

GitHub stays out of the catalog on purpose: its hosted MCP rejects generic
DCR and the bundled github/* skills (gh CLI) are the stronger integration.
New desktop `github` suggestion provider offers the github-auth skill
instead — gated on a new cached GET /api/git/gh-auth probe so already-
authenticated users never see the pill.
2026-08-15 02:03:24 -07:00
Teknium 001e2b0b85 test(desktop): add archiveSelectedSession to keybind harness deps
KeybindRuntimeDeps grew archiveSelectedSession on main after #72959 was
opened; the salvaged keybind-gate harness needs the new field to typecheck.
2026-08-15 01:56:31 -07:00
David Metcalfe 903250772f style(desktop): satisfy perfectionist import ordering in find-bar PR files
- use-keybinds.ts: sort @/app/routes after @/app/chat/close-tab and the
  right-sidebar imports (natural-asc)
- find-bar.test.tsx: sort @/app/hooks/use-keybinds before @/components and
  @/i18n imports; KeybindRuntimeDeps before useKeybinds in named imports
2026-08-15 01:56:31 -07:00
David Metcalfe 16a1ad6ef4 fix(desktop): count the right-sidebar toggle in the controller's titlebar slot too 2026-08-15 01:56:31 -07:00
David Metcalfe d0ba405ab7 test(desktop): cover the view.findInPage overlay keybind gate 2026-08-15 01:56:31 -07:00
David Metcalfe a1b24c52f7 fix(desktop): count the right-sidebar toggle in the titlebar tools width 2026-08-15 01:56:31 -07:00
David Metcalfe 4e8e991d61 fix(desktop): find bar positioning, overlay guard, and keybind gate
Three fixes for the find-in-page bar (Ctrl+F):

1. Position: replace fixed right-4 with calc() that accounts for the
   titlebar tool cluster width, preventing visual overlap with the
   layout/haptics/keybinds/settings icons. Uses existing CSS vars from
   wiring.tsx (--titlebar-tools-right, --titlebar-tools-width).

   right-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,0px)+0.5rem)]

2. Overlay guard: hide the find bar on full-screen overlay routes
   (agents, command-center, cron, profiles, settings, starmap,
   webhooks), matching the titlebar controls pattern. Prevents the
   bar from rendering behind overlays at z-50 and avoids collision
   with overlay-specific search surfaces (e.g. Settings search #69025).

3. Keybind gate: suppress the view.findInPage keybind on overlay
   routes so Ctrl+F doesn't mutate store state with no visible effect.
   Defense-in-depth alongside the component guard.

Adds regression test asserting the find bar does not render on /settings.
2026-08-15 01:56:31 -07:00
Halldrix 3f9150e5c4 fix(secrets_cli): defer bitwarden backend import to first attribute access
Address blocking review on #86782 (trevorgordon981, 2026-08-15): the
previous lazy closures in main.py only deferred the module-level
"import secrets_cli" statement, but were themselves invoked at parse
time — so the chain main -> secrets_cli -> bitwarden -> cryptography
still ran eagerly on every command, including `hermes update --check`.
The closure indirection was dead laziness.

Move the laziness to where the crypto payload actually lives:

1. secrets_cli.py: drop the module-top "from agent.secret_sources
   import bitwarden as bw" import. Each cmd_* handler now resolves the
   backend via a local _load_bw() helper, which imports
   agent.secret_sources.bitwarden on first use. register_cli() no
   longer touches crypto at all — it only wires argparse structure.

2. _BWS_VERSION is duplicated in secrets_cli as a plain string so the
   "install" subparser help text renders without importing the
   backend. agent.secret_sources.bitwarden._BWS_VERSION stays the
   source of truth; bump both together when pinning a new bws release.

3. Module-level PEP 562 __getattr__ resolves "secrets_cli.bw" lazily.
   Existing upstream tests (test_secrets_bitwarden_non_tty.py) that
   monkeypatch "hermes_cli.secrets_cli.bw.find_bws" keep working —
   monkeypatch resolves the string one level deep, triggering
   __getattr__, which imports the real bitwarden module and lets the
   patch land on the same cached module object the handlers import.

4. main.py: revert the closure indirection back to a direct parse-time
   _secrets_cli.register_cli() call — safe now that register_cli is
   crypto-free by construction. The argparse wiring is again visible
   at the call site (matching checkpoints.py / curator.py convention),
   which addresses the original parse-time-vs-post-parse contract
   concern from the previous review round.

Adds a decisive main()-level regression test requested by review:
test_main_update_check_crypto_absent_in_sys_modules spawns main() in a
subprocess with argv=['hermes', 'update', '--check'], patches
hermes_cli.main._cmd_update_check to short-circuit before any network,
and asserts cryptography.hazmat.bindings._rust stays out of
sys.modules both at dispatch time and after main() returns. This is
the exact invariant the Windows self-lock depends on; the previous
import-only tests could not observe the failure because parser
construction runs inside main().

Verification:
- scripts/run_tests.sh tests/test_lazy_secrets_import.py
  tests/test_lazy_secrets_dispatch.py
  tests/hermes_cli/test_secrets_bitwarden_non_tty.py
  -> 13/13 passed (includes the new decisive test + the 2 upstream
     tests that broke under the earlier _LazyBitwarden proxy).
- Sabotage run: same suite against the pre-fix main.py + secrets_cli.py
  fails the new decisive test with "cryptography._rust loaded by main()
  before update dispatch" — confirming the test guards the bug.
- Manual trace: at _cmd_update_check dispatch time, sys.modules
  contains hermes_cli.secrets_cli (parse-time structure only) but NOT
  agent.secret_sources.bitwarden and NOT cryptography._rust.

Refs: #86781
Refs: #83569
2026-08-15 01:55:22 -07:00
Halldrix 5618f5d5b3 fix(env_loader): use 'enabled is True' (explicit) instead of name whitelist
Two upstream tests failed with the whitelist approach:
- test_real_plugin_source_discovery_applies_dotenv (plugin source named
  HERMES_TEST_PLUGIN_BOOTSTRAP not in whitelist)
- test_external_secret_values_are_isolated_between_homes (test source
  named test-source not in whitelist)

Fix: use 'v.get("enabled") is True' instead of 'v.get("enabled", True)'
on any dict value.  This is stricter — only keys with an explicit
enabled: true pass — while remaining name-agnostic, so plugin and test
sources flow through without hardcoding a whitelist.

Refs #86781, #86782
2026-08-15 01:55:22 -07:00
Halldrix 230e5ff048 fix(main): revert secrets_cli to upstream, wrap registration in lazy closures
secrets_cli.py: revert to upstream eager import (tests rely on  as
module attribute for monkeypatch; the previous _LazyBitwarden proxy
broke 2 existing tests because agent.secret_sources.bitwarden lacks
BwsClient in the upstream codebase).

main.py: wrap secrets_cli/onepassword_secrets_cli imports in
_register_bitwarden/_register_onepassword closures.  The parser tree is
created at parse-time (register_cli attaches subparsers), but the
module import itself defers to first use — argparse only calls the
closure when it encounters the subcommand, so importing main() no
longer loads bitwarden/cryptography eagerly.

env_loader.py: keep the known-source-names gate (any dict with a
known source name and enabled: true).

Verification: 12/12 tests pass (3 sys.modules + 7 E2E subprocess +
2 upstream test_secrets_bitwarden_non_tty).
2026-08-15 01:55:22 -07:00
Halldrix 5a76c8a978 fix(update): lazy-import secrets backends + defer registry import — break Windows self-lock loop
Address review feedback from trevorgordon981 on PR #86782:

1. **Pre-register parsers at parse-time, lazy-import backends only**
   - secrets_cli.register_cli() and onepassword_secrets_cli.register_cli()
     now run eager at parser-build time (no deferral past parse_args)
   - Only the agent.secret_sources.bitwarden/onepassword imports are lazy
     (inside each cmd_* handler via _load_bitwarden()/_load_onepassword())
   - This eliminates the 'invalid choice' and infinite-recursion risks

2. **Known-source-names gate for env_loader registry**
   - Only keys in {bitwarden, onepassword, op, 1password, bw} trigger the
     registry import; a generic dict entry no longer forces crypto load
   - Prevents unrelated config dicts from paying crypto cost

3. **End-to-end tests for the real dispatch paths**
   - test_bitwarden_setup_help: runs real CLI subprocess with --help
   - test_bitwarden_status/disable/onepassword_status: run real handlers
   - test_update_check_clean/no_self_lock: run real update --check
   - test_update_check_no_cryptography: sys.modules inspection (backup)

4. **Fix flaky test_turn_lease.py** (unrelated pre-existing failure)

The architecture guarantees:
- parse-time: zero cryptography load (all backends lazy)
- dispatch-time: crypto loads exactly once per secrets command
- update path: completely clean of cryptography._rust mapping

Refs #86781, #86782
2026-08-15 01:55:22 -07:00
Halldrix a85a981107 test(lazy-secrets): fix CI compatibility — run from repo root, avoid live-system guard
Two CI failures fixed:

1. Slice 4/12 FAILED tests/gateway/test_turn_lease.py — pre-existing
   flaky test, not caused by this change (confirmed unchanged in main).

2. Slice 11/12 FAILED tests/test_lazy_secrets_import.py — the new
   test used  with cwd=tests/, which:
   (a) made  resolve to tests/hermes_cli/__init__.py
       (missing __version__), and
   (b) triggered the conftest.py live-system guard pattern match
       on the string 'update' in the code.

Fixes:
- Extract _run_isolated() helper that runs from repo_root with
  PYTHONDONTWRITEBYTECODE=1
- For the update-check test, write a temporary .py file in the
  repo root instead of using -c with 'update' in the string
- Remove pytest import (not available in the sandbox; not needed
  since the tests are simple assertions)

Refs #86782
2026-08-15 01:55:22 -07:00