Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
0736e45
fix: dedupe tool-only partial recovery markers
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
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
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
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,24 @@
## [Unreleased]


## [v0.51.94] — 2026-05-19 — Release BR (stage-387 — 10-PR full sweep batch — Slice 4b runner adapter facade + folder zip download + partial recovery marker dedupe + browser api() client-side timeout + auto-compression card rotation finish + composer draft rollback fix + metadata count reconciliation + active-session refresh on external sidecar updates + indexed context metadata + gateway-queues approval peek)

### Fixed

- **PR #2566** by @bjb2 — Add `GET /api/folder/download?session_id=...&path=...` streaming-zip endpoint with pre-flight 413 on size/file-count cap exceeded, `os.walk(followlinks=False)` plus per-symlink workspace-root resolution check, `allowZip64=True` for large files, and a "Download Folder" item in the workspace file context menu (dir items only). Configurable caps via `HERMES_WEBUI_FOLDER_ZIP_MAX_MB` (1024 default) and `HERMES_WEBUI_FOLDER_ZIP_MAX_FILES` (50000 default). `download_folder` i18n key added across all 11 locales with `// TODO: translate` fallback markers for non-en entries.
- **PR #2593** by @Michaelyklam (closes #2592) — Deduplicate cancelled/recovered partial assistant markers using the full `(content, reasoning, partial tool calls)` payload instead of only non-empty text content. Tool-only failed turns no longer append identical empty-content `_partial` messages repeatedly. Full session loads collapse adjacent duplicate partial markers from already-bloated session files while preserving a `.partial-bak-<timestamp>` backup. New helpers `_partial_message_signature()` (api/streaming.py:2593-2622) + `_partial_marker_already_present()` (api/streaming.py:2625-2641) scope the dedup search to the current user turn only.
- **PR #2597** by @dso2ng (closes #2539) — Add a 30s default client-side timeout to the shared browser `api()` helper, with per-call `timeoutMs` overrides, `AbortController`-based cancellation, a timeout toast, and explicit 60s/120s ceilings for legitimately longer update flows. Body-read phase also raced against the timeout so a server that replies headers-OK and then stalls mid-JSON rejects cleanly. New `tests/test_api_timeout.py` covers default, override, abort, and body-read-stall paths.
- **PR #2601** by @starship-s — Prevent the composer-draft rollback regression introduced by #2581's active-session external-refresh polling. Adds `opts.preserveActiveInput` to `_restoreComposerDraft` and gates the overwrite on `current && current !== text`, keeping the guard co-located with the function that owns the contract. Backend `s.save(touch_updated_at=False)` for `/api/session/draft` so draft autosaves no longer falsely advance `updated_at` and trigger the refresh poll. Supersedes parallel-discovery PR #2602.
- **PR #2603** by @starship-s — Finish the running auto-compression card after the backend rotates the session id. The `compressed` SSE listener at `static/messages.js:1829-1862` used to early-return whenever `S.session.session_id !== activeSid`, but the `state` event listener at `:1656-1662` already rotates `window._compressionUi.sessionId` to the continuation id before `compressed` arrives. The strict active-session check is replaced with a cross-session safety check that still rejects mismatched events but no longer rejects the legitimate post-rotation `done` payload, so the elapsed-timer "compressing…" state no longer freezes after rotation completes.
- **PR #2604** by @Michaelyklam (closes #2594) — Reconcile session metadata counts in the `/api/session?messages=0` fast path. Replaces the prior `max(sidecar_count, state_count)` heuristic with `len(merge_session_messages_append_only(sidecar_messages, state_db_messages))` so the metadata-only count matches the full-load count. Closes the followup issue filed against PR #2581 / v0.51.93 — sidebar refresh polling no longer loops forever when `state.db` retains old rows that the append-only merge correctly filters out.
- **PR #2605** by @LumenYoung (refs #2581) — Make the metadata-only `/api/session?messages=0&resolve_model=0` path return the persisted sidecar `message_count` from `Session._metadata_message_count` when no session-index entry exists, so the active-session external-refresh signal still trips on legacy sessions whose sidecar contains externally-appended content. Composed cleanly with #2604 (the legacy-fallback applies only when the reconciled merged count is zero).
- **PR #2573** by @espokaos-ops (closes #2510) — Persist session-level approvals when a "Allow for this session" click lands while a stream is active and `_pending` is empty. The approval flow now peeks `_gateway_queues[sid]` to recover the queued `_ApprovalEntry`'s `pattern_keys` so `approve_session()` records the approval; the next dangerous command in the same session no longer asks again. Reduced scope to peek-only per prior review note; the `agent_session_key` round-trip plumbing was dropped (it was dead on the WebUI streaming path).

