Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
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
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
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,27 @@
## [Unreleased]


## [v0.51.93] — 2026-05-19 — Release BQ (stage-386 — 10-PR full sweep batch — RFC Slice 4 runner/sidecar gate + workspace tree toggle width CSS variable + settled file:// markdown link rendering + prompt-cache coverage percentage fix + terminal shell shutdown reap + configured model picker provider preservation + profile-aware assistant display names + state.db reconciliation slice 1 + queued-message cross-session drain fix + stale-stream writeback supersede)

### Fixed

- **PR #2580** by @Michaelyklam (refs #2571) — Centralize the workspace-tree toggle slot width into a `--file-tree-toggle-width` CSS variable at `:root`, referenced from both `.file-tree-toggle` and `.file-tree-toggle-placeholder` so a future width adjustment can't silently desync the two rules. Closes the followup issue filed against PR #2563 / v0.51.92.
- **PR #2576** by @dobby-d-elf (closes #470) — Preserve labeled `file://` links in settled markdown by rewriting them to `/api/media?path=...&inline=1` before the sanitizer drops them. The streamed and settled markdown paths are now symmetric on local-file anchors, while raw `file://` image sources continue to be blocked.
- **PR #2579** by @starship-s (refs #2419, #2421) — Fix the prompt-cache hit percentage to display the fraction of the prompt served from cache (`cache_read / prompt_total`) instead of the meaningless `cache_read / (cache_read + cache_write)`. New `api/usage.py` `prompt_cache_hit_percent()` helper matches Hermes Agent's log convention; UI labels updated across all locales.
- **PR #2582** by @Michaelyklam (refs #2577) — Harden embedded workspace-terminal shell cleanup so graceful WebUI shutdowns close/reap every active PTY shell and the spawned shell receives a Linux parent-death signal (`PR_SET_PDEATHSIG`) if the WebUI process dies. The terminal close path now waits again after `SIGKILL` so timed-out shells don't remain unreaped.
- **PR #2583** by @dobby-d-elf — Make assistant display names properly profile-aware. The saved assistant-name preference applies only to the literal `default` profile; named profiles use their own profile name. Centralizes `assistantDisplayName()` resolution across composer placeholder, `document.title` via `syncTopbar()`, message role labels via `_assistantRoleHtml()`, browser notifications, cancel-copy fallback, and empty-state on session delete.
- **PR #2584** by @wirtsi (closes #2585) — Prevent queued follow-up messages from draining into the wrong chat when the user switches sessions during the 120ms `setBusy(false)` drain window. The drain-time guard re-queues against `sid` (not the currently-viewed session) and `_sendInProgressSid` captures the activeSid at the commit point so the re-entrant `send()` path no longer reads a stale `S.session.session_id`.
- **PR #2587** by @AJV20 — Allow a still-running stream that was mistakenly marked interrupted by stale-pending recovery to replace its own recovery marker when it later finishes, while continuing to block stale writeback after any newer turn appends transcript content. Three new tests in `tests/test_session_sidecar_repair.py` cover the supersede-allowed and the two refuse cases.
- **PR #2588** by @Michaelyklam (refs #2569) — Preserve the configured provider when choosing a configured model from the composer picker. `_getOptionProviderId()` now reads `data-provider` from temporary `<option data-custom="1">` rows (created by `selectModelFromDropdown` for configured models outside the native catalog), so the next send routes through the correct provider instead of falling back to whatever provider was already active.

### Changed

- **PR #2581** by @LumenYoung (refs #2194) — First recovery slice from the closed reconciliation PR #2194. Routes streaming session reconstruction and sidebar metadata through the reconciled state.db/session-summary path with a metadata-only fast path for sidebar polls and a single-snapshot reuse on the streaming hot path. Includes the reviewer-requested `_new_turn_context_from_messages` extraction so both legacy and streaming paths share the `_drop_checkpointed_current_user_from_context` + casual-fresh-chat suppression behavior (refs #1217 / #2308). 923 LOC across `api/models.py`, `api/routes.py`, `api/streaming.py`, `static/sessions.js` + four new test files; second-pass agent diff review LGTM after the streaming-path regression was caught and fixed.

### Documentation

- **PR #2575** by @Michaelyklam (refs #1925) — Advance the runtime-adapter RFC to the Slice 4 runner/sidecar planning gate after #2560 shipped the queue-staging clarification. The RFC now marks queue routing as staged by default, defines Slice 4a as a docs/test contract before any runner code lands, and pins default-off feature-flagging, restart/reattach success criteria, control parity, profile/workspace payload isolation, and explicit non-goals for legacy-backend removal or server-side queue scheduler work.

## [v0.51.92] — 2026-05-19 — Release BP (stage-385 — 7-PR full sweep batch — RFC Slice 3c clarification + workspace tree icon alignment + project move cache refresh + auto-compression handoff metadata + Grok OAuth provider catalog + anonymous custom endpoint picker fallback + PWA standalone reload + pull-to-refresh)

