Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
6caf86b
feat(workspace): download folder as zip via /api/folder/download
bjb2 May 19, 2026
960c95c
docs(runtime): define runner sidecar gate
May 19, 2026
11e1e9a
Fix settled rendering for file markdown links
dobby-d-elf May 19, 2026
467ef33
feat(webui): reconcile external session updates
LumenYoung May 13, 2026
f12fef2
fix(webui): clear stale prompts on external refresh
LumenYoung May 13, 2026
a63ab31
fix(webui): preserve reconciled session invariants
LumenYoung May 14, 2026
6ca63e5
perf(webui): keep external refresh metadata cheap
LumenYoung May 14, 2026
600bb48
fix(webui): use active state db for metadata summary
LumenYoung May 14, 2026
2e9ca28
fix: display canonical cache hit percentage
starship-s May 19, 2026
7965293
fix: centralize workspace tree toggle width
May 19, 2026
71d8a8f
fix: reap terminal shells on shutdown
May 19, 2026
2a95c1e
Fix profile-aware assistant display names
dobby-d-elf May 19, 2026
646f18c
fix: prevent queued follow-up message from draining into wrong chat
May 19, 2026
f93e288
Fix stale stream recovery writeback race
AJV20 May 19, 2026
a8d4297
fix(webui): preserve casual chat compaction guard
LumenYoung May 19, 2026
bc76482
fix: preserve provider for configured model picker selections
May 19, 2026
0736e45
fix: dedupe tool-only partial recovery markers
May 19, 2026
629ebf4
Stage 386: PR #2575
May 19, 2026
05de68f
Stage 386: PR #2580
May 19, 2026
4b72539
Stage 386: PR #2576
May 19, 2026
42c2eda
Stage 386: PR #2579
May 19, 2026
9a51219
Stage 386: PR #2582
May 19, 2026
7675f2f
Stage 386: PR #2588
May 19, 2026
0585881
Stage 386: PR #2583
May 19, 2026
86f52f6
Stage 386: PR #2581
May 19, 2026
96cb4a5
Stage 386: PR #2584
May 19, 2026
6c0f864
Stage 386: PR #2587
May 19, 2026
cf014f3
Stamp CHANGELOG for v0.51.93 (Release BQ / stage-386 / 10-PR full swe…
May 19, 2026
b1b93f9
fix(i18n): add download_folder key to all non-en locales
bjb2 May 19, 2026
acd1df1
fix: time out hung browser api requests
dso2ng May 19, 2026
0310fcc
Merge pull request #2596 from nesquena/stage-386
nesquena-hermes May 19, 2026
94ceb66
docs: clarify folder-zip cap bounds wall-clock/bandwidth not RSS
bjb2 May 19, 2026
8d2b9d4
feat(webui): render indexed context metadata
LumenYoung May 19, 2026
ebb4dff
fix: stream live tool callback events
AJV20 May 19, 2026
5770323
feat(runtime): add runner adapter facade
May 19, 2026
37df7d7
fix(webui): prevent composer draft rollback on refresh
starship-s May 19, 2026
729ed41
fix(approval): peek _gateway_queues for session-level approval when _…
May 19, 2026
692ea22
fix(streaming): finish auto-compression card after rotation
starship-s May 19, 2026
ada59d7
fix(approval): simplify gateway_keys expression and document race window
May 19, 2026
1ebfbf3
fix: reconcile session metadata counts
May 19, 2026
dc5c816
fix(webui): refresh active session on external sidecar updates
LumenYoung May 19, 2026
7dd20de
Stage 387: PR #2599
May 19, 2026
536a8b7
Stage 387: PR #2566
May 19, 2026
e63de7c
Stage 387: PR #2593
May 19, 2026
c3fd395
Stage 387: PR #2597
May 19, 2026
3a40487
Stage 387: PR #2603
May 19, 2026
4bb60d9
Stage 387: PR #2601
May 19, 2026
1ddb182
Stage 387: PR #2604
May 19, 2026
9372789
Stage 387: PR #2605
May 19, 2026
cc8ef20
Stage 387: PR #2600
May 19, 2026
6d43116
Stage 387: PR #2573
May 19, 2026
7ae97c5
Stamp CHANGELOG for v0.51.94 (Release BR / stage-387 / 10-PR full swe…
May 19, 2026
c8896ac
Merge pull request #2606 from nesquena/stage-387
nesquena-hermes May 19, 2026
612fcd3
fix: avoid duplicate live tool events
AJV20 May 19, 2026
739c948
fix(system): allow browser-only dashboard links
AJV20 May 18, 2026
f141386
test: Sanitize dashboard URL fixture
AJV20 May 19, 2026
54b6c38
feat(health): expose WebUI stream runtime diagnostics
AJV20 May 18, 2026
cb08502
fix(session): dedupe messaging transcript timestamps
AJV20 May 19, 2026
4598adf
feat: add Geist Contrast skin
intellectronica May 18, 2026
2e91c0f
fix: honour skin value in theme command
intellectronica May 19, 2026
b05fe98
docs: avoid hard-coded skin count
intellectronica May 19, 2026
86d4375
docs: include Geist Contrast in contracts index
intellectronica May 19, 2026
a9e8ab2
Stage 388: PR #2521
May 20, 2026
7c3dcce
Stage 388: PR #2598
May 20, 2026
bd819f5
Stage 388: PR #2533
May 20, 2026
a201401
Stage 388: PR #2524
May 20, 2026
ed6ee3e
Stage 388: PR #2607
May 20, 2026
b2c9bdd
Stamp CHANGELOG for stage-388 (Release BS — 5-PR batch)
May 20, 2026
1a8c7b6
test: anchor picker-active-state assertions to global #mainSettings rule
May 20, 2026
fc5639d
test: exempt skin-specific scoped overrides from session-title color …
May 20, 2026
7eccff4
Stamp CHANGELOG for v0.51.95 (Release BS / stage-388 / 5-PR batch)
May 20, 2026
9c983e6
Merge pull request #2608 from nesquena/release/stage-388
nesquena-hermes May 20, 2026
5fc7aee
feat(workspace): add backend Git operations
stocky789 May 20, 2026
0f9c64b
fix: classify CRLF-only git status noise
stocky789 May 20, 2026
898e15a
fix(workspace): restore branch changes on switch
stocky789 May 20, 2026
9ac94d3
fix(workspace): tighten git subprocess trust boundary
stocky789 May 20, 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
99 changes: 56 additions & 43 deletions CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ Production data and real cron jobs are never touched. Current snapshot:
### Themes
- Appearance is split into two axes: Theme (`system`, `dark`, `light`) and Skin
(`default`, `ares`, `mono`, `slate`, `poseidon`, `sisyphus`, `charizard`,
`sienna`, `catppuccin`, `nous`)
`sienna`, `catppuccin`, `nous`, `geist-contrast` / Geist Contrast)
- Switch via Settings -> Appearance (instant live preview) or `/theme <theme-or-skin>`
- Persists across reloads (server-side in settings.json + localStorage for flicker-free loading)
- Skins use `data-skin` plus CSS variables; dark mode resolves through the
Expand Down
7 changes: 4 additions & 3 deletions THEMES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Hermes Web UI splits **appearance** into two independent pickers:

- **Theme** — the mode: `System`, `Dark`, or `Light`. Drives the background,
text, surface, and chrome colors.
- **Skin** — the accent palette: ten named skins ship built-in. Drives only
- **Skin** — the accent palette: built-in skins ship as named keys. Drives only
the `--accent` family (active states, links, focus rings, primary actions).

You pick one of each and they combine, so the look adapts to your environment
Expand All @@ -15,13 +15,13 @@ without losing your favorite accent — pure CSS, no Python changes needed.
## Switching Appearance

**Settings panel:** Click the gear icon → **Appearance**. The **Theme** card
toggles Light/Dark/System; the **Skin** grid offers ten accent palettes.
toggles Light/Dark/System; the **Skin** grid offers the built-in accent palettes.
Preview is instant — the UI updates as you click.

**Slash command:** Type `/theme <name>` in the composer. The command accepts
both theme names (`system`, `dark`, `light`) and skin names (`default`, `ares`,
`mono`, `slate`, `poseidon`, `sisyphus`, `charizard`, `sienna`,
`catppuccin`, `nous`). It updates the matching axis and leaves the other one
`catppuccin`, `nous`, `geist-contrast`). It updates the matching axis and leaves the other one
alone.

