Commit Graph

28271 Commits

Author SHA1 Message Date
AiwendilInTheWoods d9ec9dd3fd feat(cron): make the media-send timeout configurable
The media delivery path used a hardcoded future.result(timeout=30).
Large attachments legitimately exceed it with no way to raise the limit.
Read HERMES_CRON_MEDIA_SEND_TIMEOUT, matching the existing
HERMES_CRON_SCRIPT_TIMEOUT / HERMES_CRON_TIMEOUT /
HERMES_CRON_SESSION_DB_TIMEOUT convention in the same module.
2026-08-17 17:51:08 -07:00
AiwendilInTheWoods 64b7c96e45 fix(cron): media-send failure logs an empty reason on timeout
TimeoutError carries no message and str(TimeoutError()) is the empty
string, so the media-send warning rendered with nothing after the colon.
Fall back to the exception class name when str(e) is empty.
2026-08-17 17:51:08 -07:00
Teknium d89434e352 fix(desktop): Bots pane is a Sessions-zone tab again (not stacked below) and the Cronjobs pane only appears in Bots mode
- Bot Mode's pane dock changes to { pane: 'sessions', pos: 'center' }: the
  lone-pane header auto-hide trap the old 'bottom' split worked around is
  fixed (center gains pin the header shown), so the sidebar grows a
  SESSIONS | BOTS tab strip instead of two cramped stacked panes.
- One-time persisted-layout heal (dock heal tokens in the tree store):
  installs that adopted under the old split re-home into the sessions strip
  exactly once; user-placed panes are never touched and the burned token
  never re-fights a user who re-stacks afterward.
- The profile-scoped Cronjobs (routines) pane now registers only while the
  Bots pane is on screen, via the contribution disposer driven by the new
  host.paneVisibility SDK export (reactive $paneVisible). Feature-detected
  in plugin.js with the always-registered fallback for older desktops;
  the visibility listener tears down through ctx.onDispose.
2026-08-17 17:48:39 -07:00
Teknium 4014eb7d5c fix(bot-mode): sweep CLI-born Bot Mode sessions out of the global sidebar (ownership-based)
The id-based hide sweep (66221397a) only reconciled session ids the plugin
KNOWS — canonical Bot Chats from $botMeta and group rooms' member sids. But
Bot Mode sessions are also minted OUTSIDE the plugin: bot-to-bot handoffs run
`hermes -p <bot> chat -c "Agent Inbox" / -c "Bot Chat" --create-if-missing`
via CLI, and the mention-handoff path can mint a fresh "Bot Chat" beyond the
canonical one. Those ids never enter plugin state, so the sweep never touched
them and they sat visible in the global Sessions sidebar forever (user
report: five "Bot Chat" + two "Agent Inbox" leaked rows).

Add sweepBotProfileSessions(), chained from hideOwnedBotSessions() at the
same two call sites (plugin load + gateway reconnect): enumerate each roster
bot's OWN profile via session.list (visible rows only — naturally idempotent)
and session.set_hidden any row titled exactly 'Bot Chat' or 'Agent Inbox',
or prefixed 'Group: ' (the member-session title ensureGroupChatSession has
always used). Exact-title matching means a user's real conversations inside
a bot profile are never hidden, and non-bot profiles are never listed at all.
Remote-source bots route via requestForBot to their own connection. All
feature-detected and fire-and-forget per row.

The kickoff instruction text is left unchanged: the hermes CLI has no
hidden flag on `chat -c` session creation, so the sweep is the cover for
CLI-born rows.
2026-08-17 17:48:31 -07:00
Felix 46c0c6ec42 fix plugin list provider entries 2026-08-17 17:23:30 -07:00
Teknium 24f7f9a9da docs: reflect the unified Gateways page, settings profile scope, plugins cleanup, Bot Mode group rows, and host.openWorkspace
Update the desktop docs for five just-merged desktop changes:

- Settings → Gateway + Settings → Connections are now one "Gateways" page:
  retitle every reference, describe the Add-connection flow's four kinds
  (Local / Hermes Cloud / Remote gateway / SSH) and the save-time duplicate
  rules (one local; URL-normalized dedupe across remote/cloud; user@host:port
  + remote profile for SSH), and describe the "Per-profile overrides"
  subsection that replaced the page-level Applies to chip row.
- Document the shared "Applies to" profile scope on the config-backed
  settings pages (Model, Workspace, Safety, Memory & Context, Voice, Chat,
  Advanced, Tools & Keys) and the Messaging overlay.
- Agent plugins section: bundled built-ins are hidden (user/git/project/
  pip/portable installs only), Example Plugin is gone, and the section has
  its own Applies to selector backed by plugins.manage's optional profile
  param.
- Bot Mode: group chats are standalone Discord-style roster rows and open
  in the main chat window (older builds fall back to the in-panel view).
- Desktop Plugin SDK: document the new host.openWorkspace(id, { render,
  title, minWidth, onClose }) door, its refresh/re-front semantics, and
  the feature-detection fallback pattern.

Also retitles the Settings → Gateway references in the web-dashboard guide.
No new pages; sidebars.ts unchanged. `npx docusaurus build` passes.
2026-08-17 17:23:25 -07:00
Victor Kyriazakos 8b03e65804 chore: generalize field-report attribution in code comments 2026-08-17 17:20:06 -07:00
Victor Kyriazakos c9dbdbcea7 fix(gateway): grace window keeps first-call goal persistence on healthy DBs
Review finding on the off-loop bootstrap: returning None on every cold-
cache loop-thread call silently dropped the first goal/heartbeat
persistence op even when the DB was perfectly healthy. The loop-thread
path now waits up to 250ms on the bootstrap event - a healthy init
(tens of ms) completes inside the window and the caller gets the real
DB; a contended init (the crash-loop scenario) exceeds it and degrades
to None with a bounded, watchdog-safe stall.
2026-08-17 17:20:06 -07:00
Victor Kyriazakos e997435004 fix(state): v25 prompt dedupe degrades gracefully on a contended DB
Only the initial SELECT of _dedupe_legacy_system_prompts was guarded;
a 'database is locked' on any per-row write propagated out, aborted
schema init, left the schema version below 25, and made every later
SessionDB.__init__ re-enter the same migration against the same
contended DB - the second half of the enterprise crash-loop report.

