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
47 changes: 45 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,50 @@
# Hermes Web UI -- Changelog

## [v0.51.2] — 2026-05-04 — 3-PR follow-up batch (deferred from v0.51.1) + sidebar scroll hotfix

### Fixed

- **Sidebar scroll jumps back to 0 on small lists (≤80 sessions)** — PR #1669 added DOM virtualization to `renderSessionListFromCache()` with two flaws for lists below the virtualization threshold: (1) the unconditional scroll listener triggered a full DOM rebuild on every rAF, and (2) `scrollTop` was only restored when `virtualWindow.virtualized` was true (i.e. total > 80 rows). For lists ≤ 80 rows, `scrollTop` dropped to 0 on every scroll event, producing a "scroll keeps jumping back" feel. Two-part fix: (a) always restore `scrollTop` when `listScrollTopBeforeRender > 0` regardless of virtualized flag, (b) short-circuit `_scheduleSessionVirtualizedRender` when total ≤ `SESSION_VIRTUAL_THRESHOLD_ROWS` (saves the wasteful rebuild and is belt-and-suspenders defense). Live verified: production v0.51.1 confirmed broken (scrollTop drops to 0 within 100ms); v0.51.2 confirmed working (holds at 500 across 600ms+). 3 regression tests pin both fixes.

### Added

- **PR #1664** by @Michaelyklam — LLM Wiki status panel (closes #1257). New read-only Insights card showing wiki state (entries, pages, raw files, last updated, last writer) with traffic-light status badge ("Available" / "Empty" / "Unavailable" / "Error"). New `GET /api/wiki/status` endpoint reads `WIKI_PATH` env var or `skills.config.wiki.path` config, returns metadata-only counts. `loadInsights()` parallelizes the wiki status fetch with the existing `/api/insights` call via `Promise.all`, with a `.catch` fallback so wiki failures don't break Insights.
- **PR #1662** by @Michaelyklam — Logs tab MVP (closes #1455). New top-level Logs tab in nav rail. Allowlisted server-side log file viewer (`agent` / `errors` / `gateway`) with severity highlighting (info/warning/error/debug), tail size selector (100/200/500/1000 lines), auto-refresh, copy-all. New `GET /api/logs` endpoint with strict allowlist + path-traversal guard + bounded 4 MiB tail window. 8 i18n locale entries added.
- **PR #1587** by @franksong2702 — Filter low-value CLI agent sessions (refs #1013). Source-aware sidebar visibility rules for imported CLI agent sessions: hides empty CLI rows; hides default/untitled CLI rows with fewer than 2 user turns; keeps explicitly-titled CLI sessions; keeps compression-lineage CLI sessions. Treats true CLI-origin rows as external/imported in action menu (keeps pin/move/archive/restore, hides duplicate/delete). New `_isCliSession(session)` helper in static/sessions.js for source classification.

### Pre-release verification