**Persistence:** Both choices are stored in `localStorage` for flicker-free
Expand Down Expand Up @@ -57,6 +57,7 @@ absent for light. System mode tracks the OS preference at runtime.
| **Sienna** | Warm clay and sand earth palette. Soft and natural. |
| **Catppuccin** | Catppuccin Latte/Mocha palette with Mauve accent. |
| **Nous** | Steel-blue accent with dashed technical surfaces. |
| **Geist Contrast** (`geist-contrast`) | Geist-inspired monochrome surfaces with a restrained dark-mode `#FFF175` accent. |

Each skin defines paired light + dark variants so it reads cleanly on either
theme. The skin is applied as `data-skin="<name>"` on `<html>` (the default
Expand Down
20 changes: 20 additions & 0 deletions api/compression_anchor.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,24 @@ def _content_has_part_type(content, part_types):
)


def _is_context_compression_marker(message):
"""Return true for synthetic compression/reference cards, not user turns."""
if not isinstance(message, dict):
return False
role = message.get("role")
if not role or role == "tool":
return False
text = _content_text(
message.get("content", ""),
part_types={"text", "input_text", "output_text"},
).lower().lstrip()
return (
text.startswith("[context compaction")
or text.startswith("context compaction")
or text.startswith("[your active task list was preserved across context compression]")
)


