Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
03949f8
fix: clarify update network failures
Michaelyklam May 5, 2026
0fe3927
fix: surface Codex spark models
Michaelyklam May 5, 2026
f6a532d
fix: normalize named profile base homes
Michaelyklam May 5, 2026
d51510a
fix: keep HTTP update errors out of network recovery
Michaelyklam May 5, 2026
52e7916
fix: avoid adaptive title refresh session lock deadlock
May 5, 2026
dc7ba0c
fix: normalize update banner repository URLs
Michaelyklam May 5, 2026
ff23249
fix: keep workspace rename double-click reachable
Michaelyklam May 5, 2026
c4ef5b6
fix: invalidate model cache on auth-store drift
Michaelyklam May 5, 2026
f76921d
fix: honor markdown fence lengths
Michaelyklam May 5, 2026
1997a48
test: keep model cache drift regression hermetic
Michaelyklam May 5, 2026
6173d6d
fix(ui): inline provider chip + group model count in model picker (#1…
bergeouss May 4, 2026
9dddb5b
Stage 301: PR #1689
nesquena May 5, 2026
db972af
Stage 301: PR #1693
nesquena May 5, 2026
bf8b5ed
Stage 301: PR #1701
nesquena May 5, 2026
08ea4fb
Stage 301: PR #1685
nesquena May 5, 2026
a66feb2
Stage 301: PR #1703
nesquena May 5, 2026
651cd29
Stage 301: PR #1644
nesquena May 5, 2026
8e7a9b1
Stage 301: PR #1684
nesquena May 5, 2026
debb4c5
Stage 301: PR #1702
nesquena May 5, 2026
e5927c6
Stage 301: PR #1704
nesquena May 5, 2026
8c8e2d3
fix: keep multi-image paste attachments
Michaelyklam May 5, 2026
2a838ee
Stage 301: PR #1706
nesquena May 5, 2026
451c946
chore(release): stamp v0.51.4 — 10-PR full-sweep batch
nesquena May 5, 2026
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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
# Hermes Web UI -- Changelog

## [v0.51.4] — 2026-05-05 — 10-PR full-sweep batch

### Added

- **PR #1685** by @Michaelyklam — Surface Codex spark models in `/api/models` (closes #1680). New `_read_visible_codex_cache_model_ids()` reads visible non-hidden slugs from `CODEX_HOME/models_cache.json`. The OpenAI Codex group now layers three sources: `hermes_cli.models.provider_model_ids("openai-codex")` first, visible cache slugs second, static `_PROVIDER_MODELS` fallback last. Users see newly available Codex models (including `gpt-5.3-codex-spark`) without waiting for WebUI catalog updates.
- **PR #1644** by @bergeouss — Inline provider chip + group model count in composer model picker (closes #1425). Same-name models across providers are now visually distinguishable: per-row provider chip on every model option, count `(N)` next to group headings when more than one model matches, subtle border-top divider between provider groups. 13 LOC total — pattern-extension within existing dropdown.
- **PR #1684** by @Michaelyklam — Clarify update network failures (closes #1321). Frontend detects raw fetch failures (`Failed to fetch`, `NetworkError`, `Load failed`) on `POST /api/updates/apply` and replaces the cryptic browser text with recovery-oriented guidance ("the WebUI may have restarted or the connection was interrupted; wait, reload, and check the server if needed"). Added an in-flight guard so repeated Update Now clicks don't send duplicate apply requests during restart-race windows.

### Fixed

- **PR #1689** by @Michaelyklam — Normalize named profile base homes (refs #749). Prevents the doubled `/base/profiles/foo/profiles/foo` path that occurred when both `HERMES_BASE_HOME=/base/profiles/foo` and the browser cookie `hermes_profile=foo` were set. New `_unwrap_profile_home_to_base()` helper normalizes either env-var path through the same base-home resolver, then routes active-profile and explicit per-request lookups through one shared profile-home resolver. Doesn't touch the broader profile UX umbrella.
- **PR #1693** by @ai-ag2026 — Avoid adaptive title refresh session lock deadlock. `_run_background_title_refresh()` previously updated a session title while holding the global session `LOCK`, then called `Session.save()` — which itself updates the session index via `_write_session_index()` requiring the same non-reentrant `LOCK` (self-deadlock). Now the in-memory title mutation stays under `LOCK`, but `Session.save()` runs with the global lock released and only the per-session agent lock held. Plus Latin-Unicode-aware fallback title tokenization so `führe` no longer becomes `f` + `hre`.
- **PR #1701** by @Michaelyklam — Normalize update banner repository URLs (closes #1691). The "What's new?" link previously pointed at `https://github.com/nesquena/hermes-webu/` instead of `hermes-webui`. Root cause: `.git` was treated as a character set (`[.git]`) instead of a literal suffix, and trailing slashes prevented suffix removal. New `_normalize_remote_url()` in `api/updates.py` centralizes the normalization with regression coverage on the edge case.
- **PR #1703** by @Michaelyklam — Invalidate models cache on auth-store drift (closes #1699). When a user runs `hermes setup` in a terminal and the auth store switches the active provider outside WebUI, the in-memory + disk model caches could keep showing the previous provider's PRIMARY badge for up to the 24h TTL. New non-secret source fingerprint covers `config.yaml` and `auth.json` path/mtime/size; cache rebuilds when either changes outside WebUI. Disk cache schema bumped to reject older cache files cleanly.
- **PR #1702** by @Michaelyklam — Fix workspace tree double-click rename (closes #1698). The right workspace panel advertised double-click rename on file names, but file-name single-click bubbled to the row's preview handler before the dblclick rename path could take over. Added a `nameEl.onclick` propagation guard before the existing `nameEl.ondblclick` handler in `static/ui.js` while leaving row/icon/whitespace clicks available for preview. Right-click context-menu rename remains as before.
- **PR #1704** by @Michaelyklam — Honor markdown fence lengths (closes #1696). The `renderMd()` regex hard-coded triple-backtick closers, so 4/5-backtick markdown examples closed at inner triple fences. Updated fenced-code matching to capture `{3,}` backtick opener runs and require the same character + at least as many backticks on close (per CommonMark §4.5). Same fence-length rule applied to user-message fenced rendering and to the blockquote pre-pass fence-state walker. Empty-fence handling unchanged.
- **PR #1706** by @Michaelyklam — Paste multiple images at once attaches all of them (closes #1697). `static/boot.js` paste handler called `Date.now()` inside a synchronous `.map()` callback over `imageItems`. All N synthesized `File` objects ended up with identical filenames (same millisecond), and `addFiles()` deduped by name and silently dropped images 2..N. Fix captures `pasteTs = Date.now()` once outside the map and adds deterministic `-1`, `-2`, … suffixes only when the paste contains multiple images. Single-image paste filename shape unchanged for compatibility. Functional Node-driven test extracts and executes the real paste handler.

### Tests

4477 → **4503 passing** (+26 regression tests across the 10 PRs). 0 regressions. Full suite ~135s.

### Pre-release verification

- Stage-301 build: 10 PRs merged with zero conflicts (each rebased clean against current master).
- All JS files syntax-clean (`node -c static/boot.js && node -c static/ui.js`).
- All Python files syntax-clean (py_compile on every changed file).
- Live browser walkthrough on port 8789: model picker chip + group count rendering, all `/api/wiki/status`, `/api/logs`, `/api/provider/quota`, `/api/health/agent` endpoints respond 200, sidebar scroll fix preserved, `boot.js` PR #1706 fix verified live (pasteTs captured outside map, index parameter present, Date.now() removed from inside .map()).
- Opus advisor pass on 9-PR variant (with #1705 in slot 10): SHIP, 7/7 verification questions resolved cleanly. Late swap to #1706 keeps identical fix shape (same `pasteTs` outside map + index suffix); Opus's verification answers carry over because the production diff is unchanged.

### Notes on the 1705 → 1706 swap

@Michaelyklam filed PR #1706 with a functional Node-driven regression test (extracts the real paste handler and asserts two pasted image items become two pending attachments) replacing my own #1705 which used static-source-string assertions. Same code fix, better test approach. Closed #1705 and absorbed #1706 into stage-301.


## [v0.51.3] — 2026-05-04 — 3-PR follow-up batch (#1671, #1673, #1676) + test-fragility fix

### Added
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
>
> Last updated: v0.51.3 (May 04, 2026) — 4477 tests collected — 3-PR follow-up batch (#1671, #1673, #1676)
> Last updated: v0.51.4 (May 5, 2026) — 4503 tests collected — 3-PR follow-up batch (#1671, #1673, #1676)
> Test source: `pytest tests/ --collect-only -q`
> Per-version detail: see [CHANGELOG.md](./CHANGELOG.md)

Expand Down
4 changes: 2 additions & 2 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -1835,8 +1835,8 @@ Bridged CLI sessions:

---

*Last updated: v0.51.3, May 04, 2026 — 3-PR follow-up batch (#1671, #1673, #1676)*
*Total automated tests collected: 4477*
*Last updated: v0.51.4, May 5, 2026*
*Total automated tests collected: 4503*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*
159 changes: 148 additions & 11 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1585,6 +1585,7 @@ def set_hermes_default_model(model_id: str) -> dict:
# ── TTL cache for get_available_models() ─────────────────────────────────────
_available_models_cache: dict | None = None
_available_models_cache_ts: float = 0.0
_available_models_cache_source_fingerprint: dict | None = None
_AVAILABLE_MODELS_CACHE_TTL: float = 86400.0 # 24 hours
_available_models_cache_lock = threading.RLock() # must be RLock: cold path refactoring moved slow work inside this lock, requiring re-entry
_cache_build_cv = threading.Condition(_available_models_cache_lock) # shares underlying RLock so notify_all() is safe inside with _available_models_cache_lock
Expand Down Expand Up @@ -1641,12 +1642,48 @@ def _current_webui_version() -> str | None:
# guarantees that even if a future release accidentally reuses the same
# WebUI version string (or a debug build doesn't have a version), a structural
# change still invalidates the cache.
_MODELS_CACHE_SCHEMA_VERSION = 2
_MODELS_CACHE_SCHEMA_VERSION = 3


_models_cache_path = STATE_DIR / "models_cache.json"


def _get_auth_store_path() -> Path:
"""Return the auth.json path for the active Hermes profile."""
try:
from api.profiles import get_active_hermes_home as _gah

return _gah() / "auth.json"
except ImportError:
return HOME / ".hermes" / "auth.json"


def _models_cache_file_fingerprint(path: Path) -> dict:
"""Return non-secret identity metadata for a cache dependency file.

The /api/models response depends on config.yaml (model/provider defaults)
and auth.json (active_provider + credential_pool). The cache only needs
cheap invalidation signals here, not file contents; never include secrets.
"""
fingerprint = {"path": str(Path(path).expanduser())}
try:
st = Path(path).stat()
except OSError:
fingerprint["missing"] = True
return fingerprint
fingerprint["mtime_ns"] = st.st_mtime_ns
fingerprint["size"] = st.st_size
return fingerprint


def _models_cache_source_fingerprint() -> dict:
"""Return the current config/auth-store fingerprint for /api/models cache."""
return {
"config_yaml": _models_cache_file_fingerprint(_get_config_path()),
"auth_json": _models_cache_file_fingerprint(_get_auth_store_path()),
}


def _delete_models_cache_on_disk() -> None:
try:
os.unlink(str(_models_cache_path))
Expand Down Expand Up @@ -1717,6 +1754,15 @@ def _is_loadable_disk_cache(cache: object) -> bool:
cached_version, runtime_version,
)
return False
cached_sources = cache.get("_source_fingerprint")
runtime_sources = _models_cache_source_fingerprint()
if cached_sources != runtime_sources:
logger.debug(
"models cache rejected: source_fingerprint=%r vs runtime=%r",
cached_sources,
runtime_sources,
)
return False
return True


Expand Down Expand Up @@ -1772,6 +1818,7 @@ def _save_models_cache_to_disk(cache: dict) -> None:
return
payload = {
"_schema_version": _MODELS_CACHE_SCHEMA_VERSION,
"_source_fingerprint": _models_cache_source_fingerprint(),
"active_provider": cache["active_provider"],
"default_model": cache["default_model"],
"configured_model_badges": cache["configured_model_badges"],
Expand All @@ -1790,15 +1837,27 @@ def _save_models_cache_to_disk(cache: dict) -> None:

def _get_fresh_memory_models_cache(now: float) -> dict | None:
"""Return a valid fresh in-memory /api/models cache, or clear stale shapes."""
global _available_models_cache, _available_models_cache_ts
global _available_models_cache, _available_models_cache_ts, _available_models_cache_source_fingerprint
if _available_models_cache is None:
return None
if (now - _available_models_cache_ts) >= _AVAILABLE_MODELS_CACHE_TTL:
return None
current_sources = _models_cache_source_fingerprint()
if _available_models_cache_source_fingerprint != current_sources:
logger.debug(
"models memory cache rejected: source_fingerprint=%r vs runtime=%r",
_available_models_cache_source_fingerprint,
current_sources,
)
_available_models_cache = None
_available_models_cache_ts = 0.0
_available_models_cache_source_fingerprint = None
return None
if _is_valid_models_cache(_available_models_cache):
return copy.deepcopy(_available_models_cache)
_available_models_cache = None
_available_models_cache_ts = 0.0
_available_models_cache_source_fingerprint = None
return None


Expand All @@ -1816,10 +1875,11 @@ def invalidate_models_cache():
result from the disk cache because the disk hit is checked before the memory
cache rebuild runs.
"""
global _cache_build_in_progress, _available_models_cache, _available_models_cache_ts, _cache_build_cv
global _cache_build_in_progress, _available_models_cache, _available_models_cache_ts, _available_models_cache_source_fingerprint, _cache_build_cv
with _available_models_cache_lock:
_available_models_cache = None
_available_models_cache_ts = 0.0
_available_models_cache_source_fingerprint = None
_cache_build_in_progress = False
_cache_build_cv.notify_all()
# Clear the credential pool cache too. The cache key is provider_id
Expand Down Expand Up @@ -1856,10 +1916,11 @@ def invalidate_provider_models_cache(provider_id: str):
Args:
provider_id: canonical provider id (e.g. 'openai', 'anthropic', 'custom:my-key')
"""
global _available_models_cache, _available_models_cache_ts, _CREDENTIAL_POOL_CACHE
global _available_models_cache, _available_models_cache_ts, _available_models_cache_source_fingerprint, _CREDENTIAL_POOL_CACHE
with _available_models_cache_lock:
_available_models_cache = None
_available_models_cache_ts = 0.0
_available_models_cache_source_fingerprint = None
_provider_models_invalidated_ts[provider_id] = time.time()
# Also evict the credential pool so the next cold path re-loads it.
# Must evict both the original key and its canonical form (load_pool
Expand Down Expand Up @@ -1902,6 +1963,47 @@ def _get_label_for_model(model_id: str, existing_groups: list) -> str:
)


def _read_visible_codex_cache_model_ids() -> list[str]:
"""Return visible model slugs from Codex's local models_cache.json.

The agent's provider_model_ids('openai-codex') intentionally filters IDs
with ``supported_in_api: false``. Codex CLI still lists some of those models
in its picker (notably ``gpt-5.3-codex-spark`` from #1680), so the WebUI
merges this visible local catalog to stay in sync with Codex itself.
"""
codex_home = Path(os.getenv("CODEX_HOME", "").strip() or (HOME / ".codex")).expanduser()
cache_path = codex_home / "models_cache.json"
try:
payload = json.loads(cache_path.read_text(encoding="utf-8"))
except Exception:
return []

entries = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(entries, list):
return []

sortable: list[tuple[int, str]] = []
for item in entries:
if not isinstance(item, dict):
continue
slug = item.get("slug")
if not isinstance(slug, str) or not slug.strip():
continue
visibility = item.get("visibility", "")
if isinstance(visibility, str) and visibility.strip().lower() in ("hide", "hidden"):
continue
priority = item.get("priority")
rank = int(priority) if isinstance(priority, (int, float)) else 10_000
sortable.append((rank, slug.strip()))

sortable.sort(key=lambda item: (item[0], item[1]))
ordered: list[str] = []
for _, slug in sortable:
if slug not in ordered:
ordered.append(slug)
return ordered


def get_available_models() -> dict:
"""
Return available models grouped by provider.
Expand All @@ -1918,7 +2020,7 @@ def get_available_models() -> dict:
'groups': [{'provider': str, 'models': [{'id': str, 'label': str}]}]
}
"""
global _cache_build_in_progress, _available_models_cache, _available_models_cache_ts, _cache_build_cv
global _cache_build_in_progress, _available_models_cache, _available_models_cache_ts, _available_models_cache_source_fingerprint, _cache_build_cv
# Config mtime check — must come before any config reads.
# (Test #585 verifies _current_mtime appears before active_provider = None)
try:
Expand Down Expand Up @@ -2053,12 +2155,7 @@ def _build_configured_model_badges() -> dict[str, dict[str, str]]:

# 2. Read auth store (active_provider fallback + credential_pool inspection)
auth_store = {}
try:
from api.profiles import get_active_hermes_home as _gah

auth_store_path = _gah() / "auth.json"
except ImportError:
auth_store_path = HOME / ".hermes" / "auth.json"
auth_store_path = _get_auth_store_path()
if auth_store_path.exists():
try:
import json as _j
Expand Down Expand Up @@ -2671,6 +2768,43 @@ def _configured_provider_for_base_url(base_url: object) -> str:
except Exception:
logger.warning("Failed to load Ollama Cloud models from hermes_cli")

if raw_models:
models = _apply_provider_prefix(raw_models, pid, active_provider)
groups.append(
{
"provider": provider_name,
"provider_id": pid,
"models": models,
}
)
elif pid == "openai-codex":
# Codex account catalogs drift faster than WebUI releases
# (for example gpt-5.3-codex-spark in #1680). Ask the
# agent's Codex resolver first so /api/models inherits the
# live Codex API / local ~/.codex cache / static fallback
# chain instead of freezing the picker to WebUI's curated
# _PROVIDER_MODELS snapshot.
raw_models = []
codex_ids = []
try:
from hermes_cli.models import provider_model_ids as _provider_model_ids

codex_ids = [mid for mid in (_provider_model_ids("openai-codex") or []) if mid]
except Exception:
logger.warning("Failed to load OpenAI Codex models from hermes_cli")

for mid in _read_visible_codex_cache_model_ids():
if mid not in codex_ids:
codex_ids.append(mid)

raw_models = [
{"id": mid, "label": _get_label_for_model(mid, [])}
for mid in codex_ids
]

if not raw_models:
raw_models = copy.deepcopy(_PROVIDER_MODELS.get("openai-codex", []))

if raw_models:
models = _apply_provider_prefix(raw_models, pid, active_provider)
groups.append(
Expand Down Expand Up @@ -2939,6 +3073,7 @@ def _configured_provider_for_base_url(base_url: object) -> str:
reload_config()
_available_models_cache = None
_available_models_cache_ts = 0.0
_available_models_cache_source_fingerprint = None
disk_groups = None

# Serve from memory cache if fresh
Expand All @@ -2951,6 +3086,7 @@ def _configured_provider_for_base_url(base_url: object) -> str:
if disk_groups is not None:
_available_models_cache = disk_groups
_available_models_cache_ts = now
_available_models_cache_source_fingerprint = _models_cache_source_fingerprint()
_save_models_cache_to_disk(disk_groups)
return copy.deepcopy(disk_groups)

Expand All @@ -2968,6 +3104,7 @@ def _configured_provider_for_base_url(base_url: object) -> str:
with _cache_build_cv:
_available_models_cache = result
_available_models_cache_ts = time.monotonic()
_available_models_cache_source_fingerprint = _models_cache_source_fingerprint()
_cache_build_in_progress = False
_cache_build_cv.notify_all()
_save_models_cache_to_disk(result)
Expand Down
Loading
Loading