### Added

- **PR #2599** by @Michaelyklam (refs #1925) — Add the Slice 4b `RunnerRuntimeAdapter` facade — a protocol-translator client over a future runner/sidecar backend. The facade delegates `start_run`, `observe_run`, `get_run`, and control calls to an injected runner client, normalizes results into the existing `RunStartResult`/`RunEventStream`/`RunStatus`/`ControlResult` dataclasses, carries explicit `profile`/`workspace`/`model` payload fields, and returns bounded `unsupported` control results without owning `AIAgent`, stream lifecycle, cancel/approval/clarify queues, goal state, or cached-agent table. No route wiring, no default-on runner mode, no public response-shape change.
- **PR #2600** by @LumenYoung (refs #2266) — Slimmer WebUI follow-up from the closed LCM/context-engine PR #2266. Adds rendering and persistence for context-engine compression-anchor metadata (when present on a session or live compression event) including an "Indexed context" detail line on auto-compression cards. No agent-layer clone orchestration; WebUI-only metadata surface.

## [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
Expand All @@ -16,6 +34,7 @@
- **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.
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
104 changes: 101 additions & 3 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,11 @@ def __init__(self, session_id: str=None, title: str='Untitled',
compression_anchor_message_key=None,
compression_anchor_summary=None,
pre_compression_snapshot: bool=False,
context_engine=None,
compression_anchor_engine=None,
compression_anchor_mode=None,
compression_anchor_details=None,
context_engine_state=None,
context_length=None, threshold_tokens=None,
last_prompt_tokens=None,
gateway_routing=None, gateway_routing_history=None,
Expand Down Expand Up @@ -417,6 +422,11 @@ def __init__(self, session_id: str=None, title: str='Untitled',
self.compression_anchor_message_key = compression_anchor_message_key
self.compression_anchor_summary = compression_anchor_summary
self.pre_compression_snapshot = bool(pre_compression_snapshot)
self.context_engine = context_engine
self.compression_anchor_engine = compression_anchor_engine
self.compression_anchor_mode = compression_anchor_mode
self.compression_anchor_details = compression_anchor_details if isinstance(compression_anchor_details, dict) else {}
self.context_engine_state = context_engine_state if isinstance(context_engine_state, dict) else {}
self.context_length = context_length
self.threshold_tokens = threshold_tokens
self.last_prompt_tokens = last_prompt_tokens
Expand All @@ -436,7 +446,14 @@ def __init__(self, session_id: str=None, title: str='Untitled',
self.read_only = bool(kwargs.get('read_only', False))
self.enabled_toolsets = enabled_toolsets # List[str] or None — per-session toolset override
self.composer_draft = composer_draft if isinstance(composer_draft, dict) else {}
self._metadata_message_count = None
raw_message_count = kwargs.get('message_count')
parsed_message_count = None
if raw_message_count is not None:
try:
parsed_message_count = int(raw_message_count)
except (TypeError, ValueError):
parsed_message_count = None
self._metadata_message_count = parsed_message_count if parsed_message_count is not None and parsed_message_count >= 0 else None

@property
def path(self):
Expand Down Expand Up @@ -474,6 +491,8 @@ def save(self, touch_updated_at: bool = True, skip_index: bool = False) -> None:
'pending_user_message', 'pending_attachments', 'pending_started_at',
'compression_anchor_visible_idx', 'compression_anchor_message_key',
'compression_anchor_summary', 'pre_compression_snapshot',
'context_engine', 'compression_anchor_engine', 'compression_anchor_mode',
'compression_anchor_details', 'context_engine_state',
'context_length', 'threshold_tokens', 'last_prompt_tokens',
'gateway_routing', 'gateway_routing_history', 'llm_title_generated',
'parent_session_id',
Expand Down Expand Up @@ -563,7 +582,18 @@ def load(cls, sid):
p = SESSION_DIR / f'{sid}.json'
if not p.exists():
return None
return cls(**json.loads(p.read_text(encoding='utf-8')))
data = json.loads(p.read_text(encoding='utf-8'))
data['messages'], _collapsed_partials = _collapse_adjacent_duplicate_partials(data.get('messages'))
session = cls(**data)
if _collapsed_partials:
try:
# Self-heal bloated sessions on first full load without touching
# recency/index ordering; save() creates a .bak because this
# intentionally shrinks the transcript (#2592).
session.save(touch_updated_at=False, skip_index=True)
except Exception:
logger.debug("Failed to persist collapsed duplicate partials for %s", sid, exc_info=True)
return session

@classmethod
def load_metadata_only(cls, sid):
Expand All @@ -590,7 +620,19 @@ def load_metadata_only(cls, sid):
parsed['messages'] = []
parsed['tool_calls'] = []
session = cls(**parsed)
session._metadata_message_count = _lookup_index_message_count(sid)
metadata_message_count = _lookup_index_message_count(sid)
if metadata_message_count is None:
raw_count = parsed.get('message_count')
if isinstance(raw_count, int) and raw_count >= 0:
metadata_message_count = raw_count
else:
try:
parsed_count = int(raw_count)
except (TypeError, ValueError):
parsed_count = None
if parsed_count is not None and parsed_count >= 0:
metadata_message_count = parsed_count
session._metadata_message_count = metadata_message_count
# Mark this session as a metadata-only stub. save() refuses to write
# such a session because doing so would atomically replace the
# on-disk JSON with messages=[], wiping the conversation. Any
Expand Down Expand Up @@ -641,6 +683,11 @@ def compact(self, include_runtime=False, active_stream_ids=None) -> dict:
'compression_anchor_message_key': self.compression_anchor_message_key,
'compression_anchor_summary': self.compression_anchor_summary,
'pre_compression_snapshot': self.pre_compression_snapshot,
'context_engine': self.context_engine,
'compression_anchor_engine': self.compression_anchor_engine,
'compression_anchor_mode': self.compression_anchor_mode,
'compression_anchor_details': self.compression_anchor_details,
'context_engine_state': self.context_engine_state,
'context_length': self.context_length,
'threshold_tokens': self.threshold_tokens,
'last_prompt_tokens': self.last_prompt_tokens,
Expand Down Expand Up @@ -724,6 +771,57 @@ def _normalize_journal_recovery_text(value) -> str:
return " ".join(str(value or "").split())


def _partial_message_signature(message: dict) -> tuple:
"""Return a stable identity for partial assistant markers recovered on load."""
if not isinstance(message, dict):
return ('', '', ())
tool_sig = []
for tool_call in message.get('_partial_tool_calls') or []:
if not isinstance(tool_call, dict):
continue
try:
args_sig = json.dumps(
tool_call.get('args') or {},
ensure_ascii=False,
sort_keys=True,
default=str,
)
except Exception:
args_sig = str(tool_call.get('args') or '')
tool_sig.append((
str(tool_call.get('name') or ''),
args_sig,
bool(tool_call.get('done', False)),
bool(tool_call.get('is_error', False)),
str(tool_call.get('preview') or tool_call.get('snippet') or ''),
))
return (
str(message.get('content') or '').strip(),
str(message.get('reasoning') or '').strip(),
tuple(tool_sig),
)


def _collapse_adjacent_duplicate_partials(messages) -> tuple[list, bool]:
"""Collapse repeated identical partial markers from the same failed turn."""
if not isinstance(messages, list):
return messages, False
collapsed = []
changed = False
previous_partial_sig = None
for message in messages:
if isinstance(message, dict) and message.get('_partial'):
sig = _partial_message_signature(message)
if previous_partial_sig == sig:
changed = True
continue
previous_partial_sig = sig
else:
previous_partial_sig = None
collapsed.append(message)
return collapsed, changed


def _find_existing_assistant_for_journal_content(session, content: str) -> int | None:
candidate = _normalize_journal_recovery_text(content)
if not candidate:
Expand Down
Loading
Loading