def visible_messages_for_anchor(messages, *, auto_compression: bool = False):
"""Return transcript messages that can anchor compression UI metadata.

Expand All @@ -70,6 +88,8 @@ def visible_messages_for_anchor(messages, *, auto_compression: bool = False):
role = message.get("role")
if not role or role == "tool":
continue
if _is_context_compression_marker(message):
continue

content = message.get("content", "")
has_attachments = bool(message.get("attachments"))
Expand Down
9 changes: 9 additions & 0 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4085,6 +4085,14 @@ def put_nowait(self, item: tuple[str, object]) -> None:
for q in subscribers:
q.put_nowait(item)

def diagnostic_snapshot(self) -> dict[str, int]:
"""Return non-sensitive stream observation counters for health checks."""
with self._lock:
return {
"subscriber_count": len(self._subscribers),
"offline_buffered_events": len(self._offline_buffer),
}


def create_stream_channel() -> StreamChannel:
return StreamChannel()
Expand Down Expand Up @@ -4279,6 +4287,7 @@ def _get_session_agent_lock(session_id: str) -> threading.Lock:
"sienna",
"catppuccin",
"nous",
"geist-contrast",
}
_SETTINGS_LEGACY_THEME_MAP = {
# Legacy full themes now map onto the closest supported theme + accent skin pair.
Expand Down
57 changes: 49 additions & 8 deletions api/dashboard_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import logging
import os
import urllib.request
from urllib.parse import urlparse
from urllib.parse import urlparse, urlunparse

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -61,6 +61,41 @@ def normalize_dashboard_url(raw_url: str | None) -> tuple[str, int, str, str] |
return normalized_host, port, parsed.scheme, base


def normalize_dashboard_browser_url(raw_url: str | None) -> str:
"""Return a safe browser-only dashboard link URL.