- Full pytest sequential pass: 4429 → **4457 passing** (+28). 0 regressions.
- JS syntax check on 6 modified `.js` files via `node -c`: all clean.
- Python syntax check on 9 modified `.py` files: all clean.
- QA harness: 20 pytest + 11 browser API + `/health` probe — ALL CHECKS PASSED.
- Browser-driven smoke test on 56-session sidebar:
- Logs tab: panel renders with file/tail selectors; 4 test log lines (INFO/WARNING/ERROR/DEBUG) all rendered with correct severity classes.
- LLM Wiki card: renders in Insights tab with proper "Unavailable" state and 6-grid metadata layout. Existing Insights chart (#1668) renders unaffected.
- `_isCliSession` helper: 6/6 test cases correct (null, empty object, session_source=cli → true, raw_source=CLI → true, source_label=cli → true, raw_source=web → false).
- Sidebar scroll: scrollTop=500 holds steady across 100/300/600ms; scroll-to-bottom (1986) holds across 600ms.
- Path traversal: `/api/logs?file=../../etc/passwd` correctly returns HTTP 400.
- Independent review: Opus advisor on stage-298 diff (1336 LOC). 6/6 verification questions resolved cleanly: SSRF safety, path traversal, schema redaction, JS XSS prevention, scroll-fix first-render edge case, CHANGELOG handling. **Verdict: SHIP.** 0 MUST-FIX, 2 SHOULD-FIX absorbed in-release (see below).

### Opus-applied fixes (absorbed in-release)

**From stage-299 absorption (this release):**
- **Bounded WIKI_PATH walk + forbidden-root guard** (`api/routes.py`): `_LLM_WIKI_MAX_FILES = 10000` caps `rglob` iteration in both `_llm_wiki_count_files` and `_llm_wiki_page_files` (prevents hangs on symlink loops or pathologically-large trees). `_LLM_WIKI_FORBIDDEN_ROOTS` blocklist refuses `/`, `/etc`, `/usr`, `/var`, `/opt`, `/sys`, `/proc` even if `WIKI_PATH` is misconfigured to point at them. Self-DoS prevention: `/api/wiki/status` fires on every Insights tab open via `Promise.all`, and unbounded `rglob` on a misconfigured root would block the endpoint. 6 regression tests pin the constants + behavioral guards.
- **URL-scheme guard for `docs_url` interpolation** (`static/panels.js`): `rawDocsUrl` is regex-validated against `/^https?:\/\//i` before being interpolated into the `<a href=>` attribute. `esc()` HTML-escapes but doesn't validate URL scheme; `docs_url` is server-controlled today but the contributor scaffolded it for potential config-driven use, so future-proofs against `js:` / `data:` scheme XSS.

### Surgical conflict resolution

All 3 PRs branched off pre-Kanban-v1 master, producing multi-region conflicts in `static/panels.js` and `static/style.css`. Resolved per-conflict surgically rather than via naive keep-both:

- **#1664 panels.js**: kept master's modern `_renderInsights` body (preserves the v0.51.1 chart enhancements from #1668), modified its signature to accept `wikiStatus` as 3rd parameter, AND inserted the two new wiki helper functions (`_formatLlmWikiTimestamp`, `_renderLlmWikiStatus`) before it. Verified single `_renderInsights` definition.
- **#1664 style.css**: kept master's `.insights-card { margin-bottom: 16px }` (used by other Insights cards) and ADDED all the new `.wiki-status-*` rules. Discarded contributor's modification of `.insights-card` (would have broken #1668 chart card spacing).
- **#1662 panels.js**: panel-list array union'd to include both `'kanban'` (v0.51.0) and `'logs'` (this PR). Large additive region: kept BOTH the master's Kanban switcher/modal block AND the contributor's Logs panel block. Patched a missing pair of closing braces (`}\n}\n`) at the boundary where the conflict marker truncated `archiveKanbanBoard`.
- **#1662 style.css**: display-none selector union'd to include `#mainInsights, #mainLogs` AND `:not(.showing-kanban):not(.showing-logs)` chain.
- **#1587 sessions.js**: kept master's `_isReadOnlySession` and `_sourceKeyForSession` helpers AND added the new `_isCliSession` helper. Patched a missing closing brace on `_sourceKeyForSession` introduced by conflict-marker truncation.

Both #1664 and #1662 rebased branches were force-pushed back to @Michaelyklam's fork via maintainer write access (preserving `Co-authored-by:` attribution). #1587 stayed local since the maintainer token doesn't have write access to franksong2702's fork.


## [v0.51.1] — 2026-05-04 — 11-PR contributor batch from @Michaelyklam

### Added — 11 PRs from a single overnight burst, all per-PR Phase-0 fit-screened
Expand Down Expand Up @@ -182,7 +227,6 @@ This was a large stack of work. Massive thanks to **@ai-ag2026** for the full Ka
### Note on closed-as-superseded

PR #1656 (also @Michaelyklam) was closed as superseded by #1657. Both target #1458 Bug #3, both add accept-loop heartbeat + `/health?deep=1` + 503-on-degraded. #1657 adds beyond #1656: state.db connectivity check, projects state check, FD soft-limit raise, and `docs/supervisor.md` watchdog recipe. Same author iterated; the second PR was the keeper.

## [v0.50.296] — 2026-05-04

### Fixed (3 PRs — closes #1406, #1617; refs #1362)
Expand Down Expand Up @@ -448,7 +492,6 @@ Two stale source-string assertions were broken by #1591's compact() and messages
- **Auto-fix on #1464:** ternary inversion + regression test, with `Co-authored-by: Josh Jameson` preserved.
- **Auto-fix on stage:** widened source-string anchors in two pre-existing brittle tests broken by #1591's structural changes.


## [v0.50.289] — 2026-05-03

### Fixed (1 PR — TCP keepalive on accepted connections — closes #1580)
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.1 (May 04, 2026) — 4429 tests collected — 11-PR Michaelyklam batch
> Last updated: v0.51.2 (May 04, 2026) — 4457 tests collected — 3-PR follow-up + scroll hotfix
> 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.1, May 04, 2026 — 11-PR Michaelyklam batch*
*Total automated tests collected: 4429*
*Last updated: v0.51.2, May 04, 2026 — 3-PR follow-up + scroll hotfix*
*Total automated tests collected: 4457*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*
123 changes: 122 additions & 1 deletion api/agent_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
'weixin',
}