The per-row loop now catches OperationalError, logs once, and returns.
Partial migration is safe by design: the legacy system_prompt column
is the documented read fallback for unmigrated rows, and the next
schema init resumes where the contention stopped. Tests prove rows
migrated before the failure stay migrated, the remainder stays
readable, and a later run completes it.
2026-08-17 17:20:06 -07:00
Victor Kyriazakos 8e81e2aaae fix(gateway): never construct SessionDB on the event-loop thread
SessionDB.__init__ runs schema init, and a migration against a contended
state.db blocks for seconds. The goal/heartbeat path reached it
synchronously on the gateway's event-loop thread (GoalManager() ->
load_goal -> _get_session_db -> SessionDB()), so a contended DB starved
the loop-liveness watchdog, which hard-exited with code 75 and the
supervisor restarted straight back into the same state - an unbounded
crash loop reported from an enterprise fleet.

_get_session_db now detects a running loop on the calling thread: on a
cache miss it kicks a one-shot background bootstrap thread and returns
None immediately (every caller already degrades gracefully on None);
the cached instance serves all later calls. Worker threads construct
inline as before, with a lock-guarded cache so a bootstrap race keeps
one instance and closes the loser. The heartbeat module shares this
boundary via the same _get_session_db.
2026-08-17 17:20:06 -07:00
Victor Kyriazakos 22f0f22298 fix(cron): manual runs no longer silently drop media attachments
Field report (enterprise, v0.20.0): cron jobs delivering text + PDF/image
attachments to Slack DMs deliver both on scheduled ticks but text-only on
manual `hermes cron run <job-id>`. Same box, same token, same scopes —
the divergence is process context and error visibility, not credentials.

Three defects, one bug class (attachment failures invisible + policy
divergence between the gateway process and standalone processes):