### Fixed
Expand Down
267 changes: 218 additions & 49 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
get_effective_default_model, _get_session_agent_lock,
)
from api.workspace import get_last_workspace
from api.usage import prompt_cache_hit_percent
from api.agent_sessions import (
_is_continuation_session,
read_importable_agent_session_rows,
Expand Down Expand Up @@ -634,6 +635,7 @@ def compact(self, include_runtime=False, active_stream_ids=None) -> dict:
'estimated_cost': self.estimated_cost,
'cache_read_tokens': self.cache_read_tokens,
'cache_write_tokens': self.cache_write_tokens,
'cache_hit_percent': prompt_cache_hit_percent(self.cache_read_tokens, self.input_tokens),
'personality': self.personality,
'compression_anchor_visible_idx': self.compression_anchor_visible_idx,
'compression_anchor_message_key': self.compression_anchor_message_key,
Expand Down Expand Up @@ -2226,17 +2228,15 @@ def _json_loads_if_string(value):
return value


def get_cli_session_messages(sid) -> list:
"""Read messages for a single CLI/external-agent session.
def get_state_db_session_messages(sid, *, stitch_continuations: bool = False) -> list:
"""Read messages for a Hermes session from the active profile's state.db.

Preserve tool-call/result and reasoning metadata from the agent state.db so
CLI-origin transcripts render with the same tool cards as WebUI-native
sessions. When the requested session is the tip of a compression/CLI-close
continuation chain, return the stitched full transcript across all segments
in chronological order. Returns empty list on any error.
This generic reader intentionally works for any session source, including
WebUI-origin sessions that were later updated through another Hermes surface
such as the Gateway API Server. When ``stitch_continuations`` is true it
preserves the historical CLI/external-agent behavior of walking compatible
compression/close parent segments before reading messages.
"""
if str(sid or '').startswith(f'{CLAUDE_CODE_SOURCE}_'):
return get_claude_code_session_messages(sid)
try:
import sqlite3
except ImportError:
Expand Down Expand Up @@ -2267,47 +2267,48 @@ def get_cli_session_messages(sid) -> list:
]
selected = ['role', 'content', 'timestamp'] + [c for c in optional if c in available]

cur.execute("PRAGMA table_info(sessions)")
session_cols = {str(row['name']) for row in cur.fetchall()}
session_chain = [str(sid)]
if {'parent_session_id', 'end_reason', 'started_at', 'source'}.issubset(session_cols):
cur.execute(
"""
SELECT id, source, started_at, parent_session_id, ended_at, end_reason
FROM sessions
WHERE id = ?
""",
(sid,),
)
rows_by_id = {}
row = cur.fetchone()
if row:
rows_by_id[str(row['id'])] = dict(row)
current_id = str(row['id'])
seen = {current_id}
for _ in range(20):
current = rows_by_id.get(current_id)
parent_id = current.get('parent_session_id') if current else None
if not parent_id or parent_id in seen:
break
cur.execute(
"""
SELECT id, source, started_at, parent_session_id, ended_at, end_reason
FROM sessions
WHERE id = ?
""",
(parent_id,),
)
parent_row = cur.fetchone()
if not parent_row:
break
parent_dict = dict(parent_row)
rows_by_id[str(parent_row['id'])] = parent_dict
if not _is_continuation_session(parent_dict, current):
break
session_chain.insert(0, str(parent_row['id']))
current_id = str(parent_row['id'])
seen.add(current_id)
if stitch_continuations:
cur.execute("PRAGMA table_info(sessions)")
session_cols = {str(row['name']) for row in cur.fetchall()}
if {'parent_session_id', 'end_reason', 'started_at', 'source'}.issubset(session_cols):
cur.execute(
"""
SELECT id, source, started_at, parent_session_id, ended_at, end_reason
FROM sessions
WHERE id = ?
""",
(sid,),
)
rows_by_id = {}
row = cur.fetchone()
if row:
rows_by_id[str(row['id'])] = dict(row)
current_id = str(row['id'])
seen = {current_id}
for _ in range(20):
current = rows_by_id.get(current_id)
parent_id = current.get('parent_session_id') if current else None
if not parent_id or parent_id in seen:
break
cur.execute(
"""
SELECT id, source, started_at, parent_session_id, ended_at, end_reason
FROM sessions
WHERE id = ?
""",
(parent_id,),
)
parent_row = cur.fetchone()
if not parent_row:
break
parent_dict = dict(parent_row)
rows_by_id[str(parent_row['id'])] = parent_dict
if not _is_continuation_session(parent_dict, current):
break
session_chain.insert(0, str(parent_row['id']))
current_id = str(parent_row['id'])
seen.add(current_id)

placeholders = ', '.join('?' for _ in session_chain)
cur.execute(f"""
Expand Down Expand Up @@ -2340,6 +2341,174 @@ def get_cli_session_messages(sid) -> list:
return msgs


def get_state_db_session_summary(sid) -> dict:
"""Return cheap message count/max timestamp for one state.db session.

This is intentionally narrower than ``get_state_db_session_messages`` for
metadata-only WebUI polling: callers only need a staleness signal, not a
fully materialized transcript with tool/reasoning metadata.
"""
import os
try:
import sqlite3
except ImportError:
return {}

db_path = _active_state_db_path()
if not sid or not db_path.exists():
return {}

try:
with closing(sqlite3.connect(str(db_path))) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("PRAGMA table_info(messages)")
available = {str(row['name']) for row in cur.fetchall()}
if not {'session_id', 'timestamp'}.issubset(available):
return {}
cur.execute(
"""
SELECT COUNT(*) AS message_count, MAX(timestamp) AS last_message_at
FROM messages
WHERE session_id = ?
""",
(str(sid),),
)
row = cur.fetchone()
if not row:
return {}
count = int(row['message_count'] or 0)
last_message_at = row['last_message_at']
result = {'message_count': count}
if last_message_at not in (None, ''):
try:
result['last_message_at'] = float(last_message_at)
except (TypeError, ValueError):
pass
return result
except Exception:
return {}


def _normalized_message_timestamp_for_key(value):
if value is None or value == "":
return ""
try:
timestamp = float(value)
except (TypeError, ValueError):
return str(value)
if timestamp.is_integer():
return str(int(timestamp))
return ("%.6f" % timestamp).rstrip("0").rstrip(".")


def _message_timestamp_as_float(msg):
if not isinstance(msg, dict):
return None
value = msg.get("timestamp")
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None


def _session_message_merge_key(msg: dict):
if not isinstance(msg, dict):
return ("non_dict", repr(msg))
message_identity = msg.get("id") or msg.get("message_id")
if message_identity:
return ("message_id", str(message_identity))
return (
"legacy",
str(msg.get("role") or ""),
str(msg.get("content") or ""),
_normalized_message_timestamp_for_key(msg.get("timestamp")),
str(msg.get("tool_call_id") or ""),
str(msg.get("tool_name") or msg.get("name") or ""),
)


def merge_session_messages_append_only(sidecar_messages: list, state_messages: list) -> list:
"""Merge sidecar/context and state.db messages without deleting local rows."""
sidecar_messages = list(sidecar_messages or [])
state_messages = list(state_messages or [])
if not state_messages:
return sidecar_messages
if not sidecar_messages:
return state_messages

merged_messages = []
seen_message_keys = set()
max_sidecar_timestamp = None
for msg in sidecar_messages:
timestamp = _message_timestamp_as_float(msg)
if timestamp is not None:
max_sidecar_timestamp = timestamp if max_sidecar_timestamp is None else max(max_sidecar_timestamp, timestamp)
key = _session_message_merge_key(msg)
seen_message_keys.add(key)
merged_messages.append(msg)
for msg in state_messages:
timestamp = _message_timestamp_as_float(msg)
key = _session_message_merge_key(msg)
if max_sidecar_timestamp is not None and timestamp is not None and timestamp <= max_sidecar_timestamp:
if key in seen_message_keys:
continue
if not (isinstance(key, tuple) and key[:1] == ("message_id",)):
continue
if key in seen_message_keys:
continue
# State rows at or before the newest sidecar timestamp are normally
# assumed to have already been observed by the sidecar. The <= gate
# preserves sidecar-only ordering/metadata for equal timestamps and
# prevents duplicate legacy rows when timestamp precision differs
# between stores. Explicit message ids are authoritative, though: two
# equal-timestamp messages with different ids are distinct retries.
if (
key[0] != "message_id"
and max_sidecar_timestamp is not None
and timestamp is not None
and timestamp <= max_sidecar_timestamp
):
continue
seen_message_keys.add(key)
merged_messages.append(msg)
return merged_messages


def reconciled_state_db_messages_for_session(
session, *, prefer_context: bool = False, state_messages: list | None = None
) -> list:
"""Return append-only messages reconciled with state.db for a WebUI session."""
if session is None:
return []
local_messages = []
if prefer_context:
context_messages = getattr(session, 'context_messages', None)
if isinstance(context_messages, list) and context_messages:
local_messages = context_messages
if not local_messages:
local_messages = getattr(session, 'messages', None) or []
if state_messages is None:
state_messages = get_state_db_session_messages(getattr(session, 'session_id', None))
return merge_session_messages_append_only(local_messages, state_messages)


def get_cli_session_messages(sid) -> list:
"""Read messages for a single CLI/external-agent session.

Preserve tool-call/result and reasoning metadata from the agent state.db so
CLI-origin transcripts render with the same tool cards as WebUI-native
sessions. When the requested session is the tip of a compression/CLI-close
continuation chain, return the stitched full transcript across all segments
in chronological order. Returns empty list on any error.
"""
if str(sid or '').startswith(f'{CLAUDE_CODE_SOURCE}_'):
return get_claude_code_session_messages(sid)
return get_state_db_session_messages(sid, stitch_continuations=True)


def count_conversation_rounds(sid: str, since: float | None = None) -> int:
"""Count conversation rounds for a session from state.db.

Expand Down
Loading
Loading