Unlike the server-side probe target, this value is only returned to the
browser for navigation. It may point at a public reverse-proxy hostname, but
it still rejects credentials, paths, query strings, fragments, and non-HTTP
schemes so it cannot hide secrets or script URLs in config.
"""
raw = str(raw_url or "").strip()
if not raw:
return ""
parsed = urlparse(raw)
if parsed.scheme not in {"http", "https"}:
raise ValueError("invalid dashboard URL scheme")
if parsed.username or parsed.password:
raise ValueError("invalid dashboard URL credentials")
if not parsed.hostname:
raise ValueError("invalid dashboard URL host")
if parsed.params or parsed.query or parsed.fragment:
raise ValueError("invalid dashboard URL path")
path = parsed.path or ""
if path not in ("", "/"):
raise ValueError("invalid dashboard URL path")
try:
port = parsed.port
except ValueError as exc:
raise ValueError("invalid dashboard URL port") from exc
netloc = parsed.hostname.lower()
if port is not None:
if not (1 <= port <= 65535):
raise ValueError("invalid dashboard URL port")
netloc = f"{netloc}:{port}"
return urlunparse((parsed.scheme, netloc, "", "", "", ""))


def _looks_like_official_dashboard(payload: object) -> bool:
if not isinstance(payload, dict):
return False
Expand Down Expand Up @@ -132,8 +167,7 @@ def get_dashboard_config(config_data: dict | None = None) -> dict:
enabled = "auto"
raw_url = str(dashboard_cfg.get("url") or "").strip()
if raw_url:
# Normalize before echoing so the UI never displays unsafe/stale values.
_host, _port, _scheme, raw_url = normalize_dashboard_url(raw_url)
raw_url = normalize_dashboard_browser_url(raw_url)
return {"enabled": enabled, "url": raw_url}


Expand All @@ -143,9 +177,7 @@ def save_dashboard_config(payload: dict) -> dict:
if enabled not in _DASHBOARD_ENABLED_VALUES:
raise ValueError("invalid dashboard enabled mode")
raw_url = str((payload or {}).get("url", "") or "").strip()
normalized_url = ""
if raw_url:
_host, _port, _scheme, normalized_url = normalize_dashboard_url(raw_url)
normalized_url = normalize_dashboard_browser_url(raw_url) if raw_url else ""

from api import config as webui_config

Expand Down Expand Up @@ -186,9 +218,13 @@ def get_dashboard_status(config_data: dict | None = None) -> dict:

raw_url = dashboard_cfg.get("url") or dashboard_cfg.get("target") or ""
try:
override = normalize_dashboard_url(raw_url)
browser_url = normalize_dashboard_browser_url(raw_url) if raw_url else ""
except ValueError:
return {"running": False, "enabled": enabled, "error": "invalid dashboard url"}
try:
override = normalize_dashboard_url(raw_url)
except ValueError:
override = None

targets: list[tuple[str, int, str, str]]
if override:
Expand All @@ -197,8 +233,10 @@ def get_dashboard_status(config_data: dict | None = None) -> dict:
targets = [(host, port, "http", _base_url(host, port)) for host, port in DEFAULT_DASHBOARD_TARGETS]

if enabled == "always":
if browser_url and not override:
return {"running": True, "enabled": enabled, "url": browser_url, "browser_url": browser_url}
host, port, scheme, base = targets[0]
return {"running": True, "enabled": enabled, "host": host, "port": port, "url": base}
return {"running": True, "enabled": enabled, "host": host, "port": port, "url": browser_url or base, "browser_url": browser_url or base}

if not _webui_bind_host_allows_auto_probe():
return {"running": False, "enabled": enabled}
Expand All @@ -207,5 +245,8 @@ def get_dashboard_status(config_data: dict | None = None) -> dict:
result = probe_official_dashboard(host, port, timeout=DEFAULT_DASHBOARD_TIMEOUT, scheme=scheme)
if result.get("running"):
result["enabled"] = enabled
if browser_url:
result["browser_url"] = browser_url
result["url"] = browser_url
return result
return {"running": False, "enabled": enabled}
Loading
Loading