Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
# HERMES_WEBUI_PORT=8787

# Where to store sessions, workspaces, and other state (default: ~/.hermes/webui-mvp)
# HERMES_WEBUI_STATE_DIR=~/.hermes/webui-mvp
# HERMES_WEBUI_STATE_DIR=~/.hermes/webui

# Default workspace directory shown on first launch
# HERMES_WEBUI_DEFAULT_WORKSPACE=~/workspace
Expand Down
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,19 @@

## [Unreleased]

### Fixed
## [v0.50.245] — 2026-04-30

### Fixed
- **Cron `Run Now` no longer crashes with `NameError: run_job is not defined`** — `_run_cron_tracked()` runs in a worker thread but referenced `run_job` only via a local import inside `_handle_cron_run()` (a different scope). Manual cron execution now imports `run_job` inside the worker function itself, and the redundant import is removed from the route handler. Adds AST-based regression tests so future refactors can't silently re-break the worker-thread scope. (`api/routes.py`, `tests/test_cron_run_job_import.py`) @fxd-jason — PR #1317, fixes #1310 (also addressed by #1312/#1329, closed as duplicates)
- **Context auto-compressed banner no longer repeats every turn after first compression** — the fallback compression detector compared cumulative `compression_count > 0`, which stays true forever after the first compression event, so the banner re-fired on every subsequent turn. Now snapshots `compression_count` before `run_conversation()` and compares against the snapshot, so the banner only fires when compression actually happens during the current turn. (`api/streaming.py`) @qxxaa — PR #1316
- **Mobile workspace panel sliver and composer footer overlap (#1300)** — saved desktop workspace-panel widths leaked into compact/mobile layouts, leaving a thin right-edge workspace sliver and a stale shadow on closed panels. Composer footer controls also showed icon/text overlap at intermediate widths when sidebars constrained the chat column. The fix clears/reapplies the rightpanel inline width only when the viewport is outside the compact/mobile breakpoint, hides the closed off-canvas shadow, and adds staged composer-footer container queries so workspace/model labels collapse before they overlap. (`static/boot.js`, `static/style.css`, `tests/test_mobile_layout.py`) @franksong2702 — PR #1328, fixes #1300
- **Streaming sessions stay visible in the sidebar during their first turn** — the `Untitled + 0-messages` filter (#1171) hid sessions during the initial streaming turn because PR #1184 deferred the first `save()` until the first message landed. Navigating away during a long first turn made the active conversation disappear from the sidebar (looked like data loss to users). The filter now exempts sessions with `active_stream_id` (index path) or with `active_stream_id` plus `pending_user_message` (full-scan path), so in-progress conversations remain visible while truly empty scratch sessions are still hidden. 7 new regression tests cover both filter paths and edge cases. (`api/models.py`, `tests/test_streaming_session_sidebar.py`) @franksong2702 — PR #1330, fixes #1327
- **Default model rehydration when providers share slash-qualified IDs (#1313)** — `_deduplicate_model_ids()` only de-duplicated bare IDs and skipped slash-qualified IDs entirely, so when two providers exposed the same `vendor/model` (e.g. two custom providers both listing `google/gemma-4-27b`), the dropdown contained duplicate `<option value>` entries and reopening Preferences could snap the saved default model back to the first provider that shared the ID. The dedupe now covers slash IDs as well, the configured-model badge lookup respects the matching provider, and the frontend matcher prefers the configured `active_provider` when rehydrating a saved default model. (`api/config.py`, `static/panels.js`, `static/ui.js`, `tests/test_issue1228_model_picker_duplicate_ids.py`, `tests/test_model_picker_badges.py`) @hacker2005 — PR #1326, fixes #1313
- **Configured fallback models always appear in the dropdown** — the model picker only rendered configured models that already existed in the loaded `<select>` options, so when `/api/models` exposed a fallback chain in `configured_model_badges` but the underlying provider's catalog (especially `local-ollama`) was empty or partial, the **Configured** section showed an incomplete chain. The dropdown now synthesizes entries from `configured_model_badges` for any configured model missing from the catalog, sorts them as primary → fallback 1 → fallback N, and renders them under a single "Configured" header above the per-provider groups. (`static/ui.js`, `tests/test_model_picker_badges.py`) @renatomott — PR #1322
- **Duplicate header copy buttons on language-fenced code blocks** — for code blocks with a language header, the copy button is appended to the sibling `.pre-header`, not inside `<pre>`, but the existing duplicate guard only checked inside `<pre>`. Repeated post-render passes (cache replays, streaming updates) could append duplicate copy buttons in the header. The guard now also checks the header before creating a new button. (`static/ui.js`, `tests/test_issue1096_copy_buttons.py`) @dso2ng — PR #1324, fixes #1096
- **zh-Hant locale labels — restore Traditional Chinese in tree/raw view and MCP server settings** — a recent locale-merge accidentally left Russian strings in the zh-Hant block for tree-toggle labels, the parse-failed note, and Settings → System → MCP Servers. zh-TW users saw mixed Russian/Chinese UI text in those areas. The labels are now restored to Traditional Chinese, plus a regression test that asserts no Cyrillic characters can slip back into the zh-Hant block. (`static/i18n.js`, `tests/test_chinese_locale.py`) @dso2ng — PR #1323
- **Docker `HEALTHCHECK` instruction added** — the Dockerfile was missing a `HEALTHCHECK`, so `docker ps` couldn't show health, Docker Compose `depends_on: condition: service_healthy` didn't work, and orchestration tools (K8s, Swarm) couldn't use native health probes. Added a 30s-interval HEALTHCHECK that hits the existing `/health` endpoint. (`Dockerfile`) @zichen0116 — PR #1332
- **`.env.example` state-dir default aligned with `bootstrap.py`** — `HERMES_WEBUI_STATE_DIR` in `.env.example` referenced the obsolete `~/.hermes/webui-mvp` path while `bootstrap.py` and `docker-compose.yml` already use `~/.hermes/webui`. Updated the example file so users following it land in the same state dir as the rest of the codebase. (`.env.example`) @zichen0116 — PR #1331

## [v0.50.244] — 2026-04-30

Expand Down
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -92,5 +92,8 @@ ENV HERMES_WEBUI_PORT=8787

EXPOSE 8787

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8787/health || exit 1

CMD ["/hermeswebui_init.bash"]

67 changes: 36 additions & 31 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -880,23 +880,23 @@ def _apply_provider_prefix(
def _deduplicate_model_ids(groups: list[dict]) -> None:
"""Ensure every model ID across groups is globally unique.

When multiple providers expose the same bare model ID (e.g. two
custom providers both listing ``gpt-5.4``), the dropdown cannot
distinguish them. This post-process detects such collisions and
prefixes colliding entries with ``@provider_id:`` so the frontend
can treat them as distinct options.

The first occurrence (in group order) is left bare for backward
compatibility with sessions that already store the bare model name.
If that provider is later removed from the config, the next cache
rebuild re-runs dedup — the remaining provider becomes the sole
occurrence and is left bare, so the session still matches.
When multiple providers expose the same model ID (either bare names like
``gpt-5.4`` or slash-qualified IDs like ``google/gemma-4-27b``), the
dropdown cannot distinguish them. This post-process detects such
collisions and prefixes colliding entries with ``@provider_id:`` so the
frontend can treat them as distinct options.

The first occurrence (in provider-id order) is left unchanged for backward
compatibility with sessions that already store the original bare/slash
model name. If that provider is later removed from the config, the next
cache rebuild re-runs dedup — the remaining provider becomes the sole
occurrence and is left unchanged, so the session still matches.

.. note::
The "first occurrence wins" rule means the bare ID is not stable
The "first occurrence wins" rule means the unchanged ID is not stable
across config changes (adding, removing, or reordering providers).
This is acceptable because the dedup runs on every cache rebuild,
so sessions always resolve to the current canonical bare ID.
so sessions always resolve to the current canonical unchanged ID.

The ``@provider_id:model`` format is consistent with the existing
``_apply_provider_prefix()`` function and is handled by
Expand All @@ -908,8 +908,8 @@ def _deduplicate_model_ids(groups: list[dict]) -> None:
if not groups:
return

# Collect {bare_id: [(group_idx, model_idx), ...]} in alphabetical
# provider_id order so that the "first occurrence stays bare" rule is
# Collect {model_id: [(group_idx, model_idx), ...]} in alphabetical
# provider_id order so that the "first occurrence stays unchanged" rule is
# deterministic across config edits (adding/removing/reordering providers).
sorted_group_indices = sorted(
range(len(groups)),
Expand All @@ -918,34 +918,29 @@ def _deduplicate_model_ids(groups: list[dict]) -> None:
id_map: dict[str, list[tuple[int, int]]] = {}
for gi in sorted_group_indices:
group = groups[gi]
pid = group.get("provider_id", "")
for mi, model in enumerate(group.get("models", [])):
mid = model.get("id", "")
# Skip IDs that are already provider-qualified
if mid.startswith("@") or "/" in mid:
mid = str(model.get("id", "") or "").strip()
# Skip IDs that are already provider-qualified.
if not mid or mid.startswith("@"):
continue
id_map.setdefault(mid, []).append((gi, mi))

# For any bare ID appearing in 2+ groups, prefix all but the first
# occurrence. The first stays bare for backward compat; the rest
# get ``@provider_id:id`` and a disambiguated label.
# For any ID appearing in 2+ groups, prefix all but the first occurrence.
# This handles N>2 providers correctly: the loop iterates over all
# occurrences after the first, prefixing each with its own provider_id.
for bare_id, locations in id_map.items():
for original_id, locations in id_map.items():
if len(locations) < 2:
continue
# Prefix all occurrences after the first
for gi, mi in locations[1:]:
group = groups[gi]
model = group["models"][mi]
pid = group.get("provider_id", "")
model["id"] = f"@{pid}:{bare_id}"
model["id"] = f"@{pid}:{original_id}"
provider_name = group.get("provider", pid)
# Update label to show provider for clarity
if model.get("label") != bare_id:
if model.get("label") != original_id:
model["label"] = f"{model['label']} ({provider_name})"
else:
model["label"] = f"{bare_id} ({provider_name})"
model["label"] = f"{original_id} ({provider_name})"


def resolve_model_provider(model_id: str) -> tuple:
Expand Down Expand Up @@ -1475,6 +1470,12 @@ def _build_configured_model_badges() -> dict[str, dict[str, str]]:

option_ids = [m.get("id", "") for g in groups for m in g.get("models", []) if m.get("id")]
option_lookup = {str(opt_id): str(opt_id) for opt_id in option_ids}
option_provider_lookup = {
str(m.get("id")): str(g.get("provider_id") or "")
for g in groups
for m in g.get("models", [])
if m.get("id")
}
norm_lookup: dict[str, list[str]] = {}
for opt_id in option_ids:
norm_lookup.setdefault(_norm_model_id(opt_id), []).append(opt_id)
Expand All @@ -1493,8 +1494,9 @@ def _build_configured_model_badges() -> dict[str, dict[str, str]]:
raw_candidates.append(candidate)

match_id = None
exact_match = next((option_lookup[c] for c in raw_candidates if c in option_lookup), None)
for candidate in raw_candidates:
if candidate in option_lookup:
if candidate in option_lookup and option_provider_lookup.get(candidate) == provider:
match_id = option_lookup[candidate]
break
if match_id is None:
Expand All @@ -1504,15 +1506,18 @@ def _build_configured_model_badges() -> dict[str, dict[str, str]]:
if not matches:
continue
provider_match = next(
(m for m in matches if m.startswith(f"@{provider}:") or m.startswith(f"{provider}/")),
(m for m in matches if option_provider_lookup.get(m) == provider),
None,
)
match_id = provider_match or matches[0]
match_id = provider_match or exact_match or matches[0]
if match_id:
break

badge_payload = {"role": entry["role"], "label": entry["label"], "provider": provider}
for candidate in raw_candidates:
candidate_provider = option_provider_lookup.get(candidate)
if candidate_provider and candidate_provider != provider:
continue
badges[candidate] = badge_payload
if match_id:
badges[match_id] = badge_payload
Expand Down
11 changes: 10 additions & 1 deletion api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -769,9 +769,16 @@ def all_sessions():
# No grace window: a 0-message Untitled session is never shown in the list
# regardless of age. This means page refreshes and accidental New Conversation
# clicks never leave orphan entries in the sidebar.
#
# Exception: sessions with active_stream_id set are actively streaming (#1327).
# #1184 deferred the first save() until the first message, so during the
# initial streaming turn the session still looks like Untitled+0-messages.
# Without this exemption, navigating away during a long first turn causes
# the session to vanish from the sidebar.
result = [s for s in result if not (
s.get('title', 'Untitled') == 'Untitled'
and s.get('message_count', 0) == 0
and not s.get('active_stream_id')
)]
result = [s for s in result if not _hide_from_default_sidebar(s)]
# Backfill: sessions created before Sprint 22 have no profile tag.
Expand All @@ -796,10 +803,12 @@ def all_sessions():
out.sort(key=lambda s: (getattr(s, 'pinned', False), _session_sort_timestamp(s)), reverse=True)
# Hide empty Untitled sessions from the UI entirely — kept consistent with the
# index-path filter above. No grace window: a 0-message Untitled session is
# never shown regardless of age (#1171).
# never shown regardless of age (#1171). Same streaming exemption as above (#1327).
result = [s.compact(include_runtime=True, active_stream_ids=active_stream_ids) for s in out if not (
s.title == 'Untitled'
and len(s.messages) == 0
and not s.active_stream_id
and not s.pending_user_message
)]
result = [s for s in result if not _hide_from_default_sidebar(s)]
for s in result:
Expand Down
2 changes: 1 addition & 1 deletion api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ def _cron_output_content_window(text: str, limit: int = _CRON_OUTPUT_CONTENT_LIM

def _run_cron_tracked(job):
"""Wrapper that tracks running state around cron.scheduler.run_job."""
from cron.scheduler import run_job # import here — runs inside a worker thread
try:
run_job(job)
finally:
Expand Down Expand Up @@ -3500,7 +3501,6 @@ def _handle_cron_run(handler, body):
if not job_id:
return bad(handler, "job_id required")
from cron.jobs import get_job
from cron.scheduler import run_job

job = get_job(job_id)
if not job:
Expand Down
6 changes: 5 additions & 1 deletion api/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -1883,6 +1883,10 @@ def on_tool(*cb_args, **cb_kwargs):
agent.ephemeral_system_prompt = _personality_prompt
_previous_messages = list(s.messages or [])
_previous_context_messages = list(_session_context_messages(s))
_pre_compression_count = getattr(
getattr(agent, 'context_compressor', None),
'compression_count', 0,
)

# ── Periodic checkpoint during streaming (Issue #765) ──
# The agent works on an internal copy of s.messages during run_conversation()
Expand Down Expand Up @@ -2107,7 +2111,7 @@ def _periodic_checkpoint():
# Also detect compression via the result dict or compressor state
if not _compressed:
_compressor = getattr(agent, 'context_compressor', None)
if _compressor and getattr(_compressor, 'compression_count', 0) > 0:
if _compressor and getattr(_compressor, 'compression_count', 0) > _pre_compression_count:
_compressed = True
# Notify the frontend that compression happened
if _compressed:
Expand Down
26 changes: 24 additions & 2 deletions static/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ function _isCompactWorkspaceViewport(){
return window.matchMedia('(max-width: 900px)').matches;
}

function _syncWorkspacePanelInlineWidth(){
const {panel}= _workspacePanelEls();
if(!panel) return;

const isCompact = _isCompactWorkspaceViewport();
if(isCompact){
if(panel.style.width) panel.style.removeProperty('width');
return;
}

const saved = localStorage.getItem('hermes-panel-w');
if(!saved) return;
const parsed = parseInt(saved, 10);
if(Number.isNaN(parsed) || parsed <= 0) return;
panel.style.width = `${parsed}px`;
}

function _workspacePanelEls(){
return {
layout: document.querySelector('.layout'),
Expand Down Expand Up @@ -578,6 +595,7 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
});

window.addEventListener('resize',()=>{
_syncWorkspacePanelInlineWidth();
syncWorkspacePanelState();
});

Expand All @@ -592,8 +610,12 @@ window.addEventListener('resize',()=>{
if(!handle || !targetEl) return;

// Restore saved width
const saved = localStorage.getItem(storageKey);
if(saved) targetEl.style.width = saved + 'px';
if(storageKey === 'hermes-panel-w'){
_syncWorkspacePanelInlineWidth();
}else{
const saved = localStorage.getItem(storageKey);
if(saved) targetEl.style.width = saved + 'px';
}

let startX=0, startW=0;

Expand Down
Loading
Loading