CLI_MIN_UNTITLED_MESSAGE_COUNT = 6
CLI_MIN_UNTITLED_USER_MESSAGE_COUNT = 2

SOURCE_LABELS = {
'api_server': 'API',
'cli': 'CLI',
Expand Down Expand Up @@ -71,6 +74,115 @@ def _optional_col(name: str, columns: set[str], fallback: str = "NULL") -> str:
return f"s.{name}" if name in columns else f"{fallback} AS {name}"


def _safe_lower(value) -> str:
return str(value or "").strip().lower()


def _normalize_source_name(value: object) -> str:
source = _safe_lower(value)
if not source:
return ""
if source.endswith(" session"):
source = source[:-len(" session")].strip()
return source


def _looks_like_default_cli_title(row: dict) -> bool:
"""Return True when a CLI row looks like framework-generated metadata."""
title = _safe_lower(row.get("title"))
if not title or title == "untitled":
return True
if title in {"cli", "cli session"}:
return True

source_candidates = {
_normalize_source_name(row.get("source")),
_normalize_source_name(row.get("session_source")),
_normalize_source_name(row.get("source_tag")),
_normalize_source_name(row.get("raw_source")),
_normalize_source_name(row.get("source_label")),
}
source_candidates.discard("")
source_candidates.add("cli")
return any(title == f"{candidate} session" for candidate in source_candidates)


def _as_positive_int(value) -> int:
try:
return max(0, int(float(value)))
except (TypeError, ValueError):
return 0


def _count_user_turns(row: dict) -> int:
user_turns = row.get("actual_user_message_count")
if user_turns is None:
user_turns = row.get("user_message_count")
if user_turns is None:
messages = row.get("messages") or []
if isinstance(messages, list):
return sum(
1
for msg in messages
if _safe_lower(msg.get("role") if isinstance(msg, dict) else msg) == "user"
)
return 0
return _as_positive_int(user_turns)


def _has_cli_lineage(row: dict) -> bool:
segment_count = _as_positive_int(row.get("_compression_segment_count"))
return segment_count > 1 or bool(row.get("_lineage_root_id"))


def is_cli_session_row(row: dict) -> bool:
"""Return True for rows that should be treated as CLI-imported sessions."""
if not isinstance(row, dict):
return False
source = _safe_lower(row.get("session_source"))
if source == "messaging":
return False
if source == "cli":
return True
source_tag = _safe_lower(row.get("source_tag"))
raw_source = _safe_lower(row.get("raw_source"))
source_name = _safe_lower(row.get("source"))
source_label = _safe_lower(row.get("source_label"))
if source_tag == "cli" or raw_source == "cli" or source_name == "cli" or source_label == "cli":
return True

# Legacy imported CLI rows may only be marked as CLI in sidebar metadata.
# Keep this conservative to avoid treating messaging sessions as CLI.
return bool(
row.get("is_cli_session")
and source not in MESSAGING_SOURCES
and source_tag not in MESSAGING_SOURCES
and raw_source not in MESSAGING_SOURCES
and source_name not in MESSAGING_SOURCES
and _looks_like_default_cli_title(row)
)


def is_cli_session_row_visible(row: dict) -> bool:
"""Return whether a CLI-related row should remain visible in the sidebar."""
if not isinstance(row, dict):
return False
if not is_cli_session_row(row):
return True

message_count = _as_positive_int(row.get("actual_message_count") or row.get("message_count"))
if message_count <= 0:
return False

if _has_cli_lineage(row):
return True

if not _looks_like_default_cli_title(row):
return True

return _count_user_turns(row) >= CLI_MIN_UNTITLED_USER_MESSAGE_COUNT


def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
"""Return True when ``child`` is the next segment of the same conversation.

Expand Down Expand Up @@ -201,7 +313,7 @@ def compression_tip(row: dict) -> tuple[dict | None, int]:
# touched standalone sessions — exactly the inverse of what a user
# expects from "Show agent sessions" sorted by activity.
for key in (
'id', 'model', 'message_count', 'actual_message_count',
'id', 'model', 'message_count', 'actual_message_count', 'actual_user_message_count',
'ended_at', 'end_reason', 'last_activity',
):
if key in tip:
Expand Down Expand Up @@ -255,6 +367,8 @@ def read_importable_agent_session_rows(
# source column we cannot safely distinguish WebUI rows from agent rows.
cur.execute("PRAGMA table_info(sessions)")
session_cols = {row[1] for row in cur.fetchall()}
cur.execute("PRAGMA table_info(messages)")
message_cols = {row[1] for row in cur.fetchall()}
if 'source' not in session_cols:
log.warning(
"agent session listing skipped: state.db at %s has no 'source' column "
Expand All @@ -275,6 +389,11 @@ def read_importable_agent_session_rows(
origin_chat_id_expr = _optional_col('origin_chat_id', session_cols)
origin_user_id_expr = _optional_col('origin_user_id', session_cols)
platform_expr = _optional_col('platform', session_cols)
user_message_count_expr = (
"COUNT(CASE WHEN LOWER(m.role) = 'user' THEN 1 END)"
if 'role' in message_cols
else "COUNT(m.id)"
)

where_clauses = ["s.source IS NOT NULL"]
params: list[str] = []
Expand All @@ -301,6 +420,7 @@ def read_importable_agent_session_rows(
{ended_expr},
{end_reason_expr},
COUNT(m.id) AS actual_message_count,
{user_message_count_expr} AS actual_user_message_count,
MAX(m.timestamp) AS last_activity
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
Expand All @@ -312,6 +432,7 @@ def read_importable_agent_session_rows(
)
projected = _project_agent_session_rows([dict(row) for row in cur.fetchall()])
projected = [_with_normalized_source(row) for row in projected]
projected = [row for row in projected if is_cli_session_row_visible(row)]
if limit is None:
return projected
return projected[:max(0, int(limit))]
Expand Down
18 changes: 17 additions & 1 deletion api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from api.agent_sessions import read_importable_agent_session_rows, read_session_lineage_metadata

logger = logging.getLogger(__name__)
CLI_VISIBLE_SESSION_LIMIT = 20

# ---------------------------------------------------------------------------
# Stale temp-file cleanup
Expand Down Expand Up @@ -225,6 +226,12 @@ def _last_message_timestamp(messages):
return None


def _message_role(message):
if not isinstance(message, dict):
return ''
return str(message.get('role', '')).strip().lower()


def _find_top_level_json_key(text, key):
"""Return the byte offset of a top-level JSON object key, if present."""
depth = 0
Expand Down Expand Up @@ -563,6 +570,9 @@ def compact(self, include_runtime=False, active_stream_ids=None) -> dict:
# Only emit 'parent_session_id' when set (the /branch fork link, #1342).
# Sessions without a fork must not leak None — see test_session_lineage_metadata_api.
**({'parent_session_id': self.parent_session_id} if self.parent_session_id else {}),
'user_message_count': sum(
1 for message in self.messages if _message_role(message) == 'user'
) if isinstance(self.messages, list) else 0,
'active_stream_id': self.active_stream_id,
'pending_user_message': self.pending_user_message,
'has_pending_user_message': has_pending_user_message,
Expand Down Expand Up @@ -1507,7 +1517,12 @@ def _cron_pid():
return _cron_pid_cache[0]

try:
for row in read_importable_agent_session_rows(db_path, limit=200, log=logger, exclude_sources=None):
for row in read_importable_agent_session_rows(
db_path,
limit=CLI_VISIBLE_SESSION_LIMIT,
log=logger,
exclude_sources=None,
):
sid = row['id']
raw_ts = row['last_activity'] or row['started_at']
# Prefer the CLI session's own profile from the DB; fall back to
Expand Down Expand Up @@ -1573,6 +1588,7 @@ def _cron_pid():
'_parent_lineage_root_id': row.get('_parent_lineage_root_id'),
'end_reason': row.get('end_reason'),
'actual_message_count': row.get('actual_message_count'),
'user_message_count': row.get('actual_user_message_count'),
'_lineage_root_id': row.get('_lineage_root_id'),
'_lineage_tip_id': row.get('_lineage_tip_id'),
'_compression_segment_count': row.get('_compression_segment_count'),
Expand Down
Loading
Loading