1. Standalone lane swallowed warnings: platform standalone senders
   (Slack files_upload_v2, Discord, ...) report per-file upload failures
   in result['warnings'] while returning success=True for the delivered
   text leg. _deliver_result only read result['error'], so the run was
   marked ok and the attachment vanished without a trace. Warnings now
   surface into delivery_errors (and the job's last_error).

2. Live-adapter lane swallowed media failures: _send_media_via_adapter
   logged failures at WARNING and returned None. It now returns per-file
   error strings and _deliver_result records them — text-delivered-but-
   attachment-failed is a visible partial failure on BOTH lanes.

3. Media-policy env bridge was gateway-only: gateway.strict /
   media_delivery_allow_dirs / trust_recent_files were translated from
   config.yaml to the env vars validate_media_delivery_path reads ONLY in
   gateway startup. A CLI-process manual run filtered attachment paths
   under a different policy — in strict/allowlisted deployments the exact
   reported symptom (scheduled delivers, manual drops, silently). The
   translation now lives in gateway/media_policy.apply_media_policy_env
   (idempotent, env-wins, never raises); gateway startup delegates to it
   and _deliver_result applies it before filtering. Attachments dropped
   by the policy filter are also reported in the run status instead of
   only a stderr WARNING.

On v0.20.0 specifically the failure was double-blind: the pre-9cf2cbd382
isinstance(resp, dict) gates meant upload failures were undetectable in
the sender AND unsurfaced by the scheduler. 9cf2cbd382 (in 2026.8.13)
fixed detection; this fixes visibility and policy parity.

8 new tests (tests/cron/test_media_delivery_parity.py): warnings→errors,
clean-delivery control, media-reaches-sender control, live-adapter
failure/dropped-path reporting, bridge helper semantics, strict+allowlist
end-to-end in a non-gateway process, and the .env-strict/config-allowlist
split that reproduces the field symptom. Mutation check: disabling the
warnings loop and the bridge fails exactly the 2 guarding tests.
2026-08-17 17:19:50 -07:00
Teknium b2c8a148e8 fix(desktop): fail-stop deleted-profile reconnects and evict the stale rail badge
Closes the renderer half of #88769, found while live-verifying the
profile-lifecycle fixes:

- The secondary-socket reconnect loop now fail-stops when Electron's spawn
  guard rejects with "no longer exists" / "is being deleted" — a permanent
  condition for that scope, previously retried forever on the 15s-cap
  backoff (40+ guard hits observed in 3 minutes after clicking a stale
  badge). The entry is disposed, evicted, and the active key restored to
  the primary, mirroring the existing missing-connection fail-stop.
- The Bot Mode SDK deleteProfile path now refreshes $profiles after a
  successful delete, so the profile rail drops the dead badge instead of
  keeping a clickable ghost. (The Desktop dialog path already refreshed
  via onDeleted; the SDK path was the gap.)

Tests: two fail-stop regression tests (profile-gone + mid-delete guard
rejections, sabotage-verified to fail without the fix) and a refresh
assertion on the SDK delete ordering test.
2026-08-17 17:18:47 -07:00
Teknium 3360590115 fix: address second-round SkillEvaluator review feedback
Review feedback from NVIDIA (Nir Paz), minus the LLM items (declined
on the thread: cost-by-default + prompt-injection surface; static-only
also keeps the timeout moot at ~1.5s vs the 120s ceiling):

- Incomplete-validator findings are now PRESERVED as partial evidence;
  only the validator's pass/fail verdict is excluded from the advisory
  verdict. A report with findings from an incomplete check no longer
  reads as clean.
- Clean-report wording is now "no findings from completed checks"
  whenever any validator was incomplete.
- Pinned both scanner binaries to known releases in code comments,
  config guidance, and docs: SkillEvaluator v0.1.0, SkillSpector v2.9.5.
- Tests: 29 (was 28) — partial-evidence preservation flips the old
  discard-pinning test, plus the completed-checks wording case.
2026-08-17 17:04:40 -07:00
Teknium 2c2697b52e feat: widen Tier 1 advisory scan to license + security checks
Review feedback from NVIDIA (Nir Paz): run the full deterministic
Tier 1 surface, not just pii,unicode,lint.

- TIER1_CHECKS now pii,unicode,lint,license,security. License is pure
  static (no measurable cost); security invokes NVIDIA SkillSpector in
  its keyless static-rules mode (~+1.2s per install). schema/quality
  stay excluded: hygiene signal ("author not specified" is
  high-severity upstream), wrong noise for an install prompt.
- SkillSpector is a second optional binary, pinned separately. Absent
  or failing, the security check reports status="incomplete" and the
  adapter treats it as "no opinion" — surfaced as a dim "(not run: ...)"
  note, never as a failure.
- _parse_report derives the verdict from COMPLETED validators only.
  This also absorbs a live upstream inconsistency: SkillEvaluator's
  anti-tamper cross-check on SkillSpector's risk score currently trips
  on moderate-finding skills (fail verdict with zero findings, e.g.
  github-pr-workflow at 15 MEDIUM issues / score 35). Reported to
  NVIDIA separately; either way an evidence-free fail must not render
  as an unexplained failure at install time.
- Dashboard tier1 block gains incomplete_checks.
- Docs: SkillSpector install command + not-run semantics.
- Tests: 28 (was 24) — incomplete-status exclusion, verdict derivation,
  not-run formatting.

E2E against real binaries: clean skill (no findings), skill tripping
the upstream consistency check (passed, "(not run: Security Scan)"),
seeded dirty skill (2 findings, SECRETS row). Full scan cost measured
at ~1.4-1.5s per skill, install-time only.
2026-08-17 17:04:40 -07:00
Teknium 183f18d530 feat: advisory NVIDIA SkillEvaluator Tier 1 scan on skill installs
Adds an optional, advisory second-opinion scan to the skills hub install
path using NVIDIA SkillEvaluator's deterministic, keyless Tier 1 checks
(PII, unicode smuggling, script lint).

- tools/skillevaluator_scan.py: subprocess adapter — runs the scanner
  over the quarantined bundle, parses the JSON report, classifies
  secrets-class findings (private keys, tokens, credentialed connection
  strings) apart from advisory PII findings. Every failure mode
  (binary missing, timeout, crash, bad JSON) degrades to a no-op.
- hermes_cli/skills_hub.py: prints the advisory panel after the built-in
  guard's policy decision and before the install confirmation. Findings
  are shown with file:line; secrets-class findings render red with a
  loud warning. Warn-and-continue by design — the built-in skills guard
  remains the only enforcement layer, because the upstream PII scanner
  has known false-positive classes (git@github.com, docs example
  emails, op:// references).
- hermes_cli/web_routers/skills.py: the dashboard Browse-hub scan
  endpoint returns the same advisory data in a new `tier1` field.
- config: skills.tier1_advisory (default true; no-op without the
  optional scanner binary on PATH).
- docs: user-guide/features/skills.md section with install command and
  config toggle.

Scanner install (optional):
  uv tool install --python 3.13 \
    "skillevaluator @ git+https://github.com/NVIDIA/SkillEvaluator.git"

E2E-validated against the real scanner binary: clean bundled skill (no
findings, "no findings" line), seeded dirty skill (email + credentialed
connection string -> yellow/red panel, install continues), config
disable via real config.yaml (silence). Real scan cost: ~0.2s per skill.
2026-08-17 17:04:40 -07:00
Teknium 9aa1413781 feat(desktop): extend kanban native notifications to blocker/failure events
Builds on @nductien's completion-notify module (PR #87705):

- Notify on the gateway watcher's full terminal set — blocked, gave_up,
  crashed, timed_out, block_loop_detected — not just completed. A worker
  hitting a blocker while the user is away was the original community ask.
- Route all notification copy through the kanban plugin i18n bundles
  (en/ja/zh/zh-hant), with an English-bundle fallback when the translator
  isn't bound yet.
- Wire the ctx.os.notify door so events also fire a NATIVE OS notification
  while the user is away from the Hermes window (host.notify toast covers
  the foreground). OS-door failures are isolated from the toast path.
- Docs: Desktop notifications section in kanban.md, including the
  app-running coverage window.
2026-08-17 17:00:00 -07:00
nductien 1802620847 chore(desktop): remove local markers from kanban notify comments 2026-08-17 17:00:00 -07:00
nductien a30636f817 feat(desktop): surface native OS notification on Kanban task completion
When a background agent completes a Kanban task, the Desktop app now surfaces a native OS notification with the task summary and a one-click "Open Kanban" action.

Rides the existing /events WebSocket (onEventsFrame in api.ts). A
new module completion-notify.ts implements cursor-based per-board
deduplication with baseline from GET /board, fail-closed on unknown
baseline, and reconnection safety. The notification uses the existing
host.notify() and host.navigate() SDK APIs.

Files changed:
- apps/desktop/src/plugins/kanban/api.ts (+8, -1) — wire completion-notify
- apps/desktop/src/plugins/kanban/completion-notify.ts (+89, new)
- apps/desktop/src/plugins/kanban/completion-notify.test.ts (+378, new)

19 targeted tests pass (0 fail).
2026-08-17 17:00:00 -07:00
Teknium 7999ac6e3d feat(desktop): shared 'Applies to' profile scope across settings pages and Messaging
Bring the per-profile scoping affordance the Gateway page already has to
every config-backed settings surface. A new shared nanostore
($settingsScopeOverride in store/settings-scope.ts) holds one "Applies to"
selection that persists across pages; a reusable SettingsProfileScope chip
row (app/settings/profile-scope.tsx) renders it. Hidden with fewer than two
profiles, and null (follow the active profile) keeps every request on the
exact pre-existing unscoped path.

Scoped pages:
- Model / Workspace / Safety / Memory & Context / Voice / Chat / Advanced
  (ConfigSettings): config record + schema queries and the debounced
  autosave PUT now carry the selected profile; the inner page remounts per
  scope so drafts can't leak across profiles. ModelSettings threads the
  scope through model info/options/aux/MoA fetches and assignments; memory
  provider config/OAuth panels follow.
- Tools & Keys (KeysSettings via useEnvCredentials): env list + save/
  reveal/delete target the selected profile's store.
- Messaging overlay: platforms, enable/save/clear, pairing approve/revoke
  are scoped; the list blanks on scope switch so stale rows can't be
  toggled against the wrong backend.

Plumbing: hermes.ts REST helpers gain optional trailing `profile`
parameters (config, schema, env, memory provider, messaging, pairing,
model endpoints) resolved through the existing profileScoped() ladder —
omitting them is byte-identical to before. The cron model-impact warning
is skipped for scoped applies (it belongs to the other profile's backend).
An app-wide profile switch drops the override so edits can't silently
stay pointed at the previous target.

i18n: new settings.profileScope.{appliesTo,editsProfile} keys in
en/zh/zh-hant/ja/ar.

Capabilities (app/skills) and Scheduled Jobs (app/cron) verified: both
already thread their profile scope through every fetch and mutation.
2026-08-17 16:55:14 -07:00
Teknium 56de3c1428 fix(cli): persist one-shot resumed-session turns (Bot Chat bot-to-bot messages)
Bot Mode's bot-to-bot send (`hermes -p <bot> chat --in ~ -c "Bot Chat"
--create-if-missing -Q -q "..."`) runs one turn and exits. When the turn's
in-loop transcript flush failed transiently (state.db write-lock contention
with a multiplex gateway), the one-shot path had no end-of-run durable
retry: the reply reached stdout and agent.log while the resumed titled
session's stored history never changed (#88583). The interactive CLI is
immune — it retries the flush on the next persist point and finalizes the
row on quit — but every one-shot exit path lacked both.

Fix the whole class with cli._flush_one_shot_session_store():

- final _persist_session retry at one-shot exit (idempotent — per-message
  persisted-marker stamps mean already-written turns are not re-written)
- drain queued async token-accounting deltas
- end_session(..., "cli_close") so resumed/created titled session rows no
  longer dangle open forever after one-shot runs

Wired into _finalize_single_query (quiet -Q -q AND human -q paths, ahead
of memory-provider shutdown so nothing later can lose the turn) and into
the kanban SIGTERM handler before os._exit(0), which skips atexit and the
SessionDB token-drain hook entirely (same gap class as PR #50881).

Handed-off sessions (#88234) and persistence-isolated forks
(_persist_disabled) are skipped.

Fixes #88583

🤖 Generated with Hermes Agent
2026-08-17 16:55:07 -07:00
Teknium 54b0a5bc46 chore: map contributor emails for @loafoe and @k0rnacki 2026-08-17 16:54:01 -07:00
k0rnacki dd6e098986 test(api_server): pin the recovery-net alias guards
Three cases in TestCreateAgentModelRecovery: the alias is never
cached, a poisoned cache is never served, and legitimate
last-known-good recovery still works.
2026-08-17 16:54:01 -07:00
k0rnacki 1352439036 fix(api_server): recovery net must never recover the advertised virtual alias
The #35314 empty-dispatch recovery cache accepts any previously
resolved model string, including the advertised virtual model
(hermes-agent by default) — which is never a dispatchable model.
Guard both directions: never store the alias in _last_resolved_model,
and never serve a cached value equal to it. The alias now survives at
most one turn and cannot propagate across turns through the cache.

Class bug behind #79101; complements the session-row symptom fixes
(#72739, #79102).
2026-08-17 16:54:01 -07:00
Andy Lo-A-Foe 152b1940d7 fix(api-server): stop persisting the virtual model alias as a session's model
Salvaged from #72739 (net diff onto current main; the read-side legacy-row
guard now flows through the shared _stored_session_model() helper that
landed in #88751, keeping one resolver for both chat sites).

POST /api/sessions persisted the advertised virtual alias (hermes-agent)
whenever the request omitted model or echoed the alias back; later turns
replayed it upstream as a real model id and every turn on the session
failed. Null the alias in _session_runtime_request_from_body() — shared by
session create, chat, chat-stream, and model-lock — and stop the
create-handler's raw-body fallback from bypassing that normalization.
2026-08-17 16:54:01 -07:00
hermes-seaeye[bot] 77d6c78cf5 fmt(js): npm run fix on merge (#88760)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-17 23:46:08 +00:00
Teknium ae5c4c1672 feat(desktop): Discord-style group-chat rows in Bot Mode, opening in the main chat window
Group chats in the Hermes Bots roster were rendered as a section header
("HERMES, PAPERCLIP MANAGER" + divider + tiny "Open chat" link) with each
member bot listed underneath — reading as separate DMs, not a group — and
opening the room rendered it INSIDE the narrow bots side panel.

Roster (plugin.js):
- Each group chat is now ONE standalone roster row (GroupRow), Discord-style:
  stacked member avatars (BotFace composite, org glyph when empty), group
  name, member count, latest room line as the preview with markdown syntax
  flattened, relative last-activity time, and the "needs you" badge on the
  row itself.
- The roster is a flat list: bot rows and group rows interleaved in the SAME
  pin+recency ordering (a group's recency = its newest room-log entry).
  Bots keep their individual DM rows; the group is an additional
  independent row. The groupRoster() sectioning is removed.
- New stripPreviewMarkdown() flattens **bold**, `code`, > quotes, links,
  and headings out of BotRow and GroupRow previews (raw ** and > were
  leaking into row previews).

Main-window takeover (sdk/index.ts + plugin.js):
- New generic SDK door host.openWorkspace(id, { render, title, minWidth,
  onClose }): registers a placement:'main' pane docked center into the
  workspace zone (the same shape session tiles and previews use), wires
  registerPaneCloser so tab Close / ⌘W tears the registration down, and
  reveals it. Re-opening the same id refreshes in place and re-fronts.
  Returns a disposer.
- Clicking a group row calls openGroupChat(): on desktops with
  host.openWorkspace the room opens as a tab taking over the MAIN chat
  area; older desktops keep the exact in-panel GroupChatWorkspace fallback
  (feature-detected, established Bot Mode pattern). Disband closes the
  main-window tab too; the composer/round-robin logic is untouched and
  renders identically in both hosts.

Tests: roster-groups rewritten for the flat model + new helpers;
group-chat/create-group-chat source contracts updated to the
openGroupChat door; profile-prewarm harness stubs stripPreviewMarkdown.
2026-08-17 16:39:59 -07:00
Teknium d16413ef43 fix(desktop): stop Skills Hub picker overlaying the installed list and cut Capabilities lag
Layout (overlap bug): the EmbeddedHubPicker section had no flex
containment — its fixed-height iframe viewport made the section's
min-content height rigid, so at a tall persisted drag height or a short
window the flex column starved the installed-skills area to 0px and the
hub header row painted straight over the list's sort strip and "changes
apply to new sessions" footer. The picker section now clips its own
content (overflow-hidden, shrinkable, min-h for the header row), the hub
viewport uses flex-basis instead of a hard height so it gives pixels
back first, the list region keeps a min-h-40 floor, and the sash drag is
clamped against the actual column height (list reserve) rather than just
the window.

Perf (Capabilities lag):
- The hub iframe (a full Docusaurus site) mounted eagerly with the view
  and re-mounted on every scope change (key={`picker-${scopeKey}`}).
  It now lazy-mounts the first time the Skills tab is shown, then stays
  mounted but display:none across Tools/MCP so a tab bounce never
  reloads the site. The scope key is gone — the picker fetches nothing;
  the profile rides into each install call as a prop.
- EmbeddedHubPicker is memo()ed and subscribes to the single
  UPDATE_ALL_KEY running flag via useStoreSelector instead of the whole
  $hubActions map, which churns on every tailed log line during installs.
- Collapse state now persists through the pane store (0 = collapsed),
  matching DetailPane, instead of resetting open on every mount.
- CapRow gets content-visibility:auto so 80+ row lists skip offscreen
  layout/paint (fixed row heights keep scroll geometry stable).

Validation: npm run check:lint (tsc x3 + eslint) clean; vitest
src/app/skills 9/9 passing, including a new test asserting the iframe
is absent on non-Skills tabs, mounts with the Skills tab, and survives
a tab switch hidden.
2026-08-17 16:39:52 -07:00
Teknium 76ff060748 fix(desktop): clean up Plugins settings — drop Example Plugin, hide bundled built-ins, add profile scoping
- Remove the bundled Example Plugin from the desktop renderer plugins
  (apps/desktop/src/plugins/example/). Reference/demo plugins live in the
  companion hermes-example-plugins repo (already pointed to by
  src/plugins/README.md); shipping the counter demo in everyone's Settings
  doubled UI noise for no user value.
- Agent plugins section now hides ALL repo-bundled built-ins
  (source === 'bundled': browser/browserbase, cron_providers/chronos,
  model-providers/deepinfra, platform adapters, image/video backends, …).
  The section is the control panel for plugins the user installed
  (user/git/project/pip/portable); built-ins ship enabled-by-default and
  are configured from their own surfaces. The HIDDEN_KEY_PREFIXES list
  stays as a fallback for older backends. Count pill reflects the
  filtered list.
- Add an "Applies to" profile scope selector to the Agent plugins section
  (same pattern as the Capabilities scope selector): list/toggle any
  profile's plugins without switching the whole app. Backend:
  plugins.manage now accepts an optional `profile` param via the same
  set_hermes_home_override contract as cron.manage / mcp.servers.*;
  unscoped calls are unchanged, so older backends keep working.
- i18n: new settings.plugins.agent.appliesTo key (types + en + zh; other
  locales fall back through defineLocale).
- Tests: plugins-settings.test.tsx covers bundled hiding, prefix fallback,
  count pill, selector visibility, scoped list/toggle payloads; new
  tests/test_plugins_manage_profile_scope.py mirrors the cron.manage
  profile-scope tests (scoped read, unknown-profile 4064, no override
  leak, unscoped contract unchanged).
2026-08-17 16:39:47 -07:00
Teknium 9a85769fc3 feat(desktop): unify Gateway + Connections into one Gateways settings page
Merge Settings → Connections into Settings → Gateway as a single
"Gateways" page:

- One nav entry ("Gateways"); the Connections nav entry is removed. The
  legacy `?tab=connections` deep link stays in the route enum and
  redirects to the unified page, and renders it in the interim frame so
  saved routes/bookmarks don't break or flash.
- The connections registry UI (list, add, edit, delete, test, make
  primary, update-all) moves into its own component
  (connections-registry.tsx) composed at the bottom of the Gateways
  page; connections-settings.tsx is deleted (nothing else imported it).
- The Add flow now offers ALL kinds: Local, Hermes Cloud, Remote
  gateway, and SSH (previously only remote/ssh). The Local kind button
  is disabled while the managed local entry exists; a hint points cloud
  adds at the sign-in/discovery flow above.
- Duplicate prevention, enforced in the save path on both sides of the
  IPC boundary (renderer inline error + main-process
  normalizeConnectionInput throw): only one 'local' entry ever;
  remote/cloud dupes keyed on the normalized URL (trim, strip trailing
  slashes, lowercase) across both kinds; ssh dupes keyed on normalized
  user@host:port + remote profile.
- The page-level "Applies to" ScopeChip row is gone. Per-profile
  gateway overrides are now an explicit "Per-profile overrides"
  subsection listing the default connection and each named profile,
  with an Edit affordance that drives the same scope state machinery.
- Command palette and profile-rail deep links retarget the unified
  page; i18n nav rename + new strings across types, en, ja, zh,
  zh-hant, ar.

Tests: new connections-registry.test.tsx (registry UI + dedupe helper
units, including the inline duplicate rejection), electron
connection-registry.test.ts dedupe cases, and updated
profile-rail-connect test.
2026-08-17 16:39:40 -07:00
Teknium 91878fb3c1 fix(desktop): visible eye catchlights on dark-bodied Bot Mode avatars
The BotFace catchlight dots were hardcoded white. On dark bodies
(maroon/ink/oxblood) isDarkColor() flips the pupils to light cream
(eyeFill), so a white catchlight on a cream pupil is invisible — those
avatars looked like they had no dots in their eyes (image14 report).

Catchlight contrast now follows the pupil, not the body: dark pupils
keep the white sparkle, light pupils get a dark one (rgba(0,0,0,0.6)).
The animation clock only moves the hb-hl elements (cx/cy) and never
touches fill, so the initial-render fill carries through every frame.

Verified with a rendered before/after/control strip: BEFORE maroon has
no visible dots, AFTER shows clear dark dots in the cream eyes, light
control body unchanged with white catchlights. Plugin suite green
incl. new face-catchlight.test.mjs.
2026-08-17 16:35:12 -07:00
Teknium 189de16280 fix(peer): live-verified cross-gateway bot DMs — session shape + virtual-model replay
Two bugs found by a REAL two-gateway live test (two isolated HERMES_HOMEs,
bravo running the api_server platform, alpha's agent autonomously running
`hermes peer dm` from its Bot Chat protocol; reply relayed correctly and
persisted in bravo's canonical Bot Chat):

1. peer dm parsed the session-create response flat, but api_server wraps
   the row: {"object": "hermes.session", "session": {...}} — every first DM
   to a fresh peer failed with "Peer did not return a session id" (and the
   orphaned Bot Chat then 400'd retries with duplicate-title). Parse the
   wrapped shape; the test fake now mirrors the real response shape so this
   class can't pass green again.

2. api_server: a session created with no model persists the advertised
   virtual model ("hermes-agent") on the row; session chat then replayed it
   as a REAL model id and the provider 400'd ("hermes-agent is not a valid
   model ID"). _request_agent_overrides already filters the virtual model
   for per-request bodies — apply the same filter to the stored session
   model at both chat sites (sync + stream), so it means "gateway default"
   exactly like the request-body path.

Live E2E transcript (bravo's Bot Chat, via /api/sessions/{id}/messages):
  user:      Message from 🤖 alpha (@alpha): What is your callsign?
  assistant: CALLSIGN-BRAVO-7
peer cmd unit suite 10/10 with the corrected fake.
2026-08-17 16:34:22 -07:00
Teknium 6f2dbf3a05 fix(gateway): widen scope-aware session DB resolution to the runner and release cached handles
Follow-up to the salvaged #88632 (Jack Lau) fix for #88532, extending the
same repair to the sibling frozen-at-init handle and closing the handle
lifecycle gap the per-path cache introduces.

1. GatewayRunner._session_db had the identical bug class: bound once as
   AsyncSessionDB(SessionDB()) in __init__ on the root home, while /resume,
   /title, /history and session search all execute inside
   _profile_runtime_scope on a multiplexed gateway. Convert it to the same
   property-with-pin pattern: per-access resolution of _default_db_path(),
   one cached AsyncSessionDB per resolved path under a lock, and assignment
   preserved as an explicit pin (many suites install fakes or None).
   Construction-time priming keeps the #88235 init-failure broadcast at
   startup.

2. Handle lifecycle: the per-path caches accumulate one open SessionDB per
   profile served, but the shutdown path closed only store._db /
   runner._session_db - which now resolve just the shutdown task's own
   (root) scope. Secondary profiles' handles would strand their WAL write
   locks until process exit, recreating the abandoned-handle leak
   b454e4da76 fixed and breaking --replace restarts with 'database is
   locked'. Add close_all_db_handles() / close_all_session_db_handles()
   sweeps and call both from the gateway teardown path. SessionDB.close()
   is idempotent, so the root handle being closed by both the legacy loop
   and the sweep is safe.

Tests: sweep coverage plus a runner-property scope/pin/cache test in
tests/gateway/test_multiplex_session_db_profile_scope.py.
2026-08-17 16:34:10 -07:00
Jack Lau 17ba992108 fix(gateway): resolve the session DB inside the active profile scope
Fixes #88532.

A multiplexed gateway serves every profile from one process, but
SessionStore bound a single SessionDB during __init__:

    self._db = SessionDB()

SessionDB(db_path=None) resolves _default_db_path() at call time and
does follow the context-local HERMES_HOME override, so the path
machinery was already correct. The problem was when it ran: at
construction, on the process's own root home, long before any inbound
event enters _profile_runtime_scope. Every profile's rows therefore
landed in the root state.db, even though the scope had redirected
get_hermes_home() correctly for the turn (that helper's own docstring
lists "sessions" among what it scopes).

The rows still carry the right profile_name, stamped from
source.profile by the same handler, so nothing in the data looks wrong.
The only visible symptom is the desktop listing a profile's session
under the default bot: _open_session_db_for_profile opens
profiles/<name>/state.db, which never received the write.

Look the handle up through a property instead, resolving the active
scope per access and caching one handle per resolved path so a hot
inbound path opens SQLite once per profile rather than once per
message. Construction stays under the cache lock so a concurrent first
message on a profile cannot open and then leak a second handle.

Assignment is preserved as an explicit pin, which is what the existing
suites rely on when they install a fake handle or disable the DB with
store._db = None, and a pin keeps winning across scope changes.

Behavior is unchanged when no profile scope is active, so single-profile
gateways resolve exactly the path they did before. This does not migrate
rows that already landed in the root store; those stay where they are.
2026-08-17 16:34:10 -07:00
Teknium 10134bd846 chore: map contributor email 2026-08-17 16:34:02 -07:00
BkashJEE 6c84c33546 fix(bot-mode): explain hidden cronjobs in the Routines pane empty state
Partial salvage of PR #88561 (routines-filter-hint half only; the pet
gallery pagination half is out of scope for #88263 and was skipped).

When the cron store has jobs but none surface for the active bot (older
gateway without profile scoping, no [bot:<name>] tags matching), the
Routines pane now explains that cronjobs exist but are hidden, instead
of showing the generic empty state — so users don't believe their
scheduled jobs disappeared.

Port of NousResearch/Hermes-Bot-Mode#95 (repo archived, ported upstream).
2026-08-17 16:34:02 -07:00
ScaleLeanChris 799d3fe021 fix(desktop): show legacy cronjobs in default bot
Signed-off-by: ScaleLeanChris <chris@scalelean.com>
2026-08-17 16:34:02 -07:00
Andrew Walker e3c4bfe9ab fix(bot-mode): show profile-owned cronjobs 2026-08-17 16:34:02 -07:00
Teknium aa8ceed4b6 fix(memory): keep a stale holder's late close() from evicting a fresh registry entry
Follow-up to the #88347 salvage: after release_all_under() force-closes a
profile's shared connection, a store re-created on the same path registers
a fresh entry under the same key. A stale holder that later calls close()
would pop that fresh entry (its refs were transferred nowhere), letting a
third store open a second connection to the same database — exactly the
multi-writer contention the shared registry exists to prevent. close()
now evicts the registry entry only when it is still its own.
2026-08-17 16:33:52 -07:00
liuhao1024 4f354c27b7 fix(profiles): release memory-store handles before rmtree on profile delete
The desktop's main serve process opens memory_store.db for every known
profile and nothing closed those connections before delete_profile's
rmtree — on Windows the open SQLite handles make the removal fail with
WinError 32 for both the CLI and the DELETE /api/profiles/<name> route
(#88347). POSIX unlinking of open files hid the same leak.

MemoryStore.close() is refcount-driven, so a live holder keeps the
handle forever; add MemoryStore.release_all_under(directory) to
force-close every shared connection under a directory, and call it in
delete_profile after stopping the profile backends. Inside serve the
handles live in that very process and get released; from the CLI it is
a no-op.

Fixes #88347
2026-08-17 16:33:52 -07:00
Teknium 399fdeae8b fix(bot-mode): plugin listener leaks, jsx key prop, OAuth poll overwrite
Three bugs in apps/desktop/src/plugins/hermes-bots/plugin.js:

1. register() subscribed host.state.profile / host.state.gateway listeners
   without capturing the unbind functions, so a plugin disable -> re-enable
   cycle stacked a duplicate listener per cycle that kept firing until app
   quit (same survives-disable class as the face clock before its
   onDispose hook). The unbinds are now captured and released via
   ctx.onDispose.

2. CreateRoutineDialog passed `key: createTarget` INSIDE the jsx() props
   object. The react/jsx-runtime silently ignores a `key` prop there (key
   is the third jsx() argument), so switching the routine owner never
   remounted the dialog and it kept stale per-bot form state. Moved the
   key to the third argument; the source-shape test now pins the correct
   shape and rejects the prop form.

3. beginOAuth() overwrote pollRef.current without clearing an existing
   interval, so a retry / double-click while a poll was live orphaned a
   2s poller that ran until unmount and could flip the phase from a stale
   OAuth session. It now clears any live poll before starting a new one.
2026-08-17 16:33:42 -07:00
Teknium aa2622128f fix(hermes-bots): show profile pics in group chat rooms
Community report: group chats showed neither profile pictures nor proper
names for members (names fixed in #88721; this adds the avatars).

- Every bot message in a group room now carries the speaker's avatar,
  resolved through the same botAppearance pipeline as the roster: custom
  uploaded/generated/pet images honored, backfilled PNGs dropped so the
  animated math face renders, deterministic shape+color for bots with no
  customization (including remote speakers with no local meta).
- The room header shows the member roster as overlapping faces (capped
  at 6) with a display-name tooltip, alongside the existing count.
2026-08-17 16:29:45 -07:00
Teknium 9b112e5736 refactor(desktop): extract pool teardown into pool-stop.ts with behavior tests
Follow-ups on the salvaged bounded-teardown fix:

- Replace the source-grep regression test (backend-lifecycle.test.ts asserted
  main.ts source text) with behavior-contract tests against the extracted
  createPoolStopper(): handle retention until bounded exit, stop dedup,
  stopAll completeness, and the respawn-awaits-dying-backend gate.
- Gate the registry-local spawn path on the in-flight stop too (sibling site
  the original PR predates).
- Route teardownPoolBackendAndWait through the shared stopper so profile
  delete/rename teardown covers both local pool keys with the same bounded
  semantics.
2026-08-17 16:26:02 -07:00
giga corp c07171209a fix(desktop): await pooled backend teardown 2026-08-17 16:26:02 -07:00
Teknium c6a0789bc5 fix(desktop): retire old-name renderer sockets and widen the deletion gate for renames
Follow-ups on top of the salvaged rename lifecycle:

- Retire both local renderer socket scopes for the OLD profile name before
  the rename PATCH (rename-profile-dialog), mirroring the delete-path fix
  from #88638 — a retained socket otherwise treats the rename's backend
  teardown as a transient drop and its reconnect respawns the old-name
  backend, whose ensure_hermes_home() recreates the directory the rename
  just moved (#45474).
- Hold the profile deletion gate for rename requests too, so a concurrent
  renderer reconnect entering ensureBackend() mid-rename cannot respawn the
  old-name backend.
- Regression tests for retire-before-rename ordering and the
  validation-rejected path.
2026-08-17 16:15:22 -07:00
wangdong 69abe41f36 fix(desktop): re-home backend after profile rename 2026-08-17 16:15:22 -07:00
Teknium 4b7eb02907 fix(hermes-bots): group chats never surface @default — Hermes keeps its name
Community report: in a Bot Mode group chat with the primary agent, Hermes
lost its name and rendered as @default in the room.

- Workspace speaker labels now render the roster displayName (default →
  Hermes, titles respected) instead of the raw @profile id. Clicking a
  speaker reveals the full disambiguated form — Display-gatewayname
  (@handle) — so same-named agents on two connections stay tellable
  apart on demand; naturally they just show their display name.
- Room transcripts fed to members render the default profile as Hermes
  (new groupSpeakerLabel helper).
- The per-turn prompt addresses members by @handle (@hermes for the
  primary profile) instead of @default, and parseGroupChatMentions now
  resolves @hermes back to the default member via botHandle.
2026-08-17 16:14:53 -07:00
Teknium 6229683b62 feat(cli): hermes peer — bot-to-bot DMs across machines and gateways
Bots could message teammates on their own machine (hermes -p <bot> chat) and
the desktop could relay user mentions over Connections, but a bot had NO
transport to a bot on another gateway. This adds one, with zero new server
surface: the peer's existing api_server platform is the wire.

- hermes_cli/subcommands/peer.py: `hermes peer add/list/remove/dm`.
  `dm <peer>[/<agent>]` resolves the remote agent's canonical "Bot Chat"
  (list by title, create when missing), runs one synchronous agent turn via
  POST /api/sessions/{id}/chat, and prints the reply on stdout — the exact
  cross-machine twin of the local bot-messaging command, so the Bot Mode
  protocol composes over it unchanged. Named profiles route via the peer's
  /p/<profile>/ multiplex mirror. Peer URLs live in config.yaml
  (`bot_peers`); the peer's API_SERVER_KEY is a credential and lives in
  ~/.hermes/.env as HERMES_PEER_<NAME>_KEY.
- hermes_cli/main.py: parser wiring + fast-path/session-flag command sets.
- tools/bot_mode_probe.py: when peers are registered, the injected Bot Chat
  messaging protocol gains a cross-machine paragraph (peer roster +
  `hermes peer dm` pattern) so agents discover remote teammates on their
  own; peers join the capability fingerprint so registering/removing one
  refreshes eternal Bot Chat prompts on the next message (loud, one-time,
  user-initiated — no per-turn cache drift).
- Docs: Bot Mode guide (bot-initiated DMs across machines) + cli-commands
  reference (`hermes peer` section + summary row).

Tests: tests/hermes_cli/test_peer_cmd.py (target parsing, /p/ scoping,
registry round-trip in isolated config, real-loopback-HTTP dm flow incl.
Bot Chat create-vs-reuse and bearer auth), bot_mode_probe peer-paragraph +
epoch tests. E2E: real `python -m hermes_cli.main peer ...` against a live
fake peer over HTTP with isolated HERMES_HOME (config/.env persistence,
bare + /p/<profile> routing, stdin, --json). 23 passed; ruff clean.
2026-08-17 16:13:30 -07:00
Teknium 66221397a1 fix(bot-mode): always hide Bot Mode sessions from the global Sessions sidebar
Bot Mode's group chats spawned one per-member session per room, and those
"Group: ..." rows (plus canonical Bot Chats when the old eye-toggle pref was
off) flooded the global Sessions sidebar — a 6-bot room dumped six identical
rows into recents (reported with screenshot, Aug 17).

Plugin (apps/desktop/src/plugins/hermes-bots/plugin.js):
- session.create now passes hidden:true UNCONDITIONALLY for both canonical
  Bot Chats and group-room member sessions; the $hideBotChats pref, its eye
  toggle, and its storage hydrate are removed (Bot Mode sessions are plumbing
  or plugin-owned forever-chats, never scratch conversations).
- hideOwnedBotSessions(): idempotent reconciliation sweep over every owned
  session id (bot meta canonical chats + each room's member sessions) via
  session.set_hidden, run on plugin load and on each gateway reconnect, so
  rows born visible under the old pref get cleaned up.
- The Bots session browser and canonical-chat recovery scan pass
  include_hidden:true so they still see the rows they own.

Gateway (tui_gateway/methods_session.py):
- session.list honors an include_hidden param (default off — the resume
  picker and all global callers keep dropping hidden rows).
- session.set_hidden gains a durable fallback: when no LIVE runtime session
  matches, resolve the stored session id in the target profile's state.db
  (via resolve_session_id) and flip the flag there. The sweep holds stored
  ids for chats that aren't live; the old live-only lookup 4001'd them.

Validated E2E with real imports against a temp HERMES_HOME: born-hidden row
(hidden=1), profile-scoped session.list default vs include_hidden (0 vs 1),
and stored-id sweep on a non-live legacy row (hidden=1). Plugin suite
167/167; new RPC regression tests in tests/tui_gateway/test_session_hidden_rpc.py.
2026-08-17 16:02:42 -07:00
hermes-seaeye[bot] bb92473074 fmt(js): npm run fix on merge (#88720)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-17 22:59:42 +00:00
embwl0x 55e34fb7d0 fix(desktop): avoid local profile REST backend spawns 2026-08-17 15:51:02 -07:00