Compare commits

...

2666 Commits

Author SHA1 Message Date
Botomir
8d8a88680e fix: record fallback routing metadata
Some checks failed
Browser smoke / browser-smoke (push) Has been cancelled
Tests / lint (push) Has been cancelled
Tests / test (3.11, 0) (push) Has been cancelled
Tests / test (3.11, 1) (push) Has been cancelled
Tests / test (3.11, 2) (push) Has been cancelled
Tests / test (3.12, 0) (push) Has been cancelled
Tests / test (3.12, 1) (push) Has been cancelled
Tests / test (3.12, 2) (push) Has been cancelled
Tests / test (3.13, 0) (push) Has been cancelled
Tests / test (3.13, 1) (push) Has been cancelled
Tests / test (3.13, 2) (push) Has been cancelled
2026-07-10 08:07:32 +02:00
nesquena-hermes
ae90cf620b Merge pull request #4106 from nesquena/stage-4016b
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MY (v0.51.386): voice mode survives dropped speechSynthesis onend (#3983)
2026-06-13 00:47:20 -07:00
nesquena-hermes
8b5c8e32fd docs(changelog): stamp #3983 voice-mode watchdog as v0.51.386 (Release MY) 2026-06-13 07:38:19 +00:00
nesquena-hermes
f61f88f16b Merge #4016 (re-arm browser voice mode when speechSynthesis drops, #3983) onto master 2026-06-13 07:37:31 +00:00
nesquena-hermes
1ae56799cb Merge pull request #4105 from nesquena/stage-4028
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MX (v0.51.385): profile-cookie env var aligned to HERMES_WEBUI_ prefix (#803)
2026-06-13 00:36:33 -07:00
nesquena-hermes
a31466b1a3 docs(changelog): stamp #803 profile-cookie env var rename as v0.51.385 (Release MX) 2026-06-13 07:21:34 +00:00
nesquena-hermes
6e6931a8c3 Merge #4028 (align profile cookie env var with HERMES_WEBUI_ prefix) onto master 2026-06-13 07:20:58 +00:00
nesquena-hermes
abe89f3afa Merge pull request #4104 from nesquena/stage-3899
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MW (v0.51.384): no false streaming / activity-timer reset on session switch (#3900)
2026-06-13 00:19:07 -07:00
nesquena-hermes
e129203854 test(#3899): re-anchor 4 test_inflight_stream_reuse tests to post-#3899 loadSession shape
rfind for the Phase-2 INFLIGHT branch (idle-reset added an earlier if(INFLIGHT[sid]){);
and test_load_session_attaches_sse_before_auxiliary_work now asserts each auxiliary call
individually (the old contiguous syncTopbar();renderMessages();appendThinking();loadDir
literal was legitimately broken up by the new restoreLiveTurnHtmlForSession insertion +
renderMessages(preserveScroll) arg — all calls still run after attachLiveStream). Behavior unchanged.
2026-06-13 07:10:15 +00:00
nesquena-hermes
377a1889aa test(#3899): widen 2 more fixed-window loadSession slices displaced by idle-reset block
test_issue3306 _load_session_clear_block (4000->6500) + test_issue_new_chat_draft_restore
body window (4000->6500): #3899's idle-reset + live-turn-snapshot blocks pushed the
carry-forward snapshot + destructive S.messages clear past the old fixed windows. Behavior
+ ordering unchanged; windows widened to contain them.
2026-06-13 06:55:36 +00:00
nesquena-hermes
5f3e0ab8d9 test(#3899): re-anchor 5 brittle source-window tests displaced by the idle-reset block
#3899's new idle-reset added an EARLIER if(INFLIGHT[sid]){ block in loadSession, so
4 test_regressions tests' src.find('if(INFLIGHT[sid]){') grabbed the wrong (idle-cleanup)
occurrence instead of the Phase-2 restore branch → switched to rfind (the substantive
branch). And the idle-cleanup comment changed from 'discard any browser-side inflight'
to 'reset browser-side streaming flags' → re-anchored test_frontend_drops_inflight_cache
on the new comment + nested if(!activeStreamId){...if(INFLIGHT[sid]){ form (behavior
preserved + enhanced). No production code changed.
2026-06-13 06:48:21 +00:00
nesquena-hermes
5bbddbad1e fix(#3900): false streaming + activity-timer reset on session switch (absorb #3899) + re-anchor regression tests
Production fixes (Tamaz-sujashvili, reviewed sound by maintainer):
- loadSession clears S.busy/S.activeStreamId as soon as metadata confirms no active_stream_id, before the async message-load gap (idle session no longer shows streaming chrome).
- Snapshots the live turn before wiping msgInner + seeds INFLIGHT, restores on the active-stream return path (timer/trace survive switch-back).

Re-anchored the 2 brittle regression tests per maintainer review: test_..snapshots.. now anchors on the unique 'Loading conversation...' marker (was matching the no-space 'Session not available' error path); test_..restores.. now asserts the LIVE Phase 2a restore (after loadInflightState) instead of the unreachable Phase-2b/1184 branch. CHANGELOG stamped v0.51.384 (MW).
2026-06-13 06:39:22 +00:00
nesquena-hermes
0f9b62370b Merge pull request #4102 from nesquena/stage-4094
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MV (v0.51.383): desktop tab title keeps active session name (#4086)
2026-06-12 23:21:13 -07:00
nesquena-hermes
8c54973ed4 docs(changelog): stamp #4086 document-title-owner fix as v0.51.383 (Release MV) 2026-06-13 06:12:28 +00:00
nesquena-hermes
d85fd2c967 Merge #4094 (preserve session document title during bot-name refresh) onto master 2026-06-13 06:11:24 +00:00
Lynn-Lee
46a5ce1b1a fix tab title clobber during bot name refresh 2026-06-13 14:03:02 +08:00
nesquena-hermes
3f2eb8d362 Merge pull request #4101 from nesquena/stage-4093
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MU (v0.51.382): Stable Assistant Turn Anchors activity-scene projection (inert) (#4093)
2026-06-12 23:02:04 -07:00
nesquena-hermes
4eec21c434 docs(changelog): stamp #4093 activity-scene projection (inert) as v0.51.382 (Release MU) 2026-06-13 05:56:53 +00:00
nesquena-hermes
9cf67cbeb2 Merge #4093 (anchor activity scene projection + tool_limit_reached stream label) onto master 2026-06-13 05:56:09 +00:00
nesquena-hermes
2847bd98a0 Merge pull request #4100 from nesquena/stage-4092
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MT (v0.51.381): Stable Assistant Turn Anchors settled final-answer projection (#4092)
2026-06-12 22:40:31 -07:00
nesquena-hermes
f230a16f4d docs(changelog): stamp #4092 settled final-answer projection as v0.51.381 (Release MT) 2026-06-13 05:36:35 +00:00
nesquena-hermes
841d78d4e5 Merge #4092 (assistant turn anchor settled final projection) onto master 2026-06-13 05:36:00 +00:00
Frank Song
59c2ccd9cf feat(anchor): project activity scene rows 2026-06-13 13:23:27 +08:00
Frank Song
b990bd09dd feat(anchor): project settled final answer through anchor 2026-06-13 13:23:15 +08:00
nesquena-hermes
c2ecb47554 Merge pull request #4099 from nesquena/stage-3823
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MS (v0.51.380): tool-iteration-limit stops surfaced explicitly (#3821)
2026-06-12 22:19:06 -07:00
nesquena-hermes
e4bdcbfeae Merge #3823 (tool iteration limit terminal state) onto master
# Conflicts:
#	CHANGELOG.md
2026-06-13 05:06:46 +00:00
nesquena-hermes
da70ea47f8 Merge pull request #4097 from nesquena/stage-4063
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MR (v0.51.379): Worklog detail collapse survives live refresh (#4062)
2026-06-12 21:56:30 -07:00
nesquena-hermes
2bd8b40b15 harden(#4063): stamp disclosure-state snapshot with session id (Opus belt-and-suspenders)
_captureWorklogDetailDisclosureState stamps state._sid; _restoreWorklogDetailDisclosureState
skips restore on sid mismatch. Cross-session isolation no longer depends solely on the
sessions.js #msgInner wipe invariant — a future change to that loading-placeholder
behavior can't silently replay one session's collapse state onto another.
2026-06-13 04:47:36 +00:00
nesquena-hermes
c9930d2a3d Merge #4063 (preserve Worklog detail disclosure across rebuilds) onto master
# Conflicts:
#	CHANGELOG.md
#	static/ui.js
2026-06-13 04:41:17 +00:00
nesquena-hermes
d1b6e62eb6 Merge pull request #4095 from nesquena/stage-4006
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MQ (v0.51.378): scroll auto-follow toggle + don't-yank-while-reading (#4006)
2026-06-12 21:37:07 -07:00
nesquena-hermes
f5fb50983b fix(#4006): defensive !==false at the 2 remaining settings-hydration sites (Opus note)
panels.js _autosaveAppearanceSettings (6309) + _applySavedSettingsUi (7837) read
auto_scroll_follow from a settings/body object — use !==false (default True) for
consistency with boot.js + the checkbox render, so a future partial-body refactor
can't silently default the True setting OFF. The .checked reads (6223/8170) keep !!
(a real bool).
2026-06-13 04:27:01 +00:00
nesquena-hermes
9f6ed5a61b test(#4006): re-anchor scroll tests to the explicit-settle signature + new snapshot-capture condition
scrollToBottom now calls _settleMessageScrollToBottom(false, true); renderMessages
captures scrollSnapshot when (preserveScroll || (!_autoScrollFollow && _messageUserUnpinned)).
Updated the 4 change-detector assertions in test_tars_scroll_reset_regressions.py +
test_issue1690_scroll_completion.py to the new strings; behavioral intent unchanged.
2026-06-13 04:20:25 +00:00
nesquena-hermes
6ced477822 fix(#4006): explicit-settle flag + pre-wipe snapshot capture (Codex r3)
1. _settleMessageScrollToBottom(force, explicit): when explicit (End-button /
   scrollToBottom), late-layout settling runs even with Auto-follow OFF — the
   setting only suppresses AUTOMATIC follow, not a deliberate jump. The RO + 2s
   fallback guards now bail on (!_autoScrollFollow && !explicit). scrollToBottom()
   passes explicit=true; the automatic scrollIfPinned() path stays guarded.
2. renderMessages now captures the pre-wipe scrollSnapshot when (Auto-follow off
   && user unpinned) too, so the non-preserve restore lands the reader where they
   were instead of receiving a null snapshot (no-op).
2026-06-13 04:16:48 +00:00
nesquena-hermes
36239700e6 i18n(#4006): add auto-follow setting label+desc to the remaining 11 locales (Opus note)
The keys shipped only in en+zh; added it/ja/ru/es/de/zh-Hant/pt/ko/fr/tr/pl so the
Settings → Appearance auto-follow toggle isn't English-only outside those two.
2026-06-13 04:12:11 +00:00
nesquena-hermes
2bdef4166f fix(#4006): gate the #3920 RO settle + render fall-through on _autoScrollFollow (Codex cross-PR catch)
Codex found that with Auto-follow OFF the #3920 ResizeObserver settle + 2s fallback
+ the non-preserve render path still force-scrolled to bottom (the setting was
incomplete vs the scroll machinery shipped in #3920). Fixes:
1. RO settle guard (ui.js:3153): bail when !_autoScrollFollow.
2. 2s static fallback guard (ui.js:3186): bail when !_autoScrollFollow.
3. non-preserve render fall-through (ui.js:8653): when Auto-follow off AND the user
   has unpinned, restore the scroll snapshot instead of scrollToBottom() — also
   closes the send() race (renderMessages runs before S.activeStreamId is set).
   A fresh session load (not unpinned) still lands at the bottom. Explicit
   user-initiated scrollToBottom() (End button) is untouched.
2026-06-13 04:09:58 +00:00
nesquena-hermes
00ea7766db feat(#4006): default auto-follow ON (Codex/Claude-Code parity) + fix default-hydration mismatch + CHANGELOG v0.51.378
Per maintainer decision, auto_scroll_follow defaults TRUE (sticky-bottom: follow
new output while streaming, scroll-up unpins and is respected) — matching Codex
CLI / Claude Code. The PR shipped it default-False.

Default-mismatch bug fixed (would have shown the True default as OFF for existing
users with no saved value):
- boot.js settings path: !!s.auto_scroll_follow -> s.auto_scroll_follow!==false
- boot.js no-settings fallback: false -> true
- panels.js checkbox render: !!settings.auto_scroll_follow -> !==false

Resolved the config.py + panels.js merge conflicts (kept both the Transparent
Stream setting and auto_scroll_follow). Added test_issue4006_auto_scroll_follow_default.py
pinning the True default across config/boot/panels + the follow-gate references.
2026-06-13 03:55:28 +00:00
nesquena-hermes
c02c31c837 Merge #4006 (scroll auto-follow setting + pin guards) onto master
# Conflicts:
#	api/config.py
#	static/panels.js
2026-06-13 03:54:11 +00:00
nesquena-hermes
d60fa993fc Merge pull request #4091 from nesquena/stage-3920
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MP (v0.51.377): Firefox post-stream scroll jitter (#3920)
2026-06-12 20:47:38 -07:00
nesquena-hermes
d3c694251f test(#3920): re-anchor tars scroll regression tests to the ResizeObserver settle
The 2 failing assertions pinned the removed implementation (const passes=[0,16,80,180]
setTimeout fan-out + _settleMessageScrollToBottom(true) in scrollToBottom). Updated to
assert the new behavior contract: ResizeObserver on #msgInner + 2s static fallback +
force=false so the observer runs. Behavioral intent (settle survives late markdown
growth; sync write before settle scheduling) preserved.
2026-06-13 03:39:11 +00:00
nesquena-hermes
b5eabe1494 fix(#3920): apply Codex gate findings — observe #msgInner, instance-owned RO cleanup, restore .messages overflow-anchor
Codex SHIP-ONLY-WITH-FIXES (3 real issues; Codex+Opus disagreed on #1, stricter wins):
1. CORE — .messages lost overflow-anchor:none, reopening the #1360 (d21c9720) streaming-scroll-anchor regression. Restored it (native anchoring stays OFF; the RO does the settle). Opus had judged this removal acceptable; took Codex's stricter call.
2. SILENT — the ResizeObserver observed #messages (the scroll container, fixed by flex layout, never resizes) instead of #msgInner (.messages-inner, the growing transcript node) → callback would never fire. Now observes #msgInner.
3. SILENT — stale RO callbacks mutated the global _settleRO, which could disconnect a newer active observer after settle re-entry. Now instance-owned: close over const ro, only clear the global when _settleRO===ro.
2026-06-13 03:32:16 +00:00
nesquena-hermes
f9055c5d97 fix(#3920): address review — restore sidebar overflow-anchor, wire _settleFinalScroll via 2s fallback, + CHANGELOG v0.51.377
3 maintainer-review items the contributor hadn't addressed:
1. (blocking) restored .session-list overflow-anchor:none — the PR accidentally reverted the deliberate Firefox sidebar fix (2dfe765b); only the on-topic .messages removal is kept.
2/3. _settleFinalScroll was dead code + the promised 2s static-content safety timeout was missing — wired _settleFinalScroll(token) via a single 2s top-level fallback after _settleRO.observe(el), declared _settleFinalTimer + cleared it in _cancelBottomSettle and at settle re-entry.
2026-06-13 03:25:48 +00:00
nesquena-hermes
965baeb78c Merge #3920 (Firefox scroll ResizeObserver) onto master 2026-06-13 03:23:49 +00:00
nesquena-hermes
b2c41658d6 Merge pull request #4090 from nesquena/stage-3971
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MO (v0.51.376): Hide Thinking also hides Worklog reasoning (#3903)
2026-06-12 20:22:48 -07:00
nesquena-hermes
02e201fcf1 docs(changelog): stamp #3903 Worklog-reasoning-hide as v0.51.376 (Release MO) 2026-06-13 03:19:44 +00:00
nesquena-hermes
35877dab1b fix(#3971): move show-thinking gate to the real Worklog render paths (Opus catch)
Opus final gate caught that the staged re-implementation placed the
window._showThinking===false gate in _worklogReasonNodeFromText, which has
ZERO callers — so live/settled reasoning rows (built by _syncWorklogReasonFromAnchor
and _appendWorklogReason) were never actually hidden, and removeThinking swept
data-worklog-reason-source=reasoning which the real rows (data-worklog-anchor-reason=1)
don't carry. The string-match tests passed against the dead function = green-suite blind spot.

Fix:
- Gate _syncWorklogReasonFromAnchor (live + settled): when _showThinking===false, remove any
  existing reasoning row and bail before building.
- Gate _appendWorklogReason (settled rebuild): return null when _showThinking===false.
- removeThinking + the settled-rebuild cleanup now sweep BOTH data-worklog-anchor-reason=1
  (the real attribute) and the legacy data-worklog-reason-source=reasoning.
- Re-anchor the tests to assert against the real render functions + the correct attribute.
2026-06-13 03:09:50 +00:00
nesquena-hermes
52c502a984 Merge #3971 (hide Worklog reasoning when Thinking disabled) onto master 2026-06-13 02:52:31 +00:00
nesquena-hermes
d1a9e9ffbb Merge pull request #4037 from franksong2702/franksong2702/stable-assistant-turn-anchors-slice3-registry
Add assistant turn anchor registry and shadow source harness
2026-06-12 19:45:46 -07:00
nesquena-hermes
dc909043cf Merge branch 'master' into franksong2702/stable-assistant-turn-anchors-slice3-registry 2026-06-12 19:41:20 -07:00
nesquena-hermes
bb33e76568 Merge pull request #4084 from nesquena/stage-4024
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MN (v0.51.375): Transparent Stream activity display (#3820 / #4024)
2026-06-12 19:19:57 -07:00
nesquena-hermes
9f1933f81e chore(#4024): drop docs/ui-ux/review-shots/ trifecta scratch from the release diff
These 5 PNGs (722K) are the agent trifecta review-pass screenshots copied into
the worktree so the Opus gate could read them — review scratch, not durable UX
evidence. The intended evidence lives in docs/ui-ux/evidence/pr4024-*.png and is
kept. Mirrors the .review-crops/ cleanup; gitignore the dir so review scratch
can't leak into a release diff. No code or test change.
2026-06-13 02:15:01 +00:00
Nathan Esquenazi
3dc2585c2f chore(#4024): drop stray .review-crops/ scratch from the release diff
The release diff committed 7 PNG review-crop screenshots under a root-level
.review-crops/ directory (652K) that nothing references — not the CHANGELOG,
docs HTML, or any source file. These are deep-review before/after scratch
shots, not durable UX evidence (the intended evidence lives in
docs/ui-ux/evidence/pr4024-*.png and is kept). Shipping them in v0.51.375
would bloat the repo history permanently for every user.

Remove the directory and add .review-crops/ to .gitignore (next to the
.local-review/ harness entry) so review scratch can't leak into a release
diff again — mirroring the stray-uv.lock drop in #3963.

No code or test change; the Transparent Stream feature is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:11:22 -07:00
nesquena-hermes
c45adb910a fix(#4024): wire dormant recency-fade to live turn + include Interrupted in progress-bar completed set (Opus final-gate observations)
- _applyTransparentRowFading was only called from the settled loop (which the fn skips), so live-turn recency fade never applied — now called from _syncTransparentEventControls (internally live-gated, no-ops on settled).
- _attachProgressBar isCompleted now includes Interrupted so an interrupted row's bar fills 100% instead of rendering empty.
2026-06-13 01:53:11 +00:00
nesquena-hermes
f18e8d6545 docs(changelog): stamp Transparent Stream (#3820/#4024) as v0.51.375 (Release MN) 2026-06-13 01:43:25 +00:00
nesquena-hermes
d952dfe3a2 fix(#4024): trifecta round-2 — rehydrate root-turn, always-preserve detail tab, drop redundant Running: name prefix
- C1-r2 (both Codex+Opus): _rehydrateTransparentStreamDom now also rewires the root element when it IS the assistant turn (querySelectorAll only matched descendants), so turn-toggle + event-controls re-bind after a live-turn restore.
- O-Bug2-r2: preserve the Full/Output detail tab regardless of open state.
- r2 #4: drop the 'Running: ' name prefix — the legible status badge already carries it (V6 redundancy class).
2026-06-13 01:16:56 +00:00
nesquena-hermes
5dad3e03e5 test(#4024): update CSS-literal assertions to the new quiet treatment + add 8 trifecta-fix regression guards
- Re-anchor test_old_event_fading + test_transparent_event_row_quiet_metadata_visual_rhythm to the new contract (left-rail output, inline-row args, underline active tab, WCAG fade floor, live-only fade).
- Add guards: live-turn-restore rehydrate (C1), echoed-thinking dedup (O-Bug1), thinking-card flat reset (V1), skin-proof reset (V3), legible failed status (V2), interrupted settled status (O-Edge), completion preserves expand state (O-Bug2), live-only entrance animation (V9).
2026-06-13 01:12:09 +00:00
nesquena-hermes
ed3ac13fc5 fix(#4024): trifecta UX + correctness pass — rehydrate live-restore, thinking dedup, quiet thinking rows, legible failures, skin-proof reset, compact args, flat output, live-only fade/anim
Functional (Codex+Opus trifecta findings):
- C1: restoreLiveTurnHtmlForSession now calls _rehydrateTransparentStreamDom (controls were dead after active-session live-turn restore).
- O-Bug1: per-turn echoed-thinking dedup in the transparent settled path (mirrors the compact seenReasons), fixes duplicate out-of-order Thinking rows.
- O-Bug2: tool completion preserves the row's open state + Full/Output tab.
- O-Edge: settled/reloaded done===false tools show 'Interrupted' (not a permanent Running shimmer); progress bar clears for Interrupted too.

Visual (trifecta CSS):
- V1: reset .thinking-card to flat/transparent (was the heaviest object in the stream).
- V2: failed=error color + left border, running/interrupted legible (was an invisible muted badge).
- V3: scoped !important reset beats the 6 per-skin .tool-card re-card rules.
- V4: inline key:value args (was a tall stacked form).
- V5/V7: output pre flattened to a quiet left-rail, max-height:none (one expanded zone, no nested scroll).
- V6: drop redundant tool/status pseudo-args from the detail body.
- V8: row fading gated to the live turn only + floor raised for WCAG; settled history stays full-opacity.
- V9: entrance animation scoped to #liveAssistantTurn (was replaying on every renderMessages).
- V10: larger mobile header tap targets + active detail-tab underline.

O-Bug3 (live thinking overwrite when no seq) intentionally deferred — documented follow-up; settled render is already correct and a live heuristic risks a streaming regression.
2026-06-13 01:08:41 +00:00
nesquena-hermes
92f7a24448 Merge #4024 Transparent Stream onto master (rebase)
# Conflicts:
#	CHANGELOG.md
2026-06-13 00:40:17 +00:00
nesquena-hermes
75ea7abef4 Merge pull request #4082 from nesquena/stage-mm
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MM (v0.51.374): custom-provider context-length probes carry the API key (#4059)
2026-06-12 17:35:50 -07:00
nesquena-hermes
e8529c84e5 Merge #4060: pass custom provider keys to context probes (#4059) 2026-06-13 00:17:16 +00:00
nesquena-hermes
6354a46e0d Merge pull request #4081 from nesquena/stage-ml
Some checks failed
Release & Docker / release (push) Has been cancelled
Release ML (v0.51.373): sidebar lineage segments survive streaming refresh + source-count alignment
2026-06-12 17:10:37 -07:00
nesquena-hermes
89ca46eeb5 harden(#4020): prune lineage cache by collapsed-row key too (Opus SHOULD-FIX)
Opus advisor found a key-space asymmetry: _pruneLineageReportCacheToVisibleSessions
built visibleKeys from RAW rows via _sidebarLineageKeyForRow, but the render loop
keys the lineage-report cache by _sidebarLineageKeyForRow on the COLLAPSED row,
which can differ when collapse merges segments. On a malformed/edge chain the
expanded row's cache could be evicted every payload and re-fetched ~every 5s
(partial regression of the bug #4020 fixes). Fold the collapsed rows' cache keys
into the visible set too, mirroring the _resolveSessionIdFromSidebarLineage
precedent, behind a defensive try/catch.

docs(changelog): stamp #4020+#4055 as v0.51.373 (Release ML)
2026-06-13 00:03:30 +00:00
nesquena-hermes
0fdfa4b9fa Merge #4055 into stage-ml 2026-06-12 23:43:28 +00:00
nesquena-hermes
3ddf99c6f1 Merge #4020 into stage-ml 2026-06-12 23:43:28 +00:00
Frank Song
9f0e76c182 Preserve worklog detail disclosure state 2026-06-13 07:43:12 +08:00
nesquena-hermes
dee23a3528 Merge pull request #4079 from nesquena/stage-mk
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MK (v0.51.372): markdown link-label inline code + /use skill autocomplete + mobile Worklog overflow
2026-06-12 16:41:00 -07:00
nesquena-hermes
f083cf54d1 docs(changelog): stamp Phase-2 batch as v0.51.372 (Release MK) 2026-06-12 23:37:34 +00:00
nesquena-hermes
4874126b6d Merge #4066 into stage-mk 2026-06-12 23:18:00 +00:00
nesquena-hermes
0eee1b85ec Merge #4010 into stage-mk 2026-06-12 23:18:00 +00:00
nesquena-hermes
f4ce459f79 Merge #4017 into stage-mk 2026-06-12 23:18:00 +00:00
Rod Boev
db2d6eb97a test(pytest): suppress shard nodeid floods in verbose CI 2026-06-12 16:53:03 -04:00
nesquena-hermes
46ca6aefb6 Merge pull request #4074 from nesquena/stage-mj
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MJ (v0.51.371): low-risk batch — approval polling, composer/titlebar polish, slash-command parity, skill categories, French TTS
2026-06-12 13:44:07 -07:00
nesquena-hermes
ce013de195 Merge master (docs #4061) into stage-mj 2026-06-12 20:40:35 +00:00
nesquena-hermes
8a3ba873c8 Merge pull request #4061 from rodboev/pr/4045-aimlapi-provider-preset
docs(onboarding): document AIML API custom-provider setup (#4045)
2026-06-12 13:37:31 -07:00
nesquena-hermes
31936c3026 docs(changelog): stamp Phase-1 low-risk batch as v0.51.371 (Release MJ) 2026-06-12 20:29:28 +00:00
nesquena-hermes
6ec5527386 harden(#4057): pass local skills dir to _skill_category_from_path explicitly (Opus SHOULD-FIX)
_active_skill_search_dirs filters to existing dirs, so on a host with no local
skills dir but configured external dirs the local root is dropped from the list
and the position-based skills_dirs[0]==local assumption misclassified the first
external root as local (its flat skills silently lost their category label).
Pass the local dir explicitly (backward-compatible optional param defaulting to
the old skills_dirs[0] behavior) + regression test for the absent-local-dir case.
2026-06-12 20:28:46 +00:00
nesquena-hermes
ec15da249b Merge branch 'master' into pr/4045-aimlapi-provider-preset 2026-06-12 13:22:09 -07:00
nesquena-hermes
2fea2d588b Merge #4058 into stage-mj 2026-06-12 20:14:42 +00:00
nesquena-hermes
4ad3299449 Merge #4057 into stage-mj 2026-06-12 20:14:42 +00:00
nesquena-hermes
1e5a741f50 Merge #4054 into stage-mj 2026-06-12 20:14:42 +00:00
nesquena-hermes
10481d4f34 Merge #4051 into stage-mj 2026-06-12 20:14:42 +00:00
nesquena-hermes
13fdb0755f Merge #4049 into stage-mj 2026-06-12 20:14:42 +00:00
nesquena-hermes
ffa9f533ef Merge #4048 into stage-mj 2026-06-12 20:14:42 +00:00
nesquena-hermes
1052f4ab87 Merge #4047 into stage-mj 2026-06-12 20:14:42 +00:00
nesquena-hermes
99ee11a733 Merge #4046 into stage-mj 2026-06-12 20:14:42 +00:00
nesquena-hermes
fb3d689619 Merge #4050 into stage-mj 2026-06-12 20:14:41 +00:00
nesquena-hermes
f026c03bc1 Merge pull request #4073 from nesquena/stage-mi
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MI (v0.51.370): real model catalog when /api/models rebuild times out (#3928)
2026-06-12 13:09:53 -07:00
nesquena-hermes
82fcf665eb harden(#4052): tolerate providers.*.models list-of-dicts keyed by model/name (Opus SHOULD-FIX)
The richer static-catalog builder's group-building loop used a strict item["id"]
extraction that would KeyError (caught → degrade to the minimal one-model catalog)
for legal config shapes where providers.<id>.models is a list of dicts keyed by
"model"/"name" rather than "id". Mirror the tolerant id-or-model-or-name resolution
the detection loop already uses, skip entries with nothing usable, and add a
regression test. No behavior change for the common list-of-strings / list-of-id-dicts
shapes.

docs(changelog): stamp #4052 model-picker budget fallback as v0.51.370 (Release MI)
2026-06-12 20:05:12 +00:00
nesquena-hermes
1c82dab798 Merge #4052: real static catalog when /api/models times out (#3928) 2026-06-12 19:50:52 +00:00
Rod Boev
ea05c6f3e8 test(mobile): harden worklog overflow regression parser (#4064) 2026-06-12 13:57:37 -04:00
Rod Boev
028be2fe22 fix(mobile): constrain Worklog summary overflow on narrow viewports (#4064) 2026-06-12 13:33:26 -04:00
Frank Song
d516f9dd00 fix(anchor): harden registry contract before wiring 2026-06-13 00:44:26 +08:00
Rod Boev
010127d0c0 docs(onboarding): place AIML setup with custom-provider guidance (#4045) 2026-06-12 12:41:43 -04:00
Rod Boev
b426067e6f docs(onboarding): document AIML API custom-provider setup (#4045) 2026-06-12 12:35:59 -04:00
b3nw
9ac9f8d690 fix(#4059): log unresolved custom provider key templates 2026-06-12 16:23:35 +00:00
b3nw
8ee8523340 test: isolate context-length metadata stub 2026-06-12 16:18:50 +00:00
Rod Boev
6a1109475b fix(sidebar): count inactive rows through the render path 2026-06-12 12:03:04 -04:00
Rod Boev
65c0864e34 fix(approval): clear stale approval cards while busy 2026-06-12 11:55:43 -04:00
Rod Boev
4d8fa0245e fix(#3928): stabilize the static fallback contract 2026-06-12 11:55:43 -04:00
Rod Boev
0ed902bd05 fix(sidebar): drop the dead WebUI count path 2026-06-12 11:55:43 -04:00
b3nw
b2eda41b49 fix(#4059): pass custom provider keys to context probes
Resolve custom provider API keys from the matched config snapshot and pass them through session hydration plus streaming fallback context-length probes. This prevents authenticated /v1/models endpoints from falling back to the default 256K window and clobbering larger persisted session metadata.
2026-06-12 15:53:48 +00:00
Rod Boev
66a2d577df Keep CLI sidebar regression aligned with bucketed partitioning 2026-06-12 10:05:33 -04:00
Rod Boev
dde9f06b03 test(sidebar): avoid shard churn from static regressions 2026-06-12 09:52:49 -04:00
Benoît Vézina
8003faa801 feat(tts): add French (fr-CA, fr-FR) voices to Edge TTS allowlist
Adds 4 fr-CA + 3 fr-FR Edge neural voices so francophone users can use
the Edge TTS engine instead of receiving HTTP 400 "invalid voice" on
every utterance. Pure superset of the existing allowlist; no validation
or rate-limit behavior changes.

Tests mirror the test_issue2931 in-process / mocked-edge_tts pattern:
each new voice is parametrized through _handle_tts and asserted to
reach synthesis (HTTP 200); fr-BE-CharlineNeural (real Edge voice
but intentionally unlisted) is asserted to still 400.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 09:44:24 -04:00
Rod Boev
ddce08fd70 docs(skills): pin the local-first search-dir contract 2026-06-12 09:36:10 -04:00
Rod Boev
19448c3267 fix(sidebar): keep rendered source counts on one path 2026-06-12 09:36:10 -04:00
Rod Boev
1f42f3190f fix(skills): categorize flat external skill dirs (#4053) 2026-06-12 09:21:47 -04:00
Rod Boev
c507684b10 fix(sidebar): align session source counts with rendered rows (#3966) 2026-06-12 09:14:23 -04:00
Rod Boev
5f8ab6c452 Avoid redundant skill_view calls during forced skill sends 2026-06-12 08:49:56 -04:00
Rod Boev
76f3083223 fix(#3928): keep fallback badge helper out of source-marker tests 2026-06-12 08:43:06 -04:00
Rod Boev
87183b9517 fix(#3928): return a real static catalog when /api/models times out 2026-06-12 08:35:22 -04:00
Rod Boev
80dab6f3eb fix(commands): inject forced skill content for /use (#4044) 2026-06-12 08:34:27 -04:00
Rod Boev
1529dd0841 fix(commands): route /reload-skills through the WebUI allowlists (#4043) 2026-06-12 08:31:11 -04:00
Rod Boev
e73cf3d610 test(csp): pin ipc localhost across enforcing coverage (#4040) 2026-06-12 08:18:04 -04:00
Rod Boev
7795f29946 fix(composer): preserve send-button sync under scheduled resize (#4042) 2026-06-12 08:18:04 -04:00
Rod Boev
aab43cdaff fix(approval): keep approval polling alive across busy dips (#4041) 2026-06-12 08:12:57 -04:00
Rod Boev
768ea5ee87 fix(composer): coalesce composer auto-resize reflows (#4042) 2026-06-12 07:54:48 -04:00
Rod Boev
bad4ec393a fix(titlebar): sync document.title on panel switches (#4039) 2026-06-12 07:52:57 -04:00
Rod Boev
5bffddd9ca fix(#3914): track bootstrap launcher as executable 2026-06-12 07:50:43 -04:00
Rod Boev
ddcaf6091c fix(#4040): allow Tauri IPC host in connect-src 2026-06-12 07:50:43 -04:00
gaku
6a13feaf8b refactor(profiles): align profile cookie env var with HERMES_WEBUI_* naming
The profile cookie has been configurable since #1756 via
WEBUI_PROFILE_COOKIE_NAME, the lone WebUI env var missing the HERMES_WEBUI_
prefix shared by every other setting (e.g. HERMES_WEBUI_COOKIE_NAME from #3981).

- Read HERMES_WEBUI_PROFILE_COOKIE_NAME first (canonical name)
- Keep WEBUI_PROFILE_COOKIE_NAME as a deprecated alias so existing deployments
  are unaffected; behavior is unchanged, only the name is aligned
- Warn once per process for the legacy name (this resolver runs on every
  request, so the deprecation log must not fire per-request)
- Add resolution tests covering canonical, legacy, precedence, blank, and
  warn-once paths
2026-06-12 09:55:49 +00:00
Mohamed Habib
a84bd8908d docs: add transparent stream ux evidence 2026-06-12 12:54:30 +03:00
Mohamed Habib
62743be299 style: quiet transparent stream activity rows 2026-06-12 12:52:54 +03:00
Mohamed Habib
43bb015135 merge: sync transparent stream branch with master 2026-06-12 11:25:53 +03:00
nesquena-hermes
df6cfd0a15 Merge pull request #4038 from nesquena/stage-4022
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MH — v0.51.369 — WebUI streaming honors runtime target model/base_url (#4022, fixes #3895)
2026-06-12 01:18:34 -07:00
nesquena-hermes
6204dbe9ea docs(changelog): stamp #4022 runtime base_url fix as v0.51.369 (Release MH) 2026-06-12 08:16:00 +00:00
Frank Song
53051ad4a3 fix(anchor): harden registry review findings 2026-06-12 16:15:09 +08:00
Mohamed Habib
2005f1b115 merge: sync transparent stream branch with master 2026-06-12 11:13:16 +03:00
Mohamed Habib
b42b17fbbf fix: address transparent stream review findings 2026-06-12 11:10:43 +03:00
nesquena-hermes
c35c1c6e70 test(#4022): update sprint42 runtime-route assertion for target_model threading
#4022 now calls resolve_runtime_provider(requested=..., target_model=...) so
per-model base_url normalization applies; the sprint42 test pinned the old
single-arg signature. Updated the assertion to expect target_model (pre-existing
test, not a behavior regression — confirmed it passes on master only because the
call signature changed here).
2026-06-12 07:57:26 +00:00
nesquena-hermes
005b2ca460 fix(#4022): preserve different-endpoint config base_url override (Codex CORE)
Codex caught a regression: the contributor's _runtime_preferred_base_url preferred
the runtime base_url for ALL non-custom providers, which would clobber an explicit
providers.<id>.base_url override pointing at a different host/port (LM Studio at a
LAN IP, an OpenRouter mirror). Now only prefer the runtime URL when it's the SAME
scheme+host+port as the configured one (the #3895 /v1-dedup case is path-only on
the same host); a genuinely different configured endpoint wins. Added
_same_base_url_endpoint() + 2 regression tests (different-endpoint preserved,
same-endpoint normalization).
2026-06-12 07:54:48 +00:00
Frank Song
65cb2a8b46 feat(anchor): add shadow source registry harness 2026-06-12 15:52:28 +08:00
Frank Song
acd9d2e6f4 fix(anchor): carry normalizer hardening into registry slice 2026-06-12 15:52:28 +08:00
Frank Song
56a89ef11e feat(anchor): add inert turn registry skeleton 2026-06-12 15:52:28 +08:00
nesquena-hermes
983f059acd Merge #4022 — honor runtime target model+base_url in WebUI streaming (#3895) 2026-06-12 07:48:26 +00:00
nesquena-hermes
9e96f5f6ad Merge pull request #4036 from nesquena/stage-4023
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MG — v0.51.368 — bind active-profile cookie to auth session (#4023, fixes #803)
2026-06-12 00:46:01 -07:00
nesquena-hermes
061eda9b91 docs(changelog): stamp #4023 profile-cookie session binding as v0.51.368 (Release MG) 2026-06-12 07:43:46 +00:00
nesquena-hermes
03799f8e4a harden(#4023): apply Opus security findings — verify-side name-pattern gate + require handler when auth enabled
Opus independent security review concurred SAFE and surfaced 2 LOW defense-in-depth
items, both applied: (1) verify_profile_cookie_value now validates the profile name
against _PROFILE_ID_RE itself (not only in get_profile_cookie) so a future second
caller can't return an unvalidated name; (2) build_profile_cookie raises when auth is
enabled and no handler is passed, so a future call site can't silently emit an
unsigned (session-unbound) profile cookie. +3 regression tests.
2026-06-12 07:37:48 +00:00
nesquena-hermes
aef15ca559 Merge #4023 — bind active profile cookie to session (#803) 2026-06-12 07:19:46 +00:00
nesquena-hermes
242d79eacd Merge pull request #4035 from nesquena/stage-mf-fixes
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MF — v0.51.367 — autocomplete filter + lineage merge + shutdown i18n (#4021, #4032, #4018)
2026-06-12 00:16:54 -07:00
nesquena-hermes
3eac7ccd96 docs(changelog): stamp #4021 + #4032 + #4018 fix batch as v0.51.367 (Release MF) 2026-06-12 07:14:16 +00:00
nesquena-hermes
07585a11f8 Merge #4018 2026-06-12 07:07:34 +00:00
nesquena-hermes
0e7cf6c388 Merge #4032 2026-06-12 07:07:33 +00:00
nesquena-hermes
fd28024cc4 Merge #4021 2026-06-12 07:07:33 +00:00
nesquena-hermes
95c84204d8 Merge pull request #4034 from nesquena/release/stage-md2
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.366 — Release ME (assistant turn anchor source normalizer Slice 2, #3980)
2026-06-11 23:56:04 -07:00
nesquena-hermes
39e9790597 Release v0.51.366 — Release ME (assistant turn anchor source normalizer Slice 2, #3980) 2026-06-12 06:45:02 +00:00
Mohamed Habib
7359f85c64 fix: align transparent thinking event header 2026-06-12 09:05:22 +03:00
ai-ag2026
de3b756558 fix: merge missing webui lineage display rows 2026-06-12 08:00:11 +02:00
Frank Song
d701828d72 fix(anchor): harden normalizer identity handling 2026-06-12 13:57:54 +08:00
Frank Song
2abff38ee1 fix(anchor): harden normalizer payload shaping 2026-06-12 13:57:54 +08:00
Frank Song
36123628d5 feat(anchor): add assistant turn source normalizer 2026-06-12 13:57:54 +08:00
hinotoi-agent
4dca506858 Bind profile cookie to auth session 2026-06-12 10:52:56 +08:00
Frank Song
02e72163a6 Fix tool-limit terminal failure annotation 2026-06-12 10:25:18 +08:00
Mohamed Habib
306a148fbf merge: sync transparent stream branch with master 2026-06-12 05:09:01 +03:00
Frank Song
136568fe81 fix(streaming): tighten tool limit terminal guards 2026-06-12 10:03:31 +08:00
Frank Song
08ba5a6676 Fix tool-limit terminal detection 2026-06-12 10:03:31 +08:00
Frank Song
ec23dac922 Handle tool limit terminal state 2026-06-12 10:03:31 +08:00
Mohamed Habib
fcfaa4686a feat: add transparent stream activity display 2026-06-12 04:45:41 +03:00
Rod Boev
891f392922 test(#3895): drop stale import after review cleanup 2026-06-11 21:17:50 -04:00
Rod Boev
819346304c test(#3895): replace brittle wiring assertions 2026-06-11 21:13:11 -04:00
Rod Boev
6e50e94649 test(#3895): cover credential self-heal runtime resolution 2026-06-11 21:10:48 -04:00
Rod Boev
6e133a4f44 fix(#3895): carry target model through credential self-heal 2026-06-11 21:08:24 -04:00
Rod Boev
e62a31e463 fix(#3895): preserve configured base-url fallback through self-heal 2026-06-11 21:06:16 -04:00
Rod Boev
1753cc0c60 fix(commands): hide CLI-only slash collisions from autocomplete (#3969) 2026-06-11 20:58:51 -04:00
Rod Boev
a692314acf fix(#3895): honor runtime target model and base_url in WebUI streaming 2026-06-11 20:56:22 -04:00
Rod Boev
220d255d5c fix(#4005): preserve expanded lineage segments during streaming refresh 2026-06-11 20:55:35 -04:00
Rod Boev
9d1e3910ee Scope shutdown i18n assertions to the relevant DOM 2026-06-11 20:45:54 -04:00
Rod Boev
f6ed8f7302 Make browser TTS watchdog checks brace-aware 2026-06-11 20:42:51 -04:00
Rod Boev
a8137ff21c Guard watchdog recovery from duplicate voice-mode resume 2026-06-11 19:24:57 -04:00
Rod Boev
22f9e5c973 Keep link-label rendering compatible with the autolink source guard 2026-06-11 19:08:28 -04:00
Rod Boev
4ba4f77343 Keep the Edge-branch guard test aligned with branch scope 2026-06-11 19:04:26 -04:00
Rod Boev
12e13d4680 Initialize markdown link-label sanitizing before use 2026-06-11 19:04:26 -04:00
nesquena-hermes
c275db0970 Merge pull request #4019 from nesquena/stage-md-fixes
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MD — v0.51.365 — lineage-segment open + reasoning chip fixes (#4009, #4015)
2026-06-11 16:01:18 -07:00
Rod Boev
09f19d1233 Prevent duplicate voice-mode rearm after watchdog recovery 2026-06-11 18:58:12 -04:00
Rod Boev
be75d41857 Keep the renderer fixture import lint-clean 2026-06-11 18:58:12 -04:00
nesquena-hermes
fbd7f82478 docs(changelog): stamp #4009 + #4015 fix batch as v0.51.365 (Release MD) 2026-06-11 22:55:04 +00:00
Rod Boev
0ff3860186 fix(#4002): split shutdown i18n text from static code spans 2026-06-11 18:53:39 -04:00
Rod Boev
e3765a389c fix(#4001): preserve inline code inside markdown link labels 2026-06-11 18:50:54 -04:00
Rod Boev
f4a6544121 fix(#3983): re-arm browser voice mode when speechSynthesis drops onend 2026-06-11 18:48:58 -04:00
nesquena-hermes
ff3de220d8 Merge #4015 — keep reasoning effort status on the active session model (#3958) 2026-06-11 22:48:36 +00:00
nesquena-hermes
8a849a1bb8 Merge #4009 — open clicked lineage segments without sid rewrite (#4003) 2026-06-11 22:48:36 +00:00
Rod Boev
a82eb1622f fix(#3958): keep reasoning effort status on the active session model 2026-06-11 18:46:17 -04:00
nesquena-hermes
b2482913e9 Merge pull request #4014 from nesquena/stage-3993
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MC — v0.51.364 — self-heal stuck session loads (#3993)
2026-06-11 15:45:43 -07:00
nesquena-hermes
cb93a8014e docs(changelog): stamp #3993 session-load self-heal as v0.51.364 (Release MC) 2026-06-11 22:43:07 +00:00
nesquena-hermes
952ac146e0 test(#3993): re-anchor _load_session_error_block past the new stale-load guard
The stale-load guard (#3993 Codex race fix) adds an early  at the top
of the loadSession catch block; the test helper anchored  on the first
 after the catch, which now truncated at the guard. Anchor the body
extraction past the guard (from ) so it captures the 404/non-404
self-heal body as before. Behavior unchanged.
2026-06-11 22:37:09 +00:00
nesquena-hermes
8e7d3b086d fix(#3993): add stale-load guard before self-heal so a superseded boot load can't wipe a healthy session (Codex CORE race)
currentSid is snapshotted before the awaited /api/session fetch; if the user
clicks a healthy session while a boot-time restore is in flight and that boot
load then fails non-404, _clearStuckSessionOnBoot(sid, null) would wipe the
healthy session's localStorage/URL. Guard the catch block on
_loadingSessionId !== sid (a newer load superseded this one) — re-arm the active
stream and bail before any self-heal/DOM mutation. Protects both the non-404 and
404 inline self-heal paths. + regression test.
2026-06-11 22:27:01 +00:00
nesquena-hermes
7e341a0d70 test(#3993): cover _clearStuckSessionOnBoot self-heal guards (clears on boot-failure, preserves a live session) 2026-06-11 22:20:57 +00:00
nesquena-hermes
63c62194a1 Merge #3993 — self-heal stuck session loads on non-404 failures 2026-06-11 22:19:14 +00:00
nesquena-hermes
8669b6b8b5 Merge pull request #4013 from nesquena/stage-3994
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MB — v0.51.363 — materialize CLI sessions on rename/move/update (#3994, fixes #3985)
2026-06-11 14:54:06 -07:00
nesquena-hermes
1e61aea10e docs(changelog): stamp #3994 materialize-CLI-sessions fix as v0.51.363 (Release MB) 2026-06-11 21:51:29 +00:00
nesquena-hermes
7a5c3bc374 fix(#3994): scope happy-path guard to read_only flag; complete #1436 mock stub
Refined the Codex-CORE fix: the happy-path (already-stored session) guard rejects
only an explicit read_only flag — a stored messaging session already owns its
sidecar, so the messaging-fork risk is specific to the materialize FALLBACK
(which creates a sidecar) where the _is_messaging_session_record check stays.
Also completed test_issue1436's _stub_session MagicMock (read_only=False,
_loaded_metadata_only=False) — bare MagicMock auto-attrs were truthy, tripping
the new read-only guard + _ensure_full_session_before_mutation reload.
2026-06-11 21:45:36 +00:00
nesquena-hermes
6f41fe7490 fix(#3994): reject read-only + messaging sessions on BOTH happy-path and materialize fallback (Codex CORE x2)
Codex caught two data-integrity gaps in the contributor's guard: (1) the
get_session() happy path returned a stored session without checking read_only/
messaging, so an already-imported read-only session could be mutated via
rename/update/move; (2) the fallback only checked cli_meta.read_only, but agent
rows normalize messaging sources WITHOUT setting read_only — materializing a
writable sidecar for a state.db-owned messaging session forks its title/state.
Now reject getattr(s,'read_only') OR _is_messaging_session_record on the happy
path, and cli_meta.read_only OR _is_messaging_session_record(cli_meta) in the
fallback. Replaced the messaging-stub test with 3 regression tests (stored
read-only, stored messaging, messaging cli_meta without read_only flag).
2026-06-11 21:41:51 +00:00
nesquena-hermes
e934fdde70 test(#3994): cover _get_or_materialize_session (in-store, missing, read-only-403, CLI-import, messaging-stub) 2026-06-11 21:36:28 +00:00
nesquena-hermes
89fbc898f3 Merge #3994 — materialize CLI sessions on rename/move/update (#3985) 2026-06-11 21:35:13 +00:00
nesquena-hermes
ca2538f43c Merge pull request #4012 from nesquena/stage-4008
Some checks failed
Release & Docker / release (push) Has been cancelled
Release MA — v0.51.362 — malformed providers config no longer crashes (#4008, fixes #3979)
2026-06-11 14:17:46 -07:00
nesquena-hermes
951abb7753 docs(changelog): stamp #4008 malformed-providers-config crash fix as v0.51.362 (Release MA) 2026-06-11 21:15:11 +00:00
nesquena-hermes
c572e63c50 Merge #4008 — treat malformed providers config as unconfigured (#3979) 2026-06-11 21:08:43 +00:00
Rod Boev
7d925a324a fix(#3968): autocomplete skill names for /use 2026-06-11 16:55:14 -04:00
Rod Boev
4dc4786aae fix(#3979): treat malformed providers config as unconfigured 2026-06-11 16:55:14 -04:00
Rod Boev
749f97144b fix(#4003): open clicked lineage segments without sid rewrite 2026-06-11 16:28:33 -04:00
nesquena-hermes
a023650330 Merge pull request #4007 from nesquena/stage-b1-cookie-pytest
Some checks failed
Release & Docker / release (push) Has been cancelled
Release LZ — v0.51.361 — configurable session cookie name (#3981)
2026-06-11 13:24:36 -07:00
nesquena-hermes
0ec59de3ac docs(changelog): stamp #3981 configurable cookie name as v0.51.361 (Release LZ) 2026-06-11 20:21:37 +00:00
nesquena-hermes
de7c7f98ea Merge #3981 — configurable session cookie name via env var 2026-06-11 20:15:18 +00:00
Ming
e05b99b7fd fix(scroll): guard _scrollAfterMessageRender and _finishDone against pin hysteresis snap-back
Two scroll-state bugs cause the viewport to snap to the bottom after
streaming completes, even when the user has scrolled up to read:

1. _scrollAfterMessageRender (ui.js): when preserveScroll=true and
   _scrollPinned=true (re-set by the 250px near-bottom hysteresis),
   _followMessagesAfterDomReplace() calls scrollToBottom(), overriding
   the user's position. Added !_messageUserUnpinned guard so explicitly
   scrolled-up users get their snapshot restored instead.

2. _finishDone (messages.js): the explicit scrollToBottom() at L3302
   fires when shouldFollowOnDone is true, but that flag only checks a
   120px near-bottom threshold. Added _isMessagePaneNearBottom(250)
   gate so users reading 250px+ above bottom keep their position.

Also adds a new 'auto_scroll_follow' setting (default: off) that gates
scrollIfPinned() and _shouldFollowMessagesOnDomReplace(). When disabled,
the viewport never auto-scrolls during streaming — the user controls
scroll position manually and uses the ↓ button to jump to bottom.

7-file setting-add pattern applied:
- api/config.py: _SETTINGS_DEFAULTS + _SETTINGS_BOOL_KEYS
- static/boot.js: both init paths
- static/ui.js: scrollIfPinned() + _shouldFollowMessagesOnDomReplace()
- static/index.html: checkbox in appearance settings
- static/i18n.js: en + zh-CN translations
- static/panels.js: payload, autosave, populate, full save
- messages.js: no additional changes (gated via _shouldFollowMessagesOnDomReplace)

Tests updated: test_tars_scroll_reset_regressions, test_issue1690,
test_issue3545. All 116 scroll-related tests pass.
2026-06-11 21:48:14 +02:00
nesquena-hermes
b1236bf3f9 Merge pull request #4004 from nesquena/stage-3996
Some checks failed
Release & Docker / release (push) Has been cancelled
Release LY — v0.51.360 — close idle SSE on hidden tabs (#3996, fixes #3992)
2026-06-11 12:37:09 -07:00
nesquena-hermes
6f410a81b5 docs(changelog): stamp #3996 SSE-visibility fix as v0.51.360 (Release LY) 2026-06-11 19:33:44 +00:00
nesquena-hermes
2cbae63a8d fix(#3996): preserve pending sid on hidden-tab skip so a bg-tab session reopens (Codex CORE)
If startSessionStream(sid) runs while the tab is ALREADY hidden (session loaded/
restored in a background tab), it set _sessionStreamSessionId then returned at the
hidden-skip WITHOUT recording _sessionStreamHiddenSid — so the visibility handler
(which reopens only from the holder) never reattached, silently dropping
bg_task_complete/server_turn_started until the next explicit startSessionStream.
Set _sessionStreamHiddenSid = sid on the hidden-skip return. Adds a regression test.
2026-06-11 19:17:11 +00:00
nesquena-hermes
8a22540df4 test(#3996): cover SSE visibility hooks + per-session reopen-on-re-show regression 2026-06-11 19:13:03 +00:00
nesquena-hermes
5fdfb70bce fix(#3996): reopen per-session SSE on tab re-show (reopen-bug fix)
#3996 closes the gateway + per-session SSE on hidden tabs to free HTTP/1.1
connection-pool slots (#3992). But the per-session-stream visibility hook
guarded its reopen on _sessionStreamSessionId, which stopSessionStream() nulls —
so the per-session SSE closed on hide but never reopened on re-show (regressing
live-stream reattach). Capture the id into a dedicated _sessionStreamHiddenSid
before the close and reopen from it on visibility return. Gateway hook (sessions.js)
was already correct (reopens unconditionally + re-guards internally).
2026-06-11 19:10:38 +00:00
nesquena-hermes
202d6df310 Merge #3996 — close idle SSE on hidden tabs 2026-06-11 19:08:59 +00:00
John Torcivia
8eadfb048f fix(sessions): remove self-heal from if(!data) 401 path
On 401, api() redirects to /login and returns undefined. The browser
navigates away immediately, so this code rarely runs. But even if it
did, clearing localStorage on transient auth expiry is wrong — it wipes
the saved session id and sends users to empty state after re-login.

Keep the self-heal in the catch-block else branch for non-401, non-404
errors (400/403/500/network) which genuinely trap the user on recurring
boot failures. 401 has no such property because the redirect already
breaks the retry loop.

Greptile: 'Keep the if (!data) early-return clearing-free (its old
behavior), and only call _clearStuckSessionOnBoot() from the else
branch of the catch.'

(#4028 follow-up)
2026-06-11 14:54:42 -04:00
John Torcivia
3bf3d6004e fix(sessions): remove unreachable e.status===401 branch in loadSession catch
The api() function returns undefined on 401 (redirects to login) rather
than throwing. So the 401 path exits via the if(!data) guard at line ~988,
not through the catch block — the e.status===401 branch inside the catch
is dead code (#4028 follow-up).
2026-06-11 14:52:25 -04:00
John Torcivia
cea9a749d5 fix(sessions): tighten _clearStuckSessionOnBoot guard to !currentSid only
Greptile flagged that currentSid===sid also fires on same-session
force-reloads (e.g. background poll). If the server returns a transient
500 or network error, currentSid===sid is true — the helper wipes
hermes-webui-session from localStorage and resets the URL, even though
the session still exists on the server.

Limit the self-heal to boot-time failures only (!currentSid), where the
stored session ID is definitely stale. When currentSid is set (already
viewing a session), a non-404 failure could be transient and wiping
localStorage is unnecessarily destructive.

The 404 inline self-heal (line 932) already uses this tighter guard,
so this brings the non-404 path into alignment.
2026-06-11 14:49:32 -04:00
Dima Diall
832ab09324 fix(visibility): close gateway/session SSE on hidden tabs to prevent connection pool exhaustion (#3992) 2026-06-11 18:10:29 +01:00
Dima Diall
46f87028c2 fix(routes): materialize CLI sessions on rename/move/update (fixes #3985)
When a session exists in Hermes Agent state.db but has no WebUI sidecar
(SESSION_DIR/{sid}.json), mutation routes (rename, move, update) would
return 404 "Session not found" despite the session appearing in the sidebar.

This mirrors the existing fallback in /api/session/archive:
- Try get_session() first (WebUI store)
- On KeyError, look up CLI metadata via _lookup_cli_session_metadata()
- For messaging/Claude Code (read_only): return 403 instead of silent 404
- For regular CLI sessions: import_cli_session() to materialize sidecar
- Preserve source_tag/raw_source/session_source/etc. for lineage

Routes updated:
- /api/session/rename
- /api/session/update (workspace switch)
- /api/session/move

Refs: #3746 (same class: session discovery vs mutation mismatch),
#3915 (session store empty but data exists in Agent store)
2026-06-11 17:06:16 +01:00
John Torcivia
4193c38b61 Merge pull request #1 from syntheticgio/fix/session-load-failure-self-heal
fix: self-heal on non-404 session load failures
2026-06-11 11:44:24 -04:00
John Torcivia
906c2ef9fd Merge remote-tracking branch 'origin/master' into fix/session-load-failure-self-heal
# Conflicts:
#	static/sessions.js
2026-06-11 11:43:35 -04:00
John Torcivia
3bca16fdc2 fix: self-heal on non-404 session load failures
When loadSession() fails during boot with a non-404 error (401, 400,
500, network), the session ID stays stuck in localStorage and the URL,
causing repeated failures on every page refresh.

The 404 path already had inline self-heal (clears localStorage + URL).
This extends it to all error cases:

- Added _clearStuckSessionOnBoot() helper to consolidate the self-heal
  logic for non-404 errors.
- On 401 redirect (api() returns undefined): clears the stuck session
  ID and shows a more informative message.
- On other non-404 errors: clears the stuck session ID and shows a
  better error message distinguishing auth failures from other errors.

Preserves the guard that prevents clearing localStorage when clicking
into a *different* dead session while already viewing a healthy one.
2026-06-11 11:35:08 -04:00
gakugaku
d9c35f58dd feat(auth): make session cookie name configurable via env var
Add HERMES_WEBUI_COOKIE_NAME so multiple WebUI instances sharing a
hostname (different ports) can use distinct auth cookie names. Browsers
scope cookies by host, not host+port (RFC 6265), so same-host instances
otherwise trample each other's `hermes_session` cookie and log users out.

- Resolve the cookie name via _resolve_cookie_name(): env > default,
  mirroring the existing _resolve_session_ttl() pattern.
- Keep `hermes_session` as the default for backwards compatibility.
- Validate against the RFC 6265 token grammar; fall back to the default
  with a logged warning on empty or malformed values.
- Cover default, override, empty, invalid, and Set-Cookie paths in
  tests/test_auth_sessions.py::TestCookieNameResolution.
2026-06-11 08:15:35 +00:00
nesquena-hermes
48860418c9 Release LW stage — v0.51.359 (#3962 assistant turn anchor phase 0 scaffold) (#3977)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat(anchor): add stable assistant turn phase 0 scaffold

* chore: stamp v0.51.359 — Release LW (assistant turn anchor phase 0 scaffold, #3962)

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <agent@local>
2026-06-10 23:14:18 -07:00
Frank Song
c4284dc77c fix: hide Worklog reasoning with Thinking 2026-06-11 13:30:23 +08:00
nesquena-hermes
1126e54132 Merge PR #3973 — v0.51.358 first-run password bootstrap hardening (#3964)
Some checks failed
Release & Docker / release (push) Has been cancelled
Release LV — v0.51.358 (first-run password bootstrap hardening, #3964)
2026-06-10 21:54:09 -07:00
Hermes Agent
f2ef2851d3 Release LV stage — v0.51.358 (#3964 first-run password bootstrap local gate)
#3964 [security] gate first-password bootstrap (_set_password on POST /api/settings
while auth disabled) to local clients — blocks remote unauth first-run ownership.
Uses request-start auth snapshot (auth_enabled_before), so no mid-request TOCTOU.
Self-rebased onto v0.51.357 (8-behind, 3-dot fidelity verified byte-identical).

#3970 (oauth single-flight) DROPPED from this stage: Codex+Opus both caught a
check-then-insert race — _pending_oauth_flow_for releases the lock before the
device-code request + flow insertion, so concurrent unauth starts still spawn
multiple workers (Codex empirically reproduced w/ 2 threads). Returned to author
w/ the atomic per-(provider,home) start-lock fix. Re-gating the (N-1) stage.

Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
2026-06-11 04:39:34 +00:00
nesquena-hermes
5dceb2993c Merge PR #3963 — v0.51.357 mid-stream flicker tie fix (#3877)
Some checks failed
Release & Docker / release (push) Has been cancelled
fix(streaming): re-attach preserved live turn on length tie — residual mid-stream flicker (#3877 reopen)
2026-06-10 21:06:28 -07:00
Hermes Agent
4857f928bd docs(changelog): stamp #3877 flicker fix as v0.51.357 (Release LU) 2026-06-11 03:58:00 +00:00
Hermes Agent
0a561f240a chore(streaming): move dataset.sessionId stamp to the whole-turn restore paths only (Opus/Codex nit)
Both reviewers noted the _preservedLiveTurn.dataset.sessionId stamp was a dead
write in the segment-swap path (only the segment enters the DOM there; the
rebuilt turn was already stamped at build time). Move the stamp into the two
whole-turn paths where the preserved turn actually enters the DOM. No behavior
change; removes the dead write.
2026-06-11 03:20:17 +00:00
Hermes Agent
02c6696e53 chore: drop stray uv.lock accidentally swept in (not tracked on master) 2026-06-11 03:14:03 +00:00
Hermes Agent
3ec93ff6bf fix(streaming): count live-worklog shell/reason as preserved structure (Codex r2)
Codex round 2 caught that _structuralCount() only enumerated .tool-call-group/
.tool-card-row/.tool-worklog-group but NOT the live Worklog shell
(.live-worklog[data-live-worklog-shell=1]) or its .wl-reason content (built by
_flushPendingSegmentRender / ensureLiveWorklogContainer, see messages.js:2386 +
ui.js:6712/6655). So a turn whose live-ahead structure is a Worklog (not a bare
tool-call-group) undercounted preserved structure → wrongly took the segment-swap
path → detached the visible live Worklog container for a frame.

Fix: add .live-worklog[data-live-worklog-shell=1] and .wl-reason to the structural
count (mirrors the canonical 'has current worklog content' selector set at
sessions.js:1125-1131). Added a structural-test assertion + verified live: a
preserved turn with a live worklog .wl-reason the rebuild lacks now takes the
whole-turn restore path and the worklog + parser both survive.

Convergence: Codex findings narrowing each round (tie-guard → tail-segment →
worklog-selector), all real, all fixed + empirically verified.
2026-06-11 03:12:40 +00:00
Hermes Agent
c37c0d1397 fix(streaming): whole-turn restore when live DOM is structurally ahead (Opus)
Opus flagged a tool-card-frame-loss edge in the segment-only swap: the premise of
#3877 is that the live DOM can be AHEAD of S.messages, so a tool/worklog group can
land in the live turn between the last throttled persist and a mid-stream rebuild.
The rebuild (built from the lagging S.messages) would then lack that group, and a
segment-only swap would drop it for a frame (empirically confirmed: a live-only
tool card present before the rebuild was absent after).

Fix: count structural blocks ([data-live-assistant=1] + tool/worklog/thinking
groups) on both turns. Segment-swap only when the rebuild is the structural
SUPERSET (rebuilt >= preserved); otherwise restore the WHOLE preserved turn so
live-only structure the user already saw never vanishes. Verified live: tool-card-
ahead → whole-turn restore keeps the card + parser; single-segment tie → segment
swap, parser connected; multi-live-segment tail → tail parser connected, both
segments kept.

Also addresses Opus's dead-code note implicitly — the dataset.sessionId stamp now
matters on the whole-turn restore path (it's the branch that actually inserts the
preserved wrapper).
2026-06-11 03:03:55 +00:00
Hermes Agent
95e874d7be fix(streaming): target the parser-owned TAIL live segment in the #3877 re-attach (Codex CORE)
Codex caught an asymmetry in the first cut: the rebuilt side selected the tail
live segment ([...].pop()) but the preserved side used querySelector() = the
FIRST [data-live-assistant=1] segment. In a multi-live-segment turn (reconnect /
post-tool activity boundaries — see messages.js ensureAssistantRow re-attaching
to the LAST live segment), the smd parser writes into the tail segment, so
swapping the first preserved segment would move the wrong node and leave the
parser-owned tail detached — re-introducing the flicker for multi-segment turns.

Fix: select the preserved segment from querySelectorAll, defaulting to the LAST
(tail), and prefer the one whose data-live-segment-seq matches the rebuilt tail;
compute _preservedLen from that segment. Added a structural regression test
pinning the tail selection.

Verified live: a 2-live-segment turn (seq 1 + seq 2, parser on seq 2) keeps the
parser-owned tail connected across a mid-stream rebuild (was orphaned with the
first-segment selection).
2026-06-11 02:57:46 +00:00
Hermes Agent
9803a037fe fix(streaming): re-attach preserved live turn on length tie (#3877 reopen)
The #3877 fix (#3892) preserves the live assistant turn's DOM node across a
mid-stream renderMessages() rebuild and swaps it back in when the rebuilt turn
has less streamed text than the preserved (smd-parser-referenced) node. But the
guard was strict: _rebuiltLen < _preservedLen. At the throttled session
write-back boundary the rebuilt turn's live segment can carry EXACTLY the same
text length as the preserved node, so the strict guard skipped the swap and left
the streaming parser writing into the now-detached original node — the residual
'disappears, then reappears' frame the reporter still saw on v0.51.347.

Fix:
- Relax the guard to _rebuiltLen <= _preservedLen so the equal-length tie also
  restores the parser-referenced node (on a tie the preserved node is strictly
  preferable: it holds the live parser reference and nothing is lost). When the
  rebuilt turn genuinely has MORE content (reconnect where S.messages caught up
  past the parser) the guard still skips and the parser re-resolves to the fuller
  node.
- Swap at the SEGMENT level (replace only the rebuilt live segment with the
  preserved one) instead of a whole-turn replaceWith, so a multi-segment turn
  (earlier settled segments + tool/worklog groups built by the rebuild) keeps
  that rebuilt-only structure. Whole-turn replace remains the fallback when the
  rebuilt turn has no live segment to target.

Verified live (isolated server on the edited ui.js, real shipped renderMessages):
- single-segment tie: parser node stays connected (was orphaned on master);
- multi-segment tie: parser connected AND both assistant segments preserved;
- rebuilt-longer: correctly keeps the fuller rebuilt node, no content loss.

Updated the #3877 structural tests to pin the <= guard + segment-level swap.

Closes #3877.
2026-06-11 02:51:14 +00:00
nesquena-hermes
c77c8312c8 Merge PR #3960 — v0.51.356 per-profile providers & models (#3957)
Some checks failed
Release & Docker / release (push) Has been cancelled
fix(profiles): scope /api/providers + /api/models to the active profile (#3957)
2026-06-10 19:18:49 -07:00
Hermes Agent
808b3fa511 docs(changelog): stamp #3957 fix as v0.51.356 (Release LT) 2026-06-11 02:15:28 +00:00
Hermes Agent
85ee6ec2c8 fix(profiles): address greptile P2 flags on #3960
- _load_models_cache_from_disk: resolve _get_models_cache_path() once
  (was called twice — exists() then open()) (greptile P2).
- conftest: use a _MISSING sentinel for the _active_profile restore guard so
  it restores whenever api.profiles was importable pre-test, independent of the
  value (greptile P2; _active_profile defaults to 'default' so the prior
  is-not-None guard was functionally fine but technically value-dependent).

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-06-11 01:56:58 +00:00
Hermes Agent
b5d382a3bb fix(profiles): scope the detached models-rebuild worker to the active profile (#3957)
Codex + Opus both independently caught a CORE gap in the first cut: the
bounded /api/models rebuild runs on a detached 'models-catalog-rebuild' daemon
thread that inherits neither the request-profile thread-local (#798) nor
os.environ. So on a non-default profile the worker probed the DEFAULT profile's
credentials and, when the 4s budget was exceeded, published the rebuilt catalog
to the DEFAULT profile's disk cache (cross-contamination) — exactly the slow
path a non-default cold rebuild takes.

Fix:
- profile_scope_for_detached_worker(profile_name): sets the request-profile TLS
  AND applies the profile .env on the worker thread, restoring both on exit
  (no-op for default). Distinct from profile_env_for_active_request (which reads
  the current thread's TLS and must not clear it).
- get_available_models() captures the active profile on the request thread and
  wraps the rebuild worker body (probe + over-budget publish + disk save +
  fingerprint) in that scope; the legacy synchronous rebuild applies the profile
  env on the foreground. /api/models route no longer wraps (the work moved into
  get_available_models so ALL callers — chat/start, resolution — are fixed).
- 2 new regression tests incl. the worker-thread before/inside/after assertion.

Empirically verified: a fresh worker thread resolved models_cache.json/default
WITHOUT the scope (the bug) and models_cache.work.json + the work .env + the
work auth.json WITH it.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-06-11 01:41:24 +00:00
Hermes Agent
70d391c44a fix(profiles): scope /api/providers + /api/models to the active profile (#3957)
On a non-default profile, Settings → Providers timed out and the model
picker showed only the default profile's models. WebUI profile switching is
per-client/cookie-scoped (#798), but two read-only paths resolved from the
process-global default profile:

- Facet A: /api/providers + /api/models did not apply the active profile's
  .env around the read, so get_auth_status() / provider_model_ids() / custom
  key lookups resolved the default profile's credentials. On a non-default
  profile the auth probes could stall past the 30s frontend abort.
- Facet B: the /api/models disk cache was a single import-time
  STATE_DIR/models_cache.json shared across every profile, while the cache
  fingerprint is profile-specific -> a non-default profile rejected the shared
  snapshot every read and cold-rebuilt (the slow serial-probe path).

Fix:
- api.profiles.profile_env_for_active_request(): applies the active
  per-request profile's .env for the duration of the read (delegates to the
  existing profile_env_for_background_worker used by streaming). No-op for the
  default/root profile, so single-profile deployments are byte-identical.
- api.config._get_models_cache_path(): profile-keys the disk cache filename
  (models_cache.<profile>.json) derived from the default path; default profile
  keeps models_cache.json unchanged (no file migration).
- routes.py: wrap both GET handlers in profile_env_for_active_request.
- conftest: restore api.profiles._active_profile + clear request-profile TLS
  after each test (a pre-existing isolation hole that profile-keyed cache
  paths newly surface under sharding).

Tested: 10 new regression tests; live before/after on isolated servers shows
a non-default profile now surfaces its configured provider (deepseek) with
its own credentials + its own cache file, where master showed only the
default profile's providers and one shared cache.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-06-11 01:17:22 +00:00
nesquena-hermes
d9cf050cf3 Merge pull request #3956 from nesquena/release/stage-3573
Some checks failed
Release & Docker / release (push) Has been cancelled
Release LS — v0.51.355 (#3573 conversation outline panel)
2026-06-10 16:58:13 -07:00
nesquena-hermes
7dc8b35a7e fix(outline): keep outline valid on the todos sidebar panel (Opus follow-up) 2026-06-10 23:55:05 +00:00
nesquena-hermes
02e7436a80 chore: stamp v0.51.355 — Release LS (conversation outline panel) 2026-06-10 23:47:07 +00:00
nesquena-hermes
922efdf234 feat(outline): opt-in conversation outline panel (#2124)
Adds an opt-in, default-off, desktop-only floating panel that lists the user's
questions in a conversation as a numbered jump-list (click to scroll + flash the
message). Gated behind a Settings → Preferences toggle (show_conversation_outline)
wired through the existing show_* preference boot/load/autosave/save paths.

Review follow-ups applied on absorb:
- Outline is now strictly chat-only: leaving the chat view (Settings, Tasks,
  Insights, …) hides the toggle button AND closes the panel; returning to chat
  restores the toggle (panel stays closed until re-opened). Implemented by gating
  _outlineAllowed() on the active panel and re-evaluating via a MutationObserver on
  the <main> showing-<panel> class (switchPanel is a global fn declaration that
  can't be reliably wrapped from this script).
- Fixed a latent visual bug: #outlinePanelWrapper{display:flex} (id selector)
  outranked the UA [hidden]{display:none}, so wrapper.hidden=true never actually
  hid the panel — the × close button and auto-close had no visual effect. Added
  #outlinePanelWrapper[hidden]{display:none;}.
- Regression tests for both.

Closes #2124.

Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-06-10 23:46:39 +00:00
nesquena-hermes
b1a4750466 Merge pull request #3951 from nesquena/release/stage-lr2
Some checks failed
Release & Docker / release (push) Has been cancelled
Release LR — v0.51.354 (#3950 preserve @provider:model picks across cold catalogs)
2026-06-10 15:20:16 -07:00
nesquena-hermes
d7a97600ed chore: stamp v0.51.354 — Release LR (@provider:model cold-catalog preservation) 2026-06-10 22:17:01 +00:00
nesquena-hermes
cdd76c04b1 fix(model-resolver): preserve @provider:model picks across cold catalogs
_resolve_compatible_session_model_state() no longer reverts an explicit
@provider:model selection to the default when the provider's group is missing from
the cached catalog snapshot. explicit picks always honored; non-explicit (2nd+ turn
/ chat switch) preservation requires the provider to be KNOWN/CONFIGURED via the new
_provider_is_known_or_configured() (static registry + custom-provider config, NOT the
cold catalog) — so a cold live-discovery provider (ollama-cloud/deepseek/xai) is
preserved while a genuinely-unknown provider (@removed:...) falls through to
default-repair. A known-but-unconfigured builtin is deliberately preserved (surfaces
a clear runtime auth error rather than a silent swap; a cheap env/config credential
check would mis-classify OAuth/auth-store providers). Keeps the #3867 cached-catalog
hot path intact.

Co-authored-by: starship-s <starship-s@users.noreply.github.com>
2026-06-10 22:16:25 +00:00
nesquena-hermes
a212fcd330 Merge pull request #3949 from nesquena/release/stage-3427
Some checks failed
Release & Docker / release (push) Has been cancelled
Release LQ — v0.51.353 (#3427 cross-client live-turn recovery)
2026-06-10 13:27:09 -07:00
nesquena-hermes
c05b6e478e chore: stamp v0.51.353 — Release LQ (cross-client live-turn recovery) 2026-06-10 20:12:36 +00:00
nesquena-hermes
6bac70d298 fix: preserve live stream output across session switches (cross-client)
Adds a server-side run-journal live snapshot (_run_journal_live_snapshot) returned
in GET /api/session as runtime_journal_snapshot, so a FRESH client (another device,
or a tab with no in-memory snapshot) opening an in-progress session immediately sees
the already-streamed assistant text + tool cards rebuilt from the server. Composes
with the existing _replay_run_journal cursor path (seeds lastRunJournalSeq so replay
resumes from the snapshot cutoff, not duplicating it) and keys tool cards by the same
5 id aliases (tid/id/tool_call_id/tool_use_id/call_id) as #3763 so SSE replay replaces
rather than duplicates snapshot cards. Payload values truncated; redaction test added.

Co-authored-by: t3chn0pr13st <technopriest@live.ru>
2026-06-10 20:12:05 +00:00
nesquena-hermes
64c536641a Merge pull request #3948 from nesquena/release/stage-p2
Some checks failed
Release & Docker / release (push) Has been cancelled
Release LP — v0.51.352 (medium round: jump-to-response, STATE_DIR warning, J/K nav)
2026-06-10 13:02:42 -07:00
nesquena-hermes
2374e12a9b chore: stamp v0.51.352 — Release LP (medium round) 2026-06-10 19:49:47 +00:00
nesquena-hermes
337e3b50ab feat(#3845): add J/K keyboard shortcuts for previous/next session navigation
Global j/k keydown bindings navigate the session list (j=next, k=prev), guarded
by _isInteractiveSwipeTarget so they never fire while typing in the composer or
any input/textarea/contenteditable. Modifier-key combos are ignored.

Closes #3845.

Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-06-10 19:49:14 +00:00
nesquena-hermes
6988995389 fix(#3915): warn on STATE_DIR divergence at startup when session store is empty
When SESSION_DIR has no session .json files and the index is empty/absent,
print_startup_config() now scans sibling state directories and prints a diagnostic
pointing the user at the populated sibling + the HERMES_WEBUI_STATE_DIR to set —
fully fail-safe (try/except, warning-only, no behavior change).

Closes #3915.

Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-06-10 19:48:41 +00:00
nesquena-hermes
97f92b3a4b fix(#3852): jump-to-question button targets response start
The per-turn jump button now scrolls to the START OF THE RESPONSE (the assistant
segment) instead of the user's question, with a fail-safe fallback to the question
row when the assistant segment isn't found. Label updated to 'to response' across
all 13 locales.

Closes #3852.

Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-06-10 19:48:41 +00:00
nesquena-hermes
0af629fbb9 Merge pull request #3946 from nesquena/release/stage-brick
Some checks failed
Release & Docker / release (push) Has been cancelled
Release LO — v0.51.351 (Phase 0 brick batch: data-loss + mobile stream reattach)
2026-06-10 12:14:46 -07:00
nesquena-hermes
21154870a8 chore: stamp v0.51.351 — Release LO (Phase 0 brick batch) 2026-06-10 19:11:58 +00:00
nesquena-hermes
2391630685 fix(#3929): preserve partial work on turn-level errors
Extract _build_partial_message() shared by cancel_stream() and the new
_snapshot_and_append_partial_on_error(); the two provider/exception error paths
in _run_agent_streaming now snapshot accumulated text/reasoning/tool_calls under
STREAMS_LOCK and append a _partial assistant message instead of discarding it.

Closes #3929.

Co-authored-by: b3nw <150195942+b3nw@users.noreply.github.com>
2026-06-10 19:11:58 +00:00
nesquena-hermes
f00d1bd1cd fix(#3863): reattach live stream after mobile app-switch offline recovery
_recoverFromOfflineSoftly() now probes stream status and calls attachLiveStream()
after refreshSession() so a live stream resumes after Android PWA backgrounding.

Closes #3863.

Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-06-10 19:11:58 +00:00
Rod Boev
299a7a9553 docs(#3794): clarify how to stop each launch method in Quick start (#3942)
* docs(#3794): clarify how to stop each launch method in Quick start

* docs(#3794): use lsof/ss for PID discovery instead of implying bootstrap prints it

---------

Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
2026-06-10 12:11:45 -07:00
Rod Boev
845aa60f66 docs(#3733): document three-service unified setup and warn about dashboard write conflicts (#3938)
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
2026-06-10 12:08:53 -07:00
Frank Song
4be0dab31e docs(rfc): add stable assistant turn anchors (#3927)
* docs(rfc): add stable assistant turn anchors

* docs(rfc): address anchor review comments

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-06-10 12:03:18 -07:00
Ming
5ded20c443 fix: address Greptile review — null guards, cancelAnimationFrame, overflow-anchor
- Guard _settleRO against null in ResizeObserver callbacks (P1 crash)
- _cancelBottomSettle: also cancelAnimationFrame(_settleRAF) (P2 stale timers)
- Remove overflow-anchor:none from .messages — let Firefox use native scroll
  anchoring to handle DOM-growth scroll adjustments

Addresses review comments from @greptile-apps on #3920.
2026-06-10 14:02:25 +02:00
nesquena-hermes
85d0e522e0 Stamp v0.51.350 — Release LN (session-move / project-delete timeout fix) (#3923)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-06-10 02:56:30 -07:00
nesquena-hermes
6be19804f5 fix(routes): bound session/move lock + safe project-delete unlink during streaming (#3746) (#3922)
Two distinct timeout causes, both surfacing as the client's 30s 'Request timed
out' toast with no server-side signal:

A) /api/session/move acquired the per-session agent lock with a bare unbounded
   'with _get_session_agent_lock(sid):'. The streaming thread holds that same
   lock during checkpoint saves; on slow file I/O (WSL/DrvFs) the move could
   block past the client abort. Now acquires with timeout=5 and returns HTTP 503
   on contention (lock kept, not dropped, since s.save() still races the writer).

B) /api/projects/delete unlinked every assigned session via get_session()+save()
   — O(N) full-messages reserialize. For an actively-streaming session we now
   clear project_id on the LIVE CACHED Session object under LOCK (the streaming
   thread persists it on its next save — the worker always does a final save at
   turn completion) instead of issuing a competing s.save(); falls back to a
   direct save when not cached. Non-streaming sessions unchanged.

Also guards the '+ New project and move' shortcut (sessions.js) against the new
503 so it shows a toast instead of an unhandled rejection, keeping the #2551
authoritative refetch in both the success and catch paths.

Adds tests/test_issue3746_session_move_delete_timeout.py (behavioral lock-timeout
test + structural guards for both handlers + the frontend 503 guard). Widened the
#2551 new-project-refetch test's fixed byte-window to a block-scoped search so the
try/catch wrap (which preserves the refetch) doesn't trip a brittle offset assertion.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-06-10 02:53:01 -07:00
Ming
55e713b945 fix(scroll): replace rAF polling with ResizeObserver to fix Firefox chat scroll jump
Firefox settles layout more slowly than Safari. The previous rAF-based
settlePoll approach still triggered visible step-wise scroll jumps in
Firefox because each requestAnimationFrame read of scrollHeight caused
an independent reflow that Firefox composited as a visible step.

Replace the multi-rAF polling loop with a ResizeObserver on the
messages container. The observer is notified passively when layout
changes (KaTeX, Mermaid, Prism, images), then a 50ms debounce timer
fires a single final scrollTop write. No intermediate scrollTop writes
means Firefox never paints intermediate positions.

Changes:
- Replace _settlePoll() loop with ResizeObserver in _settleMessageScrollToBottom()
- Add _settleRO and _settleTimer globals for cleanup
- Update _cancelBottomSettle() to disconnect observer and clear timer
- Add 2s safety timeout for static content edge case

Fixes Firefox-specific scroll jump after streaming responses complete.
Safari behavior unchanged (was already smooth).
2026-06-10 11:17:28 +02:00
nesquena-hermes
6c83fbaa39 Stamp v0.51.349 — Release LM (custom-proxy model ID preservation) (#3919)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-06-10 02:07:18 -07:00
nesquena-hermes
92e74c6cd3 fix(config): preserve intrinsic vendor prefix in model ID for custom proxy (#3872) (#3918)
A bare 'custom' provider with a remote base_url is a vendor-routing proxy
(LiteLLM, Bedrock gateway, etc.). A slashed model id like bedrock/opus-4-6 is
intrinsic — the proxy routes on the full string. The prior heuristic stripped
ANY known-provider prefix (bedrock is also a provider name), truncating it to
opus-4-6 and causing 403 'model not allowed'.

Fix: in the custom-base_url branch, strip the prefix only when (a) the configured
provider is a real first-party provider pointed at an OpenAI-compatible proxy
(provider=openai + proxy base_url — the #433 path), OR (b) for a bare 'custom'
provider, the bare id is genuinely a first-party model of that prefix's catalog
(openai/gpt-5.4 -> gpt-5.4, since gpt-5.4 IS an OpenAI model). An intrinsic
routing prefix whose bare id is NOT first-party of that namespace is preserved
(bedrock/opus-4-6 stays whole). Unknown prefixes (zai-org/...) preserved as before.

Both prior behaviors work in tandem with the fix:
  - #433 sprint40: bare custom + openai/gpt-5.4 -> gpt-5.4 (redundant, strip)
  - #433 model_resolver: provider=openai + google/gemma -> gemma (proxy strip)
  - #548: custom + zai-org/GLM-5.1 -> preserved (unknown prefix)
  - #1625: custom + loopback base_url -> preserved (local server)
  - #3872: custom + bedrock/opus-4-6 -> preserved (intrinsic vendor prefix)

Adds regression tests covering all of the above.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: haolf000 <haolf000@users.noreply.github.com>
2026-06-10 01:56:01 -07:00
nesquena-hermes
19080f73b1 Release v0.51.348 — Release LL (Phase 0 hotfix: timeout regression + data-loss + leaks) (#3917)
Some checks failed
Release & Docker / release (push) Has been cancelled
* stage v0.51.348: Phase 0 hotfix — approval/clarify timeout regression (#3913), queue/draft durability (#3906), settings auto-reopen (#3909), kanban FD leak (#3904)

* stage v0.51.348: re-anchor 4 SSE frontend tests to poll-only design (#3913); apply Opus SHOULD-FIX — immediate first poll tick so pending approval/clarify cards show instantly

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-06-10 01:12:48 -07:00
nesquena-hermes
e8d71a2fb8 Release v0.51.347 — Release LK (streaming & render reliability cluster #3892 #3898 #3885 #3882 #3868) (#3902)
Some checks failed
Release & Docker / release (push) Has been cancelled
* stage v0.51.347: render/stream cluster (#3892 #3898 #3885 #3882 #3868) + 2 Opus SHOULD-FIX

* stage v0.51.347: trim #3885 error-guard comment to fit diagnostic-test window

* Stamp v0.51.347 — Release LK (streaming & render reliability cluster)

* Remove stray uv.lock accidentally staged (not part of any cluster PR)

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-09 17:02:35 -07:00
Tamaz_Sujashvili
3807c247e9 Fix false streaming and live UI reset when switching sessions.
Clear stale busy/stream state before async message loads and restore
snapshotted turn HTML when returning to an active stream.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 03:09:44 +04:00
nesquena-hermes
aa5cd6e8ff Release v0.51.346 — Release LJ (PWA notification controls #3229) (#3897)
Some checks failed
Release & Docker / release (push) Has been cancelled
* Add PWA notification controls (#3229, #3196)

Rebased onto fresh master (#3229 was 281 commits behind). Routes browser
notifications through the service worker (reg.showNotification) with a direct
Notification fallback, threads the originating session deep link/tag into the
payload, and adds Enable/Send-test/permission-status controls in Settings.
notificationclick prefers an already-open target-session tab before opening a
new window. Added the 6 new i18n keys to the Polish (pl) locale block, which
the original PR missed.

Co-authored-by: AJV20 <AJV20@users.noreply.github.com>

* Apply Opus review fixes to #3229 PWA notifications

- MUST-FIX: _showPwaNotification used navigator.serviceWorker.ready, which
  never settles when no SW registration ever activates (reverse-proxy MIME
  mishap, SW disabled) — every notification was silently dropped, a regression
  vs master. Switch to getRegistration() raced against a 2s timeout, falling
  back to new Notification().
- SHOULD-FIX: notificationclick matched client.url === targetUrl exactly, but
  _sessionUrlForSid copies the current query/hash into the deep link, so an
  already-open session tab missed the match and spawned a duplicate window.
  Compare pathnames instead.
- SHOULD-FIX: "Send test" with permission denied was a silent no-op — now
  surfaces the notifications_denied toast on the force path.
- SHOULD-FIX: permission-status span went stale after granting via the button
  — refresh it in requestNotificationPermission().then.
- Consistency: the request-permission grant branch now has the same
  new Notification fallback as the granted branch.
- Update test_pwa_notification_controls.py to assert the pathname match.

* Stamp v0.51.346 — Release LJ (PWA notification controls #3229/#3196)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: AJV20 <AJV20@users.noreply.github.com>
2026-06-09 15:54:21 -07:00
nesquena-hermes
cc7f8a274a Release v0.51.345 — Release LI (model override picker for scheduled jobs #3809) (#3896)
Some checks failed
Release & Docker / release (push) Has been cancelled
Absorbs contributor PR #3809 (@b3nw), rebased onto fresh master (was ~20 behind,
panels.js conflict resolved by merging the new !isNoAgent skill-tags guard with
the model-select call).

Adds a Model Override dropdown to the Tasks scheduled-jobs create/edit form,
populated from /api/models grouped by provider, persisting model+provider,
clearable to default, disabled in no-agent mode. Surfaces hermes-agent's existing
per-job model override (CLI parity).

greptile P1s (override cleared on fast-save / on API failure) verified
ALREADY-FIXED in PR head; also applied an Opus UX hardening (keep the model
select disabled on a failed /api/models load so the user can't think they
cleared the override). UX approved by Nathan via screenshots.

Pre-merge fixes:
- i18n: the PR added the 3 cron_model_* keys to all locales but left 10 of them
  as 'TODO: translate' English stubs (only es was done), tripping
  test_zh_hant_locale. Provided real translations for de/zh/zh-Hant/ru/ja/fr/pl/
  it/pt/tr.
- test isolation: #3809's new test file shifts pytest-shard composition so
  test_issue2863's background-rebuild test ran after a test that leaves the
  #3884 _SESSION_INDEX_REBUILD_THREAD globals populated, suppressing the fresh
  thread it asserts on. Made that test hermetic (joins+clears the rebuild-thread
  globals up front) so it passes regardless of shard run order.

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-06-09 14:45:59 -07:00
nesquena-hermes
1cc8772179 Release v0.51.344 — Release LH (sidebar fork-lineage grouping #3799/#3884) (#3893)
Some checks failed
Release & Docker / release (push) Has been cancelled
* Release v0.51.344 — Release LH (sidebar fork-lineage grouping #3799/#3884)

Absorbs #3884 (@rodboev): manual forks are kept as sidebar lineage boundaries
so a forked session isn't collapsed under a compression-continuation root,
while enriched child-session rows stay independently visible until the later
attachment pass. Also addresses the greptile TOCTOU flag: the background
index-rebuild thread now pins + re-checks its (SESSION_DIR, SESSION_INDEX_FILE)
target under _SESSION_INDEX_REBUILD_LOCK before writing.

Rebased onto fresh master, content byte-identical to PR head, full-suite +
Codex + Opus gated.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(models): propagate target kwargs in index-rebuild fallback (Opus SHOULD-FIX)

Opus advisor stage-344: the _write_session_index fast-path fallback recursed
with _write_session_index(updates=None) and no kwargs, falling back to the
global SESSION_DIR. Safe today (the only kwargs-caller passes updates=None and
never reaches the fast path) but the invariant was implicit. Propagate the
resolved session_dir/session_index_file so a target-scoped rebuild falls back
to that same target.

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-09 13:37:59 -07:00
nesquena-hermes
c89468212b Release v0.51.343 — Release LG (Phase-1 batch: #3883 + #3878 + #3880) (#3891)
Some checks failed
Release & Docker / release (push) Has been cancelled
Phase-1 low-risk batch, each rebased onto fresh master + gated fresh:

- #3883 (@rodboev, #3740): sidebar refreshes a stale message_count:0 index row
  from its sidecar when user_message_count>0 + sidecar mtime newer than index,
  self-healing the interrupted-stream stale-count case beyond compression lineage.
- #3878 (@rodboev, #3833): manual workspace refresh clears the dir cache and
  re-fetches expanded descendants so background-written files become visible.
- #3880 (@koshikai): translate the 11 remaining English strings in the ja locale.

greptile flags evaluated: #3878 P1 relative-path + P2 stale-comment already fixed
in PR head; #3883 P2 missing-snapshot-test already covered by the PR's own
test_all_sessions_refreshes_stale_zero_count_snapshot_row_from_sidecar, P2
double-stat is a bounded cheap micro-opt (FOLD); #3880 'needs screenshots'
rejected (in-place translation of existing keys, no UI shape change).

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: koshikai <koshikai@users.noreply.github.com>
2026-06-09 13:03:15 -07:00
nesquena-hermes
f44d8c971d Release v0.51.342 — Release LF (transcript + sidebar reliability: #3875 + #3887 + #3831) (#3889)
Some checks failed
Release & Docker / release (push) Has been cancelled
* Release v0.51.342 — Release LF (blank-transcript brick fix #3875)

Fixes #3875: chat transcript rendering as only a stack of date separators
with no message bodies. The live-to-final/Worklog redesign (#3401) folds
intermediate assistant segments into a collapsed Worklog and hides the source
segment; when a turn's ONLY content is folded into a collapsed Worklog (empty
final assistant message from an interrupted/autonomous run, or a reload where
S.toolCalls did not hydrate so the Worklog has no expandable steps), every
segment is hidden and the turn paints blank — leaving a bare column of date
dividers.

Adds a defensive fail-safe invariant at the end of renderMessages(): a settled
assistant turn never renders with zero visible content. Blank turns get their
folded Worklog expanded (or hidden segments un-hidden as a last resort). Turns
with any visible answer are untouched, preserving the intended collapsed-Worklog
UX. Reproduced + verified fixed in an isolated browser (clean Chrome profile to
defeat the ?v= asset-cache); RED on master (blank 'Worklog' chip), GREEN with
the fix (Worklog expanded, content visible).

Includes #3875 structural regression coverage.

* docs(ui): clarify revealed-flag intent in #3875 fail-safe (greptile P2)

Address greptile review on PR #3889: the 'revealed' flag means 'turn has a
visible non-empty Worklog group' not 'we just expanded one'. An already-open
non-empty group is itself visible, so the last-resort un-hide is correctly
skipped. Comment-only; no behavior change.

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-09 12:43:18 -07:00
nesquena-hermes
057307b3f9 fix(session): retire stale truncation watermark on new committed turn (#3831) (#3890)
* fix(session): retire stale truncation watermark on new committed turn (#3831)

retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).

Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.

Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.

Closes #3831

* chore(changelog): clarify watermark-retirement timing to commit-time

Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)

Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 12:39:32 -07:00
nesquena-hermes
5d4d7fe9fd fix(sidebar): prime idx_messages_session before CLI-session scan (#3887) (#3888)
* fix(sidebar): prime idx_messages_session before CLI-session scan (#3887)

The sidebar's CLI-session scan (read_importable_agent_session_rows) orders
candidate sessions by a correlated MAX(timestamp) subquery over messages,
which collapses to a full per-session table scan when the agent's standard
idx_messages_session ON messages(session_id, timestamp) index is absent.
A normally-migrated hermes-agent state.db has it; a db that lost its
migrations (older hermes-agent, or a hand-rebuilt/reimported db) does not,
and /api/sessions then stalls for seconds on every refresh (the 5s cache
TTL never settles -> repeating 'Slow WebUI request still running' warnings).

Prime the index with CREATE INDEX IF NOT EXISTS before the scan: a no-op
when it already exists, a ~20ms self-heal otherwise (measured 13.3s ->
0.009s on a no-index 8k-session db). Best-effort: degrades silently on a
read-only db, a locked db, or a minimal schema without a timestamp column.

Closes #3887

* test(#3887): skip read-only-db test under root (greptile P2)

Root bypasses POSIX permission bits, so chmod 0444 doesn't make the file
read-only for root — the prime would succeed and the test would validate the
wrong path, giving false confidence on root-run CI. Skip under root; the
production handler's except sqlite3.Error: pass covers the read-only/locked/
corrupted/older-schema cases regardless.

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-09 12:35:13 -07:00
nesquena-hermes
35098632ea Release v0.51.341 — Release LE (stale thinking-dot placeholder fix #3869/#3876) (#3886)
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes #3869: empty legacy three-dot thinking spinners piled up as stale
rows after the agent finished thinking. The live-to-final redesign (#3401)
made the thinking-card-row wrapper class unconditional, which broke
finalizeThinkingCard()'s dots-only detection — it treated the wrapper class
itself as a "has content" signal, so the dots-only removal branch went dead.
Narrow hasContent to the actual .thinking-card element so dots-only spinners
are removed on finalize while real Worklog Thinking Cards are preserved.

Includes #3869 regression coverage (brace-walks finalizeThinkingCard, asserts
the narrowed check + that real thinking cards are not removed).

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-09 10:19:22 -07:00
nesquena-hermes
26e133e3e8 [HELD — independent review pending] Release v0.51.340 — bg_task agent wakeup (trio #2968+#2971+#2979) (#3867)
Some checks failed
Release & Docker / release (push) Has been cancelled
* stage bg_task trio combined (#2979 superset) on master for deep review

* fix(bg_task): unsubscribe SessionChannel on header-write failure (Codex deep-review catch) + regression test

* test: realign on-subscribe-recovery anchor to subscribe_to_session_channel after leak fix

* CHANGELOG: bg_task trio as v0.51.340 LD (HELD pending independent review)

* bg_task trio: apply 3 independent-review (greptile) fixes

1. start_session_turn now threads the session PROFILE model defaults
   (_read_profile_model_config) into the wakeup model-resolve, so a brand-new
   session with an empty model falls back to the profile default not global
   DEFAULT_MODEL. Updated the white-box spy test signature accordingly.
2. /api/session/stream omits the Connection header (HTTP/1.1 keep-alive
   default) to match the #3103 long-lived-SSE pattern.
3. Reaper now prunes _LAST_EMIT_TS for collected sessions so the coalesce
   timestamp map can't grow one permanent entry per session forever.

nesquena APPROVED the PR; these are the 3 non-blocking greptile suggestions.

* test: realign _start_session_turn adapter stub lambda to new profile-defaults signature
2026-06-08 22:36:18 -07:00
Frank Song
c031cf5c6d docs(rfc): mark Transparent Stream RFC Accepted + add spike note (#3820) (#3864)
Direction confirmed by @nesquena (Discord) and the RFC is merged (#3862), so
flip Status Proposed -> Accepted. Also record the settled-path spike result on
integration point A: ~31 lines, no new data, compact previews via the existing
buildToolCard, and reload consistency for free from the rebuild cleanup; DOM
insertion order verified with a node harness. Doc-only.

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
2026-06-08 21:09:35 -07:00
nesquena-hermes
cf4bed3d35 Release v0.51.339 — Release LC (targeted workspace create actions, #3855 + #3858) (#3865)
Some checks failed
Release & Docker / release (push) Has been cancelled
#3855 (@b3nw): workspace context menus (root/dir/file rows) gain New File/New
Folder targeting the clicked location instead of always S.currentDir. promptNewFile/
promptNewFolder take a targetDir param; prompt title names the target. i18n complete
(13 locales). Live-verified end-to-end: New File from src/ menu creates inside src/
(on disk), not root. Opus SHIP-safe (backend safe_resolve is the enforced trust
boundary; frontend join is naive but backend-gated). Context menu screenshot-approved.

#3858 (@b3nw): adds cancelLabel:t('status_no') ('No') to the post-folder-create
'Add as space?' confirm (was an unlabeled 'Cancel').

Both rebased onto master; combined cleanly (both touch promptNewFolder, no conflict).
Suite 8349, ESLint/scope-undef/ruff CLEAN. greptile flags evaluated → stale/resolved.

Co-authored-by: b3nw <b3nw@duck.com>
2026-06-08 20:55:15 -07:00
Frank Song
c6ac6aa5c9 docs(rfc): Transparent Stream activity display mode (#3820) (#3862)
* docs(rfc): add Transparent Stream activity display mode RFC (#3820)

Proposes Transparent Stream as an opt-in, chronological activity display
mode alongside the default Compact Worklog (#3400/#3401). Captures the
display-mode split agreed in #3820: each tool call as a first-class
chronological event, interleaved with reasoning/progress, with compact
previews, consistent across live, settled, and reload/replay paths.

Documents the asymmetry in the existing `simplified_tool_calling` toggle
(live-only, no settled/reload branch) and the three concrete integration
points so the follow-up can be sliced safely. Doc-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(rfc): refine Transparent Stream rollout scope

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:38:41 -07:00
nesquena-hermes
8d4ed1f90d Release v0.51.338 — Release LB (saved prompts library, #3571) (#3860)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat(composer): add saved prompts library with per-profile storage (#2732)

* fix(composer): move saved-prompts popup out of .composer-left to preserve DOM test (#2732)

* fix(composer): correct ARIA roles, add server-side prompt limits (#2732)

* fix(composer): surface save-prompt errors instead of silent success toast (#2732)

* Release v0.51.338 — Release LB (saved prompts library, #3571)

Composer saved-prompts library (@rodboev): bookmark button → popup of saved
prompts; click to insert, save current input, delete. Persists to
$HERMES_HOME/webui/saved_prompts.json with server-side caps (8000 chars / 200).

Maintainer work (per Nathan): conditions were (a) verify it actually works and
(b) hide on mobile. Both met:
- Live-verified load/save/delete all persist through the UI.
- Added mobile-hide (#btnSavedPrompts,.saved-prompts-popup display:none in the
  @media max-width:640px + 900px composer blocks). DOM-verified visible at 1280px,
  vision-confirmed absent from the composer at 390px.
- Added missing Polish (pl) i18n for the 5 saved_prompts_* keys (PR had en+others
  but not pl — failed locale-parity).
- Added tests/test_issue3571_saved_prompts.py (mobile-hide + caps + wiring guards).

Full suite green, ESLint/scope-undef CLEAN, Opus SHIP-safe (auth-gated, CSRF,
XSS-safe, sane caps), Codex SAFE-TO-SHIP.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-08 19:32:12 -07:00
nesquena-hermes
de4509702d Release v0.51.337 — Release LA (model-picker keyboard nav #2952 + mobile new-chat #3531) (#3857)
Some checks failed
Release & Docker / release (push) Has been cancelled
Two small, aesthetic-safe UX wins:
- #2952 (@Sanjays2402): model-picker arrow-key navigation + Enter-to-select.
  Highlight reuses existing hover style, invisible until keyboard used.
  Opus SHIP-safe, live key-drive verified (multi-row traversal + wrap + Enter).
- #3531 (@franksong2702): mobile titlebar '+' new-chat button. Shares the
  existing reload-button styling, mobile-only, mirrors new-chat pending state.
  390px screenshot vision-verified: cleanly aligned in the titlebar.

Both rebased onto master (CHANGELOG-only / merge-commit conflicts resolved;
code verified byte-identical to PR heads). Full suite green on each (8336/8334),
ESLint/scope-undef CLEAN, zero blocking bot flags.

Co-authored-by: Sanjays2402 <Sanjays2402@users.noreply.github.com>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-08 18:47:55 -07:00
nesquena-hermes
3063c4930f Release v0.51.336 — Release KZ (fix per-token inline-thinking perf regression, #3633 follow-up) (#3854)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(streaming): make per-token inline-thinking extraction linear (#3633 follow-up)

Codex post-merge perf catch on #3633: _parseStreamState() and
syncInflightAssistantMessage() call _extractInlineThinkingFromContent on the
FULL accumulated assistantText on EVERY streamed token. The #3633 rewrite made
that a full char-by-char walk, so cost was O(n^2) over a stream — a Node harness
measured ~88s (no-tag) / ~103s (leading <think> block) for 2000x100-char tokens,
which would freeze the main thread on long reasoning-model responses.

Two fixes (Python api/streaming.py + JS static/messages.js twin, line-for-line parity):
1. Fast path: if the text contains no complete thinking opener AND (when
   streaming) its tail is not a prefix of an opener, return unchanged without the
   char walk — two cheap substring scans. Handles the common no-tag case.
2. Bulk-skip plain trailing content: track the next complete opener via
   str.find/indexOf (_next_inline_thinking_opener / _nextThinkingOpener); once no
   opener remains ahead, append the remainder and stop instead of walking it
   (streaming still suppresses a trailing partial-opener prefix). Handles the
   leading-block-then-long-answer case.

Result: ~88s/103s → ~0.5s/0.9s (Python), ~0.18s/0.21s (JS). All behavioral cases
(persist + streaming, code-awareness, position-aware unclosed, leading whitespace)
verified unchanged in both twins. Added a per-token streaming perf regression test
and wired _nextThinkingOpener into the node driver harness.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(streaming): perf bulk-skip must respect code context for partial-opener tails

Codex catch on the perf fix: the no-complete-opener bulk-skip suppressed a
trailing partial opener (e.g. '<thi') unconditionally during streaming, but a
partial opener INSIDE inline-backtick / fenced / indented code must stay visible
(master parity). Now, when streaming and the tail is a partial opener, fall
through to the code-aware char walk (bounded — a partial tail is a transient
single token) instead of bulk-skipping; only a PLAIN-text partial opener is
suppressed as a forming block. Added _text_tail_is_partial_opener /
_textTailIsPartialOpener (Python + JS parity) + regression tests for the
inside-code vs plain partial-tail cases.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* docs(changelog): v0.51.336 KZ — inline-thinking streaming perf fix

---------

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
2026-06-08 18:16:52 -07:00
nesquena-hermes
066f727271 Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633) (#3853)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(streaming): normalize inline thinking extraction across live and persisted turns (#3599)

# Conflicts:
#	api/streaming.py
#	static/messages.js
#	static/ui.js

* fix(streaming): code-aware inline-thinking extraction + position-aware unclosed handling

Codex deep-review caught two regressions in the leading-only -> full-scan
rewrite (both silent data-mangling on the persist/reload path):

1. Code-span unawareness: the scanner only protected triple fences, so a
   literal <think> in an inline single-backtick code span or an indented
   (>=4-space/tab) code block got silently extracted into reasoning. Added
   _inline_thinking_indented_code_at + inline-backtick tracking (Python +
   the JS twin _thinkingIndentedCodeAt), so all three code contexts now keep
   thinking tags visible.

2. Unclosed-tag truncation: any unmatched open tag moved the trailing prose
   into reasoning. Now position-aware — a LEADING unclosed block (cut off
   mid-thought) is still reasoning (#3455 intent), but an unclosed tag AFTER
   visible content stays visible so literal typed tags don't truncate prose.
   Gated partial handling on the previously-unused options.streaming param
   (live streaming keeps 'still thinking' behavior; persist/reload does not).

Updated 2 tests that pinned the buggy behavior + added 4 regression tests
(inline-backtick, indented-code, mid-body-unclosed-visible, leading-unclosed-
extracted). Updated the node driver harness to include the new helper.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(streaming): recognize fenced code blocks indented 1-3 spaces

Codex round-3: a fence indented 1-3 spaces is valid Markdown but the fence
detector only matched at column 0, so a literal think tag inside such a fence
(not 4+-space indented code either) was still extracted. Both detectors
(_inline_thinking_fence_marker_at / _thinkingFenceMarkerAt) now walk back over
up to 3 leading spaces to a line start. Added backtick + tilde indented-fence
regression tests.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(streaming): O(n) inline-thinking scan + merge separate reasoning on reload

Round-4 Codex deep-review caught two real issues in my own fixes:

1. PERF (O(n^2)): the indented-code check (_inline_thinking_indented_code_at /
   _thinkingIndentedCodeAt) scanned to line boundaries at EVERY character index,
   plus the leading check sliced+stripped the whole prefix per unclosed tag. On
   long no-newline content this was quadratic (~8.4s @ 200k, called repeatedly
   on the streaming path). Replaced with incremental O(1)-per-iteration line
   state (_line_is_indented_code / _lineIsIndentedCode evaluated only at line
   starts) + a seen_nonspace flag. 200k now extracts in ~55-140ms.

2. RELOAD reasoning-drop: renderMessages() seeded the shared extractor with ''
   so a message with BOTH an inline <think> block AND a separate m.reasoning
   payload showed only the inline part — the separate payload was dropped
   because the !thinkingText worklog resolution was then skipped. Now seeds with
   the message's direct reasoning (m.reasoning_content||m.reasoning||...) so the
   two MERGE (deduped); separate-only reasoning is preserved without promoting
   it into visible prose.

Python + JS twins kept line-for-line parity. Added merge + perf + reload
regression tests; updated the reload structure test and the node driver harness
for the renamed helper.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(streaming): revert reload reasoning-seed; keep O(n) perf fix

Codex round-4 finding #2 (seed renderMessages' inline extractor with
m.reasoning so a separate payload merges) turned out to VIOLATE a deliberate
architectural invariant pinned by test_issue2565 +
test_sprint42: the reload content-extraction path must NOT touch
m.reasoning/m.reasoning_content — reasoning metadata is owned exclusively by
the Worklog Thinking Card path (_worklogReasoningTextFromMessage /
_assistantReasoningPayloadText), never conflated with inline-content
extraction (which would risk promoting provider reasoning into final-answer
prose). Reverted the ui.js seed to the PR's original `thinkingText` arg.

The inline+separate merge is still a genuine extractor capability (exercised
by the live streaming path via liveReasoningText) and is covered by a unit
test, just not invoked from the reload render path by design.

The O(n) perf fix (finding #1) and the code-awareness + position-aware
unclosed handling (rounds 1-3) are all retained.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(streaming): only lstrip extracted content when a leading block was removed

Codex round-5 catch: the extractor unconditionally lstripped the final content
(.lstrip() / .replace(/^\s+/,'')) even when NO thinking block was extracted, so
an assistant reply that legitimately starts with an indented code block or blank
lines lost its leading whitespace on live display, reload, and persistence. This
was a real regression vs master (master returned non-thinking content unchanged).

Now track leading_removed (set only when a LEADING thinking block/prefix is
actually extracted) and lstrip only in that case. Mid-body / no-thinking content
keeps its exact leading whitespace. Python + JS twins kept in parity; added
backend regression tests (indented-first preserved, leading-blank preserved,
leading-think still strips).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(streaming): reconnect restore prefers raw inflight accumulator

Codex round-6 CORE catch: on reconnect, the single-live-message restore used
(_liveInflightAssistant.content || ''). Because the PR now splits a leading
unclosed <think> into empty content, restoring from the split content dropped
the open tag — so a later </think> token leaked into the visible reply and
corrupted the live accumulator. Restore from
(_fullInflightAssistant || _liveInflightAssistant.content || '') so the raw
open tag survives reconnect and the accumulator stays correct. Added a
reconnect-restore regression test.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633)

Unify inline-thinking (<think>/<|channel>/<|turn|>) extraction across live,
reload, and persisted turns (#3599/#3633, @rodboev). Deep-reviewed: Opus +
6 Codex rounds; maintainer fixes resolved every Codex finding — code-awareness
(inline-backtick/indented/1-3-space fences keep literal tags visible),
position-aware unclosed handling, O(n) line scanning (was O(n^2) on long
content), conditional lstrip (preserve leading whitespace when no leading block
removed), and a reconnect-restore CORE fix (raw accumulator preferred so an open
<think> tag survives reconnect). Python + JS twins in parity. Full suite 8330,
Opus SHIP-SAFE, Codex SAFE-TO-SHIP, ESLint/scope-undef/ruff clean.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-08 17:28:05 -07:00
nesquena-hermes
a71dbcd6bb Release v0.51.334 — Release KX (new-message cue when scrolled up, #3631) (#3849)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(streaming): show new-message cue when preserving scroll position (#3545)

# Conflicts:
#	static/ui.js
#	tests/test_issue1690_scroll_completion.py
#	tests/test_tars_scroll_reset_regressions.py

* i18n: add missing Polish (pl) translation for session_new_message keys

The PR added session_new_message / session_new_message_label to 12 of 13
locales; Polish was missing both, which fails the per-locale parity test.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* fix(streaming): keep forced follow path for pinned users in preserve-scroll branch

Codex CORE catch: the PR's preserve-scroll branch used
'if(_scrollPinned) scrollIfPinned()' which skips the synchronous bottom
write unless distance>500 and can have its delayed settles cancelled by the
DOM-rebuild scroll event — leaving a pinned reader a few lines above the
settled final response. Restore master's _followMessagesAfterDomReplace()
forced-scrollToBottom() path for pinned/near-bottom users; only genuinely
scrolled-up (unpinned, not near bottom) users restore their viewport and
get the new-message cue. Updated the 3 structure-pinning tests to assert the
corrected (safer) shape while preserving their behavioral intent.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* Release v0.51.334 — Release KX (new-message cue when scrolled up, #3631)

New-message cue on the jump-to-bottom button when the user has scrolled up
during a live turn (#3545/#3631, @rodboev). Deep-reviewed (Opus+Codex);
maintainer fixes during re-gate: (1) restored master's forced follow path
for pinned/near-bottom users (Codex CORE: scrollIfPinned could leave a pinned
reader short of the settled response) + updated 3 structure-pinning tests to
the corrected shape; (2) added missing Polish (pl) i18n keys (PR had 12/13).
Full suite 8308, ESLint/scope-undef CLEAN, Opus SHIP-safe, Codex SAFE-TO-SHIP.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-08 15:58:13 -07:00
nesquena-hermes
52993af88a Release v0.51.333 — Release KW (collapse old interim progress notes, #3574) (#3848)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat(streaming): collapse old interim progress notes after 3 visible (#2403)

* fix(streaming): delegated handler for interim-collapse toggle survives live-turn restore

The interim-collapse toggle attached its click listener via per-element
addEventListener at creation time. snapshotLiveTurnHtmlForSession /
restoreLiveTurnHtmlForSession rebuild the live turn via outerHTML/innerHTML
on session switch, which strips JS listeners — so a restored toggle was
visible but inert and the collapsed interim notes became permanently
unreachable for the rest of the turn.

Replace with a stateless document-level delegated click handler
(_interimCollapseDelegatedClick) that resolves the toggle via closest(),
reads state from the DOM (.interim-collapsed) + data-threshold, and works
on both freshly-created and innerHTML-restored toggles. Add 4 regression
tests pinning the delegated-handler contract.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* Release v0.51.333 — Release KW (collapse old interim progress notes, #3574)

Collapse old interim progress notes after 3 visible during a live turn
(#3574, @rodboev). Maintainer fix during re-gate: replaced the per-element
toggle listener with a stateless document-level delegated handler so the
toggle survives the live-turn DOM restore (Codex caught: innerHTML rebuild
dropped the listener → collapsed notes unreachable). Full suite 8303,
ESLint/scope-undef CLEAN, Opus SHIP-safe, Codex SAFE-TO-SHIP after fix,
collapse + manual-expand-guard + restore-path delegated handler all live-verified.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-08 15:36:39 -07:00
nesquena-hermes
2fd039cce4 Release v0.51.332 — Release KV (distinguish script cron jobs in Tasks, #3589) (#3847)
Some checks failed
Release & Docker / release (push) Has been cancelled
Script cron jobs (no_agent) in the Tasks panel now show a script badge,
banner, script path + working directory, and 'Script output' run labels
instead of an empty Prompt card (#3589, @pamnard).

Self-rebased onto v0.51.331 (was CONFLICTING, CHANGELOG-only stale base).
Maintainer fix: added the 10 new cron i18n keys to Polish (pl), which the
PR had missed (12/13 locales) — all keys now 13/13, locale tests pass.
Full suite 8285 passed, ESLint/scope-undef/ruff CLEAN, Opus SHIP-safe.

Co-authored-by: pamnard <pamnard@users.noreply.github.com>
2026-06-08 14:58:56 -07:00
nesquena-hermes
d560a312f2 Release v0.51.331 — Release KU (dismissible error toasts, #3844) (#3846)
Some checks failed
Release & Docker / release (push) Has been cancelled
Error toasts now render an explicit Dismiss button + non-error toasts
dismiss on click (#3844, fixes #3842). Frontend-only (ui.js + style.css +
structural test). Full suite 8278 passed, ESLint runtime + scope-undef +
ruff gates CLEAN, Opus SHIP-safe.

Co-authored-by: claw-io <claw-io@users.noreply.github.com>
2026-06-08 14:39:34 -07:00
nesquena-hermes
cf07c0a02d Release v0.51.330 — Release KT (#3716 api docstring backfill, partial) (#3841)
Some checks failed
Release & Docker / release (push) Has been cancelled
Backfill docstrings for api/oauth.py + api/kanban_bridge.py (51 functions, verified accurate to current behavior). Conflicted files dropped for follow-up. Docstring-only, no behavior change. Full suite 8275, CI 11/11. Co-authored-by: camr <camr@users.noreply.github.com>
2026-06-08 12:22:22 -07:00
nesquena-hermes
a0e5b9042f Release v0.51.329 — Release KS (#3814 O(n) index map + #3815 startup recovery skip) (#3840)
Some checks failed
Release & Docker / release (push) Has been cancelled
#3814: O(n) index-count map for /api/sessions. #3815: skip backup-less sidecars on startup recovery. Full suite 8275, Codex SAFE, Opus SHIP, CI 11/11. Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-06-08 11:49:26 -07:00
nesquena-hermes
7674bfb449 Release v0.51.328 — Release KR (#3800 full compaction summaries + #3061 pending-intent RFC) (#3839)
Some checks failed
Release & Docker / release (push) Has been cancelled
#3835 (#3800): preserve full compaction summaries. #3061: pending-intent RFC (docs). Full suite 8273, Codex SAFE, Opus SHIP, CI 11/11. #3837 dropped (red CI). Co-authored-by: rodboev <rodboev@users.noreply.github.com> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-08 11:35:39 -07:00
nesquena-hermes
b96f0f6d6f Release v0.51.327 — Release KQ (brick wave: #3829 + #3828 + #3822) (#3836)
Some checks failed
Release & Docker / release (push) Has been cancelled
Brick wave: stale-cache freshness (#3829), compression-tail preservation (#3828), interrupt-successor race (#3822, +bounded-guard hardening). Full suite 8271, Codex SAFE, Opus SHIP, CI 11/11. Co-authored-by: dso2ng <dso2ng@users.noreply.github.com> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-08 11:06:59 -07:00
nesquena-hermes
396d0d0abd Release v0.51.326 — Release KP (#3618 + #3802 + #3762 + #3810) (#3816)
Some checks failed
Release & Docker / release (push) Has been cancelled
Batch: mic STT capability probe+fallback (#3618, live-drive verified), journal cleanup on delete (#3802), minimal-schema SQL guard (#3762), Help hover readability (#3810). Full suite 8265, Codex SAFE, Opus SHIP, CI 11/11. Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: dso2ng <dso2ng@users.noreply.github.com>
2026-06-07 23:18:51 -07:00
nesquena-hermes
cadbe1e5d3 Release v0.51.325 — Release KO (in-app Help tab, #3518) (#3806)
Some checks failed
Release & Docker / release (push) Has been cancelled
#3518 (@rodboev): in-app Help tab in Settings (docs + GitHub issues links), redesigned as polished icon-led cards. UX-approved by Nathan. Full suite 8242, CI 11/11. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-07 18:59:01 -07:00
nesquena-hermes
0f4931d81b Release v0.51.324 — Release KN (i18n: settings + gateway-status translations, #3801) (#3805)
Some checks failed
Release & Docker / release (push) Has been cancelled
#3801 (@leszek3737): translate settings/gateway-status labels across 13 locales. +placeholder-parity fix for gateway_session_count pluralization. Full suite 8235, 469 locale tests pass, CI 11/11. Co-authored-by: leszek3737 <leszek3737@users.noreply.github.com>
2026-06-07 17:58:04 -07:00
nesquena-hermes
1d5c054815 Release v0.51.323 — Release KM (7-day provider spend chart, #3600) (#3804)
#3600 (@rodboev): 7-day spend chart + monthly pace in the provider quota card. UX-approved by Nathan. Full suite 8235, Codex SAFE (backend contract verified), Opus SHIP + refresh-keeps-chart fix (live-verified). CI 11/11. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-07 17:41:59 -07:00
nesquena-hermes
ee982a7581 Release v0.51.322 — Release KL (sortable + filterable markdown tables, #3728) (#3803)
Some checks failed
Release & Docker / release (push) Has been cancelled
#3728 (@rodboev): markdown tables sortable+filterable on desktop, suppressed <=640px so mobile is unchanged. UX-approved by Nathan via Telegram. Full suite 8234, CI 11/11, live-driven. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-07 16:35:28 -07:00
nesquena-hermes
9d94298278 Release v0.51.321 — Release KK (Phase 3 light: load renderable transcript tails, #3790) (#3798)
Some checks failed
Release & Docker / release (push) Has been cancelled
Phase-3-light. #3790 (@ai-ag2026): expand cold-load transcript window to ~msg_limit renderable rows so tool-heavy sessions don't open showing 1-2 messages. Codex CORE fix: explicit expand_renderable flag (cold-load only; Load-earlier keeps raw cap). Also fixed a recurring CI timing flake (git-parallel test → deterministic Barrier). Full suite 8228, Codex SAFE, Opus SHIP, CI 11/11. Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-06-07 15:22:44 -07:00
nesquena-hermes
59de540b3d Release v0.51.320 — Release KJ (Phase 2: Polish (pl) language support, #3781) (#3796)
Some checks failed
Release & Docker / release (push) Has been cancelled
Complete Polish locale. #3781 (@leszek3737). Full suite green, CI 11/11, 180 locale/parity tests pass. Co-authored-by: leszek3737 <leszek3737@users.noreply.github.com>
2026-06-07 13:48:52 -07:00
nesquena-hermes
2e1aa3c99c Release v0.51.319 — Release KI (Phase 3 light: refresh stale continuation metadata, #3789) (#3795)
Some checks failed
Release & Docker / release (push) Has been cancelled
Phase-3-LOW. #3789 (@ai-ag2026, refs #3740): refresh stale-indexed compression continuation rows from sidecar; gate excludes session_source='fork' (release-gate MUST-FIX). Full suite 8213, Opus SHIP, Codex MUST-FIX applied, CI 11/11. Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-06-07 13:12:23 -07:00
nesquena-hermes
ce9adc5e2c Release v0.51.318 — Release KH (Phase 3 light: warm account-usage probe worker pool, #3722) (#3792)
Some checks failed
Release & Docker / release (push) Has been cancelled
Phase-3-LOW backend refactor. #3722 (@rodboev, #1912): per-probe subprocess.run -> warm worker pool for codex/anthropic quota probes; all hardening retained + idle reaper + credential invalidation + fallback. Codex SAFE, Opus SHIP, real-thread self-verify clean, CI 11/11. Follow-up #3787. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-07 12:07:13 -07:00
nesquena-hermes
bc6949219d Release v0.51.317 — Release KG (Phase 3 light: align CSP enforcement with report policy, #3727) (#3786)
Some checks failed
Release & Docker / release (push) Has been cancelled
Phase-3-LOW backend security fix. #3727 (@rodboev, #1909): enforced CSP now honors the same connect-src as report-only (built from one shared template). Full suite 8205 passed, Codex SAFE (live-verified headers), Opus SHIP. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-07 10:52:07 -07:00
nesquena-hermes
07235674ad Release v0.51.316 — Release KF (Phase 2: agent-source dependency audit, #3723) (#3785)
Some checks failed
Release & Docker / release (push) Has been cancelled
Phase-2 release. #3723 (@rodboev): read-only agent-source dependency audit script + contract docs + test, no runtime change. (#3781 Polish locale dropped from batch — incomplete locale, kicked back to contributor.) Full suite 8199 passed, Opus SHIP, Codex clean. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-07 10:38:49 -07:00
nesquena-hermes
ee229f32ad Release v0.51.315 — Release KE (Phase 1: cross-platform workspace-fallback tests, #3780) (#3783)
Some checks failed
Release & Docker / release (push) Has been cancelled
Phase-1 test-portability fix (#3780/#3771, @rodboev): replace hard-coded POSIX paths + chmod simulation with monkeypatch-based simulation. Release gate caught + fixed a test-isolation regression (media-fixtures dir polluting workspace-suggest) — dot-prefixed it. Full suite 8188 passed. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-07 10:11:52 -07:00
nesquena-hermes
7b60151cc1 Release v0.51.314 — Release KD (test infra: reliable test-server boot + diagnostics) (#3782)
Some checks failed
Release & Docker / release (push) Has been cancelled
Test-harness only. conftest test-server fixture now captures server output, fails fast on early subprocess death, retries once, raises timeout to 45s, and surfaces the log tail on failure — fixing the recurring ConnectionRefused boot-cascade flake. Full suite 8187 passed, Codex SAFE, Opus SHIP-IT. No app code changed.
2026-06-07 09:38:29 -07:00
nesquena-hermes
1bc190fb61 Release v0.51.313 — Release KC (instant profile switcher — skip per-profile alias scan) (#3779)
Some checks failed
Release & Docker / release (push) Has been cancelled
list_profiles_api() builds rows from cheap upstream helpers and skips find_alias_for_profile (which reads ~2.4GB of binaries from ~/.local/bin per dropdown open), with a 4s TTL cache + graceful fallback. 4700ms->250ms cold, 0ms cached. Full suite 8183 passed, Codex SAFE, Opus SHIP-IT. Supersedes #3772.
2026-06-07 09:27:32 -07:00
nesquena-hermes
f1f56a901b Release v0.51.312 — Release KB (brick-wave: purge stale __pycache__ after self-update, fixes #3774) (#3778)
Some checks failed
Release & Docker / release (push) Has been cancelled
Fix-ourselves pickup of #3774 (@bambalados). _purge_agent_pycache() before os.execv() in _schedule_restart() so the re-exec'd process recompiles freshly-pulled source — fixes AttributeError on first chat after self-update. Full suite 8180 passed, Codex SAFE, Opus SHIP-IT. Co-authored-by: bambalados <bambalados@users.noreply.github.com>
2026-06-07 08:26:36 -07:00
nesquena-hermes
938ac9f55b Release v0.51.311 — Release KA (brick-wave: workspace Git RCE hardening #3769 + stale-snapshot sidebar visibility #3770) (#3776)
Some checks failed
Release & Docker / release (push) Has been cancelled
Brick-wave batch. #3769 (@Hinotoi-agent) hardens workspace Git config execution against repo-local RCE; #3770 (@ai-ag2026) keeps fuller pre-compression snapshots visible when _index.json is stale. Full suite 8176 passed, Codex SAFE, Opus SHIP IT. Co-authored-by: Hinotoi-agent; Co-authored-by: ai-ag2026
2026-06-07 08:05:30 -07:00
nesquena-hermes
4b390e115c Release v0.51.310 — Release JZ (#3760 — long-press project chips to manage on touch) (#3767)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat(sidebar): long-press project chips to open the context menu on touch (#3760)

Project filter chips could only be deleted/renamed via the right-click context
menu (oncontextmenu), which has no touch equivalent — so mobile/tablet users had
no way to delete a project from the sidebar; the list grew forever.

Adds a 500ms long-press gesture mirroring the existing session-item long-press
pattern: touchstart schedules the menu, touchmove cancels on >10px drift,
touchend suppresses the synthetic click when the long-press fired, touchcancel
cleans up. `.project-chip.long-pressing` gives accent + slight-scale feedback;
`touch-action:manipulation` + `user-select:none` + `-webkit-touch-callout:none`
prevent the native callout/selection from competing.

Maintainer fix on top of the contributor PR (multi-touch correctness, flagged in
review): touchstart now clears any in-flight `_lpTimer` before scheduling a new
one (a second finger / stray touchstart previously orphaned the prior timer,
which then fired unsuppressed ~500ms later and popped the menu after the gesture
was cancelled), and the timer body bails if `_lpHandled` is already set so a
stale fire is a no-op — matching the session-item belt-and-suspenders. Also
dropped a stale, unrelated issue reference from the original comment.

Co-authored-by: reinocheong <[email protected]>

* docs(changelog): stamp v0.51.310 — Release JZ (stage-3760 long-press project chips)

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 21:36:24 -07:00
nesquena-hermes
a20ef5e0c3 Release v0.51.309 — Release JY (#3763 — replay restored live tool cards on reconnect, fixes #3707) (#3766)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(streaming): replay restored live tool cards on reconnect (#3763, fixes #3707)

Post-#3401 (#3400 live-to-final epic) recovery residual. When a running session
is restored from its in-memory live-turn snapshot and then reattached to the SSE
stream, the restore-success path skipped replaying persisted live tool calls,
leaving restored live text/thinking but an EMPTY Worklog until a later SSE event
or the final render rebuilt the turn.

- Extract the persisted-tool-card replay into replayPersistedLiveToolCards()
  (reads S.toolCalls or INFLIGHT[sid].toolCalls); run it on restoredLiveTurn &&
  didReconnect, not only the !restoredLiveTurn fallback.
- Dedup safety: restore-success replay passes {skipUnkeyedRestoredDuplicates:true}
  — when the restored snapshot already has .tool-card-row rows, an UNKEYED
  persisted tool is skipped to avoid a duplicate; keyed cards still replay and
  appendLiveToolCard's tid-dedup replaces the correct restored row.
- appendLiveToolCard() and the new liveToolReplayId() both key on
  tid||id||tool_call_id||tool_use_id||call_id (consistent 5-alias set), so the
  dedup covers all known id shapes.
- Both replay sites pass {sessionId, streamId} so the ownership guard applies.
- Regression coverage: restore-success+reconnect replays tools; unkeyed-restored
  duplicates skipped; all-id-alias dedup; prior ordering invariants preserved.

Correct post-#3401 fix for #3707 (supersedes the closed #3724).

Co-authored-by: franksong2702 <[email protected]>

* docs(changelog): stamp v0.51.309 — Release JY (stage-a5b #3763)

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 21:11:13 -07:00
nesquena-hermes
b6431eb677 Release v0.51.308 — Release JX (#3765 — gate onboarding-complete like its siblings) (#3765)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(security): gate /api/onboarding/complete on the local-network check (#3765)

Sibling-path gap surfaced by the #3758 release gate. /api/onboarding/oauth/start,
/setup, and /probe are gated by _onboarding_gate_allows(), but
/api/onboarding/complete was not — it called complete_onboarding() unconditionally
(persists onboarding_completed=True, which hides the first-run wizard). On a
passwordless public bind, an unauthenticated no-Origin POST passes generic CSRF
and could flip the wizard off.

Pre-existing (the endpoint was ungated before #3758 too; #3758 only refactored the
three already-gated siblings). Low severity — it toggles a UI flag, not credentials
or access — but the inconsistency is a real hole, so close it the same way as its
siblings.

- Gate /api/onboarding/complete with _onboarding_gate_allows() → 403 when denied.
- Regression tests: public client (no forwarded headers) → 403 + complete_onboarding
  NOT called; loopback client → 200; auth-enabled → 200.
- Mark the legacy _is_local_from_handler mirror in test_onboarding_network.py as a
  STALE pre-#3758 contract (it trusts unauthenticated XFF); the authoritative
  trust-matrix tests live in test_security_review_fixes.py. Migrating the mirror to
  delegate to the real helper is tracked as follow-up test debt, out of scope here.

* docs(changelog): stamp v0.51.308 — Release JX (#3765 onboarding-complete sibling-consistency gate)

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 20:42:23 -07:00
nesquena-hermes
70596e6993 Release v0.51.307 — Release JW (stage-a3 — onboarding spoof fix + update-check CSRF, #3758 partial) (#3764)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(security): ignore spoofable forwarded IPs in onboarding gate + make update-check CSRF-safe (#3758, partial)

Ships the two unambiguous slices of #3758's security review. The two slices with
breakage risk for existing installs — the Docker-default public-bind-requires-auth
gate and removing /tmp from the /api/media allowed roots — are held for separate
review/decision.

Onboarding forwarded-IP spoof hardening (+ release-gate CORE fix):
- The unauthenticated first-run onboarding local-network gate now IGNORES
  X-Forwarded-For / X-Real-IP by default (a direct client can spoof them to a
  private/loopback address to bypass the gate), trusting them only when
  HERMES_WEBUI_TRUST_FORWARDED_FOR=1 is set behind a trusted proxy (rightmost
  proxy-appended hop).
- Release-gate (Codex) CORE catch + refinement: when forwarded headers are
  present but untrusted, the header is ignored and locality is judged by the raw
  socket — but a PRIVATE/LAN raw socket (a separate proxy box that could forward
  an arbitrary public client) is no longer treated as local; only a LOOPBACK raw
  socket is (genuine same-host; a remote attacker can't forge a 127.0.0.1 TCP
  source). This closes the new fail-open the initial refactor introduced (public
  client behind a LAN proxy read as local) while preserving genuine same-host
  onboarding. LAN-proxy operators must set HERMES_WEBUI_TRUST_FORWARDED_FOR=1.
  Regression tests lock the full matrix (spoof-block, LAN-proxy-deny,
  loopback-allow, trusted-proxy-rightmost-hop, direct-public-deny).
- Three duplicated inline gate blocks unified into _onboarding_gate_allows /
  _onboarding_request_is_local; ONBOARDING_OPEN normalized to canonical truthy
  values via _truthy_env.

Update-check CSRF hardening:
- GET /api/updates/check is cache-only (cached_update_status(): no network/git
  mutation); forced refresh moves to POST /api/updates/check {force:true}; both
  frontend call sites updated and the test_api_timeout contract assertion updated.
- cached_update_status() preserves cached agent info when include_agent re-enabled.

Docker log masking: ENV_OBFUSCATE_PART also masks PASSWORD/SECRET/CREDENTIAL/COOKIE/SESSION.

Held for separate review (NOT in this PR): public-bind-requires-auth startup gate
(server.py + Dockerfile default) and the /api/media /tmp-root removal.

Co-authored-by: fantasticsquirrel <[email protected]>

* docs(changelog): stamp v0.51.307 — Release JW (stage-a3 #3758 partial)

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 19:50:56 -07:00
nesquena-hermes
6bf455fa40 Release v0.51.306 — Release JV (stage-a2 — branchy compression lineage freshest-tip) (#3761)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(sessions): resolve branchy compression lineage to the freshest tip (#3751)

Compression parents can have multiple continuation-looking children when a stale
segment is resumed after a newer compressed branch already exists. The previous
projection followed the newest DIRECT child only, so it could hide the deeper
branch with the latest real activity and make a conversation look missing/stale
after compaction or session rotation.

- compression_tip() now walks ALL reachable continuation descendants (DFS) and
  selects the freshest importable (messageful) tip by (last_activity, depth).
- read_session_lineage_metadata() expands descendants from the materialized
  ancestors via the parent index (scoped, 20-hop cap), pulls per-session message
  stats from the messages table, and exposes a canonical _lineage_tip_id so the
  WebUI sidebar collapse picks the same tip as the projection.

Backward-compat hardening (two release-gate Codex findings, both fixed + tested):
The new message-stats / tip-scoring code must not raise on older/minimal or
non-standard state.db schemas, which previously collapsed the whole projection:
  - messages table with NO `timestamp` column → MAX(timestamp) raised in SQL →
    read_session_lineage_metadata returned {} (lost all lineage metadata).
  - ISO-8601 TEXT messages.timestamp → float()/raw comparison raised TypeError;
    in read_importable_agent_session_rows that propagated through compression_tip
    and get_cli_sessions() swallowed it, hiding ALL imported agent rows.
Fixes:
  - PRAGMA table_info(messages) detection: require session_id, only SELECT
    MAX(timestamp) when present (else NULL + COUNT only); fall back to message_count.
  - new _as_score() helper (first numerically-coercible value, else next
    candidate e.g. started_at) used at EVERY tip-scoring / sort site in both
    compression_tip() and freshest_continuation_tip(), plus the projection sort.
  - regression tests: lineage metadata survives REAL/absent/TEXT messages.timestamp,
    and read_importable_agent_session_rows survives a TEXT timestamp (no empty hide).

Co-authored-by: ai-ag2026 <[email protected]>

* docs(changelog): stamp v0.51.306 — Release JV (stage-a2 #3751)

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 19:10:04 -07:00
nesquena-hermes
c7a389e0a7 Release v0.51.305 — Release JU (stage-p2b — dormant unified-SessionDB adapter) (#3759)
Some checks failed
Release & Docker / release (push) Has been cancelled
* refactor(sessions): add dormant JSON-backed SessionDB adapter (#3720, #3383)

First, lowest-risk slice of the unified-session-db migration: a SessionDB-shaped
adapter over the existing WebUI JSON store, behind a dormant experimental flag
(experimental.unified_session_db, default false). No runtime call site is rewired
— is_unified_session_db_enabled() has no live callers, so persistence behavior is
unchanged until a later migration PR opts in. Includes the adapter, the dormant
config flag + _apply_config_defaults wiring, an architecture doc, and adapter tests.

Also adds docs/architecture/ to the .gitignore docs allowlist (the docs/* rule
excludes subdirectories; the new architecture doc and its presence test would
otherwise be silently dropped on a clean checkout).

Co-authored-by: rodboev <[email protected]>

* docs(changelog): stamp v0.51.305 — Release JU (stage-p2b #3720)

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 18:16:07 -07:00
nesquena-hermes
3a8a51e507 Release v0.51.304 — Release JT (stage-p2a — un-held terminal reaper + opt-in Docker GPU) (#3757)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(terminal): reap reparented terminal descendants by process group (#3725, #2577)

Embedded-terminal descendants reparented to the WebUI process could linger as
zombies. The reaper now calls os.waitpid(-terminal_pgid, WNOHANG) scoped to the
terminal's own process group (terminals spawn with start_new_session=True, so
proc.pid == pgid) rather than process-wide waitpid(-1), which would otherwise
reap unrelated WebUI subprocess children and silently coerce their exit codes to
0. Bounded by a 64-iteration limit and lock-guarded. Runs on reader cleanup and
terminal close.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* docs(docker): add opt-in GPU runtime image path (#3721, #3243)

The default image stays CPU-only. A new INSTALL_GPU_LIBS=1 build arg installs
VA-API user-space libraries for users passing through host GPU devices, and
docker_init.bash preserves Docker --group-add supplemental groups (e.g. render/
video for /dev/dri) when dropping privileges to the runtime user. Default
(INSTALL_GPU_LIBS=0) is a no-op. Docs + regression test included.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* docs(changelog): stamp v0.51.304 — Release JT (stage-p2a #3725 #3721)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-06 18:04:16 -07:00
nesquena-hermes
4580f58496 Release v0.51.303 — Release JS (stage-p1a — cron toggle + config var expansion + git-discard hardening) (#3756)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(cron): toggle run output rows instead of re-fetching when already open (#3732)

_loadRunContent() only ever expanded, so clicking an already-open cron run row
re-fetched its content pointlessly. It now toggles: an open row collapses (clears
the expansion state + resets the toggle button) and returns early, avoiding the
redundant API call.

Co-authored-by: mysoul12138 <mysoul12138@users.noreply.github.com>

* feat(config): expand ${VAR} references in config.yaml at load time (#3736)

hermes-agent already expands ${ENV_VAR} in config.yaml, but the WebUI's own
loader stored the raw dict, leaving literal ${...} strings. Recursively expand
${VAR} against os.environ on both config load paths (reload_config and
_load_yaml_config_file); unset vars are left untouched (${VAR} preserved).

Co-authored-by: Carry00 <Carry00@users.noreply.github.com>

* fix(security): anchor untracked-file deletes in git_discard (#3702)

git_discard(delete_untracked=True) used raw shutil.rmtree / Path.unlink after a
separate safe_resolve_ws validation, leaving a validation-to-use symlink-swap
window. Route untracked deletes through the anchored helpers (rmtree_anchored /
unlink_anchored) so a swapped path component is rejected at delete time; preserve
the prior missing_ok tolerance for benign concurrent-removal races. Adds
regression coverage for both the symlink-swap block and the concurrent-missing case.

Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>

* docs(changelog): stamp v0.51.303 — Release JS (stage-p1a #3732 #3736 #3702)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: mysoul12138 <mysoul12138@users.noreply.github.com>
Co-authored-by: Carry00 <Carry00@users.noreply.github.com>
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
2026-06-06 17:24:18 -07:00
nesquena-hermes
bf088cbbc4 Release v0.51.302 — Release JR (stage-brick — mobile/iOS brick + large-session perf hotfixes) (#3754)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(ui): stop hidden toast from intercepting clicks on mobile (#3735)

The .toast container kept pointer-events:auto while hidden (opacity:0), so its
fixed padding sat over mobile profile action buttons and ate their clicks. Set
pointer-events:none when hidden; restore auto on .toast.show.

Co-authored-by: timlawrenz <timlawrenz@users.noreply.github.com>

* fix(sessions): rename saves on blur so iOS Safari rename works (#3729)

iOS Safari has no Enter key; the keyboard 'Done' button fires blur, and the old
onblur=cancel discarded the rename. Flip blur to save (Escape still cancels) for
session rename and project create/rename, with a _finishDone guard to prevent a
double-fire between blur and the API callback.

Co-authored-by: reinocheong <reinocheong@users.noreply.github.com>

* perf(session): skip fuzzy dedup matching for giant merge payloads (#3730)

Large tool/log payloads made _matching_visible_duplicate() casefold+regex-tokenize
multi-megabyte contents on every visible key, so /api/session took 10s+ and blocked
/api/sessions for ~19s. Keep loose normalization lazy+cached and skip substring/fuzzy
matching for non-exact payloads >200KB; exact visible-key matches still short-circuit.

Co-authored-by: alvistar <alvistar@users.noreply.github.com>

* docs(changelog): stamp v0.51.302 — Release JR (stage-brick brick/perf hotfixes #3735 #3729 #3730)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: timlawrenz <timlawrenz@users.noreply.github.com>
Co-authored-by: reinocheong <reinocheong@users.noreply.github.com>
Co-authored-by: alvistar <alvistar@users.noreply.github.com>
2026-06-06 16:58:13 -07:00
nesquena-hermes
1649a22f5a Release v0.51.301 — stage-3710 (hide test-helper console windows on Windows #3710) (#3753)
Some checks failed
Release & Docker / release (push) Has been cancelled
* test(windows): hide test helper console windows (#3706)

* test(windows): use CREATE_NO_WINDOW constant (#3706)

* test(windows): hide test-helper console windows on Windows (#3710 fixes #3706)

@rodboev. Long-lived test helper subprocesses (test server, browser-smoke, TLS helper,
ctl helpers) now spawn with CREATE_NO_WINDOW on Windows so a local pytest run doesn't
pop focus-stealing console windows. sys.platform=='win32' guarded → no-op on macOS/Linux
(expands to **{}). Test-only, no production code, no user impact. + CHANGELOG v0.51.301.

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 16:16:39 -07:00
nesquena-hermes
1cb9d3b550 fix(#3717): context-length indicator honors provider per-model overrides (#3726) (#3752)
Some checks failed
Release & Docker / release (push) Has been cancelled
@rodboev. providers.<name>.models.<model>.context_length overrides (standard provider,
no base_url) were invisible to the session context resolver → wrong window shown/persisted,
could trip auto-compression at the wrong threshold. New _context_length_lookup_inputs_for_model
helper resolves provider config / base_url / custom_providers across route-load, session-save,
and SSE-usage paths; provider-scoped overrides match by provider name and forward as
config_context_length (returned before any base-url-gated probe).

Maintainer pre-merge items both already satisfied in PR head: no-base_url regression test
(test_route_resolver_uses_provider_model_context_length_without_base_url) present; session-save
_cfg_base_url assigned before the helper call (safe-bound, no NameError on TypeError fallback).
Verified api code byte-identical to PR head; 14 context-length tests pass. + CHANGELOG v0.51.300.

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 15:57:09 -07:00
nesquena-hermes
1fed11c085 Release v0.51.299 — stage-3713 (update flow waits for new server instance #3713) (#3749)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(#3619): update flow waits for a genuinely new server instance before reload (#3713)

@rodboev. _waitForServerThenReload() reloaded on the first healthy /health response
without confirming the server instance changed, so a slow restart could reload onto
the old process or show a premature error toast. Now captures a baseline server identity
from /health's existing server_started_at and only reloads on a changed identity.
Frontend-only (server_started_at already in /health on master). ui.js verified
byte-identical to PR head; test_update_banner_fixes 82 passed. + CHANGELOG v0.51.299.

* fix(#3713): reload on observed outage when uptime-only identity not lower (Codex catch)

Codex found a CORE edge case: when a deployment strips server_started_at and BOTH the
baseline and replacement /health expose only uptime_seconds, the reload trigger relied
solely on 'next.uptime < baseline.uptime'. If the old server's baseline uptime was very
low (~0) and the new instance is first seen at equal/higher uptime, that never fires and
the user is stranded on the restart banner.

Fix: track _observedOutage (set in the /health probe catch arm) and, for the
uptime-only-on-both-sides case, treat an outage-then-healthy sequence as the new instance
and reload. A real restart almost always produces an outage given the pre-probe interval,
so this closes the gap without weakening the identity check. + regression test.

* fix(#3713): harden outage fallback — require >=2 consecutive outages, count non-OK (Codex round 2)

Codex round 2 found two refinements to the outage fallback: (1) a single transient
fetch blip could set the flag and reload onto the still-running old server; (2) a
reverse-proxy 502/503 returns r.ok===false (not a thrown error) so the proxy-outage
case didn't register. Replaced the boolean with a _consecutiveOutages counter: require
>=2 consecutive outages before the uptime-only reload; count BOTH thrown errors and
non-OK/non-status-ok responses; reset to 0 when the old server answers healthy (so
unrelated blips can't accumulate). Updated the regression test to the counter contract.

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 15:38:51 -07:00
nesquena-hermes
ce4c2c1de1 Release v0.51.298 — stage-3719 (live model probe for custom providers with model config #3719) (#3747)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(#3718): /api/models/live probes upstream for custom providers with model config (#3719)

@DanielMaly. Config model IDs were added to the ids list before the 'if not ids:' guard,
so a custom provider with a model: field skipped the live /v1/models probe and Settings'
refresh returned only the config entry. Now collects config IDs separately, always probes
for custom providers, merges live (priority) + config (fallback). Includes the maintainer
review follow-ups (CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS constant + behavioral tests).

Captured all 3 logical PR commits' net effect; routes.py + test verified byte-identical
to the PR head. + CHANGELOG v0.51.298.

* test(#3718): remove unused BytesIO import (ruff F401)

* test(#3719): update timeout assertion to CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS

The #3719 maintainer-review commit replaced the hardcoded urlopen timeout=8 with the
CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS constant (5.0). test_named_custom_live_fetch_uses_matching_entry_endpoint
asserted the old literal 8. Reference the constant directly now so the assertion can't
drift again. Not a behavior change — only the live-probe timeout value (8s -> 5s) moved,
URL + auth unchanged.

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 14:48:39 -07:00
nesquena-hermes
4f842d5095 Release v0.51.297 — stage-3711 (terminal remote-backend guard #3711) (#3745)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(terminal): guard embedded terminal on remote backends (#3673)

* fix(terminal): add missing remote-backend locale key

* fix(terminal): add missing remote-backend locale coverage (#3673)

* docs(changelog): v0.51.297 — terminal remote-backend guard (#3711) only

Dropped #3725 (descendant reaper) from this stage: Codex caught a SILENT exit-code
clobber — its process-wide os.waitpid(-1, WNOHANG) can reap a sibling WebUI child that
another subsystem is waiting on, coercing that child's returncode to 0 (failures become
successes). Held for the contributor to scope the reaper to terminal PGIDs
(os.waitpid(-term.proc.pid, WNOHANG)) or a terminal-PGID registry.

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 14:17:17 -07:00
nesquena-hermes
91a89fb5d5 Release v0.51.296 — stage-3731 (remote-workspace blocked-root security fix #3731) (#3744)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: reject blocked roots for remote workspaces

* test: cover remote blocked root subpaths

* docs(changelog): v0.51.296 security fix + backfill v0.51.295 entries

- v0.51.296: #3731 remote-workspace blocked-root rejection.
- Backfill the v0.51.295 release block (the #3739 model-pick entry + promote the
  #3570 revert out of [Unreleased]) which a stage-rebuild dropped from the prior
  release's CHANGELOG. git-describe versioning makes CHANGELOG-after-tag acceptable.

---------

Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com>
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 13:49:59 -07:00
nesquena-hermes
65c4bc9fa2 Release v0.51.295 — stage-3739/3742 (model-pick revert fix #3739 + session-status revert #3742) (#3743)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: honor explicit model pick, suppress silent revert on cross-family selection (#3737)

When a user changes the model in the composer dropdown and sends,
_resolve_compatible_session_model_state previously had no way to
distinguish an explicit user pick from stale session state. The
profile-aware branch (v0.51.290, PR #3448) and the legacy block
both rewrote bare cross-family models to the profile default, and
the client unconditionally applied effective_model — silently
discarding the user's choice.

Backend: accept explicit_model_pick flag (default False) on
_resolve_compatible_session_model_state. Guard both the
profile-aware branch (routes.py:2024) and the legacy block
(routes.py:2124) to skip cross-provider normalization when set.
_handle_chat_start extracts the flag and passes it through.

Frontend: consult _readPendingSessionModel (sessionStorage, 10-min
window) to detect explicit picks and include the flag. Add a toast
as defense-in-depth when the server still returns effective_model.

Closes #3737

* fix: tighten explicit-pick detection and add regression tests (#3737)

Greptile P2-1: compare model_provider in pending pick detection,
not just model name, to avoid false-positive flag when the
session provider changes between pick and send.

Greptile P2-2: only show the defense-in-depth toast when an
explicit pick was actually overridden — stale-session
normalizations are expected behavior and should be silent.

Add two regression tests for the profile-branch guard:
- explicit_model_pick=True → cross-family model survives
- explicit_model_pick=False → existing normalization preserved

* revert(sidebar): remove manual session status labels (#3570)

The manual per-session status labels (Todo / In Progress / Done) added in
v0.51.284 (#3570) stored state only in browser localStorage keyed by session
id, with no server-side backing — so labels did not persist across browsers
or devices (a user who labeled sessions on one machine saw none after moving
to a laptop). They also rendered as three flat top-level entries in the
session context menu, crowding the root menu.

Per maintainer decision, remove the feature entirely for now. It can be
reintroduced later with proper server-side persistence and a less intrusive
menu treatment.

Removes:
- JS state/cycle helpers + SESSION_MANUAL_STATUS_KEY (static/sessions.js)
- context-menu status entries + sidebar status badge render
- .session-manual-status* CSS (static/style.css)
- session_status_* locale strings across all locales (static/i18n.js)

Full suite: 8084 passed, 0 failed. ESLint runtime gate: clean.

reverts #3570

* fix(#3737): keep explicit-pick marker until send consumes it (Codex catch)

Codex found the explicit_model_pick flag never engaged in the normal flow: boot.js
modelSelect.onchange cleared the pending-pick marker right after /api/session/update,
so by the time send() ran _readPendingSessionModel returned null, _explicitPick was
false, and the server's profile-provider branch still reverted the cross-family pick
(the exact #3737 bug). The flag only worked in the rare race where send beat the
session-update round-trip.

Fix (Codex prescription): do NOT clear the marker in onchange; clear it in send()
immediately after reading a matching pending pick, so it's consumed for that send only.
onchange still RECORDS the pick (_rememberPendingSessionModel) — only the premature
clear is removed.

* test(#3737): lock client clear-timing wiring (onchange records, send consumes)

Static source guards for the Codex clear-timing fix: onchange must record the
pending pick and NOT clear it post-session-update; send() must consume (clear) it
only after reading a matching _explicitPick, and send the flag only when truthy.
Complements the author's resolver-level tests in test_provider_mismatch.py.

* test(#3737): realign refresh-persistence test to the moved pending-pick clear

The Codex clear-timing fix moved the pending-pick clear out of modelSelect.onchange
into send() (consume-on-send). test_model_selection_records_pending_state_before_async_session_update
asserted the OLD onchange-clears behavior (assert _clearPendingSessionModel in body).
Updated to assert the NEW correct behavior (onchange must NOT clear it — it survives to
send). The test's core refresh-survives invariant (marker recorded before the async
session-update; reapplied on load) is unchanged and still passes; only the stale
clear-location assertion is flipped. Not a regression-blessing: the refresh-survives
feature is intact, the marker lifecycle is more correct.

---------

Co-authored-by: John Doe <johndoe@example.com>
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 13:39:14 -07:00
nesquena-hermes
e3a7c93dc6 [HELD — independent review pending] Release v0.51.294 — stage-3401 (live-to-final redesign #3401 + 4 deep-review fixes) (#3741)
Some checks failed
Release & Docker / release (push) Has been cancelled
* Harden interrupted recovery control filtering

* Redesign live-to-final assistant replies

* Fix live activity anchor test fixture

* Fix CI lint issues for live reply tests

* Strengthen live progress prompt contract

* Recover PR #3401 refresh on origin/master

* Repair live-to-final refresh regressions

* Fix live worklog refresh regressions

* Show live footer timer on initial stream start

* Restore live stream shell after reload

* Preserve per-frame live SSE replay cursors

* Preserve reasoning as Worklog Thinking cards

* Quiet Worklog Thinking card styling

* Align Worklog Thinking card styling

* Scope live Worklog Thinking cards by segment

* Suppress exact duplicate settled Thinking

* Close #3401 merge review test gaps

* fix(#3401): resolve 4 deep-review regressions (inline-think, reconnect-dup, neon skin, busy-gate worklog)

Deep review (Codex diff-vs-master + live-browser drive) of the live-to-final refactor
surfaced 4 regressions vs master that the rewritten suite no longer guarded:

1. Inline <think>…</think>answer reasoning vanished — _assistantReasoningPayloadText
   used $-anchored regexes so a leading think block + visible answer extracted nothing
   and the Thinking card never rendered. Removed the 3 $ anchors to match the
   (non-anchored) display stripper. Live: inline-think thinking-only turn now renders.
2. (CORE) reconnect/reload duplicated the live reply — _rememberRunJournalCursor advanced
   a closure-local seq but never wrote INFLIGHT[activeSid].lastRunJournalSeq, so a reload
   replayed the journal from after_seq=0 over restored lastAssistantText. Now mirrors the
   cursor onto INFLIGHT + schedules a throttled persist.
3. Neon skin silently broke — PR deleted the :root[data-skin="neon"] CSS but left Neon in
   the picker. Restored the neon CSS block from master.
4. Settled tool-worklog rebuild gated purely on !S.busy — dropped every prior settled
   turn's worklog when renderMessages re-ran during an active stream (switch-back to an
   in-progress session). Restored master's !S.busy || (S.toolCalls && S.toolCalls.length).
   Live: busy re-render now preserves tool cards (4→4, was 4→0).

Live-verified all 4 + confirmed #3709/#3592 invariants still hold (1 thinking card, none
below footer; distinct siblings preserved). + tests/test_issue3401_deep_review_fixes.py (7).

* test(#3401): realign 3 stale source-shape assertions to the deep-review fixes

Fix commit changed two source literals that existing stage tests scanned for:
- test_live_activity_timeline.py (x2): split anchor 'if(!S.busy){' → the restored
  'if(!S.busy || (S.toolCalls&&S.toolCalls.length)){' guard (fix 4).
- test_run_journal_frontend_static.py: 'after_seq=0' not in source — fix 2's comment
  contained that literal; rephrased the comment to 'the zero floor (after_seq of 0)'.
Intent of all three assertions unchanged; only the matched string updated. No code
behavior change.

* docs(changelog): v0.51.294 — Release JJ (stage-3401, #3401 live-to-final redesign)

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: Nathan-Hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 12:12:37 -07:00
nesquena-hermes
32d46f4450 Release v0.51.293 — Release JI (stage-s5 — thinking card no longer renders twice #3709) (#3715)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(#3709): thinking card no longer renders twice (in Activity + below answer)

The #3592 inline-render branch (v0.51.258) emitted a thinking card for a
thinking-only message even when a sibling tool-message in the same turn already
built an Activity group carrying that turn's thinking — so the card showed twice,
the second one stranded below the answer + 'Done in …' footer (insertAdjacentHTML
'beforeend' on a segment that already had body+footer).

Fix (keeps #3592, does NOT revert it):
- A1: precompute turnsWithActivityGroup (turns whose segments have tool cards);
  the inline branch only renders when the anchor turn is NOT in that set.
- A2: when it does render inline, insert 'beforebegin' the .msg-body/.msg-foot so
  the card sits above the answer, not orphaned below the footer.
- B: strip thinking against the TURN's combined visible answer
  (_turnVisibleTextByRawIdx), so a trailing thinking-only message that echoes the
  answer gets de-duped even though its own body is empty.

Live-verified in browser: #3709 repro (tool+trailing-thinking) → exactly 1 card in
Activity, above footer; #3592 repro (thinking-only) → exactly 1 inline card, not
buried in a collapsed group. + regression test tests/test_issue3709_*.

Supersedes #3708 (which deleted the inline branch outright, re-breaking #3592).

* fix(#3709): merge suppressed sibling thinking into the Activity group (Codex re-gate)

Codex caught a content-loss edge in the first cut: when A1 suppresses a
thinking-only sibling's inline card (its turn has an Activity group), the group
only rendered assistantThinking.get(aIdx) for the TOOL message — so a sibling
with DISTINCT reasoning was neither inline nor in the group → dropped.

Fix: aggregate all of a turn's thinking (turnThinkingParts, de-duped, index
order) and render that merged text once per turn in the Activity group
(_renderedTurnThinking guard). Live-verified: tool-thinking A + distinct
sibling-thinking B → 1 merged node carrying both, no loss. + regression test.

* fix(#3709): shared anchor resolver so inline-suppression & group placement agree (Codex re-gate #2)

Codex caught a fallback-anchor mismatch: turnsWithActivityGroup was populated only
from assistantSegments.get(tcIdx) (direct segment), but the group-render path falls
back to a nearby earlier segment when a tool's assistant_msg_idx has no directly
rendered segment (legacy/rebased). So a fallback-anchored group's turn wasn't in
turnsWithActivityGroup → the sibling rendered inline AND the group rendered → dup
again. Fix: one shared _anchorRowForActivityIdx(aIdx) helper (direct-or-fallback)
used by the precompute, the inline branch, and the group render — they now agree.
Live-verified all three repros still pass.

* test(#3709): update test_compact_activity assertion to mergedThinking var

The brittle source-scan asserted _thinkingActivityNode(thinkingText, false) — the
#3709 fix renders the turn's MERGED thinking via _thinkingActivityNode(mergedThinking,
false) into the same Activity body. Intent (settled thinking renders inside the
Activity disclosure alongside tools) unchanged; only the source variable. Updated to
assert the new variable, kept all intent assertions.

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 00:19:29 -07:00
nesquena-hermes
9720e0b03d Release v0.51.292 — Release JH (stage-s4 — compression-exhausted turns surface as errors #3316 fixes #3315) (#3705)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(#3315): surface compression-exhausted/no-final-answer turns as errors (#3316)

When Hermes Agent exhausts context compression in a long tool-heavy turn, the
streamed result can end on a tool result / assistant(tool_calls) turn with no
final assistant answer. WebUI was finalizing that as a completed response.
Now _session_lacks_final_assistant_answer() + _agent_result_terminal_failure()
classify these as terminal failures and surface an apperror instead. The
compression session-id migration + pre-compression snapshot now run BEFORE the
terminal-failure return (ordering bug from the prior hold) so state stays
consistent when exhaustion fires after the agent rotated session_id.

Co-authored-by: Frank Song <franksong2702@gmail.com>

* docs(changelog): v0.51.292 — Release JH (stage-s4, #3316 fixes #3315)

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-05 22:45:09 -07:00
nesquena-hermes
534280e058 Release v0.51.291 — Release JG (stage-s2 — preserve live turn content on switch-away #3668) (#3704)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(#3668): snapshot live turn before stream teardown on session switch

The 'stays gone' variant: switching away from a streaming session during a quiet
window (mid tool-exec / silent thinking, between content SSE events) left a
stale/absent live-turn snapshot, so restoreLiveTurnHtmlForSession() failed on
switch-back and loadSession()'s fallback rebuilt with an empty appendThinking(),
permanently losing streamed thinking/tool content (only the elapsed clock
survived). closeLiveStream() now snapshots the live-turn DOM via
snapshotLiveTurnHtmlForSession(sessionId) BEFORE closing the source + tearing
down LIVE_STREAMS, so switch-back always restores the exact state shown at
switch-away. + regression test asserting snapshot precedes teardown.

* docs(changelog): v0.51.291 — Release JG (stage-s2, #3668)

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-05 22:26:43 -07:00
nesquena-hermes
47020925d5 Release v0.51.290 — Release JF (stage-s1 — profile provider/model resolution #3448 fixes #3405) (#3703)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(#3405): respect profile provider/model in session resolution (#3448)

Profile-bound sessions now resolve their provider/model from the profile
instead of silently falling back to the global active provider — fixes wrong
credentials/billing and silent context truncation. Repairs stale models under
the profile provider (incl. the openai-codex + openai/ slash-model case) while
preserving native slash IDs on openrouter/custom.

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* docs(changelog): v0.51.290 — Release JF (stage-s1, #3448 fixes #3405)

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-05 22:17:27 -07:00
nesquena-hermes
d83de226fe docs(changelog): stamp v0.51.289 (sidebar ReferenceError #3696 hotfix + scope gate) (#3699)
The v0.51.289 tag ships the #3696 sidebar-crash hotfix + the scope_undef_gate
(merged in #3698, commit da5bf69a). This stamps the CHANGELOG [Unreleased]
section to the v0.51.289 release header. Docs-only.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-06-05 20:56:16 -07:00
nesquena-hermes
da5bf69aee fix(sidebar): hoist _sessionAttentionState to fix ReferenceError crash (#3696) + scope-undef prevention gate (#3698)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(sidebar): hoist _sessionAttentionState to top-level scope (#3696)

_sessionAttentionState was declared inside renderSessionListFromCache() and
relied on function hoisting, but the top-level function _sidebarRowHasVisible
Messages (reached via renderSessionListFromCache -> _partitionSidebarSessionRows)
called it bare. Hoisting is scoped to the enclosing function, so every sidebar
cache-render threw 'ReferenceError: _sessionAttentionState is not defined' and
the session list went blank. Regressed in #3672 (v0.51.269) when _sidebarRow
HasVisibleMessages was extracted to top level.

Fix: move _sessionAttentionState to top-level scope (it is pure — only uses its
arg plus the i18n global t), so both the visibility predicate and the nested
per-row renderer can reach it.

Prevention (the durable half): add scripts/scope_undef_gate.py — models the
classic-<script> shared global scope (union of all static files' top-level
symbols) and runs ESLint no-undef per file, flagging a function defined nested
but called from a sibling scope. Wired into CI (.github/workflows/tests.yml lint
job) alongside the existing no-const-assign runtime gate, plus an in-suite test
(test_static_js_scope_undef.py) and a focused structural regression test
(test_issue3696_session_attention_scope.py). RED/GREEN-validated against the
broken tree.

* fix(streaming): thread source param into stale-stream bailout; tighten scope gate

Opus review of #3698 found the new scope_undef_gate's 'source' allowlist entry
was masking a real same-class bug: _bailOutOfTerminalEventsFromStaleStream
(declared inside attachLiveStream, params activeSid/streamId/uploaded/options)
called _closeSource(source) against a 'source' not in its lexical scope. All 5
call sites are inside _wireSSE(source), but JS scope is lexical not dynamic, so
the helper would throw ReferenceError: source is not defined on the stale-stream
terminal-event path (user back in an active session whose old stream finalizes
late).

Fix: thread source as an explicit parameter (declaration + all 5 call sites),
the same make-the-dependency-explicit fix as #3696 — and REMOVE the 'source'
allowlist entry so the gate stays gated against that name (it now passes because
the bug is fixed, not because it's allowlisted). Added the documented
false-negative classes from Opus's review to the gate docstring (name-collision
shadowing, destructuring-regex gap, exposure escape hatches, name-keyed
allowlist) and a focused regression test.

This is the prevention gate catching a real latent bug on its first outing.

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-06-05 20:49:45 -07:00
nesquena-hermes
d4f26f5156 Release v0.51.288 — Release JD (stage-r24 — collapsible approval card #3515) (#3697)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat(approval): make the approval card collapsible (#3515)

Adds a collapse toggle to the approval card header so users can shrink it
to a thin header strip and keep the tool-call rationale/transcript above
readable. Full ARIA (aria-expanded/controls/label), chevron swap, and
transcript reflow that preserves near-bottom scroll. Closes #3007.

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* docs(changelog): v0.51.288 — Release JD (stage-r24)

* fix(approval): clear collapsed state for a distinct queued approval (#3515)

Codex regression-gate finding: showApprovalCard's sameApproval check didn't
include approval_id and didn't clear .collapsed in the !sameApproval branch, so
a NEW/parallel approval arriving while the card was already collapsed could
render collapsed with its command + action buttons hidden. Add approval_id to
the signature; clear .collapsed for a distinct approval before syncing. +2 regression tests.

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-05 19:52:52 -07:00
nesquena-hermes
0b223e91bc Release v0.51.287 — Release JC (stage-r22 — WeCom session classification #3653 + worker-profile picker hiding #3662) (#3695)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat(sessions): classify WeCom gateway sessions as messaging (#3653)

Co-authored-by: Frank Song <franksong2702@gmail.com>

* feat(profiles): hide worker profiles from chat picker (#3662)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* docs(changelog): v0.51.287 — Release JC (stage-r22)

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-05 18:56:43 -07:00
nesquena-hermes
2736f443b1 Release v0.51.286 — Release JB (stage-r21 — sidebar tab reordering #3067) (#3694)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat: allow sidebar tab reordering via drag (#3067)

Drag-reorder for sidebar tab chips in Settings, persisted via a sanitized
tab_order setting (collapses duplicates, rejects chat/settings, strips
non-strings).

Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>

* docs(changelog): v0.51.286 — Release JB (stage-r21)

---------

Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-05 18:39:42 -07:00
nesquena-hermes
e9bb354908 Release v0.51.285 — Release JA (stage-r19 — update-reload server-identity race fix #3654) (#3693)
Some checks failed
Release & Docker / release (push) Has been cancelled
* Fix update reload readiness race — poll /health server identity before reload (#3654)

Replaces the raw-uptime comparison (couldn't distinguish a fresh old process
from the restarted one) with a stable server_started_at identity read before
the update POST; reloads only when the identity changes. Both the force-update
and regular apply paths read + pass the baseline. (#874, #3654)

Co-authored-by: Frank Song <franksong2702@gmail.com>

* docs(changelog): v0.51.285 — Release JA (stage-r19)

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-05 18:13:35 -07:00
nesquena-hermes
988348682c Release v0.51.284 — Release IZ (stage-w4 — sidebar status labels + cron-sessions toggle #3570 #3514) (#3692)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat(sidebar): add show_cron_sessions toggle to surface cron sessions (#3514, #2841)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* feat(sidebar): add manual session status labels (#3570)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* docs(changelog): v0.51.284 — Release IZ (stage-w4)

* fix(settings): persist show_cron_sessions in the explicit Save Settings path too (#3514)

Codex regression-gate follow-up: the autosave path (_preferencesPayloadFromUi)
included show_cron_sessions but the explicit saveSettings() button path read/saved
show_cli_sessions and dropped the cron checkbox — clicking Save Settings silently
omitted it. Read settingsShowCronSessions + add body.show_cron_sessions (gated on
CLI sessions, mirroring autosave).

* fix(settings): gate show_cron_sessions identically in BOTH save paths (#3514)

Codex round-2: my saveSettings() gate exposed that the autosave path
(_preferencesPayloadFromUi) posted the raw cron checkbox state ungated, so
show_cli_sessions=false + show_cron_sessions=true could persist via autosave.
Gate autosave on showCliCb too; update the regression test to assert both
paths gate on settingsShowCliSessions.

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-06-05 17:48:27 -07:00
nesquena-hermes
a7241f75fc Release v0.51.283 — Release IY (stage-w2 — composer queue hint during auto-compaction #3512) (#3690)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat(composer): surface that messages queue during auto-compaction (#3512, #3079)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* docs(changelog): v0.51.283 — Release IY (stage-w2)

* fix(composer): restore placeholder on ALL compaction-exit paths, not just clearCompressionUi (#3512)

Codex+Opus both caught: setCompressionUi(done) and the live-anchored SSE
window._compressionUi=null paths bypassed clearCompressionUi, leaving the
'will queue' placeholder stuck after compaction. Factor restore into
_restoreCompressionPlaceholder() + call from every compaction-exit path.

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-06-05 16:52:28 -07:00
nesquena-hermes
fd45b54c17 Merge pull request #3689 from nesquena/release/stage-3544
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.282 — Release IX (stage-3544 — surface memory/skill saves in Activity summary)
2026-06-05 16:16:49 -07:00
nesquena-hermes
b26bb559d5 feat(ux): surface memory/skill saves in Activity summary (#3544)
Absorbs contributor PR #3544 (@rodboev, closes #3340) with two fixes:

1. DETECTION VOCAB (would never fire): the original gated on action names
   {save,create,update,upsert}, which don't match the real agent tool enums —
   memory.action is add|replace|remove, skill_manage.action is
   create|patch|edit|delete|write_file|remove_file. Split into per-tool
   predicates with the correct vocabularies: _isMemorySave gates memory on
   {add,replace}; _isSkillUpdate gates skill_manage on {create,patch,edit,
   write_file}. Deletions excluded so the saved/updated verbs stay accurate;
   running/errored excluded.

2. SNAPSHOT/RESTORE PERSISTENCE (Codex catch): classification lived only on the
   row._tcData JS property, which does NOT survive the outerHTML/innerHTML
   snapshot+restore the live tool-call group uses on session switch/restore —
   a restored memory/skill row would be re-counted as a generic tool and the
   suffix would silently vanish. buildToolCard now also stamps durable
   data-memory-save / data-skill-update attributes, and _syncToolCallGroupSummary
   counts them as a fallback when _tcData is absent. Verified live across a real
   outerHTML round-trip: label identical before/after.

Replaces the PR's static source assertions with a node-driven behavioral test
(11 cases) covering the real action vocabularies, exclusions, case-insensitivity,
null-arg safety, and the durable-attribute persistence guard.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-05 23:09:00 +00:00
nesquena-hermes
e663bc98d6 Merge pull request #3688 from nesquena/release/stage-verdigris
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.281 — Release IW (stage-verdigris — Verdigris emerald/bronze skin #3602)
2026-06-05 15:27:56 -07:00
nesquena-hermes
10413b4d65 Release v0.51.281 — Release IW (stage-verdigris — Verdigris emerald/bronze skin #3602)
Adds the Verdigris dark-only appearance skin (emerald/forest-green + bronze-gold),
renamed from the contributor's 'Hermes Agent' to a descriptive material name per
maintainer naming convention. Registered across all 5 skin sites (config allowlist,
boot.js swatch, index.html FOUC map, i18n in 12 locales, scoped CSS palette) + test.
Also fixes the zeus i18n test (zeus is no longer the trailing skin token).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-05 22:18:47 +00:00
nesquena-hermes
ffc1ab6fd6 Release v0.51.280 — Release IV (stage-p3i — Windows self-update restart fix #3647) (#3687)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(updates): Windows self-update restart via detached Popen + bind-retry (os.execv doesn't replace proc on Windows) (#3647)

Co-authored-by: jja881 <jja881@users.noreply.github.com>

* docs(changelog): v0.51.280 — Release IV (stage-p3i)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: jja881 <jja881@users.noreply.github.com>
2026-06-05 14:54:59 -07:00
nesquena-hermes
b5caf83ff9 Release v0.51.279 — Release IU (stage-p3h — preserve Activity/streaming turn on mid-stream scroll #3665) (#3686)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(streaming): preserve Activity + streaming turn when loading earlier messages mid-stream (#3665, #3346)

Co-authored-by: mysoul12138 <839465496@qq.com>

* docs(changelog): v0.51.279 — Release IU (stage-p3h)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: mysoul12138 <839465496@qq.com>
2026-06-05 14:45:31 -07:00
nesquena-hermes
8f89b4f825 Release v0.51.278 — Release IT (stage-p3g — repair inline PDF preview #3652) (#3684)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(ui): repair inline PDF preview (blob module loader + CSP worker-src) (#3652, #3649)

Co-authored-by: sky <example@email.com>

* docs(changelog): v0.51.278 — Release IT (stage-p3g, #3652 only); widen CSP test window

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: sky <example@email.com>
2026-06-05 14:27:38 -07:00
nesquena-hermes
8ef698ea05 Release v0.51.277 — Release IS (stage-p3f — preserve context-window in usage indicator #3663) (#3683)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(ui): preserve resolved context window in usage indicator (#3663, #3185, #3660)

Co-authored-by: Frank Song <franksong2702@gmail.com>

* docs(changelog): v0.51.277 — Release IS (stage-p3f)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-06-05 13:45:15 -07:00
nesquena-hermes
87084dfebf Release v0.51.276 — Release IR (stage-p3e — preserve manually-named session titles #3542) (#3682)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat(sessions): skip adaptive auto-rename for manually-named sessions (#3542, #3230)

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* docs(changelog): v0.51.276 — Release IR (stage-p3e)

* fix(sessions): clear manual_title lock on /api/session/clear (#3542)

Codex regression-gate follow-up: the clear endpoint reset the title to
Untitled directly, stranding manual_title=True so the reused session never
auto-named again. Route the reset through apply_session_title_rename (which
clears the lock for auto-labels) + add a behavioral and a static-guard test.

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-06-05 13:34:54 -07:00
nesquena-hermes
165454dd7b Release v0.51.275 — Release IQ (stage-p3d — approval SSE state extraction #3575) (#3681)
Some checks failed
Release & Docker / release (push) Has been cancelled
* refactor(routes): extract approval SSE state into api/route_approvals.py (#3575)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* docs(changelog): v0.51.275 — Release IQ (stage-p3d)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-06-05 13:10:45 -07:00
nesquena-hermes
9c6a96f483 Release v0.51.274 — Release IP (stage-p3c — symlink-swap TOCTOU hardening #3630) (#3680)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(security): harden routes file APIs against symlink swaps (#3630, #3450)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* docs(changelog): v0.51.274 — Release IP (stage-p3c)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-06-05 12:58:11 -07:00
nesquena-hermes
9d605c41e2 Release v0.51.273 — Release IO (stage-p3b — cron-output traversal guard #3661) (#3679)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(security): reject traversal-shaped job_id in cron output endpoint (#3661)

Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com>

* docs(changelog): v0.51.273 — Release IO (stage-p3b)

* test(cron): guard new cron-output tests with @requires_agent_modules (#3661)

The two new direct-handler tests import cron.jobs, which lives in hermes-agent
and is NOT installed in CI — without the marker they error/hang in the no-agent
CI shard (caught by the shard-0 timeout). Mirrors how the other 30 agent-dependent
tests skip cleanly when hermes-agent modules aren't importable.

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com>
2026-06-05 12:45:47 -07:00
nesquena-hermes
405c38b7d2 Release v0.51.272 — Release IN (stage-p3a — conflict-safe self-update recovery #3667) (#3678)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(updates): use stash apply (not pop) for conflict-safe self-update recovery (#3667)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* docs(changelog): v0.51.272 — Release IN (stage-p3a, #3667 only)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-06-05 12:28:31 -07:00
nesquena-hermes
84baa27004 docs(rfc): refine live-to-final RFC (rebased #3547) (#3677)
* Refine live-to-final long-running session RFC

* Mark RFC accepted, decouple from live PR status, normalize terminal state names

Three follow-up adjustments to the refined live-to-final RFC:

- Status: Proposed -> Accepted, since the doc is now referenced as the parent
  contract for follow-up slices; live implementation status stays in #3400.
- Keep volatile PR/merge state out of the RFC body. The Public Inventory and
  Delivery map now state that their classification/vehicle columns record
  durable scope, and that #3400 is authoritative for open/merged/superseded
  status. Dropped the point-in-time "has shipped through release" / "remains an
  active PR" assertions that would drift as PRs land.
- Normalize terminal-state naming: use the backticked snake_case identifiers
  (`cancelled`, `compression_exhausted`, `tool_limit_reached`, `no_response`,
  `interrupted`, `error`) consistently in prose, and add a note that these name
  product states, not a wire/enum or persisted schema contract (consistent with
  Scope, which does not own a backend schema change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add artifact handoff scope to live-to-final RFC

* Add live-to-final lifecycle flowchart to RFC

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 12:12:12 -07:00
nesquena-hermes
d882949173 Release v0.51.271 — Release IM (stage-m1 — named custom provider binding #3626) (#3676)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(providers): preserve named custom provider binding in model send (#3626)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* docs(changelog): v0.51.271 — Release IM (stage-m1, #3626 only; #3629 dropped)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-06-05 11:58:25 -07:00
nesquena-hermes
bcd8c05f24 test(#3668): lock approval/clarify card re-show on session switch (#3675)
Regression test for #3668. The reporter observed clarify/approval cards
appearing to vanish when switching away from a blocked session and back,
making the agent look stuck. The teardown half they cited (sessions.js
hides the cards on switch) is real, but the re-show half ships in the same
loadSession(): per-session in-memory pending caches + _renderPendingPrompts
ForActiveSession() + polling re-arm + SSE 'initial' re-fetch. Verified
already-working live (shipped v0.51.19 / #1829); this test locks the
invariant so it cannot silently regress.

A node-driver runs the real extracted JS functions through the
switch-away -> switch-back sequence (RED/GREEN-validated against a
simulated over-broad teardown that clears the cache).

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-06-05 11:54:42 -07:00
nesquena-hermes
58528a4d88 Release v0.51.270 — Release IL (stage-u1 — un-hold batch: #3517 #3624 #3613) (#3674)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat(commands): add /use to force a skill for the next turn (#3517, #2977)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* fix(auth): cap pending passkey challenges by evicting oldest, not rejecting (#3624)

Co-authored-by: Hinotobi <paperlantern.agent@gmail.com>

* fix(providers): expose model-provider plugins in WebUI (#3613)

Co-authored-by: Pamnard <pamnard@users.noreply.github.com>

* docs(changelog): v0.51.270 — Release IL (stage-u1, 3-PR un-hold batch; #3448 + #3618 dropped)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Hinotobi <paperlantern.agent@gmail.com>
Co-authored-by: Pamnard <pamnard@users.noreply.github.com>
2026-06-05 11:35:26 -07:00
nesquena-hermes
2c7b530071 Release v0.51.269 — Release IK (stage-b2 — sidebar perf + search scope + Windows ctl) (#3672)
Some checks failed
Release & Docker / release (push) Has been cancelled
* perf(ui): single-pass sidebar session row partitioning (#3658)

Co-authored-by: Pamnard <pamnard@users.noreply.github.com>

* fix(search): scope session search to active profile (#3646)

Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com>

* fix(ctl): tree-kill ctl.sh stop on Windows (#3670)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* docs(changelog): v0.51.269 — Release IK (stage-b2)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Pamnard <pamnard@users.noreply.github.com>
Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-06-05 10:53:45 -07:00
nesquena-hermes
442b033e67 Release v0.51.268 — Release IJ (stage-b1 — low-risk perf + provider/clarify fixes) (#3671)
Some checks failed
Release & Docker / release (push) Has been cancelled
* perf(providers): O(1) codex cache merge membership checks (#3656)

Co-authored-by: Pamnard <pamnard@users.noreply.github.com>

* fix(models): add MiniMax-M3 to WebUI MiniMax fallback catalog test (#3627)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* fix(config): make DeepSeek reasoning-effort heuristic position-independent (#3650)

Co-authored-by: happy5318 <happy5318@users.noreply.github.com>

* fix(clarify): don't stash clarify draft while submission is in flight (#3651)

Co-authored-by: carryzuo00 <carryzuo00@gmail.com>

* perf(sessions): batch lineage report child fetch by parent id (#3659)

Co-authored-by: Pamnard <pamnard@users.noreply.github.com>

* perf(sessions): batch orphan sidecar state.db existence probes (#3657)

Co-authored-by: Pamnard <pamnard@users.noreply.github.com>

* test(streaming): pin DOM-INFLIGHT reattach invariant (#3572)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* docs(changelog): v0.51.268 — Release IJ (stage-b1)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Pamnard <pamnard@users.noreply.github.com>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: happy5318 <happy5318@users.noreply.github.com>
Co-authored-by: carryzuo00 <carryzuo00@gmail.com>
2026-06-05 10:44:02 -07:00
nesquena-hermes
f1211e1f0c Release v0.51.267 — Release II (stage-r17) (#3648)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.267 — Release II (stage-r17)

Security hardening cluster — 3 @zapabob PRs (forwarded-header trust + TTS prosody validation).

### Security
| Issue/PR | Author | Hardening |
|----------|--------|-----------|
| #3640 | @zapabob | `/api/tts` per-client throttle no longer trusts `X-Forwarded-For` by default (can't spoof to evade the rate limit); forwarded IP honored only behind a trusted-proxy opt-in. |
| #3642 | @zapabob | CSRF same-origin check no longer trusts `X-Forwarded-Host`/`X-Real-Host` by default (closes a forwarded-host CSRF bypass); opt-in keeps legit reverse-proxy deploys working; default uses the real `Host`. |
| #3643 | @zapabob | Browser-provided TTS prosody (rate/pitch/volume) validated against the `±N%` / `±NHz` grammar before `edge_tts.Communicate`. |

### Attribution
Each contributor branch was **rebased onto current master and pushed back to @zapabob's fork** (native authorship preserved), so the source PRs are current/mergeable. Shipped here as one release because all three add a `[Unreleased]` CHANGELOG entry at the same location (merging individually would force a rebase-cascade). Source PRs #3640/#3642/#3643 closed as merged-via-release with credit.

### Gate
- Full pytest suite: **7779 passed, 0 failed**
- ruff: CLEAN
- revert-guard: PASS (all 3 branches rebased; master is an ancestor)
- Codex (regression): **SAFE TO SHIP** — each hardening is **default-secure AND opt-in-compatible** (no legit reverse-proxy/tunnel deploy breaks on update): CSRF forwarded-host default-off + opt-in works + normal same-origin still passes; TTS prosody rejects out-of-grammar input, legit `+N%` passes; TTS throttle ignores spoofed XFF by default.

Co-authored-by: zapabob <1920071390@campus.ouj.ac.jp>
2026-06-05 01:19:59 -07:00
nesquena-hermes
4c545a33f3 test(#3635): add profile-switcher source-of-truth invariant (rebased from #3639) (#3644)
Test-only. Adds TestProfileSwitcherSourceOfTruthInvariant generalizing the #3635
fix so the chip + dropdown can't re-split their source of truth (both must read
S.activeProfile). Rebased onto current master — the original #3639 branch was
stacked on the pre-squash #3637 and would have reverted ~5 shipped releases
(IF/IG/IH) if merged as-is; this carries ONLY the +74-line test delta.

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: nesquena <nesquena@users.noreply.github.com>
2026-06-05 00:51:23 -07:00
nesquena-hermes
9b933e2c83 Release v0.51.266 — Release IH (stage-r16) (#3641)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.266 — Release IH (stage-r16)

One agent-authored APPROVED fix + two un-held streaming/SSE fixes.

### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3635 (#3637) | @nesquena-hermes (nesquena APPROVED) | Composer profile chip reads `S.activeProfile` again — a #3331 regression keyed it on the loaded session's profile, so opening a cross-profile session made the chip disagree with the dropdown checkmark and misrepresent where the next message routes. #3331's project/session-op scoping is unaffected. |
| #3587 (#3605) | @rodboev | Reasoning persists to the correct intermediate assistant message in multi-turn tool flows. The index only advanced in `on_interim_assistant` (suppressed for contentless tool-call messages) → post-tool reasoning was mis-attributed; it now also advances at the `on_tool` boundary, guarded against over-increment. **(un-held — finding resolved)** |
| #2660 (#3558) | @franksong2702 | Session-event SSE no longer wakes every tab across profiles and never drops a relevant refresh — profile attached when known, root/`default` aliases stay unscoped, and the `maxsize=1` queue falls back to unscoped refresh-all on a profile-mismatch coalesce. **(un-held — both findings resolved)** |

### Gate
- Full pytest suite: **7770 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** — chip matches dropdown/routing (no #3331 scoping regression), reasoning-index advance composes with the agent's tool/interim callback ordering, session-events coalesce safely with no dropped refresh and no profile data leak (`/api/sessions` still server-side filtered).

Co-authored-by: nesquena <nesquena@users.noreply.github.com>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-05 00:44:03 -07:00
nesquena-hermes
cab9b0672c Release v0.51.265 — Release IG (stage-r15) (#3638)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.265 — Release IG (stage-r15)

Un-held: owner-aware `cancelStream()` (#3344) — author addressed the active-session SSE-settle gap.

### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3344 | @franksong2702 | Stop/Cancel no longer leaves the UI falsely idle, drops the cancellation transcript, or leaks the old stream's tokens. `cancelStream()` is owner- + terminal-settle-aware: **active session** → leave the SSE open so the backend terminal `cancel` event clears INFLIGHT / renders "Task cancelled" / refreshes sidebar; **stale owner** (`activeStreamId!==streamId`) → tear down the SSE; local clear only on exact ownership (no null-window clobber of a turn started mid-cancel). |

### Un-hold note
Held twice earlier: (1) a null-window clobber (cleared busy on `!S.activeStreamId`, hitting a queued new turn), (2) the blocker — it called `closeLiveStream()` on the active session, killing the SSE before the terminal cancel event could settle. The author's rework fixes both (clear requires `activeStreamId===streamId`; SSE closed only for the stale-owner path). Codex confirmed the backend reliably emits the terminal cancel SSE frame the new design relies on (no busy-hang risk).

### Gate
- Full pytest suite: **7742 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** — verified active cancel keeps the SSE for settle, the terminal event reliably fires, cancelled:false clears only on exact ownership, stale-owner teardown + network-error paths consistent.

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-05 00:23:59 -07:00
nesquena-hermes
4cf40a317a Release v0.51.264 — Release IF (stage-r14) (#3636)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.264 — Release IF (stage-r14)

Un-held sibling pair (#3585 + #3586) — both addressed the findings from the earlier hold; re-reviewed fresh.

### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3585 | @rodboev | Cron sessions no longer flood the CLI sidebar window (restored the `("cron","webui")` exclusion in `_load_cli_sessions_uncached`). |
| #3586 | @rodboev | Messaging sessions keep their source label after a refresh **and open + send correctly** — `is_cli_session_row()` classifies them non-CLI, and the sidebar open/import path now uses `_isMessagingSession()` so a reclassified Discord/Telegram/Slack row is imported on open (no transient stub → no `/api/chat/start` 404). |

### Un-hold note
These were held earlier today because the `is_cli_session_row()` reclassification (#3586) created a CORE open-path regression — opening a reclassified messaging session 404'd on the next send. The author pushed a fix adding the `_isMessagingSession()` import gate at all open/lineage/refresh paths (+ regression test `test_issue3603_external_session_import_gate.py`), and Codex confirmed both that AND the secondary webui-recovery concern (cron-only exclusion now keeps `source='webui'` sidecar-less recovery rows) are resolved.

### Gate
- Full pytest suite: **7729 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** — open→import→send path verified (messaging rows go through `/api/session/import_cli` before `/api/chat/start`); `is_cli_session_row` classification correct; the pair composes in `_load_cli_sessions_uncached`.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-05 00:11:48 -07:00
nesquena-hermes
52261ebdaf Release v0.51.263 — Release IE (stage-r13) (#3625)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.263 — Release IE (stage-r13)

Batch 1 (fresh) — trimmed to the clean pair after the gate held two.

### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3621 | @luanxu-dev | `/codex-runtime` + `/codex_runtime` now run as a WebUI slash command (routed through the executor reusing the agent's `codex_runtime_switch`) instead of being sent to the model as a chat message. |

### Tests
| Issue/PR | Author | Change |
|----------|--------|--------|
| #3595 | @rodboev | Regression coverage for the already-shipped `activity_feed_expanded_default` setting. |

### Held back from this batch (Codex regression gate)
- **#3624** (passkey-challenge cap, security) — the cap **raises** `PasskeyRateLimitError` when full instead of **evicting oldest**, so an attacker (or 8 abandoned legit attempts per context) can lock out genuine registration/login until TTL — the protection becomes a lockout DoS. Held with the oldest-first-eviction fix.
- **#3618** (prefer server-side STT) — forcing MediaRecorder by default breaks browser `SpeechRecognition` dictation on installs with **no** server STT configured (`_transcribeBlob` only toasts on failure, never falls back). Held with the graceful-fallback fix.

### Gate
- Full pytest suite: **7714 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN
- Codex (regression): SHIP-ONLY-WITH-FIXES (#3624 DoS-lockout + #3618 STT-no-fallback) → both dropped/held → **SAFE TO SHIP** (verified no passkeys.py/boot.js remnants, codex-runtime dispatch reaches the allowlist).

Co-authored-by: luanxu-dev <luanxu-dev@users.noreply.github.com>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-04 23:56:44 -07:00
nesquena-hermes
163df17410 Release v0.51.262 — Release ID (stage-r12) (#3617)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.262 — Release ID (stage-r12)

Phase-3 light slice (no-screenshot items) — 3 PRs.

### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3432 (#3532) | @franksong2702 | Normalize the recall-prefill terminal `user` turn so WebUI doesn't send adjacent `user` roles to strict chat templates (Mistral/Gemma/Jinja). `_normalize_prefill_messages_before_user_turn()` in both `streaming.py` + `gateway_chat.py`; drops only the terminal user tail, preserves assistant/system/mid-list context. **Rebased onto master** (was CONFLICTING). |
| #2558 (#3516) | @rodboev | "Reveal in file manager" now translates container workspace paths (`/workspace`) back to the host mount path for Docker deployments (traversal-safe via `safe_resolve` + sibling-prefix guard). |

### Changed
| Issue/PR | Author | Change |
|----------|--------|--------|
| (#3539) | @Lyr-GW | Completed the Chinese (Simplified) `zh` localization (MCP controls, tool-list pagination) with all interpolations preserved, and the language dropdown now applies the locale **instantly** on change. |

### Review fix absorbed (Codex)
#3539 also added an `allowed=['en','zh']` filter to the Settings language dropdown, which dropped the 9 other shipped locales (it/ja/ru/es/de/pt/ko/fr/tr) — and since save falls back to `en` when the select has no matching option, an existing user of those locales would be **silently reset to English** on a Settings save. Removed the filter (dropdown enumerates all `LOCALES` again, matching master); partially-translated locales fall back per-key to English at render. + regression test `test_issue3539_language_dropdown_all_locales.py`.

### Gate
- Full pytest suite: **7701 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): SHIP-ONLY-WITH-FIXES (dropdown drops-locales) → fixed → **SAFE TO SHIP** (verified prefill drops only terminal user tail in both paths, Docker path-translation traversal-safe, zh interpolations preserved)

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: Lyr-GW <Lyr-GW@users.noreply.github.com>
2026-06-04 17:16:31 -07:00
nesquena-hermes
6703978c60 Release v0.51.261 — Release IC (stage-r11) (#3616)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.261 — Release IC (stage-r11)

Live Todos panel via an explicit `todo_state` SSE contract.

### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3373 follow-up (#3454) | @v2psv | The Todos side panel now tracks `todo` tool state **live during an active run** instead of staying stale until settle / rolling back on a mid-stream reload. A dedicated `todo_state` SSE event sends a full, redacted, idempotent snapshot on todo-tool completion (no more truncated `tool_complete.preview`); the same `api.todo_state` parser feeds live + cold-load; live snapshots persist into INFLIGHT so reload/reattach restores the panel; cold-load vs INFLIGHT reconciled by timestamp (incl. the `coldTs===0` compressed-session edge); legacy reverse-scan kept as fallback for old servers. |

### Gate
- Full pytest suite: **7692 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** — verified the new `todo_state` SSE handler composes with existing dispatch (no double-subscribe), INFLIGHT persistence is cleared on terminal/cancel (composes with discard_session + turn-journal), timestamp reconciliation can't let a stale local snapshot win, redaction holds, the legacy reverse-scan fallback still works with no double-render, and the `models.py` change is todo-scoped (no CLI-classification interaction).

Co-authored-by: v2psv <v2psv@users.noreply.github.com>
2026-06-04 16:46:20 -07:00
nesquena-hermes
ba987040c7 Release v0.51.260 — Release IB (stage-r8) (#3614)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.260 — Release IB (stage-r8)

Un-held safety fixes (author resolved my earlier hold findings; re-reviewed fresh) + a clean fix batch. 6 PRs.

### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3535 (#3538) | @rodboev | **Self-update recovers from a stash-pop conflict without data loss.** Was a BRICK bug (`git reset --merge` + `git stash drop` discarded local mods while reporting success). Now keeps the stash, returns `ok:false` + "preserved in `stash@{0}`", no restart on conflict. *(was held — fix verified)* |
| #1909 s3 (#3562) | @rodboev | **Auth `Secure` cookie no longer locks out plain-HTTP LAN/Tailscale users.** Secure now keys only on real TLS evidence (env / TLS socket / opt-in `TRUST_FORWARDED_PROTO`); non-loopback plain-HTTP is no longer force-Secure. SameSite back to `Lax`. *(was held — fix verified)* |
| #2785 (#3559) | @franksong2702 | Clearer cron/gateway diagnostics for single-container Docker (gateway configured, no daemon → jobs silently don't fire). |
| #3555 | @lambyangzhao | Long TTS responses chunked at sentence boundaries (works around the browser's ~32K silent-truncation). |
| #3340 (#3342) | @rly09 | Persistent-state toast when a turn has saved memory / created-updated a skill. |
| #3533 | @franksong2702 | `/reload-mcp` marked `cli_only` so the WebUI doesn't dispatch it as an LLM prompt. |

### Gate
- Full pytest suite: **7681 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** — confirmed the stash-conflict path never drops the stash / never restarts on conflict, auth Secure handles LAN-HTTP correctly with no header-forgery hole, `/reload-mcp` allowlisted, state-toast has a real backend writer + active-session guard, diagnostics leak no paths, TTS chunking preserves order.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Co-authored-by: lambyangzhao <lambyangzhao@users.noreply.github.com>
Co-authored-by: rly09 <rly09@users.noreply.github.com>
2026-06-04 15:21:41 -07:00
nesquena-hermes
efbb0a5bda Release v0.51.259 — Release IA (stage-r7) (#3612)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.259 — Release IA (stage-r7)

Two ship-ready @rodboev bug-fixes from today (the clean subset of the prioritized 6).

### Fixed
| Issue | Fix |
|-------|-----|
| #3582 | **Edge-TTS playback no longer has a ~31s delay / playback error** — `_handle_tts` streamed audio without `Content-Length` on an HTTP/1.0 server; audio is now buffered and sent with an exact `Content-Length`. |
| #3583 | **CLI-bridge message reconstruction strips orphaned `tool_calls`** (assistant `tool_calls` with no matching `tool` response, left by an aborted bridge) so the next request no longer 400s on strict providers. |

### Held back from the 6-PR batch (Codex regression gate caught a real defect in each)
- **#3586/#3603** (`is_cli_session_row` reclassification) — CORE: messaging rows become non-CLI, but the sidebar open path only imports when `is_cli_session`, so opening a Discord/Telegram session shows a transient stub and the next send 404s on `/api/chat/start`. Needs a client import-gate fix + live verify. **Held.**
- **#3585/#3604** (cron-overflow) — removing `exclude_sources=None` also re-excludes `source='webui'` rows, dropping sidecarless WebUI session recovery from `/api/sessions`. Needs a separate webui recovery pass. **Held.**
- **#3587** (intermediate reasoning) — `on_interim_assistant` is suppressed upstream for contentless tool-call assistant messages (`run_agent.py:3834`), so advancing the reasoning index there never fires at tool-call boundaries → mis-attribution. **Held.**
- **#3538** (self-update stash-pop) — BRICK data-loss (`git reset --merge` + `git stash drop` discards user mods), still unaddressed. **Held.**

### Gate
- Full pytest suite: **7645 passed, 0 failed**
- ruff: CLEAN
- Codex (regression): 3 rounds — 4 PRs dropped/held for real regressions → **SAFE TO SHIP** on the clean 2

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-04 15:04:45 -07:00
nesquena-hermes
1bad85feb4 Release v0.51.258 — Release HZ (stage-r6) (#3611)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.258 — Release HZ (stage-r6)

Fresh-arrival low-risk pair (both @rodboev, landed in the last sweep window).

### Fixed
| Issue | Fix |
|-------|-----|
| #3597 | The "update available" banner now shows from **any panel** (Settings → System "Check now", etc.), not just the Chat view — it was positioned inside the chat surface so it only rendered there. |
| #3592 | Under Simplified Tool Calling, an assistant turn with **thinking but no tool calls** now renders that thinking inline on settlement instead of burying it in an empty collapsed activity group. |

### Review fix absorbed (Codex)
#3592's inline-render `continue` skipped the activity-group creation that carried the turn's `data-turn-duration`, but the footer still suppressed the "Done in …" duration for any `assistantThinking` turn → thinking-only turns silently lost their duration display. Fixed: footer duration is now suppressed **only** for turns that actually build an activity group (`toolCallAssistantIdxs.has(mi)`), so thinking-only inline turns keep "Done in …". + regression test.

### Gate
- Full pytest suite: **7631 passed, 0 failed**
- ESLint: CLEAN · browser-smoke: CLEAN
- Codex (regression): SHIP-ONLY-WITH-FIXES (duration-drop) → fixed → **SAFE TO SHIP**

### Sweep note
#3603 + #3604 (sidebar CLI-session classification, same author/area) were **not** included — they assert contradictory models for a sidecar-less `source='cli'` recovery row; flagged on both PRs for the author to reconcile.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-04 14:28:00 -07:00
nesquena-hermes
6903891139 Release v0.51.257 — Release HY (stage-r5) (#3598)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.257 — Release HY (stage-r5)

Two rebased ★★★ fixes.

### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #3546 | @rodboev | **"Refresh Models" on a provider card no longer returns "Error: Not found".** Sent `POST /api/models/refresh` but no route matched (404). Added the route, wired to the existing `invalidate_provider_models_cache(provider_id)`. |
| #3548 | @franksong2702 | **Credential self-heal no longer writes to a dead `SessionDB` handle.** A credential-refresh evicted/closed the cached agent, but the retry rebuilt a new agent from kwargs still holding the old closed `SessionDB` → persistence silently targeted a dead handle. Per-request `SessionDB` construction centralized + refreshed on the retry. |

### Dropped from this batch
- **#2660** (session-event SSE profile scoping) — the Codex regression gate found **two SILENT dropped-refresh bugs** the scoping introduced: (1) the `maxsize=1` subscriber-queue coalescing overwrites a pending profile-A event with a profile-B event → A-tabs filter B out and never refresh for the A change; (2) renamed-root/`default` alias mismatch (backend `_profiles_match` treats them equal, the client filter uses a literal `!==`). A dropped refresh (stale sidebar) is worse than the extra refreshes the PR removes. Held with `changes-requested` + repros + the fail-safe fix (coalesce to unscoped on a profile mismatch; normalize root aliases).

### Gate
- Full pytest suite: **7622 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): SHIP-ONLY-WITH-FIXES (#2660 dropped-refresh bugs) → #2660 dropped + held → **SAFE TO SHIP**

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-04 13:04:01 -07:00
nesquena-hermes
3ca1188f64 Release v0.51.256 — Release HX (stage-r4) (#3596)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.256 — Release HX (stage-r4)

Performance — bound WebUI memory growth & idle CPU on large installs.

### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #3506 | @nesquena-hermes (reported w/ profiling by @djenttleman) | On a large install (~615 sessions / 40k messages / 454 MB state.db) the WebUI process climbed ~100 MB → ~1.5 GB RSS over days and held high idle CPU. Three root causes fixed: (1) `session_lifecycle._sessions` grew unbounded → new `discard_session()` drops the entry at agent-eviction boundaries, only when no in-flight commit / no uncommitted memory work (retry invariant preserved); (2) cache caps now operator-tunable (`HERMES_WEBUI_AGENT_CACHE_MAX` default 50→25, `HERMES_WEBUI_SESSIONS_MAX`); (3) GatewayWatcher computes a cheap fingerprint before the expensive per-session `MAX(messages.timestamp)` projection and only re-projects on change. |

### Rebase + review notes
- Rebased onto current master; the code diff was verified **byte-identical to the nesquena-APPROVED head** at rebase time (only CHANGELOG re-resolved).
- The Codex regression gate then surfaced **two correctness gaps** the approval didn't catch, both fixed here with regression tests:
  1. **Watcher fingerprint missed same-count transcript rewrites.** `/retry`,`/undo`,`/compress` (`SessionDB.replace_messages`) rewrite messages with new timestamps but can leave `message_count` unchanged → stale sidebar `last_activity`. Fixed with a **per-session** grouped message aggregate (`id, count, user_count, MAX(timestamp)`) over the same non-excluded sessions (a global MAX would miss a rewrite of an older, non-newest session); cron/webui stay excluded so idle churn still doesn't re-project.
  2. **LRU agent-cache eviction could close a live worker's agent** (`popitem(last=False)`, liveness-blind — pre-existing, but the lower 50→25 cap made it more likely). Eviction now snapshots `ACTIVE_RUNS` session_ids (before the cache lock — no nested lock) and skips live sessions, deferring (temporarily exceeding cap) rather than closing a live agent.

### Gate
- Full pytest suite: **7612 passed, 0 failed** (one boot-cascade flake re-run; clean on re-run)
- ruff: CLEAN · Codex (regression): 4 rounds → both gaps + a stale test fixed → **SAFE TO SHIP**

Co-authored-by: nesquena <nesquena@users.noreply.github.com>
2026-06-04 12:28:06 -07:00
nesquena-hermes
8066d384ca Release v0.51.255 — Release HW (stage-r3) (#3594)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.255 — Release HW (stage-r3)

Backend hardening — single PR.

### Fixed
| PR | Author | Fix |
|----|--------|-----|
| #3561 | @rodboev | Turn journal (crash-recovery backbone) writes **pid-scoped shards** (`{sid}~{pid}.jsonl`) instead of one shared `{sid}.jsonl`, so concurrent processes (e.g. a self-restart overlap) can't interleave-corrupt large JSON lines. `read_turn_journal` merges all shards + the legacy file and sorts by `created_at` — recovery unchanged, backward-compatible. |

### Gate
- Full pytest suite: **7593 passed, 0 failed**
- ruff: CLEAN · 18 turn-journal tests pass
- Codex (regression): **SAFE TO SHIP** — verified legacy+shard merge (no data loss on upgrade), `~` separator can't collide with a session id, the cross-shard `created_at` sort doesn't break recovery (it derives state by timestamp; stream lookup keys by unique `stream_id`), and no reader/writer bypasses `_journal_path`.
- *Non-blocking note:* old `{sid}~{oldpid}.jsonl` shards aren't pruned, so the journal dir can grow across restarts — storage hygiene, not a core-flow regression. Worth a follow-up cleanup (e.g. drop shards with no live pid on session delete).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-04 11:24:25 -07:00
nesquena-hermes
11c0d1667f Release v0.51.254 — Release HV (stage-r2) (#3593)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.254 — Release HV (stage-r2)

Phase-2 medium wave 1 — 4 PRs (UI/mobile/cancel fixes + an un-held model dedup).

### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3528 | @franksong2702 | Render partial tool calls after cancel — interrupted turns keep their `_partial_tool_calls` rows in the transcript + fallback tool-cards. (Codex confirmed it stays render-only, not forwarded to the provider API.) |
| #3550 | @lurebat | Android offline recovery soft-reattaches the live stream instead of hard-reloading the page on a transient background/disconnect. |
| #3479 | @mvanhorn | iOS Safari no longer snaps the conversation to the top when a handoff/compression card is inserted mid-stream or on `refreshSession()`. |
| #3478 | @JayC-L | **Un-held:** named custom providers (`@custom:name:model`) dedup against bare model IDs without regressing Ollama multi-colon tags (`qwen2.5:7b-instruct-q4`). Only `@custom:` IDs strip the two-segment prefix. |

### Hold-sweep note
#3478/#3489 was held yesterday for an Ollama multi-colon-tag regression risk (a blanket `lastIndexOf` would lose the model). The author pushed a scoped fix (only `@custom:` IDs use `lastIndexOf`); I verified `_normId` in node against the regression cases — Ollama bare tags are preserved. Un-held + shipped.

### Gate
- Full pytest suite: **7588 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** — #3552 partial-tool-calls verified render-only (no `_API_SAFE_MSG_KEYS` leak / no 400-on-strict-provider, the v0.50.251 #1375 trap); #3551 no EventSource double-subscribe; #3541 no regression vs the #3525 scroll-follow shipped in v0.51.253; #3489 no over-dedup.

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Co-authored-by: lurebat <lurebat@users.noreply.github.com>
Co-authored-by: mvanhorn <mvanhorn@users.noreply.github.com>
Co-authored-by: JayC-L <JayC-L@users.noreply.github.com>
2026-06-04 11:13:45 -07:00
nesquena-hermes
ba70926e51 Release v0.51.253 — Release HU (stage-r1) (#3591)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.253 — Release HU (stage-r1)

Phase-1 low-risk batch — 7 PRs (no intervention beyond apply + one inline MUST-FIX).

### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3525 | @TomBanksAU | Streaming DOM-replace "follow" window tightened 1200px→120px — a reader who scrolled up mid-stream no longer gets snapped to the bottom on completion. |
| #3556 | @ai-ag2026 | Topbar count distinguishes a partially-loaded transcript ("loaded of total" via server `message_count`); fully-loaded keeps the tool-row-filtered count. |
| #3502 follow-up | @rodboev | Sidebar messaging source badges (Telegram/Discord/…) render as chips, not just CLI ones. |
| — | @Karlineal | `.pre-header+pre` margin override scoped under `.msg-body` (removes a 10px gap above code blocks). |

### Tests
- `test_ctl_script.py` kills orphan fake-python trees on Windows; conftest `_discover_python` checks the Windows venv layout (`Scripts/python.exe`). (#3537, #3577, @rodboev)

### Docs
- Explicit WebUI–Agent compatibility policy + Docker pinning guidance. (#3232, @franksong2702)

### Dropped from this batch
- **#3538** (self-update stash-pop recovery) — the Codex regression gate found a **BRICK-class data-loss**: the recovery path runs `git reset --merge` then `git stash drop`, permanently discarding the user's local modifications while returning `ok:true` + scheduling a restart. Held with `changes-requested` + a repro and the fix (keep the stash, return `ok:false`, no restart). Concept is good; the destructive `stash drop` must go.

### Gate
- Full pytest suite: **7575 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): SHIP-ONLY-WITH-FIXES (BRICK data-loss #3538 + tool-row count regression #3556) → #3538 dropped, #3556 fixed inline → **SAFE TO SHIP**

Co-authored-by: TomBanksAU <TomBanksAU@users.noreply.github.com>
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: Karlineal <Karlineal@users.noreply.github.com>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-04 10:50:34 -07:00
nesquena-hermes
f64b06ab4f Release v0.51.252 — Release HT (stage-q24) (#3530)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.252 — Release HT (stage-q24)

Two trivially-safe @rodboev changes (independent).

### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #2481 | @rodboev | The floating "selected-text reply" button now has `user-select:none`, so its own label can't get caught in a text selection (no bleed-through). CSS one-liner. |

### Docs
- README **Compatibility** section: upgrade WebUI + hermes-agent together until the stable agent API (#2491) lands. (@rodboev)

### Dropped from this batch
- **#2977 `/use` skill command** was staged here but **dropped** — the Codex regression gate found an async stale-directive race (`cmdUse()` awaits `/api/skills` but `send()` doesn't await the handler → a fast next send can miss it, or a stale directive leaks to a later message) plus an over-eager `finally` clear that silently discards the directive on a local slash-command early-return. Held with `changes-requested` + a detailed rework note (tracked pending promise + clear-on-consume). Concept approved; needs lifecycle hardening.

### Gate
- Full pytest suite: **7570 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** — `user-select:none` scoped to the button only, README docs-only, no `/use` code remains

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 21:41:05 -07:00
nesquena-hermes
15e654d468 Release v0.51.251 — Release HS (stage-q23) (#3527)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.251 — Release HS (stage-q23)

UX-verified live (path dropdown opens on `~/`).

### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #3433 | @puneetdixit200 | **Composer `~/` path autocomplete** (TUI parity). Typing a `~/` token in the composer opens a home-directory path-suggestion dropdown. Reuses the existing slash-command dropdown (positioning + keyboard nav) and the trusted `/api/workspaces/suggest` endpoint; replaces only the matched token on selection (surrounding text preserved). Slash-command autocomplete still takes precedence for `/`-prefixed input. |

### Gate
- Full pytest suite: **7568 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN · live-verified the dropdown opens on `~/`
- Codex (regression): **SAFE TO SHIP** — slash-precedence preserved, `~/../../etc` → no suggestions (path-escape blocked via root-confined endpoint), bounds-clamped token replacement, esc-escaped, URLSearchParams-encoded

Co-authored-by: puneetdixit200 <puneetdixit200@users.noreply.github.com>
2026-06-03 21:12:23 -07:00
nesquena-hermes
703aba3f3e Release v0.51.250 — Release HR (stage-q22) (#3526)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.250 — Release HR (stage-q22)

UX-approved (dark + light-fallback screenshots).

### Added
| PR | Author | Feature |
|----|--------|---------|
| #3328 | @heagandev | **Zeus appearance skin** — OLED-near-black dark surfaces that keep the default **gold accent** (a high-contrast "gold on black" look no existing skin offered). Selectable from Settings → Appearance or `/theme skin zeus`. Dark-focused; falls back to the default light palette in light mode. |

### Notes
- The PR was 2 days / ~19 releases stale and CONFLICTING; re-applied surgically onto current master (CSS palette + `zeus` registered at all 5 sites: config allowlist, boot.js swatch, index.html boot-map, i18n `cmd_theme` ×12 locales, picker) + THEMES.md doc row. The PR's own `test_zeus_skin.py` (6 tests) passes against the re-applied version.
- Fully scoped + additive: Codex verified every new CSS rule is under `:root.dark[data-skin="zeus"]` — no bleed into the default appearance or other skins.

### Gate
- Full pytest suite: **7563 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN · vision-verified dark (OLED+gold) + light (clean fallback)
- Codex (regression): **SAFE TO SHIP**

Co-authored-by: heagandev <heagandev@users.noreply.github.com>
2026-06-03 20:54:02 -07:00
Frank Song
ba27fee721 Add long-running live-to-final replies RFC (#3464)
Merging the RFC as the agreed product contract for long-running-session assistant replies. Thank you @franksong2702! 🙏

It's docs-only (no code), well-structured, and gives the project a shared vocabulary for the follow-up implementation slices — in particular the honest terminal-state set (completed / cancelled / interrupted / compression-exhausted / tool-limit-reached / no-response / error, specific-wins-over-generic) and the live → settled → recovery/replay lifecycle. Nathan blessed merging it as the north-star contract.
2026-06-03 20:52:09 -07:00
nesquena-hermes
0501564098 Release v0.51.249 — Release HQ (stage-q21) (#3523)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.249 — Release HQ (stage-q21)

Small opt-in feature. UX-approved (screenshot of the toggle in Settings → Preferences).

### Added
| Issue | Author | Feature |
|-------|--------|---------|
| #2974 | @rodboev | **"Auto-expand terminal on output"** preference (Settings → Preferences, **off by default**). When enabled, the collapsed embedded terminal panel expands automatically the first time a running command emits output. Fires once per stream (guarded on open && collapsed — not per chunk), and uses `expandComposerTerminal({focus:false})` so it doesn't steal focus from the composer. Backend-persisted boolean mirroring the `simplified_tool_calling` pattern; default-off = no behavior change on upgrade. |

### Gate
- Full pytest suite: **7557 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN · screenshot vision-verified (toggle renders cleanly under "Compact tool activity", unchecked by default)
- Codex (regression): **SAFE TO SHIP** (clean first pass) — no-arg `expandComposerTerminal` callers unchanged, default-off incl. settings-load-failure path, all 5 plumb sites mirror the existing pattern

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 20:15:53 -07:00
nesquena-hermes
d828be6daa Release v0.51.248 — Release HP (stage-q20) (#3522)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.248 — Release HP (stage-q20)

Bug-fix.

### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #2782 | @rodboev | **A WebUI session whose sidecar was deleted server-side (e.g. `docker compose --force-recreate`) but whose messages remain in `state.db` no longer bricks the chat.** It used to look alive (`GET` 200 from a CLI stub) while every action 404'd (`POST /api/session/draft`, `/api/chat/start`). The GET handler now consults `_index.json`: a deleted **WebUI-origin** session (webui/fork/blank-non-CLI source) returns 404 so the client self-heals (clears saved id, strips the stale `/session/<id>` URL, falls through to the welcome screen). Genuine CLI/imported sessions keep their 200 read-only stub. Client self-heal now also covers mid-session sidecar deletion of the current session. |

### Review fix absorbed (Codex CORE catch)
The first cut collapsed `source_tag or raw_source or session_source or ""`, defaulting a **blank-source** row to WebUI — which would wrongly 404 a **legacy CLI/imported** session that carries `is_cli_session:true` with blank source fields. Now classified **per-field**: any `webui`/`fork` → 404; any explicit non-WebUI source → keep the 200 CLI stub; all-blank → 404 only when NOT `is_cli_session` and NOT `read_only`. + 2 regression tests.

### Gate
- Full pytest suite: **7555 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN · 10 stale-session-restore tests (incl. 2 Codex-catch regressions)
- Codex (regression): CORE legacy-CLI false-404 → per-field fix → **SAFE TO SHIP**

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 19:41:22 -07:00
nesquena-hermes
81e748b455 Release v0.51.247 — Release HO (stage-q19) (#3521)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.247 — Release HO (stage-q19)

Backend correctness fix.

### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #3505 | @franksong2702 | **Reasoning effort is coerced to a level the active model/provider actually supports** before each request, instead of being sent verbatim and rejected. `openai-codex` `gpt-5` no longer gets `max` (→ `xhigh`); `o1`/`o3`/`o4` clamp to `low`/`medium`/`high`. Coercion only steps *down* (never escalates); `none`/unset preserved. The capability filter is applied across heuristic / models.dev / Copilot / LM Studio paths. |

This is the narrow, correct fix for the detection gap that #3431 tried to address by removing the chip-visibility gate (which we shelved). The chip-visibility gate is **untouched** (Codex confirmed) — `get_reasoning_status`/`_applyReasoningChip` still hide the chip for unconfirmed models.

### Review fix absorbed (Codex + self-flagged)
The first cut **dropped** a configured effort for *unrecognized* models, because capability detection returns `[]` for both "known-unsupported" and "simply-unknown" (custom providers, aggregator-rewritten ids, new releases) — that's a behavior change vs master (which sent it verbatim) and would silently disable reasoning. Fixed: an **empty** capability set now **preserves** the configured effort (provider stays the final authority; worst case = the same rejected request master already produces, i.e. no regression). Known-bad clamps return *non-empty* filtered sets, so they still degrade correctly. Nathan chose this "preserve-for-unknown" behavior. + regression test.

### Gate
- Full pytest suite: **7548 passed, 0 failed**
- ruff: CLEAN · 48 reasoning tests pass (incl. preserve-for-unknown + codex-clamp + never-escalate)
- Codex (regression): SHIP-ONLY-WITH-FIXES (unknown-model drop) → fixed → **SAFE TO SHIP**
- Verified empirically: gpt-5/codex max→xhigh, o3 max/xhigh→high, unknown high→high (preserved), none/unset preserved

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-03 19:21:26 -07:00
nesquena-hermes
772a5c17ed Release v0.51.246 — Release HN (stage-q18) (#3513)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.246 — Release HN (stage-q18)

Backend bug-fix.

### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #3225 | @rodboev | **WebUI session rename now syncs the new title to the agent's `state.db`**, so the TUI/CLI stop showing the stale name. `/api/session/rename` now calls `_sync_session_title_to_insights(s)` after `s.save()` and before `publish_session_list_changed` — exactly mirroring the sibling `/api/session/title/regenerate` handler. Gated on the `sync_to_insights` setting and exception-contained (a sync failure can't break the rename). |

### Gate
- Full pytest suite: **7544 passed, 0 failed**
- ruff: CLEAN · 1 new regression test (call present + sync-before-publish ordering) + 82 rename/title-sync tests pass
- Codex (regression): **SAFE TO SHIP** — mirrors the regenerate handler (sync after lock release, gated, exception-contained), no deadlock, no stale data

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 18:56:24 -07:00
nesquena-hermes
c243126ccb Release v0.51.245 — Release HM (stage-q17) (#3511)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.245 — Release HM (stage-q17)

Small UX bug-fix.

### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #3338 | @rodboev (#3502) | **Messaging sessions (Telegram, Discord, WeChat, …) now show their platform source badge in the chat-pane topbar**, not just the sidebar. The topbar badge was gated on `is_cli_session` (intentionally `false` for messaging sources), so it vanished once the session opened. Gate removed; a recovered native session stamped `source_label:"WebUI"` stays un-badged. Reuses the existing `.topbar-source-badge` styling — no new chrome. |

Picked #3502 over the duplicate **#3499** (same issue/files) — #3502 adds the `WebUI` self-source suppression and a stronger regression test. #3499 closed as superseded with credit.

### Gate
- Full pytest suite: **7543 passed, 0 failed** (first run hit the known boot-cascade flake — 376 connection-refused across 29 files; clean on re-run, as expected for a JS-only change)
- ESLint: CLEAN · browser-smoke: CLEAN · 8 source-contract tests pin the fix
- Codex (regression): **SAFE TO SHIP** — both renderers (panels.js + ui.js) fixed consistently, WebUI-suppression correct, read-only suffix intact, `textContent` XSS-safe

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 18:46:42 -07:00
nesquena-hermes
c8f8ca3428 Release v0.51.244 — Release HL (stage-q16) (#3509)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.244 — Release HL (stage-q16)

UX-approved direction via Telegram (workspace drag-drop polish you requested). All 4 drag-drop flows verified live in-browser.

### Added
| PR | Author | Feature |
|----|--------|---------|
| #3402 / #3424 | @pamnard | **Drop OS files/folders onto a specific workspace folder row or breadcrumb** to upload into that directory (not just the current dir). OS folder drops are traversed (`webkitGetAsEntry`/`readEntries`) preserving nested structure. Uploads via the existing `/api/workspace/upload` (no new backend). |

### Fixed
- **Composer drop-zone jank**: dragging a workspace file (or OS file) over the composer footer rendered a translucent overlay that let the textarea/chips/icons bleed through and collide with the hint text. Now a clean, fully-opaque box with a single centered **context-aware** label — *"Drop to insert workspace reference"* (workspace file → `@path` insert) vs *"Drop files to attach"* (OS file → message attach).
- **Drag-drop handler coexistence (CORE, caught in review)**: #3424's OS-upload binding assigned `el.ondrop` on folder rows, which **overwrote** the drag-to-move handler from #3422 (also `el.ondrop`) — silently breaking move-to-folder (the ws-path drop fell through to the composer as an `@path` insert). Fixed by binding the OS-upload handlers via `addEventListener` so they compose; each handler gates on its own drag type.

### Drag-drop matrix — all verified LIVE in-browser (real drag→drop, asserted on disk)
| Flow | Result |
|------|--------|
| OS image → composer footer | ✓ attaches |
| workspace file → composer footer | ✓ inserts `@path` |
| workspace file → workspace folder | ✓ moves on disk (report.md → docs/) |
| OS file → workspace folder | ✓ uploads into target folder |

### Scope note
#3424's PR branch carried the OLD pre-hardening `_handle_file_move`. Applied **frontend-only** — master's hardened move backend (v0.51.243, TOCTOU/symlink fixes) is untouched (Codex confirmed no `api/routes.py` diff).

### Gate
- Full pytest suite: **7542 passed, 9 skipped, 3 xpassed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): CORE handler-clobber → fixed → **SAFE TO SHIP**

Co-authored-by: pamnard <pamnard@users.noreply.github.com>
2026-06-03 18:19:09 -07:00
nesquena-hermes
a7f60689a8 Release v0.51.243 — Release HK (stage-q15) (#3507)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.243 — Release HK (stage-q15)

UX-approved direction via Telegram (workspace feature you named). Backend security hardened through 3 Codex rounds.

### Added
| PR | Author | Feature |
|----|--------|---------|
| #3402 / #3422 | @pamnard | **Drag a file or folder in the workspace tree onto another folder row (or breadcrumb segment) to move it** within the workspace. New `POST /api/file/move`. Drop handlers use `stopPropagation` so the composer `@path` drag (#1097) and OS-file upload-drop (#3411) are unchanged. |

### Verified
- Live end-to-end: `notes.txt` → `docs/` confirmed on disk; legit moves return the correct `new_path`.
- 13 tests (incl. folder-into-self/descendant guard, existing-target collision, and 3 security regressions).

### Security hardening absorbed (3 Codex rounds — all fixed + regression-tested)
1. **TOCTOU symlink race on destination**: a path-based `source.rename(dest)` could be raced by swapping `dest_dir` to an external symlink between validation and rename. Now opens both parent dirs via the workspace-anchored `open_anchored_fd` (openat + `O_NOFOLLOW`, same helper as the upload hardening) and uses `os.rename(leaf, leaf, src_dir_fd=…, dst_dir_fd=…)` with an fd-based collision check; path-based fallback only where `dir_fd` is unsupported.
2. **Symlinked workspace root** returned a confusing 400 after a successful move — returned `new_path` now computed against `ws_root.resolve()`.
3. **Symlinked source entry**: `safe_resolve` follows the final symlink, so moving `link.txt` would move its *target* and dangle the link — now rejected via no-follow `lstat` on the lexical path.

### Gate
- Full pytest suite: **7530 passed, 9 skipped, 3 xpassed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): SHIP-ONLY-WITH-FIXES ×3 → all applied + tested → **SAFE TO SHIP**

Co-authored-by: pamnard <pamnard@users.noreply.github.com>
2026-06-03 17:41:11 -07:00
nesquena-hermes
c0d86db5a8 Release v0.51.242 — Release HJ (stage-q14) (#3500)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.242 — Release HJ (stage-q14)

UX-approved via Telegram (Nathan — dark/light/mobile screenshots).

### Added
| PR | Author | Feature |
|----|--------|---------|
| #3440 | @t3chn0pr13st | **Graphite appearance skin** — a quiet, neutral-gray "workbench" alternative to the default gold/cream. Selectable from Settings → Appearance and `/theme skin graphite`. Both light + dark palettes on the existing CSS-variable token system; tightened typography, shadows, active-sidebar spacing, code-block framing. |

### Why it's safe
- **Fully scoped + additive**: every new CSS rule (and every `!important`) is under `[data-skin="graphite"]` — Codex verified zero bleed into the default appearance or other skins. `api/config.py` keeps the default skin as `default` and only *adds* `graphite` to the allowed set. No i18n keys dropped (only the `/theme` help string gains `graphite`).
- Opt-in; a user has to select it. Default experience unchanged.

### Test-robustness fix (absorbed)
The new graphite scoped selectors (e.g. `:root[data-skin="graphite"] .session-item.active .session-time{…}`) appear in `style.css` *before* the canonical unscoped rules, which broke 3 naive first-occurrence CSS-contract tests (`test_issue677` scroll-btn-overlay, `test_issue856_pinned_indicator_layout`, `test_workspace_panel_session_list`). Fixed those 3 to anchor on the canonical **unscoped** rule (start-of-line regex) instead of the first `.selector` match — robust against this and future skins. Verified they still pass on clean master CSS (invariant preserved, not weakened).

### Gate
- Full pytest suite: **7517 passed, 9 skipped, 3 xpassed, 0 failed**
- ESLint runtime gate: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** (verified all CSS scoped, default skin unchanged, no i18n key loss)
- Vision-verified dark + light + mobile; UX-approved by Nathan via Telegram

Co-authored-by: t3chn0pr13st <t3chn0pr13st@users.noreply.github.com>
2026-06-03 16:46:53 -07:00
nesquena-hermes
e7930ad9a5 Release v0.51.241 — Release HI (stage-q13) (#3498)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.241 — Release HI (stage-q13)

UX-flow bug-fix — approved via Telegram.

### Fixed
| PR | Author | Fix |
|----|--------|-----|
| #3471 (#3333) | @starGazerK | **New Chat keeps your unsent draft after peeking at history.** Start a New Chat draft → open a previous conversation → click New Chat: the draft is no longer lost. Empty New-Chat sessions are hidden from the sidebar, so there was no way back to the session holding the draft — New Chat just created another fresh empty session. The entrypoint now remembers the candidate empty draft session (one `localStorage` pointer) and, before creating a fresh session, re-validates it via `/api/session`, routing back only if it is still a safe empty draft (zero messages, no active stream, no pending message, not worktree-backed, matching profile, non-empty server-side `composer_draft`). |

### Why it's safe for existing installs
- When there is no remembered draft, it's a **pure no-op fall-through** to the existing `newSession()` path — no behavior change.
- Preserves the "zero-message sessions stay hidden from the sidebar" contract.
- Conservative multi-guard validation; the pointer is cleared on draft-clear (after send) so an emptied draft never traps you on New Chat.

### Verified live (end-to-end on a test server)
- **Positive**: typed a draft → visited a 2-message history session → clicked New Chat → landed back on the draft session with the text restored.
- **Negative**: emptied the draft → New Chat created a fresh session (no accidental trap).

### Absorbed on review (Codex CORE MUST-FIX)
The PR added `await _saveComposerDraftNow(...)` before the session-switch, which opened a rapid-switch race: clicking session B then quickly C could let B's stale continuation blank C's freshly-loaded state. Added `if (_loadingSessionId !== sid) return;` immediately after the awaited save and before the destructive state-clear (mirrors the existing #1060 stale-guard) + a regression test pinning the guard's position.

### Gate
- Full pytest suite: **7510 passed, 9 skipped, 3 xpassed, 0 failed**
- ESLint runtime gate: CLEAN · browser-smoke: CLEAN
- Codex (regression): SHIP-ONLY-WITH-FIXES (rapid-switch race) → fixed → re-reviewed **SAFE TO SHIP**
- `tests/test_issue_new_chat_draft_restore.py` (7 assertions incl. the race-guard; live-verified behavior)

UX-flow change, no visual/layout delta — approved via Telegram.

Co-authored-by: starGazerK <starGazerK@users.noreply.github.com>
2026-06-03 15:24:31 -07:00
nesquena-hermes
1d7a139c26 Release v0.51.240 — Release HH (stage-q12) (#3496)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.240 — Release HH (stage-q12)

Mobile UX bug-fix — UX-approved via Telegram.

### Fixed
| PR | Author | Fix |
|----|--------|-----|
| #3470 | @cnogrin | On mobile/touch, you can now **swipe up to stop streaming auto-scroll**. Previously the view snapped to the bottom on every token with no way to read earlier content while a response streamed — `_recordNonMessageScrollIntent()` only detected upward intent on the wheel path (`e.deltaY`), and touch events have no `deltaY`. Now tracks `_touchStartY` on `touchstart` and treats a `touchmove` that drags the finger down >8px (= scroll up into history, `scrollTop` decreases) as upward intent, setting the same `_messageUserUnpinned` flag the wheel path + scroll listener use. |

### Why this is safe for existing installs
- **Only ADDS a touch-unpin path** — wheel + desktop behavior completely untouched, no existing branch modified.
- New `touchstart`/`touchend`/`touchcancel` listeners are **passive + capture-only** (they only write `_touchStartY`), so they can't interfere with existing touch handling.
- Net effect for users: mobile users *gain* the ability to scroll up during streaming (which was simply broken before). No one's working flow changes.

### Absorbed on review (Codex CORE MUST-FIX)
The contributor's gesture sign was inverted (`dy<-8` = finger up = *follow* the stream), which would have unpinned in the wrong direction. Corrected to `dy>8` to match the existing scroll listener's `movedUp` semantics; fixed the comment; strengthened the regression test to pin the gesture direction.

### Gate
- Full pytest suite: **7503 passed, 9 skipped, 3 xpassed, 0 failed**
- ESLint runtime gate: CLEAN · browser-smoke: CLEAN
- Codex (regression): SHIP-ONLY-WITH-FIXES (inverted sign) → fixed → re-reviewed **SAFE TO SHIP**
- Regression test `tests/test_issue3470_touch_unpin_streaming_scroll.py` (verified to fail against master)

UX-approved via Telegram (mobile touch-gesture behavior, no visual/layout delta to screenshot).

Co-authored-by: cnogrin <cnogrin@users.noreply.github.com>
2026-06-03 14:33:50 -07:00
nesquena-hermes
7e8d0ddbea Release v0.51.239 — Release HG (stage-q10) (#3494)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.239 — Release HG (stage-q10)

Phase 3 MEDIUM-ring **salvage** from #3407. The source PR bundled a universal reliability fix with debug scaffolding + Android-specific work; this release ships only the clean, universal nugget.

### Fixed
| Salvaged from | Author | Fix |
|---|---|---|
| #3407 | @PatrickNoFilter | `server.py` ignores `SIGPIPE` (`SIG_IGN`) at import time so a client dropping the connection mid-response (tab close mid-stream, network drop, mobile backgrounding, dropped long-poll, `/api/updates/check` timeout) can't silently `Term` the whole process. The broken write now surfaces as a catchable `BrokenPipeError`; the server keeps serving. |

### Why salvage, not merge whole
#3407 (585L, 11 commits) bundles three groups: (1) the SIGPIPE fix + a 271-line `diag_shim.py` debug module, (2) an Android-cgroup-specific `os.fork`/`setsid` restart rewrite in `updates.py`, (3) personal deploy scripts (`start-webui.sh`/`watchdog-loop.sh`, which the author notes are "user-side infra, not in the server tree"). Only the SIGPIPE fix is universal, low-risk, and ship-ready — the rest is investigation tooling for a now-solved mystery or platform-specific. The source PR is held with a detailed split explanation.

### Added safety over the source PR
The original used a bare `signal.signal(signal.SIGPIPE, ...)` which would `AttributeError` on Windows (no `SIGPIPE`). The salvaged version is `getattr`-guarded so it's a no-op on Windows, preserving the native-Windows support shipped in #1952 (HD).

### Gate results
- **Full pytest suite**: 7498 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff**: CLEAN · **browser-smoke**: CLEAN
- **Codex (regression)**: SAFE TO SHIP — verified the getattr Windows-guard, that the ignore disposition lands correctly across the `os.execv` self-restart, and that subprocess children use `restore_signals=True` so the ignore doesn't leak to git/shell/editor children.

Regression test `tests/test_issue3407_sigpipe_ignore.py` pins SIG_IGN on POSIX, no-raise import, and the getattr guard.

Co-authored-by: PatrickNoFilter <PatrickNoFilter@users.noreply.github.com>
2026-06-03 12:38:52 -07:00
nesquena-hermes
1fe8950022 Release v0.51.238 — Release HF (stage-q9) (#3493)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.238 — Release HF (stage-q9)

Phase 3 MEDIUM-ring pick (3-factor: contributor×impact×mitigated-risk) — high-impact perf fix to the most-clicked affordance from a regular contributor (@franksong2702 ★★★), small code surface, CI-green.

### Fixed
| PR | Author | Fix |
|----|--------|-----|
| #2518 follow-up | @franksong2702 | Clicking **New Conversation** on a cold start no longer hangs 3–4s on a catalog rebuild. `newSession()` fills `model_provider` from `window._activeProvider` (then prev-session) when the dropdown carries none, so `POST /api/session/new` takes the fast path on the first click too. |

### Pre-release dual gate caught a wrong-backend routing bug (fixed + regression-tested)
The server fast path passes `(model, provider)` through **without validating the pair**, so naively attaching the active provider to *any* bare model could silently route to the wrong backend (e.g. bare `claude-opus-4.8` + active `openrouter`). **Codex** flagged this; **Opus** had judged it acceptable ("respect the selection over silent swap"). I took the stricter, empirically-grounded path and added a **family-mismatch guard** mirroring the server's own bare-prefix→provider map (`gpt`→openai, `claude`→anthropic, `gemini`→google): when the model's known family differs from the fallback provider, `model_provider` stays `null` so the server slow-path's family repair runs. This keeps the perf win for the common matching case while closing the mis-route. Backend behavioral tests confirm fast-path-on-match + slow-path-on-mismatch. (Also re-anchored the source-shape test assertions on the real `reqBody.model_provider=` assignment per Codex's 2nd note.)

### Gate results
- **Full pytest suite**: 7495 passed, 9 skipped, 3 xpassed, **0 failed**
- **ESLint runtime gate**: CLEAN  ·  **ruff**: CLEAN  ·  **browser-smoke**: CLEAN
- **Codex (regression)**: SHIP ONLY WITH FIXES → guard + test-anchor applied → re-reviewed **SAFE TO SHIP**
- **Opus (correctness)**: reviewed the original (judged acceptable); the shipped version is strictly safer (adds the family guard)

Note: `docs/pr-media/2518/{PR_BODY.md,bench.py}` are the contributor's review aids, included per the tracked `docs/pr-media/` convention (157 files already tracked) — not app code.

Closes #2518.

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-03 12:23:46 -07:00
nesquena-hermes
74dcc4fe6f Release v0.51.237 — Release HE (stage-q8): reconcile early-cancel against live worker state (#3492)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.237 — Release HE (stage-q8)

Phase 3 MEDIUM-ring pick (3-factor framework): a real concurrency/state-consistency fix from a regular contributor (@franksong2702, ★★★ 38 merges). The hardest review of the sweep — the dual gate caught **two** silent data-loss bugs across two review rounds.

### Fixed
| PR | Author | Fix |
|----|--------|-----|
| #3475 | @franksong2702 | Cancelling a live turn immediately after send now reliably stops the worker and settles the session to a cancelled state (was: spinner over a blank page). `cancel_stream()` falls back to the live active-run registry (`ACTIVE_RUNS`) + session agent cache when `STREAMS` has already detached, so the worker still receives `interrupt("Cancelled by user")`. `/api/session` reports run-journal active state from the live registry instead of trusting a persisted `active_stream_id`. |

### Pre-release dual gate caught TWO silent data-loss bugs (both fixed + regression-tested)
The PR's refactor moved `agent.interrupt()` ahead of the partial-text/reasoning/tool-call snapshot, and that snapshot was no longer under `streams_lock`:
1. **Codex round 1** — the worker's `finally` (which pops `STREAM_PARTIAL_TEXT`/`STREAM_REASONING_TEXT`/`STREAM_LIVE_TOOL_CALLS` under `STREAMS_LOCK`) could clear those buffers the instant `interrupt()` wakes it, so a cancelled turn **silently lost its already-streamed text**. (Opus reviewed the original and said ship — it assumed the snapshot was still lock-protected; the stale comment claimed so but the code wasn't. Verified against the actual code → Codex was right.)
2. **Codex round 2** — my first fix only snapshotted on the `STREAMS`-present path; the detached `ACTIVE_RUNS`-only path (the case this PR adds) still lost text. Fixed by hoisting the snapshot above the `if stream_present` branch so it runs unconditionally under the lock.

Both fixes have regression tests **verified to fail against the buggy versions** (`test_cancel_preserves_partial_text_when_interrupt_pops_buffers` + `test_cancel_preserves_partial_text_on_detached_active_run_path`).

### Gate results
- **Full pytest suite**: 7481 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN
- **Codex (regression)**: SHIP ONLY WITH FIXES (×2 rounds) → both MUST-FIXes applied → re-reviewed **SAFE TO SHIP**
- **Opus (correctness)**: reviewed the original (SAFE); the shipped version is strictly safer (adds the under-lock snapshot Opus deemed unnecessary)
- Deadlock concern cleared: no path takes `ACTIVE_RUNS_LOCK` then `STREAMS_LOCK`.

Rebased onto current master (streaming.py/routes.py merged clean — no overlap with the #3468 dedup change shipped in HC).

Closes #3475.

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-03 11:26:12 -07:00
nesquena-hermes
6f68fdb6ff Release v0.51.236 — Release HD (stage-q7) (#3491)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.236 — Release HD (stage-q7)

First Phase 3 (deep-review) release — picked by the 3-factor framework (contributor × impact × mitigated-risk): high-impact (#1952 native Windows support), backend-only (no screenshots), well-mitigated risk (POSIX path provably unchanged), from a contributor active this session (@rodboev, #3446/#3486 shipped earlier today).

### Added
| PR | Author | Fix |
|----|--------|-----|
| #1952 | @rodboev | Native Windows support for `bootstrap.py` + the embedded terminal: POSIX-only `fcntl`/`termios`/`select` guarded behind `_TERMINAL_SUPPORTED`; terminal entry points raise `NotImplementedError`/no-op on Windows; bootstrap Windows block → warning; auto-install errors clearly on native Windows (WSL unaffected); foreground uses `Popen`+exit on Windows instead of `os.execv`. **POSIX behavior unchanged on every path.** |

### Absorbed on the way in (fix-it-ourselves, reviewed fresh)
- `subprocess.CREATE_NEW_PROCESS_GROUP` → `getattr(subprocess, ..., 0)` — the constant is Windows-only, so a win32-simulating test `AttributeError`'d on Linux. Mirrors the `SO_EXCLUSIVEADDRUSE` getattr guard.
- Fixed 2 over-reaching tests in `test_windows_native_support.py` — one was launching a **real installer subprocess** via an unstubbed `subprocess.run` (now stubbed; harness 2.8s vs 80s); removed unused imports.
- Updated `test_onboarding_static.py` — it asserted the OLD "Native Windows is not supported" hard-block string this PR intentionally replaces; now asserts the new experimental-warning + auto-install guard.
- Help-text accuracy: `--foreground` help now describes the Windows Popen path (Opus nit).

### Gate results
- **Full pytest suite**: 7478 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN (gate hardened mid-release to auto-detect the cached chromium revision)
- **Codex (regression)**: SAFE TO SHIP (simulated `sys.platform=win32`, verified POSIX modules not imported + all terminal guards complete + POSIX foreground still uses execv)
- **Opus (correctness)**: SAFE TO SHIP (POSIX path provably unchanged, all fcntl/termios/select guarded, Popen+exit correct; noted inherent-Windows trade-offs that aren't PR bugs)

Note: the Windows *runtime* path can't be executed on the Linux CI box; it was reviewed statically by both reviewers + the contributor's 209-line test (win32 simulated via monkeypatch). Linux/POSIX no-regression is fully verified.

Closes #1952.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 10:46:06 -07:00
nesquena-hermes
aae584ba57 Release v0.51.235 — Release HC (stage-q5) (#3490)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.235 — Release HC (stage-q5)

Single fix in the data-sensitive transcript-merge path, with a Codex-found MUST-FIX applied.

### Fix
| PR | Author | Fix |
|----|--------|-----|
| #3468 | @jasonjcwu | `_find_current_user_turn` returns the **last** matching user turn instead of the first, so post-compression `result_messages` (which carries the full history) no longer replays the entire conversation when the user repeats a similar question (137-msg session → 89 duplicate replays). |

### Pre-release review caught a CORE issue (fixed before ship)
Codex's regression gate found that a naive last-match could be overridden by a **synthetic `role:"user"` continuation prompt** (the agent loop injects "Continue"/empty-recovery nudges — verified at `conversation_loop.py:1763/4183/4356`) that only *substring*-matches the user text — anchoring the merge **past** the real turn and dropping the assistant/tool output in between. Applied the fix: track **strong** (exact `_looks_like_current_user_turn`) and **weak** (substring) matches separately and return `last_strong → last_weak → fallback`. The real turn (strong) always wins over a later synthetic continuation (weak).

Opus reviewed the original and said ship; the applied fix is strictly safer than what it reviewed.

### Gate results
- **Full pytest suite**: 7465 passed, 8 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN
- **Codex (regression)**: SHIP ONLY WITH FIXES → fix applied → re-reviewed **SAFE TO SHIP**
- **Opus (correctness)**: SHIP IT (on the pre-fix code; applied fix is strictly safer)
- **Regression test** (`tests/test_issue3468_duplicate_after_compression.py`, 7 cases): pins the last-match behavior, the strong-beats-later-weak invariant, and the end-to-end no-duplicate-replay invariant — each **verified to fail against the pre-fix logic**.

Co-authored-by: jasonjcwu <jasonjcwu@users.noreply.github.com>
2026-06-03 10:11:32 -07:00
nesquena-hermes
ed46c65719 Release v0.51.234 — Release HB (stage-q4) (#3488)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.234 — Release HB (stage-q4)

Two medium-risk backend/infra fixes. All gates green.

### Fixes
| PR | Author | Fix |
|----|--------|-----|
| #3289 | @rodboev | Refuse server startup when a live instance already serves the port (Windows/macOS silent port-sharing hazard). Live-listener probe (`GET /health`, 2s timeout) + Windows `SO_EXCLUSIVEADDRUSE` — **preserves fast restart** (POSIX keeps `allow_reuse_address=True`; a dying socket in the kernel backlog times out → startup proceeds). |
| #3486 | @dso2ng | Allow remote/SSH terminal profiles to use target-side workspace paths under `terminal.cwd` without a server-local `stat()`. Local profiles unchanged — bypass only fires for remote backends and only for paths contained within `terminal.cwd`. |

### History note on #3289
This PR was **held earlier this sweep** — its original form globally disabled `SO_REUSEADDR`, which a Codex gate flagged as breaking fast restart (TIME_WAIT bricks rebind for ~60s). The contributor reworked it along the suggested lines (live-listener probe instead of the global disable). This release ships the reworked version. Unheld → full pickup → full gate.

### Gate results
- **Full pytest suite**: 7458 passed, 8 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN (real server boots fine with the new startup probe)
- **Codex (regression)**: SAFE TO SHIP (verified fast-rebind preserved + remote bypass gated on backend+containment, local validation unchanged)
- **Opus (correctness + security)**: SAFE TO SHIP (probe false-positive, `_is_within` containment, local-profile bypass all hold up; applied its one minor double-call cleanup note)

Closes #3289.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: dso2ng <dso2ng@users.noreply.github.com>
2026-06-03 09:53:32 -07:00
nesquena-hermes
7b02883dcf Release v0.51.233 — Release HA (stage-q3) (#3487)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.233 — Release HA (stage-q3)

Single high-impact data-integrity fix.

### Fix
| PR | Author | Fix |
|----|--------|-----|
| #3472 | @Mubashirrrr | Guard `/api/session/truncate` `keep_count` against a **silent persisted transcript wipe** (negative value sliced as `messages[:-N]`, deleting the newest N messages and saving to disk) and an HTTP 500 (non-numeric). Now validates before the destructive slice — non-int → 400, negative → 400 — mirroring the existing `/api/session/branch` guard. `keep_count=0` "clear all" semantics preserved. |

### Gate results
- **Full pytest suite**: 7450 passed, 7 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN (`/`, `/#settings`, `/#sessions` — zero console errors)
- **Codex (regression)**: SAFE TO SHIP (probed the route directly — negative/non-numeric → 400 without mutation; `keep_count=2`/`0` still work)
- **Opus (correctness)**: SAFE TO SHIP (guard placed before slice + save; both front-end callers compute from a non-negative DOM index)

Co-authored-by: Mubashirrrr <Mubashirrrr@users.noreply.github.com>
2026-06-03 09:34:31 -07:00
nesquena-hermes
89b034b925 Release v0.51.232 — Release GZ (stage-q2) (#3485)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.232 — Release GZ (stage-q2)

Two low-risk fixes batched into one release. All gates green.

### Fixes
| PR | Author | Fix |
|----|--------|-----|
| #3473 | @Mubashirrrr | Guard malformed/negative numeric query params on the cron endpoints (`/api/crons/output`, `/api/crons/recent`) — no more 500 on `?limit=abc`, no oldest-entry drop on a negative limit |
| #3480 | @koshikai | Translate 80 previously-untranslated Japanese (`ja`) locale strings (MCP controls, tool summaries, toasts) — interpolation placeholders preserved, no keys added/removed |

### Gate results
- **Full pytest suite**: 7446 passed, 7 skipped, 3 xpassed, **0 failed**
- **ESLint runtime gate**: CLEAN
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN (`/`, `/#settings`, `/#sessions` — zero console errors)
- **Codex (regression)**: SAFE TO SHIP (flagged a CHANGELOG endpoint-path typo `/api/cron/` → `/api/crons/`, fixed)
- **Opus (correctness)**: SAFE TO SHIP (flagged an inline-comment imprecision about which entries a negative slice drops — fixed in both the comment and CHANGELOG)

Co-authored-by: Mubashirrrr <Mubashirrrr@users.noreply.github.com>
Co-authored-by: koshikai <koshikai@users.noreply.github.com>
2026-06-03 09:24:51 -07:00
nesquena-hermes
0b0a179e4c Release v0.51.231 — Release GY (stage-q1) (#3484)
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.231 — Release GY (stage-q1)

Four independent, low-risk fixes batched into one release. All gates green.

### Fixes
| PR | Author | Fix |
|----|--------|-----|
| #3368 | @nesquena-hermes | `/model` resolves models from the truncated `extra_models` tail of a large provider catalog (completes the half-done #3368 fix; @garyd9's `mimo-v2.5` case) |
| #3457 | @pix0127 | Auto-hide the Plugins settings tab when no plugins are installed; deep-link fallback to Conversation |
| #3474 | @Mubashirrrr | Guard malformed/negative `depth` on `/api/sessions/search` (no more 500 on `?depth=deep`, no silent newest-message drop on negative) |
| #3433 | @sjh9714 | Resolve symlinked home before the trust comparison so `~/` workspace suggestions work when the process home is a symlink |

### Note on #3457
The original contributor patch reassigned a `const section` (a **runtime `TypeError` brick** on the settings panel) and placed the hidden-tab fallback *after* the panes were already toggled. Refactored to `let` + hoist the fallback above all consumers of `section`. ESLint runtime gate now clean.

### Gate results
- **Full pytest suite**: 7441 passed, 7 skipped, 3 xpassed, **0 failed**
- **ESLint runtime gate**: CLEAN (caught + fixed the #3457 const-reassign)
- **ruff forward gate**: CLEAN (no new violations on changed lines)
- **browser-smoke gate**: CLEAN (`/`, `/#settings`, `/#sessions` — zero console errors)
- **Codex (regression)**: SAFE TO SHIP
- **Opus (correctness)**: SAFE TO SHIP

Closes #3368. Closes #3457. Closes #3433.

Co-authored-by: pix0127 <pix0127@users.noreply.github.com>
Co-authored-by: Mubashirrrr <Mubashirrrr@users.noreply.github.com>
Co-authored-by: sjh9714 <sjh9714@users.noreply.github.com>
2026-06-03 09:13:01 -07:00
nesquena-hermes
2d20d336ea Release v0.51.230 — Release GX (stage-p14 — extract <think> to m.reasoning #3455 + LLM Wiki last-writer #1257) (#3466)
Some checks failed
Release & Docker / release (push) Has been cancelled
* Release v0.51.230 (stage-p14): extract <think> to m.reasoning #3455 + LLM Wiki last-writer (#1257)

Salvage of #3455 (@gsurenull): dropped the stale api/config.py bits (MiniMax-M3 +
SCHEMA_VERSION 3->4 — both already on master via #3374). Kept the two genuine fixes:
(1) _splitThinkFromContent persist-path extraction of inline <think> blocks into
m.reasoning (fixes 30-50% session bloat for reasoning-only providers like MiniMax-M3);
(2) LLM Wiki status Last-writer 3-tier fallback (was always 'Not available' since #1257).
Added 9 Node-driven think-split regression tests (data-loss guards: content-before/after
preserved, unclosed blocks intact, lookalike tags not extracted).

* fix(#3455): renderer-matching think extraction + wiki symlink/bounded-read guards (Codex review)

Codex review of stage-p14 found 3 SILENT bugs, all fixed:
(1) DATA-LOSS: _splitThinkFromContent's Pass-2 whole-body scan extracted a CLOSED literal
<think>...</think> from visible prose/code (e.g. inside a fenced code block) into m.reasoning,
emptying it — more aggressive than the renderer (which only strips LEADING blocks). Removed
Pass 2; extraction now matches _streamDisplay semantics (leading-only, loop captures
consecutive leading blocks). +fenced-code regression test.
(2) PRIVACY: _llm_wiki_last_writer followed symlinked .md pages resolving OUTSIDE the wiki
(is_file follows symlinks), leaking external frontmatter. Now requires resolved path under
wiki_root. +symlink-containment regression test.
(3) CONTRACT/PERF: replaced full read_text() with bounded line-by-line reads (frontmatter
block only / capped log-heading scan), never page bodies.

* fix(#3455): think-split is leading-single (renderer-matching) + fix 2 stale source-match tests

Codex re-review finding #2: looping consecutive leading blocks diverged from the renderer
(_streamDisplay/_parseStreamState strip ONE leading block). Now extracts exactly one leading
block. Also updated 2 tests that asserted pre-split implementation strings:
test_live_stream_tokens_persist (content:assistantText -> content:split.content, invariant
preserved) and the consecutive-blocks test. NOTE: Codex finding #1 (client-only split doesn't
persist server-side) is a separate architectural decision pending Nathan.

* feat(#3455): split inline <think> server-side before s.save() so persisted file is compacted (Codex #1)

Codex finding #1: the think-split was client-only, so the SAVED session file still
carried inline <think> blocks (bloat) — the fix only compacted the browser copy.
Added _split_thinking_from_content (api/streaming.py), a server-side twin of the JS
helper with identical leading-only/single-block semantics, applied to the final
assistant message before s.save() (extended the existing reasoning-persist block).
Merges with on_reasoning-stream reasoning. +8 backend-parity regression tests covering
the mid-body-code-block data-loss guard, unclosed-intact, single-leading, none-content.

* test: update 3 save-path source-assertion tests for #3455 server-side think-split

The backend think-split (api/streaming.py reasoning-persist block) changed the literal
code shape + grew the pre-save block, breaking 8 source-assertion tests that anchor on it:
- test_sprint42: assert _rm['reasoning']=_reasoning_text -> now _merged_reasoning/_existing_reasoning
  + _split_thinking_from_content present (intent preserved: reasoning persisted before save).
- test_pr1318 (6) + test_pr1341: re-anchored the locator from the changed 'if _reasoning_text
  and s.messages:' line to the stable 'Persist reasoning trace in the session' comment marker;
  bumped the 1341 byte-distance limit 15000->16000 (the test self-documents bumping on legit
  pre-save growth). All behavioral invariants (reasoning persisted + context fields before save)
  unchanged.

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-02 22:48:35 -07:00
nesquena-hermes
7c6fb33549 Release v0.51.229 — Release GW (stage-p13 — /model never silently snaps a versioned name to a -tier variant #3368) (#3465)
Some checks failed
Release & Docker / release (push) Has been cancelled
* Release v0.51.229 (stage-p13): /model never silently snaps versioned name to -tier variant (#3368, @nesquena-hermes)

Agent-authored, nesquena-APPROVED. Rebased onto current master. Both _findModelInDropdown
(ui.js) and _bestModelMatch (commands.js) reject a prefix-snap when the typed target ends
in a version digit and the longer option's extra text is a variant/tier suffix (.pro) rather
than a version continuation (.digit). Adds _nearestModelSuggestion + 'did you mean?' toast.
34 tests pass (14 new #3368 + 20 regression: #1188 fuzzy + #3360 collision).

* fix(commands): /model did-you-mean toast renders suggestion + single quotes (#3368 review)

Live-render review of the approved #3437 caught two toast-assembly bugs in cmdModel:
(1) t('model_did_you_mean') was called WITHOUT the suggestion arg — model_did_you_mean
is a (m)=>... template that t() invokes, so it rendered 'did you mean "undefined"?';
fixed to t('model_did_you_mean', suggestion). (2) no_model_match already ends with an
opening quote, so '"${args}"' doubled it ('No model matching ""deepseek-v4""'); fixed
to '${args}"'. +4 source-assertion regression tests. Verified live: toast now reads
'No model matching "deepseek-v4" — did you mean "deepseek/deepseek-v4-pro"?'.

* fix(commands): slash-qualified versioned no-snap falls through to suggestion (#3368 Codex CORE)

Codex review found a 2nd no-snap layer the version guard missed: for a slash-qualified
versioned query (e.g. 'xiaomi/mimo-v2.5') whose only near catalog entry is a rejected
tier variant ('xiaomi/mimo-v2.5-pro'), cmdModel's cross-provider /api/session/update
fallback would silently persist the invalid model + 'Switched to...'. Now gated on
!versionedNoSnap (_looksLikeVersionedModel(bare) && a near suggestion exists) so it falls
through to the 'did you mean?' toast; genuinely off-catalog providers (no near variant)
still direct-update. Verified live: '/model deepseek/deepseek-v4' no longer switches, shows
suggestion toast. +1 regression test.

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-02 21:35:09 -07:00
nesquena-hermes
9374e66855 Release v0.51.228 — Release GV (stage-p12 — workspace tree-drop #3411 + large-markdown preview #3378) (#3463)
Some checks failed
Release & Docker / release (push) Has been cancelled
* Release v0.51.228 (stage-p12): workspace tree-drop #3411 + large-markdown preview #3378

#3411 (@pamnard): stopPropagation on workspace file-tree OS-file dragenter/dragover/drop
so a tree drop uploads to the workspace WITHOUT also attaching to the composer.
#3378 (@starGazerK): raise md rich-render ceiling 64KB/1500L->256KB/5000L + backend
file read 200KB->400KB, add 'Render as markdown anyway' force button (reuses cached
raw content, no extra fetch).

* fix(workspace): force-render uses fresh path-scoped cache, blocked while dirty (#3378 Codex follow-up)

Codex review of #3378 found the markdown force-render path had two SILENT bugs:
(1) saving a md file from the plain-text fallback didn't update _previewRawContent,
so a later force-render showed stale pre-save content; (2) the cache-reuse check
'path===_previewCurrentPath' was tautological (var just assigned), so a force-render
after a file switch could render the previous file's cached content. Fixes: track
_previewRawContentPath (set on fetch AND save), reuse cache only when it matches the
requested path, and block force-render while the editor is dirty/open. +3 regression
tests. (#3411 was cleared clean by Codex.)

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-02 20:58:22 -07:00
nesquena-hermes
fdfb935b5e Release v0.51.227 — Release GU (stage-p11 — keep active New Chat visible in sidebar #3408) (#3461)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(sidebar): keep active New Chat visible before first message (#3408, @AJV20)

Squashed net diff of #3408. Injects ONLY the active ephemeral session into the
sidebar render rows (when the server list omits it) so a freshly-created New Chat
stays visible/selected before its first turn; inactive empty sessions stay
filtered as before. New Chat also resets a CLI source-filter back to webui so the
active chat isn't immediately hidden.

* fix(sidebar): gate active-row reinjection to 0-message ephemeral only (#3408 Codex follow-up)

Codex review found _ensureActiveSessionRowPresent re-injected ANY active session
after search-merge — so an active conversation WITH messages that was correctly
filtered out by the search query would pollute unrelated search results. Gate the
reinjection to Number(activeRow.message_count||0)<=0 so only the freshly-created
0-message ephemeral chat is re-added; an active chat with messages stays filtered
by search as before. Added a regression test asserting the gate.

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-02 20:24:48 -07:00
nesquena-hermes
0b5458f3da Release v0.51.226 — Release GT (stage-p9 — mobile composer context-usage ring #3062 + activity-feed default-expand setting #3080) (#3459)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat(composer): replace mobile config-button sliders icon with context-usage ring (#3062, @NottheGuy007)

Squashed net diff of #3062 (the PR branch's tip commits were deletes of files not
present in our tree). Replaces the composerMobileCtxBadge text badge with an SVG
progress ring (ctx-arc + ctx-num) showing real-time context-window usage: ring
fill via stroke-dashoffset, centered percentage, color-coded green<=50%
orange<=85% red>85%. Ring resets to 0%/green on new session.

* feat(activity): add 'expand activity feed by default' appearance setting (#3080, @AJV20)

Squashed net diff of #3080. Adds a Settings -> Appearance checkbox
(activity_feed_expanded_default, default off) to expand new Activity disclosures
by default; preserves manual per-turn collapse/expand (explicit toggle still
wins); live 'Waiting on model' rows explain what the agent is doing before/after
tool calls. i18n keys for all locales.

* docs(changelog): v0.51.226 — context-usage ring (#3062) + activity-feed default-expand setting (#3080)

* test(mobile): update touch-target test for #3062 ring (badge removed)

#3062 replaced the composerMobileCtxBadge text badge with the SVG context-usage
ring (composerMobileCtxRing) but left 3 stale references in tests/test_mobile_layout.py.
The full suite caught test_mobile_composer_primary_controls_keep_touch_friendly_sizing
asserting the removed .composer-mobile-ctx-badge CSS rule + #composerMobileCtxBadge
element. Updated the assertion to the new ring: confirm composerMobileCtxRing exists,
the old badge is fully gone (not dangling), and the ring SVG is aria-hidden so it
stays a decorative overlay that doesn't steal the config button's 44px touch target
(which is still asserted via .composer-mobile-config-btn above). 56/56 mobile-layout
tests pass. Codex: no production JS dereferences the removed badge.

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-02 19:48:01 -07:00
nesquena-hermes
ec46f05c92 Release v0.51.225 — Release GS (stage-p7 — remote gateway health probe resolves gateway_state #3355) (#3458)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(health): probe /health/detailed first and unify gateway env vars (#3355)

* docs(changelog): v0.51.225 — remote gateway health probe gateway_state fix (#3355)

* fix(health): normalize gateway URL health-suffix + cap remote body read (#3355 Codex follow-up)

Codex review of #3355 found two issues:
(1) SILENT — a gateway env var already pointing at a health path (e.g.
    GATEWAY_HEALTH_URL=http://host/health) produced doubled paths like
    /health/health/detailed once probe paths were appended. Now strip a trailing
    /health/detailed, /health, /v1/health, /status suffix before appending
    (mirrors api/updates.py).
(2) CORE — the new resp.read() on a 2xx body was unbounded; a large/trickled
    remote response could hang /api/health/agent or balloon memory. Cap the read
    at _REMOTE_PROBE_BODY_LIMIT_BYTES (64KB)+1 and skip JSON parse when over cap
    (still report the gateway alive, just without parsed gateway_state).

Adds regression tests for both (no doubled /health/health path; oversized body
is capped + does not hang). Also updated _FakeResp.read to accept the size arg.

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-02 18:56:25 -07:00
nesquena-hermes
bb5aa02523 Release v0.51.224 — Release GR (stage-p6 — profile tool/skill config authoritative on streaming worker #3294) (#3456)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: respect profile toolset/skill config on WebUI streaming worker (#3294)

The streaming agent runs on a detached worker thread that does not inherit
the per-request thread-local profile context (set from the hermes_profile
cookie on the HTTP handler thread). On that worker, the ambient get_config()
resolves via get_active_profile_name() which falls back to the process-global
_active_profile (usually 'default'). A session under a non-default profile
with an empty platform_toolsets.cli therefore loaded the DEFAULT profile's
full toolset list, inflating a tools-disabled profile's prompt from ~400 to
~15K input tokens.

Add api.config.get_config_for_profile_home() — a race-free direct disk read
of an explicit profile home's config.yaml (no shared-cache mutation), which
defers to get_config() when the requested home matches the ambient path so
in-memory test overrides are preserved. The streaming worker now resolves
_cfg from the session's own profile home, fixing toolsets, prefill context,
and fallback chains in one place.

Closes #3294

Co-authored-by: gottipx <gottipx@users.noreply.github.com>

* docs(changelog): v0.51.224 — #3294 profile toolset config (dropped #3405 to hold per Codex stale-model-repair finding)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: gottipx <gottipx@users.noreply.github.com>
2026-06-02 18:33:22 -07:00
nesquena-hermes
b850a88e8b Release v0.51.223 — Release GQ (stage-p5 — openai-api first-class picker provider #3443 + MiniMax-M3 #3374) (#3453)
Some checks failed
Release & Docker / release (push) Has been cancelled
* feat: upgrade MiniMax default model to M3

Add MiniMax-M3 as the new default and prune deprecated older
versions (M2.5/M2.5-highspeed/M2.1/M2) from the model catalog.
M2.7 (and M2.7-highspeed) is retained as the legacy compatible
option for users who pin to it.

Updated:
  - api/config.py: _FALLBACK_MODELS adds minimax/MiniMax-M3 (placed
    before M2.7 so the dropdown surfaces it first)
  - api/config.py: _PROVIDER_MODELS['minimax'] adds M3 first, removes
    M2.5/M2.5-highspeed/M2.1
  - api/config.py: _PROVIDER_MODELS['minimax-cn'] adds M3 first,
    removes M2.5/M2.1/M2
  - tests/test_minimax_provider.py: updated CN catalog assertions
    to match the new {M3, M2.7} list

API URL and TTS configuration are unchanged.

Co-Authored-By: Octopus <liyuan851277048@icloud.com>

* fix(models): register openai-api as first-class picker provider

* fix(models): detect OPENAI_API_KEY as openai-api, not bare openai (#3443 Codex follow-up)

Codex review found #3444 added the openai-api picker entry but the env-detection
side still did detected_providers.add('openai') for OPENAI_API_KEY. The agent
registry has only openai-api and openai-codex (no bare openai), so a env-only
OPENAI_API_KEY setup emitted @openai: picker entries the agent can't resolve on
the send path. Detect openai-api to match the registry. Adds a regression test.

* docs(changelog): v0.51.223 — re-stamp keep-set (#3443 openai-api + #3374 MiniMax-M3); dropped #3289 + #3264 to hold per Codex

---------

Co-authored-by: octo-patch <octo-patch@github.com>
Co-authored-by: Octopus <liyuan851277048@icloud.com>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-02 18:08:01 -07:00
nesquena-hermes
cccb97d970 Release v0.51.222 — Release GP (stage-p4 — backend bugfix batch: title language drift #3293 + orphaned CLI sidecar prune #3238 + pin-quota lineage #3288) (#3452)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: reject cross-script drifted auto-generated session titles (#3293)

The title-language mismatch guard only knew two states: German (de) or empty,
and _title_language_mismatch early-returned False whenever the user start
wasn't German. So an English conversation whose LLM-generated title came back
in Chinese / Spanish / Russian sailed through and persisted with
llm_title_generated=true. The German case was the only one covered because
that's the one prior report it was built for.

Generalize from a German-specific binary to a language-agnostic cross-script
check. Add _script_counts() + _dominant_script() (cheap, dependency-free
Unicode-block classification: latin / cjk / cyrillic / arabic / hebrew / greek
/ devanagari). _title_language_mismatch now rejects a title that introduces a
substantial amount (>=35% of alphabetic chars, min 2) of a script different
from the conversation start's dominant script — so short titles that embed a
borrowed Latin technical term still trip, while an English title with a single
foreign place-name does not. The legacy German->English same-script heuristic
is preserved verbatim.

Kept api/streaming.py ASCII-only (the test_title_generation_source_has_no_cjk_
literals guard) — all CJK examples live in the test file, not the source.

Closes #3293

Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com>

* fix: prune orphaned imported-CLI sidecars from the WebUI sidebar (#3238)

When a CLI/agent session is opened in WebUI it gets a WebUI-owned sidecar
(webui/sessions/<id>.json + _index.json row) so it can render and reopen;
all_sessions() then returns it independently of the agent state.db. If the
user later deletes that session from the CLI / local Hermes storage, nothing
pruned the sidecar — the merge loop only overlays CLI metadata when a matching
state.db row exists and otherwise continues, so the stale row lingered in the
sidebar indefinitely (there is no WebUI delete affordance for CLI rows).

Add api.models.agent_session_row_exists(): an exact, uncapped existence probe
against the state.db sessions table. The sidebar merge loop now drops a row
that is_cli_session_row + not WebUI-native + absent from cli_by_id + whose
state.db row is genuinely gone, and calls prune_session_from_index() so
_index.json self-heals.

The state.db probe is deliberate: get_cli_sessions() caps at
CLI_VISIBLE_SESSION_LIMIT (20), so a still-existing session can fall out of
that window and look deleted — pruning on cli_by_id absence alone would delete
live sessions. WebUI-native rows with a CLI ancestor are never pruned, and any
probe error degrades to keep-the-row so a transient failure can't lose data.

Closes #3238

Co-authored-by: Luxciax <Luxciax@users.noreply.github.com>

* fix: count pin quota by visible session lineage

* docs(changelog): v0.51.222 — backend bugfix batch (#3293 title drift, #3238 sidecar prune, #3288 pin lineage)

* fix(pins): forks count as own pin lineage, not collapsed to parent (#3288 Codex follow-up)

Codex review of the batch found a pin-limit UNDERCOUNT: _session_row_lineage_root_id
followed any parent_session_id to the root, but /api/session/branch creates
independent visible fork sessions that also carry parent_session_id (session_source=
'fork'). Two pinned forks of the same parent collapsed to one quota lineage, letting
a user exceed pinned_sessions_limit with no 400. Fix: a fork returns its own id as
its lineage root (it's a separately-visible session); only compression/continuation
rows still collapse to a shared root. Adds a regression test with two pinned forks +
the parent counting as three distinct lineages, and confirms the existing
pre-compression-snapshot collapse case still passes.

* test(pins): update #2508/#2821 source-match tests for #3288 lineage rename

#3288 replaced the raw-session-id pin counter (pinned_ids set) with a
visible-lineage counter (pinned_lineage_ids via _visible_pinned_lineage_ids over
persisted_rows/candidate_rows). Two pre-existing source-string-matching tests
asserted the OLD implementation literals (pinned_ids = {, _session_field(existing,
session_id...), len(pinned_ids) >=). Updated both to assert the new mechanism while
preserving the invariants they actually guard: snapshot computed BEFORE LOCK (no
all_sessions()-inside-LOCK deadlock), quota filtering via the shared
_session_counts_toward_pin_quota helper, and the limit/400 guard. Behaviour
unchanged; these were implementation-detail assertions, not behaviour tests.

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com>
Co-authored-by: Luxciax <Luxciax@users.noreply.github.com>
Co-authored-by: Andy Kang <andrewkang.kr@gmail.com>
2026-06-02 17:35:18 -07:00
nesquena-hermes
7c48c37629 Release v0.51.221 — Release GO (stage-p3e — block all workspace symlink escapes + portable TOCTOU hardening [security]) (#3398) (#3451)
Some checks failed
Release & Docker / release (push) Has been cancelled
* [security] fix(workspace): block all symlink escapes from the selected workspace (#3398, @Hinotoi-agent)

Previously safe_resolve_ws allowed a symlink placed inside a workspace to resolve
to an external host path as long as it wasn't a system dir (/etc, /proc, etc).
But the workspace file API is reachable by LLM agent tool calls (read_file_content),
so an in-workspace symlink to ~/.ssh, ~/.hermes/auth.json (credentials), etc. was a
real read path. Now ANY symlink escape is blocked: safe_resolve_ws resolves and
requires the result stay under the workspace root; list_dir hides escaping symlinks
(they could never be opened anyway); internal symlinks resolving back under the
workspace still work. Updated the upload symlink-target test to accept the new
400 'Path traversal blocked' rejection (was 403) — the invariant (nothing lands
outside the workspace) is unchanged.

Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>

* docs(changelog): v0.51.221 release header for #3398 symlink-escape security fix

* [security] harden workspace file API against symlink-swap TOCTOU via portable anchored openat-walk (#3398 follow-up)

Codex review of #3398 flagged that safe_resolve_ws() validates a path but
list_dir/read_file_content/upload/extraction then re-open by pathname, leaving a
TOCTOU window: a symlink swapped in AFTER the check could still escape. (This
race pre-existed #3398; closing it here so the containment is complete.)

A first attempt used /proc/self/fd for the post-open containment check, but that
BRICKS workspace browsing on macOS/Windows (no /proc → every read/list rejected).
This version is portable:

- open_anchored_fd(): opens the (already symlink-resolved) target
  component-by-component from the workspace root via openat (dir_fd) + O_NOFOLLOW.
  Every component must be a real non-symlink entry, so a component swapped to a
  symlink mid-flight is refused. No /proc dependency. Used by read_file_content
  (read from the fd) and list_dir (enumerate via os.scandir(fd), per-entry
  fstatat/readlinkat).
- open_anchored_create_fd(): same anchored walk for writes, creating missing
  intermediate dirs with mkdir(dir_fd=) and the leaf with O_CREAT|O_EXCL|
  O_NOFOLLOW. Used by the workspace upload write AND archive (zip+tar) member
  writes, anchored against the TRUE workspace root (not the mutable extraction
  dest_dir, closing Codex's root-swap finding). fd-leak-safe on rejection.
- Portability: gated on os.supports_dir_fd; platforms without it (Windows, where
  symlink creation needs admin) fall back to a plain O_NOFOLLOW open/exclusive
  create — no new race protection but no regression vs the prior path-based code.

Legit in-workspace symlinks still resolve and read/list fine (safe_resolve_ws
collapses them to a real in-workspace path, which the anchored walk then opens).
Verified: the swap-race leaks external content against the old path-based read
and is blocked here; macOS-class symlinked-root workspaces work; no fd leak over
300 rejected creates. Adds TOCTOU + anchored-create regression tests.

* [security] close 3 more #3398 TOCTOU gaps from Codex r3: root-swap, pre-create mkdir, Windows list_dir fallback

Codex round-3 review found three residual issues in the anchored openat-walk:

1. (CORE) The workspace ROOT itself could be swapped to a symlink after
   resolve() but before the root os.open() — add _O_NOFOLLOW to the root open in
   open_anchored_fd() and open_anchored_create_fd() (and make_anchored_dir()), so
   a raced root symlink is refused. Verified: root-swap race now blocked.

2. (SILENT) Upload/extraction still did pathname Path.mkdir() AFTER the
   containment check, so a raced symlink component could make the server create
   dirs outside the workspace before the anchored file create rejected. Removed
   the redundant member_path.parent.mkdir() calls (open_anchored_create_fd
   already creates intermediates via anchored mkdirat) and replaced the two
   base-dir mkdirs (upload target dir + archive extraction root) with a new
   make_anchored_dir() that walks from the true workspace root via
   openat+O_NOFOLLOW + mkdir(dir_fd=).

3. (CORE) list_dir() unconditionally used os.scandir(fd)/os.stat(dir_fd=)/
   os.readlink(dir_fd=), which would brick workspace browsing on platforms
   without os.supports_dir_fd (Windows). Split list_dir() into a _DIR_FD_OK
   anchored branch and a path-based fallback branch (prior behaviour) sharing one
   _process() entry builder. open_anchored_create_fd()'s Windows fallback now also
   creates parent dirs.

Adds regression tests: no-dir_fd fallback (list+read+create+symlink filtering)
and the root-swap race. All prior TOCTOU + anchored-create tests still green.

* fix(workspace): portable symlink-loop filtering in list_dir via follow-stat ELOOP

CI on Python 3.13 caught test_mutual_symlink_loop_filtered failing: a mutual
symlink loop (a->b->a) was NOT filtered from the listing. Root cause: the new
readlink-based cycle detection relied on (target_resolved / raw_link).resolve()
RAISING on a loop, but Path.resolve() loop handling differs by Python version
(3.11 raises RuntimeError, 3.13 can return a path), so the loop slipped through
on 3.13.

Fix: compute a version-independent 'reachable' flag per symlink via
os.stat(..., follow_symlinks=True) — the syscall reliably returns ELOOP for
mutual/self loops and ENOENT for broken targets on every platform/version. A
symlink whose follow-stat raises can never be opened, so list_dir filters it.
Applied in both the dir_fd-anchored branch (fd-relative stat) and the Windows
path-based fallback branch. Mutual loop now filtered on all versions.

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
2026-06-02 16:56:30 -07:00
nesquena-hermes
42aef61b31 Merge pull request #3442 from nesquena/docs/contributor-attribution-backfill
docs: backfill contributor attribution for absorbed PRs (graph credit + CONTRIBUTORS.md)
2026-06-02 14:46:38 -07:00
nesquena-hermes
f25ff515a0 docs: backfill contributor attribution for absorbed PRs (#2622, #2931, #3104, #3220, #3223, #3337)
Six contributor PRs were shipped via the cherry-pick/absorb path but their
absorb commits never carried a `Co-authored-by:` trailer, so the contributors
received zero commit credit on their GitHub contribution graphs. Three of them
(@antoniocarlos97ss, @liuqiangweb-svg, @pix0127) were also missing from
CONTRIBUTORS.md entirely; the other three (@AJV20, @mysoul12138) were already
listed via CHANGELOG attribution but still lacked the graph credit.

This commit:
  - Adds the three missing contributors to CONTRIBUTORS.md (single-PR section).
  - Carries Co-authored-by trailers for all six so each gets a real commit on
    their contribution graph (the non-history-rewrite way to repair this).
  - Bumps the tracked totals (194 -> 197 contributors, 843 -> 846 credits).

The shipped work, by PR:
  #2622 (@pix0127)            WebUI dashboard plugin system w/ iframe isolation
  #2931 (@liuqiangweb-svg)    Edge TTS as an alternative speech engine
  #3104 (@antoniocarlos97ss)  workspace file upload + drag-drop w/ archive extract
  #3220 (@AJV20)              generated media artifact cards
  #3223 (@AJV20)              manual session title regeneration
  #3337 (@mysoul12138)        syntax highlighting in workspace file preview

Co-authored-by: pix0127 <8500500+pix0127@users.noreply.github.com>
Co-authored-by: Andy <281253538+liuqiangweb-svg@users.noreply.github.com>
Co-authored-by: antoniocarlos97ss <101895404+antoniocarlos97ss@users.noreply.github.com>
Co-authored-by: AJV20 <24819659+AJV20@users.noreply.github.com>
Co-authored-by: mysoul12138 <203929894+mysoul12138@users.noreply.github.com>
2026-06-02 21:39:59 +00:00
nesquena-hermes
b774b03900 Merge pull request #3441 from nesquena/release/stage-p3c
Some checks failed
Release & Docker / release (push) Has been cancelled
Release GN — v0.51.220 (fix aux title generation 422 with @provider: model ids, #3430)
2026-06-02 14:25:07 -07:00
nesquena-hermes
70068522fb docs(changelog): v0.51.220 release header for #3430 aux-title @provider fix 2026-06-02 21:16:20 +00:00
nesquena-hermes
c556d649b7 fix: strip @provider: prefix from auxiliary title_generation model id (#3430, @pamnard)
Manual title regeneration (POST /api/session/title/regenerate) and background
aux title generation failed with 422 / llm_error_aux when
auxiliary.title_generation.model in config.yaml used the WebUI-internal
@provider:model picker format (e.g. @gemini:gemini-3.1-flash-lite) — the raw
@-qualified id was forwarded to the provider API verbatim. Normalize it through
the canonical _split_webui_provider_model_value() helper before the aux call.

Co-authored-by: pamnard <pamnard@users.noreply.github.com>
2026-06-02 21:14:49 +00:00
nesquena-hermes
b9a0b77baa Merge pull request #3439 from nesquena/release/stage-p3b
Some checks failed
Release & Docker / release (push) Has been cancelled
Release GM — v0.51.219 (extend URI-scheme model-ID fix to backend normalization, #3436)
2026-06-02 14:12:09 -07:00
nesquena-hermes
3091ac9512 docs(changelog): v0.51.219 release header for #3436 backend URI-scheme fix 2026-06-02 21:04:27 +00:00
nesquena-hermes
264863cc37 fix(config): extend #3429 URI-scheme fix to backend normalization + model-key matching (#3436, @b3nw)
v0.51.218 fixed getModelLabel() (the visible chip) for URI-scheme model IDs but
left the same first-segment-slash-strip bug in the matching/dedup paths:
api/config.py _norm_model_id + _get_label_for_model, and static/ui.js
_normalizeConfiguredModelKey. For gpt://folder/model/latest those treat the
path slashes as provider delimiters, mis-normalizing the model-identity key
(the #3360 collision class, for URI ids). Adds a URI-scheme guard to all three
so the scheme is not stripped. Kept v0.51.218's getModelLabel (more thorough
than #3436's variant — it extracts the model name vs returning the whole id);
took #3436's backend + _normalizeConfiguredModelKey halves with backend/frontend
parity tests.

Co-authored-by: b3nw <b3nw@duck.com>
2026-06-02 21:04:02 +00:00
nesquena-hermes
f7087a3d1d Merge pull request #3438 from nesquena/release/stage-p3a
Some checks failed
Release & Docker / release (push) Has been cancelled
Release GL — v0.51.218 (fix getModelLabel mangling URI-scheme model IDs, #3429 regression)
2026-06-02 13:56:35 -07:00
nesquena-hermes
25e95a7366 fix(ui): #3429 round-2 — never fall back to authority or placeholder (Codex MUST-FIX)
Codex re-check: degenerate URIs still leaked — gpt://folder123 returned the
authority, gpt://folder123/${MODEL} returned the placeholder. Removed the _all[0]
authority fallback and guarded the literal-last-path fallback against placeholders;
a URI with no usable model segment now falls back to the raw id. Added regression
cases for gpt://folder123 and gpt://folder123/${MODEL}.
2026-06-02 20:49:29 +00:00
nesquena-hermes
c223b418a5 fix(ui): #3429 follow-up — drop authority, keep digit-leading model names (Codex MUST-FIX)
Codex gate found two edges in the first cut: (1) the candidate segment list
included the URI authority, so gpt://folder123/v4 and .../latest returned the
folder; (2) _isVersionTail matched any digit-leading segment, dropping a real
model named 2026-model. Fix: build path segments from AFTER the authority only;
tighten the version-tail regex to pure version tokens (latest/stable/v4/1.2),
not mixed names; fall back to last-usable (non-placeholder) path segment so the
authority is never returned. Added edge-case regression tests.
2026-06-02 20:44:59 +00:00
nesquena-hermes
d7133699d7 docs(changelog): v0.51.218 release header for #3429 getModelLabel URI fix 2026-06-02 20:37:34 +00:00
nesquena-hermes
c81c9649dd fix(ui): getModelLabel must not mangle URI-scheme model IDs (#3429 regression from #3366)
#3366 changed getModelLabel() to strip only the first /-segment (fixing #3360
multi-slash proxy IDs). That regressed URI-scheme IDs like Yandex
gpt://${FOLDER}/deepseek-v4-flash/latest — indexOf('/') lands inside the ://
and leaves /${FOLDER}/... path junk in the composer model chip. Detect a
scheme:// id, drop scheme+authority, and take the last meaningful path segment
(skipping ${...} env-var placeholders and bare version tails like latest).
Non-URI multi-slash IDs keep the #3360 first-segment-strip behavior unchanged.
Node-driven regression test covers the URI case + the #3360 non-regression.
2026-06-02 20:36:59 +00:00
nesquena-hermes
6703b39a2d Merge pull request #3435 from nesquena/release/stage-p2f
Some checks failed
Release & Docker / release (push) Has been cancelled
Release GK — v0.51.217 (decode + complete zh-Hant locale; fix missing fr provider_mismatch_warning)
2026-06-02 13:19:50 -07:00
nesquena-hermes
e28d5a5a4d docs(changelog): note fr provider_mismatch_warning fix in v0.51.217 2026-06-02 20:09:00 +00:00
nesquena-hermes
b5880c1ed4 i18n(fr): add missing provider_mismatch_warning (exposed by #3414 zh-Hant dup removal)
#3414 removed a stale duplicate provider_mismatch_warning from the zh-Hant block
(correct cleanup). On master, test_all_locales_have_warning_key passed only
because that zh-Hant duplicate masked a pre-existing gap: the fr (French) block
was missing provider_mismatch_warning entirely (0 occurrences). Removing the dup
exposed it. Added the French translation so all 12 locales have the key.
2026-06-02 20:08:43 +00:00
nesquena-hermes
599966e53d docs(changelog): v0.51.217 release header for #3414 zh-Hant decode 2026-06-02 20:02:19 +00:00
nesquena-hermes
ab76ccbc55 i18n: decode and complete zh-Hant locale strings (#3414, @PeterDaveHello)
Decode the \u-escaped zh-Hant block to literal Traditional Chinese and backfill
missing keys so zh-Hant has full coverage of the en key set (verified: 0 en keys
missing from zh-Hant). Preserves the v0.51.212 #3396 session_title_regenerate*
translations (not reverted to English). Locale-only, no runtime behavior change.

Co-authored-by: Peter Dave Hello <hsu@peterdavehello.org>
2026-06-02 20:01:53 +00:00
nesquena-hermes
67936f5c8b Merge pull request #3434 from nesquena/release/stage-p2e
Some checks failed
Release & Docker / release (push) Has been cancelled
Release GJ — v0.51.216 (fix consecutive-user-turn rejection on strict chat templates)
2026-06-02 12:57:04 -07:00
nesquena-hermes
f47b5b45aa test: rewrite gateway-like-metadata test for #3324 system-prompt relocation (Opus MUST-FIX)
test_webui_session_context_adds_gateway_like_metadata asserted the removed
prefill-user-message API. Rewrote it to assert (a) the prefill helper no longer
appends a session-context user message, and (b) the same gateway-like metadata
(connected platforms, paused-platform exclusion, home-channel name shown,
chat_id never leaks) is now carried in _webui_ephemeral_system_prompt.
2026-06-02 19:43:26 +00:00
nesquena-hermes
8ad0db5dbe fix(gateway): carry WebUI delivery context on gateway-backed path too (Codex MUST-FIX on #3324)
#3324 moved the WebUI session/delivery context from a prefill user message into
_webui_ephemeral_system_prompt, but the gateway-backed WebUI chat path built its
own system message from the bare _WEBUI_PROGRESS_PROMPT + prefill helper — which
now returns only recall prefill. That silently dropped connected platforms, home
channels, delivery hints, and session framing on Gateway-routed WebUI chats
(same #3278 data-drop class, on the path the author missed). Build the gateway
request's system message with _webui_ephemeral_system_prompt(surface_context,
config_data=cfg) so the context is preserved. Regression test asserts the
delivery context is present in the gateway system message.
2026-06-02 19:42:14 +00:00
nesquena-hermes
1630fb8d95 fix(streaming): inject WebUI delivery context via system prompt, not prefill user message (#3324, @aether-agent)
Consecutive user turns (session-context prefill + actual message) made models
with strict chat templates (Mistral, Gemma via llama.cpp) reject the request
with a Jinja 500. Move the platform/delivery context (connected platforms, home
channels, scheduled-task delivery hints) from _webui_session_context_message
(a prefill user message) into _webui_delivery_context_prompt, appended to the
ephemeral system prompt. Session framing stays in _webui_surface_context_prompt.
Context is preserved — just role-alternation-safe. Closes #3276.

Co-authored-by: aether-agent <aether-agent@users.noreply.github.com>
2026-06-02 19:36:25 +00:00
nesquena-hermes
60369cab69 Merge pull request #3428 from nesquena/release/stage-p2d
Some checks failed
Release & Docker / release (push) Has been cancelled
Release GI — v0.51.215 (deduplicate legacy messages in append-only merge)
2026-06-02 12:27:50 -07:00
nesquena-hermes
9aee98eef6 docs(changelog): v0.51.215 release header for #3393 legacy-dedup fix 2026-06-02 19:20:44 +00:00
nesquena-hermes
1d9b2ed730 fix: deduplicate legacy messages in merge_session_messages_append_only (#3393, @thanhtoantnt)
Adds _session_message_dedup_key (full-precision timestamp) so true duplicates
(same role + content + EXACT timestamp) fold, while legitimately-repeated
identical turns with sub-second-distinct timestamps survive — avoiding the
#3268 data-loss class. Wired into both the no-sidecar path and the merge loop's
seen_dedup_keys guard. Closes #3346.

Co-authored-by: thanhtoantnt <thanhtoantnt@users.noreply.github.com>
2026-06-02 19:20:23 +00:00
nesquena-hermes
a1d44d6482 Merge pull request #3426 from nesquena/release/stage-p2c
Some checks failed
Release & Docker / release (push) Has been cancelled
Release GH — v0.51.214 (preserve loaded transcript width on same-session external refresh)
2026-06-02 12:17:53 -07:00
nesquena-hermes
3603027b74 test: update loadSession source-assertion tests for #3326 conditional renderMessages arg
#3326 changed loadSession's INFLIGHT + idle render calls from bare
renderMessages() to renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined),
and added reload-width-hint handling inside _ensureMessagesLoaded. Updated 5
source-pinning tests to match the new (behaviorally-equivalent) call form:
- test_regressions / test_issue2341: match 'renderMessages(' call form
- test_parallel_session_switch: match call form + widen 600->850 block window
- test_issue1690: assert the CONDITIONAL preserveScroll (cross-session still bottom-pins)
- test_issue3162: widen _ensureMessagesLoaded slice 2000->2600
2026-06-02 19:11:15 +00:00
nesquena-hermes
d9c1777469 docs(changelog): v0.51.214 release header for #3326 transcript-width fix 2026-06-02 18:53:31 +00:00
nesquena-hermes
7c4c8120a3 fix: preserve loaded transcript width on same-session external refresh (#3326, @viraatdas)
Capture a same-session force-reload hint (loaded renderable/message counts,
known count, truncation flag) BEFORE clearing the in-memory transcript, so the
authoritative reload requests a width that preserves what was loaded instead of
collapsing a long session to the default 30-message tail window mid-read.
Same-session force reloads render with preserveScroll. Resolves conflict with
the shipped #3306 carry-forward snapshot by keeping both (complementary).
Closes #3239.

Co-authored-by: viraatdas <viraatdas@users.noreply.github.com>
2026-06-02 18:53:12 +00:00
nesquena-hermes
e156cbf356 Merge pull request #3423 from nesquena/release/stage-p2b
Some checks failed
Release & Docker / release (push) Has been cancelled
Release GG — v0.51.213 (keep gateway context visible in chat transcripts)
2026-06-02 11:50:07 -07:00
nesquena-hermes
ebfff8e074 fix(stream): preserve repeated identical visible turns in gateway backfill (Codex MUST-FIX on #3300)
_merge_display_messages_after_agent_result shared one _emitted set between
context-backfill rows and the visible-display backbone; _message_identity
ignores timestamps, so a second identical visible user turn was suppressed as
already-emitted. Now the display backbone is always appended in order; a
separate _context_inserted set + the _display_id_set guard dedupe only the
spliced-in context rows. Adds a regression test with two identical visible
user turns + a context-only gap.
2026-06-02 18:43:25 +00:00
nesquena-hermes
f07629bb4e docs(changelog): v0.51.213 release header for #3300 gateway transcript fix 2026-06-02 18:36:21 +00:00
nesquena-hermes
952930c10e fix: keep gateway context visible in chat transcripts (#3300, @AJV20)
Backfill model-context turns into the visible transcript before saving the
latest reply while keeping hidden [context compaction] markers out of the
visible transcript, so sidebar/header message counts no longer collapse a
compacted gateway session to a 2-message conversation. Resolves the
transcript-loss regression the v0.51.197 Codex gate flagged on the original.

Co-authored-by: AJV20 <24819659+AJV20@users.noreply.github.com>
2026-06-02 18:28:41 +00:00
nesquena-hermes
ab05670daf Merge pull request #3416 from nesquena/release/stage-batch2
Some checks failed
Release & Docker / release (push) Has been cancelled
Release GF — v0.51.212 (i18n regenerate-title + self-restart argv + todos cold-load)
2026-06-02 10:44:10 -07:00
Vanshaj Pahwa
39a9029932 i18n: add CHANGELOG entry for session_title_regenerate* localization (#3364) 2026-06-02 17:34:48 +00:00
Vanshaj Pahwa
cdde338fc2 i18n: translate session_title_regenerate* keys in non-English locales (#3364)
The "Regenerate title" session-menu action (#3223) added five i18n keys
(session_title_regenerate, _desc, _regenerating, _regenerated,
_regenerate_failed). They shipped as English text in every non-English
locale block.

Translate all five keys across it, ja, ru, es, de, zh, zh-TW, pt, ko,
fr and tr, matching each locale's existing terminology (e.g. the word
used for "title" in session_rename_desc). zh and zh-TW are written as
\u escapes to match the existing style of those blocks.

Closes #3364
2026-06-02 17:33:49 +00:00
Qi Zhou
5c595c08d6 fix(todos): hydrate cold-load state from session snapshot 2026-06-02 17:33:49 +00:00
PatrickNoFilter
1402b56155 fix(updates): re-exec correctly in both source and frozen builds
You were right — the original analysis was inverted. The unprefixed
form (sys.argv only) is the correct fix for frozen/packaged builds
(PyInstaller, zipapp, etc.) where sys.argv[0] == sys.executable ==
<binary>; master's [sys.executable] + sys.argv form re-inserts the
binary as argv[1] in that case, which the interpreter then tries to
parse as the script to run — leading to a recursive-reexec that
never reaches bind().

But for a source checkout launched as `python server.py` via
bootstrap.py / ctl.sh / start.sh, sys.argv[0] is the SCRIPT path and
sys.executable is the interpreter. CPython treats argv[1] as the
script to run, so we must pass [sys.executable] + sys.argv — the
canonical CPython re-exec idiom.

The two cases are mutually exclusive on argv shape, so a flat swap
can't be right for both. Distinguish with sys.frozen (set by
PyInstaller / zipapp / similar) and pick the right form per case.

Also reworded the last-resort except comment — the prior
"(e.g. frozen binary)" parenthetical was misleading; this branch is
the unconditional fallback for any execv failure, not specifically
the frozen case.
2026-06-02 17:33:49 +00:00
nesquena-hermes
f12608487f Merge pull request #3415 from nesquena/release/stage-batch1
Some checks failed
Release & Docker / release (push) Has been cancelled
Release GE — v0.51.211 (reasoning heuristics + /model shortest-match + Copilot env-token filter)
2026-06-02 10:28:27 -07:00
Vanshaj Pahwa
63442869d6 Add CHANGELOG entry for /model prefix-match fix (#3368) 2026-06-02 17:12:07 +00:00
Vanshaj Pahwa
0ea5e4ef69 Fix model selection shadowing shorter model names (#3368)
The /model slash command's fuzzy fallback used a substring match that
took the first option containing the query, so "/model mimo-v2.5"
selected "mimo-v2.5-pro" instead of "mimo-v2.5".

Replace both fallback loops with a shared _bestModelMatch helper that
prefers an exact id/label match and otherwise returns the shortest
option containing the query, so a more specific query is no longer
shadowed by a longer variant. The longer variant is still selectable by
querying its full name.

Closes #3368
2026-06-02 17:11:37 +00:00
b3nw
68716b3e4f fix: generalized reasoning capability heuristics for custom providers (#3377) 2026-06-02 17:11:32 +00:00
happy5318
2892d3d6c0 fix: also filter GH_TOKEN env var from Copilot credential pool
Follow-up to PR #3382 review feedback. There are three GitHub-related
env vars that can be auto-detected:

- COPILOT_GITHUB_TOKEN: user-specific config, should NOT be filtered
- GITHUB_TOKEN: classic PAT, should be filtered (already done)
- GH_TOKEN: another env var, should also be filtered (this commit)

Use a frozenset (_AMBIENT_GH_ENV_SOURCES) to clearly define which
env var sources are ambient vs user-configured.
2026-06-02 17:11:32 +00:00
happy5318
02a1849ca4 fix: filter GITHUB_TOKEN env var from Copilot credential pool
Copilot API does not support classic PATs (ghp_*), only OAuth tokens (gho_*)
or fine-grained PATs (github_pat_*). When GITHUB_TOKEN env var contains a
classic PAT, it should be filtered from the credential pool to prevent
Copilot from appearing in the model picker dropdown.

The _is_ambient_gh_cli_entry() function now also filters entries with
source='env:GITHUB_TOKEN', treating them as ambient/auto-detected rather
than user-explicitly-added credentials.

Fixes issue where Copilot group appeared in WebUI model picker even when
gh auth token was an unsupported classic PAT.
2026-06-02 17:11:32 +00:00
nesquena-hermes
4baa26bb5b Merge pull request #3380 from nesquena/release/stage-batch1
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.210 — Release GD (stage-batch1: model-picker multi-slash fix + extensionless preview highlighting)
2026-06-01 22:23:26 -07:00
nesquena-hermes
7e97cdc335 docs: CHANGELOG for v0.51.210 (stage-batch1) 2026-06-02 05:08:44 +00:00
AJV20
6234473be4 fix: highlight extensionless workspace code files
(cherry picked from commit 3e61de7a734e46fbc5aa4820517ac2c7e6edad7d)
2026-06-02 05:08:30 +00:00
b3nw
d06776a4c8 fix: model picker snaps to wrong model with multi-slash IDs (#3360)
When a custom/proxy provider serves models whose IDs share the same base
name across vendor prefixes (e.g. vendor_a/deepseek/deepseek-v4-pro vs
vendor_b/deepseek/deepseek-v4-pro), several normalization functions use
split('/').pop() (or split('/')[-1]) which discards all segments except
the last.  This causes three user-facing symptoms: (1) clicking one
model selects a different colliding model, (2) configured-model badges
attach to the wrong dropdown entry, and (3) the model-chip label in the
composer bar is truncated to just the base model name.

Root cause: all three callers take only the last slash-segment instead
of stripping only the first (provider) segment and preserving the
remaining vendor hierarchy.

Fix 1 — _findModelInDropdown (static/ui.js): Move the exact string match
before the provider-aware normalized match.  Previously, when all models
share the same provider ID (common with LLM proxy setups), the normalized
match returned whichever colliding option appeared first in DOM order,
even though an exact match existed.

Fix 2 — _normalizeConfiguredModelKey (static/ui.js) and _norm_model_id
(api/config.py): Replace split('/').pop() / split('/')[-1] with a first-
segment-only strip (regex on frontend, split('/',1) on backend), matching
the strategy already used by _findModelInDropdown's norm lambda.  This
prevents multi-slash IDs from colliding in badge assignment and the
configured-entry dedup set.  Additionally, strip colon-qualified provider
prefixes (e.g. custom:name/) before the slash strip so badge-key variants
like 'custom:llm-proxy/opencode_go/model' merge correctly with the bare
'opencode_go/model' in the configured section dedup.

Fix 3 — getModelLabel (static/ui.js) and _get_label_for_model
(api/config.py): Same split('/').pop() to first-segment-strip change so
the composer-bar model chip and backend label preserve vendor context
(e.g. shows 'opencode_go/deepseek-v4-pro' instead of 'deepseek-v4-pro').

Verification: 9 new regression tests (test_issue3360) covering exact-
match priority, multi-slash normalization, and backend/frontend parity.
Updated 1 existing test (test_norm_model_id_trailing_empty_guard) that
asserted the old split('/').pop() pattern.  All 25 related tests pass.

AI Usage: Gemini (gemini-2.5-pro), via Antigravity IDE, pair-programmed.

(cherry picked from commit a454fecd2b3a83f7da34473c883be069171aeac9)
2026-06-02 05:08:30 +00:00
nesquena-hermes
866969161e Merge pull request #3376 from nesquena/review/pr-2622
Some checks failed
Release & Docker / release (push) Has been cancelled
Release GC — v0.51.209 (#2622 WebUI dashboard plugin system with iframe isolation)
2026-06-01 21:14:00 -07:00
nesquena-hermes
4b0c0c9c25 release: stamp v0.51.209 (Release GC — dashboard plugin system) 2026-06-02 04:09:42 +00:00
nesquena-hermes
be4496d23f fix(#2622): plugin card UX — legible Open button, single badge, visible toggle + reject protocol-relative tab.path
Nathan screenshot feedback on the Plugins card:
- Open button rendered as a yellow block with INVISIBLE text: --accent-text
  resolves to the same gold as --accent in the default theme (text==bg). Switched
  to a ghost/outline button (accent text + border on the card surface; fills on
  hover) — always legible regardless of theme.
- Removed the redundant DOUBLE 'Enabled' badge (the dashboard-specific badge
  duplicated the generic activation badge; kept the generic one).
- Toggle slider knob was hard to see on the gold 'on' state; added a drop shadow.
Also Opus SHOULD-FIX: _VALID_PLUGIN_TAB_PATH now rejects a leading '//'
(protocol-relative URL → remote origin in iframe.src). Test updated.
2026-06-02 03:58:02 +00:00
nesquena-hermes
63dd0c35e7 fix(#2622): asset route only serves built dist/ assets, not plugin source/config (Codex CORE)
Codex CORE (verified live): _PLUGIN_STATIC_ROOTS points at dashboard/ and
serve_plugin_static() served ANY file beneath it — so /dashboard-plugins/<name>/
plugin_api.py leaked plugin backend SOURCE, and manifest.json / .env were
reachable too. Now constrained: served path must be under dist/ or static/, no
dotfiles in any path segment, and a static-extension allowlist (refuses .py/.json/
.env/.toml/.sh etc.). Verified: plugin_api.py, manifest.json, dist/.env,
dist/config.py all 404; dist/app.js still serves. Regression test added.
2026-06-02 03:47:39 +00:00
nesquena-hermes
816a4a93f9 harden(#2622): DOM-bound plugin handlers + tab.path validation (Codex round-2)
Codex found two more once the config-guard bug was fixed (Opus concurred on #2):
1. panels.js _buildPluginCard built the Open button + enable toggle with inline
   onclick/onchange that interpolated tab.path / plugin.key into a JS-string-in-
   attribute context — HTML-escaping is insufficient there (quote breakout).
   Now rendered inert + bound via addEventListener with RAW closure values.
2. tab.path was unvalidated. Added _VALID_PLUGIN_TAB_PATH (^/[A-Za-z0-9._~/-]{0,255}$)
   in load_plugins() — absolute, no quotes/query/fragment/control chars.
Also Opus nit: deep-merge now coerces dashboard_plugins values to bool + str keys.
Regression tests added for both.
2026-06-02 03:39:41 +00:00
nesquena-hermes
944e739b40 fix(#2622): restore settings allowlist guard (Opus HALT) + validate plugin name
CRITICAL (Opus HALT on prior commit): the PR's edit to save_settings() replaced
'if k in _SETTINGS_ALLOWED_KEYS' with 'if k=="dashboard_plugins": continue' and
orphaned the whole validation body under the continue. Effects: (a) settings save
broken for every key except dashboard_plugins; (b) the allowlist security boundary
gone -> any client key (password_hash, signing_key_*) became settable. Restored the
guard + correct indentation; dashboard_plugins handled by the deep-merge above.
Verified in-process: language persists, password_hash/signing_key injection
rejected, dashboard_plugins still deep-merges.

Also (Opus SHOULD-FIX #3): validate plugin name against ^[a-z][a-z0-9_-]{0,63}$
so a manifest name like '../foo' can't make the URL-space ambiguous.

Regression tests added for both (the allowlist bug had ZERO coverage).
2026-06-02 03:22:25 +00:00
nesquena-hermes
f92373efd2 test(#2622): regression coverage for plugin asset isolation + enable-gate
4 tests: opt-in default-off, enable-gate reads settings, asset route sends
sandbox CSP + nosniff, both routes enforce the enable-gate server-side.
2026-06-02 03:15:01 +00:00
nesquena-hermes
6f9d455348 fix(#2622): harden plugin asset isolation + server-side enable-gate + i18n key
Deep-review (Opus MUST-FIX + Codex kick-back) findings, all confirmed with repros:

1. Same-origin XSS via direct nav to a plugin's raw .html/.svg asset: the
   /dashboard-plugins/<name>/ route served plugin-controlled HTML with text/html
   at the WebUI origin and NO sandbox header (only the in-panel iframe + the
   page route were sandboxed). Verified: <script>alert(document.cookie)</script>
   in a plugin html ran same-origin. Fix: send 'Content-Security-Policy: sandbox
   allow-scripts allow-forms allow-popups' + 'X-Content-Type-Options: nosniff'
   on the asset response (null-origin, same as the page route).

2. 'Disabled' was UI-only: toggling a plugin off just hid the Open button; its
   page + asset URLs kept serving. Fix: new _dashboard_plugin_enabled() gates
   BOTH the asset route and the page route server-side (opt-in, default off,
   disabled => 404).

3. i18n: panels.js referenced t('plugins_enable_toggle') but the PR defined a
   mismatched English-only 'settings_plugins_enable_toggle' (dead key) — toggle
   label fell back to literal AND tripped the locale-parity gate. Renamed to
   plugins_enable_toggle and added to all 12 locales (zh-Hant gets Traditional
   啟用, not Simplified).
2026-06-02 03:12:58 +00:00
nesquena-hermes
852a776823 review(#2622): lint cleanup + harden two no-op tests
- Remove unused imports (importlib, sys in api/plugins.py; pytest in test).
- Two tests asserted 'X or True' (always pass — meaningless). Rewrote them to
  actually verify the /plugins/ route allowlists plugin.css + uses relative_to
  traversal guard, and that manifest fields are html.escape()'d before IIFE-shell
  interpolation. Dropped a dead 'original_plugins' local for a real dict assertion.
2026-06-02 02:51:07 +00:00
nesquena-hermes
bf3ff69c5e feat(plugins): add WebUI dashboard plugin system with iframe isolation (#2622, @pix0127) 2026-06-02 02:48:11 +00:00
nesquena-hermes
3cafec3004 Merge pull request #3372 from nesquena/hotfix/upload-hardening
Some checks failed
Release & Docker / release (push) Has been cancelled
Release GB — v0.51.208 (workspace upload hardening hotfix)
2026-06-01 19:38:37 -07:00
nesquena-hermes
2ab0b56078 refine(hotfix): Opus SHOULD-FIX — drop redundant equality clause + tighten symlink test
- The is_relative_to() check already covers the workspace==target equality case
  (is_relative_to(A,A) is True), so the '!= workspace' prefix was redundant.
- Symlink test now asserts a hard 403 (proves the guard fires, not just that
  nothing leaked) and cleans up the out-of-workspace escape dir in a finally.
2026-06-02 02:28:44 +00:00
nesquena-hermes
f112d8aa9a fix: keep parse_multipart self-contained (local MAX_UPLOAD_BYTES import)
test_sprint1's parse_multipart tests exec() the function's source in an isolated
namespace with only re/email.parser imported; referencing the MAX_UPLOAD_BYTES
module global there NameErrored. Import it locally inside the function (with a
defensive fallback) so the function is self-contained and the isolated-exec
tests pass.
2026-06-02 02:27:21 +00:00
nesquena-hermes
1702a7604c lint: raise ... from None for the Content-Length parse guard (B904) 2026-06-02 02:20:43 +00:00
nesquena-hermes
fbcae5f71e fix: harden workspace upload surface (#3104 follow-up hotfix)
Codex regression-gate findings on the shipped #3104 upload code, each verified
with a repro and fixed:

1. Negative Content-Length bypassed the size cap → unbounded rfile.read(-1).
   The per-handler 'content_length > MAX_UPLOAD_BYTES' check is False for a
   negative value, so the guard is now centralized in parse_multipart()
   (validates [0, MAX_UPLOAD_BYTES]) — protects all four upload handlers.
2. .tar/.tbz2/.txz uploads silently skipped extraction (is_archive suffix set
   was narrower than extract_archive's) → now matches.
3. Rejected archives (zip-slip/zip-bomb/corrupt/too-many-members) showed a
   misleading 'Uploaded' success toast → workspace.js now surfaces extract_error.
4. An in-workspace symlink subpath let mkdir/writes escape the workspace root →
   target_dir is now required to be is_relative_to(workspace) before mkdir.

Regression tests added (negative+oversize CL, .tar extraction, symlink target).
2026-06-02 02:20:05 +00:00
nesquena-hermes
36faa02364 Merge pull request #3369 from nesquena/review/pr-2931
Some checks failed
Release & Docker / release (push) Has been cancelled
Release GA — v0.51.207 (#2931 Edge TTS as an alternative speech engine)
2026-06-01 19:15:12 -07:00
nesquena-hermes
08f8aba89d release: stamp v0.51.207 (Release GA — Edge TTS speech engine) 2026-06-02 02:10:43 +00:00
nesquena-hermes
8164c42f94 fix(#2931): restore dropped console.error in top-level boot catch (Codex SILENT finding)
The PR silently removed 'console.error([hermes] boot failed, e)' from the
top-level boot .catch() that exists on master, so caught boot-path failures
(session restore / inflight recovery / gateway startup) would no longer surface
in the console or the browser-smoke gate. Restored to match master.
2026-06-02 02:05:56 +00:00
nesquena-hermes
a1a142f4f7 test(#2931): pin auth OFF in the endpoint tests for suite-order determinism
The 2 endpoint tests passed in isolation but flaked in the full suite: a prior
test can leave is_auth_enabled() True globally, so the no-cookie requests got a
401 before reaching the method/length/voice/rate-limit guards under test. The
autouse fixture now monkeypatches api.auth.is_auth_enabled -> False (the handler
imports it at call time) so the validation-path assertions are deterministic.
2026-06-02 02:04:56 +00:00
nesquena-hermes
08fe4f51f0 fix(#2931): Edge TTS playback path (GET->POST), stoppable hands-free audio, test isolation
Both advisors (Opus MUST-FIX + Codex SHIP-ONLY-WITH-FIXES) caught that the Edge
TTS playback was broken despite the settings selector working:

1. ui.js _playEdgeTts used new Audio('/api/tts?text=...') — a GET — but /api/tts
   is POST-only (405) and registered only in handle_post. The per-message speaker
   button + auto-read silently failed in edge mode, and the GET leaked message
   text into the query string/access log. Rewritten to POST JSON + blob object
   URL (mirrors the working boot.js path), and now surfaces server errors
   (503 not-installed, 429 rate-limit) via toast instead of silent dead air.
2. boot.js hands-free Edge audio was a local var never assigned to the shared
   _playingEdgeAudio handle, so stopTTS() (from _deactivate) couldn't stop it.
   Now registered + cleared on end/error.
3. Test isolation: the _tts_limiter function-attribute singleton persisted across
   the whole suite, flaking 2 of my endpoint tests in the full run. Converted the
   reset to an autouse fixture (before+after each) + unique per-test client IPs.
2026-06-02 01:58:13 +00:00
nesquena-hermes
6801272f51 review(#2931): make edge-tts an optional dependency + add endpoint test coverage
Maintainer review changes on the Edge TTS PR:
- edge-tts was added as a HARD base requirement, but the /api/tts handler is
  designed optional (ImportError returns 503). Moved it out of requirements.txt
  with an OPTIONAL comment + install hint; updated the 503 message from the
  nonexistent 'see docs' to the actual install command. Keeps minimal base deps.
- PR shipped NO tests for a new auth+rate-limited+allowlisted network endpoint.
  Added tests/test_issue2931_edge_tts_endpoint.py covering method (405), missing
  text (400), over-length (400), voice allowlist (400), per-client rate limit
  (429) — all in-process, no real synthesis/network.
2026-06-02 01:28:35 +00:00
nesquena-hermes
1c29d6ac9c feat: add Edge TTS as alternative speech engine (#2931, @liuqiangweb-svg) 2026-06-02 01:21:39 +00:00
nesquena-hermes
f0c3668d97 Merge pull request #3367 from nesquena/review/pr-3104
Some checks failed
Release & Docker / release (push) Has been cancelled
Release FZ — v0.51.206 (#3104 workspace file upload + drag-and-drop with archive extraction)
2026-06-01 18:18:50 -07:00
nesquena-hermes
bb9b8054b1 release: stamp v0.51.206 (Release FZ — workspace file upload) 2026-06-02 01:15:43 +00:00
nesquena-hermes
6bb8d570e8 i18n(#3104): add uploading/uploaded translations to all covered locales
The English-only addition broke the locale-parity tests (es/zh/ja/ru/tr/ko all
enforce full key coverage vs English). Added translated uploading/uploaded to
it/ja/ru/es/de/zh/zh-Hant/pt/ko/fr/tr so every locale covers the new keys.
2026-06-02 01:11:01 +00:00
nesquena-hermes
df69de92f1 fix(#3104): add missing 'uploading'/'uploaded' i18n keys to English locale
The PR referenced t('uploading') and t('uploaded') in static/workspace.js with
JS fallbacks but never defined the keys, so test_static_literal_i18n_keys_exist_in_english_locale
(the i18n-key existence gate, also run in CI) went red. Added both to the English
locale (and the Korean block's English-placeholder upload keys for consistency).
2026-06-02 00:58:45 +00:00
nesquena-hermes
9690725f8e harden(#3104): archive member-count cap + bounded extraction-dir dedup (Opus SHOULD-FIX)
Opus review SHOULD-FIX on the upload surface:
- Add _MAX_ARCHIVE_MEMBERS=10000 cap in extract_archive (both zip + tar loops):
  a tiny archive with millions of members slips under the byte cap but can
  exhaust inodes/fds. Trips before extraction, cleaned up via the existing
  rmtree-on-exception. Regression test added.
- Bound the extraction-dir collision-suffix loop (was while-True) to 1000 tries.
Other Opus SHOULD-FIX items (member-count #1 done; #2 done) filed as follow-up
or N/A: same-field multi-file collapse doesn't apply (frontend sends one request
per file); .tar.gz stem cosmetic.
2026-06-02 00:52:50 +00:00
nesquena-hermes
d0917f2b24 fix(#3104): correct dedup filename reporting + make zip-bomb cap testable
Two issues caught by the PR's own tests against the out-of-process test server:
1. Dedup reporting bug: after a filename collision the file was correctly
   written to e.g. report-1.pdf, but the JSON response reported the ORIGINAL
   name (safe_name) — now reports dest.name. (Real user-facing bug.)
2. Zip-bomb cap was untestable: the test monkeypatched _MAX_EXTRACTED_BYTES in
   the pytest process, which has no effect on the separate server process where
   extraction runs. Made the cap env-configurable (HERMES_WEBUI_MAX_EXTRACTED_MB,
   read at call time via _max_extracted_bytes(); defaults to 10x upload cap),
   set it to 5MB in the conftest server env, and rewrote the test to upload a
   compressible archive that genuinely extracts past the cap. Also asserts no
   partial extraction dir is left behind.

Plus lint: unused field_name loop var -> _field_name, unused os import in test.
2026-06-02 00:41:11 +00:00
nesquena-hermes
ff81591e8e feat(workspace): add file upload + drag-drop with archive extraction (#3104, @antoniocarlos97ss) 2026-06-02 00:26:53 +00:00
nesquena-hermes
383e0501db Merge pull request #3363 from nesquena/release/stage-hi1
Some checks failed
Release & Docker / release (push) Has been cancelled
Release FY — v0.51.205 (stage-hi1: workspace syntax highlighting + generated-image rendering + manual title regeneration)
2026-06-01 17:20:49 -07:00
nesquena-hermes
7f37fdb724 release: stamp v0.51.205 (Release FY — stage-hi1) 2026-06-02 00:16:13 +00:00
nesquena-hermes
07a50ebf3b polish(#3220): clarify download button on dark images (border + backdrop blur)
Nathan feedback: the download icon read as 'oddly placed' on dark generated
images. Geometry was already correct (8px inset on the image corner) — the
issue was low contrast: a flat rgba(0,0,0,.55) button blends into a dark image.
Add a subtle 1px white border, soft shadow, and 3px backdrop-blur so the button
reads as a clear chip-on-image regardless of the underlying pixels (the standard
treatment for on-image controls). CSS-only.
2026-06-02 00:09:32 +00:00
nesquena-hermes
a66a008ad3 fix(#3220): shrink-wrap artifact-image wrapper so download button overlays the image
The .msg-artifact-image span was stretching wider than the image (inline-block
in a block context filled the line), so the absolutely-positioned download
button (right:8px of the wrapper) floated ~240px to the right of the image.
Add width:fit-content + max-width so the wrapper hugs the image; the button now
correctly overlays the image's top-right corner. line-height:0 removes inline
descender gap.
2026-06-01 23:49:10 +00:00
nesquena-hermes
e5ddb2ae1f iterate(stage-hi1 v2): #3220 larger generated images, #3223 compact icon+label menu
Nathan feedback round 2:
- #3220: generated images were rendering at the 120x90 upload-thumbnail size
  (too small for the subject of the message). Now render at natural aspect
  ratio up to 360px (max-height 360, responsive max-width), lightbox preserved.
- #3223: drop the per-item subtitle/description lines from the session action
  menu — show only icon + label (VS Code / browser / ChatGPT pattern). The
  description is preserved as a hover tooltip (title=). Makes the menu ~40%
  shorter, less crowded, and structurally less prone to viewport clipping.
2026-06-01 23:44:31 +00:00
nesquena-hermes
031a3ce2e9 polish(#3220): esc() download label + scrub orphaned media_open i18n keys
Opus iter2 SHOULD-FIX (both non-blocking, applied for cleanliness):
- esc() the media_download title/aria-label so a future translator's quote
  can't break out of the attribute (defense-in-depth).
- Remove the now-dead media_open key from all 11 locales + the ui.js fallback
  dict (the Open button was dropped in the clean-image redesign).
2026-06-01 23:03:48 +00:00
nesquena-hermes
8ce02caf11 iterate(stage-hi1): Nathan UX feedback — #3220 clean image+hover-download, #3337 uniform code bg, #3223 menu clip fix
#3220: redesign generated-image rendering from a permanent bordered card
(filename + Open/Download buttons) to a clean inline image with click-to-zoom
lightbox + a hover/focus-revealed Download overlay, matching ChatGPT/Claude/
Gemini. Drops redundant Open (lightbox already covers it). Tests updated.

#3337: fix two-tone code background — Prism's prism-tomorrow theme styled the
parent <pre> gray while the <code> was navy var(--code-bg), so dark theme showed
a gray frame around navy code. Override BOTH .preview-code[class*=language-] and
its <code> to var(--code-bg) (mirrors the chat code-block fix at .msg-body pre).

#3223: fix action-menu clipping — the new Regenerate-title row made the 9-item
menu tall enough to overflow the viewport bottom when opened on a top-anchored
row at short viewports (e.g. 1280x720). _positionSessionActionMenu now clamps
the menu within both viewport edges and caps max-height with scroll when the
menu is taller than the viewport.
2026-06-01 22:48:01 +00:00
nesquena-hermes
c1156b4c67 fix(#3337): prevent Prism highlight leaking across workspace files
Maintainer fix on stage (browser-test catch): Prism.highlightElement()
propagates the language-* class onto the parent <pre>, so previewing a
.css file then a .txt file rendered the plain text with CSS grammar.
Strip any stale language-* from #previewCode before each render and only
call highlightElement when a language was assigned. Adds regression test
(tests/test_issue3337_workspace_preview_highlight.py) and bumps the
_openSessionActionMenu scan window in test_1466 to cover the new
Regenerate-title action lines (#3223).
2026-06-01 21:56:26 +00:00
nesquena-hermes
546afe8374 feat: add manual session title regeneration (#3223, @AJV20)
Closes #3106. Adds /api/session/title/regenerate endpoint + session-action
menu item. Preserves chronology (touch_updated_at=False), guards read-only
and imported sessions, syncs to state.db when Insights sync enabled.

Maintainer refinement (Opus SHOULD-FIX): scope the is_imported guard to the
regenerate action only instead of broadening the shared _isReadOnlySession()
helper, which also gates rename/pin/archive/move/fork. Matches the backend
403 guard. Test updated to assert the scoped shape.
2026-06-01 21:39:51 +00:00
nesquena-hermes
949dc7aac8 feat: render generated media artifact cards (#3220, @AJV20)
- CHANGELOG entry moved to ### Added (feature, not fix)
- zh-Hant locale: use Traditional 開啟/下載 instead of simplified forms
2026-06-01 21:21:32 +00:00
nesquena-hermes
2d1b4642e2 feat: syntax highlighting in workspace file preview (#3337, @mysoul12138) 2026-06-01 21:20:42 +00:00
nesquena-hermes
b718cf436f Merge pull request #3361 from nesquena/release/stage-batch17
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.204: project/session operations honor the session's own profile (#3331)
2026-06-01 14:12:01 -07:00
nesquena-hermes
c5d4806a64 fix(#3331): gate findings — empty-session profile retag, root-alias project filter, create-profile validation
Codex+Opus gate findings on the profile-scoping PR:
1. panels.js: retag S.session.profile on ANY profile switch (was inside the
   if(data.default_model) block, so model-less profile switches left a stale chip).
2. sessions.js: project-picker filter now mirrors the server's root-alias
   tolerance (default <-> renamed-root) so a server-approved 'default' project
   isn't hidden for a renamed-root session.
3. routes.py /api/projects/create: validate the optional client-supplied profile
   via _PROFILE_ID_RE before stamping (was trusting raw client input -> could
   create hidden cross-profile rows). Updated the PR's string-assertion test.
2026-06-01 21:05:32 +00:00
nesquena-hermes
3cf4d15dc6 fix: align project/session operations with session profile instead of global active profile (#3331)
Co-authored-by: PINKIIILQWQ <PINKIIILQWQ@users.noreply.github.com>
2026-06-01 20:57:40 +00:00
nesquena-hermes
99b3745b01 Merge pull request #3359 from nesquena/release/stage-batch15
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.203: sticky manual unpin for streaming chat scroll (#3343)
2026-06-01 13:44:57 -07:00
nesquena-hermes
d09f2e4efb feat: sticky manual unpin for streaming chat scroll (#3343)
Supersedes the v0.51.199 proximity-re-pin (#3330) and the #3250 upward-intent
timeout with a sticky-unpin model (ChatGPT/Claude/Codex behavior): scroll up =
stay put until you return to the bottom or click the scroll-to-bottom control.
Reconciled against the shipped #3330 code: removed the now-dead
_recentMessageUpwardIntent reference from the #3319 rAF retry, kept the
load-time -Infinity intent-init fix, kept the pinned-only >500 catch-up.

Co-authored-by: pamnard <pamnard@users.noreply.github.com>
2026-06-01 20:34:06 +00:00
nesquena-hermes
720695a0c2 Merge pull request #3358 from nesquena/release/stage-batch14
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.202: filter interrupted-recovery control text from visible transcript (#3321)
2026-06-01 13:23:59 -07:00
nesquena-hermes
d40c2e38d0 test: drop unused 're' import in test_live_stream_ux (ruff F401) 2026-06-01 20:21:40 +00:00
nesquena-hermes
7d2be7f52c fix(#3321): drop provider_details_label recovery-control heuristic (over-filtered genuine interruptions)
Codex+Opus regression-gate finding: _isRecoveryControlMessage /
_streamRecoveryControlMessage fell back to matching
provider_details_label==='interruption details'. But a GENUINE 'Response
interrupted' card (Stop button, real provider crash) carries that exact label,
so the filter would drop a real user-facing interruption from the transcript on
the next render/restore — the inverse of the #3300 data-loss class. Require the
explicit server-set recovery_control marker; keep only the two fully-anchored
synthetic-text matches for pre-marker backward-compat. Adds a node-driven
regression test (revert-verified) asserting a label-only interruption card and a
real user turn stay visible while marker + strict text are filtered.
2026-06-01 20:14:29 +00:00
nesquena-hermes
5db97e1985 docs: v0.51.202 release header (stage-batch14 — #3321) 2026-06-01 20:07:46 +00:00
nesquena-hermes
f50b4fc2fa fix: filter interrupted recovery control text from visible transcript (#3321)
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-01 20:07:16 +00:00
nesquena-hermes
fa374f61d8 Merge pull request #3356 from nesquena/release/stage-batch13
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.201: colored diff lines in tool-card snippets (#3336)
2026-06-01 13:04:00 -07:00
nesquena-hermes
94ad2a6b8d docs: v0.51.201 release header + fix #3336 tool-card diff test extraction list 2026-06-01 19:54:45 +00:00
nesquena-hermes
0b7f32f5d9 feat: color diff lines in tool card snippets (#3336)
Co-authored-by: mysoul12138 <mysoul12138@users.noreply.github.com>
2026-06-01 19:53:40 +00:00
nesquena-hermes
1c6e34b7b6 Merge pull request #3354 from nesquena/release/stage-batch12
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.200: remote-gateway health probe + ephemeral-turn-field preservation
2026-06-01 12:52:18 -07:00
nesquena-hermes
042294e2a1 test: update sessions.js string-assertions for #3313 carry-forward rename
#3313 applies the ephemeral-field carry-forward at all 5 wholesale S.messages
replace sites (loadSession/_ensureMessagesLoaded/_loadOlderMessages/
_ensureAllMessagesLoaded/startGatewaySSE), renaming the replace RHS from
msgs/next to _msgsToAssign/_nextToAssign. These pre-existing tests pinned the
old literals; updated to match the new RHS (or made LHS-agnostic) while
preserving the invariants they protect (bump-before-replace, session-switch
guard, shorter-transcript guard).
2026-06-01 19:46:36 +00:00
nesquena-hermes
c0cfc1b975 docs: v0.51.200 release header (stage-batch12 — #3312 #3313) 2026-06-01 19:38:30 +00:00
nesquena-hermes
0cae7c644c fix: preserve ephemeral turn fields when loadSession force-reloads (#3313)
Co-authored-by: Sanjays2402 <Sanjays2402@users.noreply.github.com>
2026-06-01 19:38:00 +00:00
nesquena-hermes
329aae6979 fix: probe remote gateway via HERMES_API_URL before local fallback (#3312)
Rebased onto master + removed an unused 'io' import flagged by the ruff gate.

Co-authored-by: Sanjays2402 <Sanjays2402@users.noreply.github.com>
2026-06-01 19:38:00 +00:00
nesquena-hermes
b6e4899887 Merge pull request #3353 from nesquena/release/stage-batch11
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.199: pinned-scroll recovery + inline-math currency false-positive
2026-06-01 12:25:17 -07:00
nesquena-hermes
5d4ce75041 fix(#3330): init scroll-intent timestamps to -Infinity so load-time isn't read as intent
Codex follow-up finding: _lastMessageUpwardIntentMs/_lastNonMessageScrollIntentMs
initialized to 0, so _recentMessageUpwardIntent() returned true for the first 2s
after load (performance.now() < MESSAGE_UPWARD_INTENT_MS=2000) even with no user
scroll — which would disable the new #3319 retry guard during initial load. Sentinel
-Infinity makes 'no event recorded yet' read as no-intent. Also tightens the
pre-existing scrollIfPinned/settle callers that read the same helpers.
2026-06-01 19:19:08 +00:00
nesquena-hermes
3160b7bc36 docs: note digit-leading inline-math workaround in v0.51.199 changelog (Opus nit) 2026-06-01 19:18:08 +00:00
nesquena-hermes
fd3c4696e7 fix(#3330): guard the pinned-scroll rAF retry against user scroll-up during streaming
Codex regression-gate finding: the new requestAnimationFrame retry in
_setMessageScrollToBottom re-asserted scrollTop=scrollHeight + _scrollPinned=true
on the next layout frame unconditionally, so a user who scrolled up in that ~16ms
window during streaming would be snapped back and re-pinned, bypassing the
scrollIfPinned early-return guards. Re-check _messageUserUnpinned / upward-intent /
non-message-scroll-intent / !_scrollPinned inside the retry; on hit, only release
the programmatic-scroll latch and bail.
2026-06-01 19:14:14 +00:00
nesquena-hermes
14e447964f docs: v0.51.199 release header (stage-batch11 — #3330 #3311) 2026-06-01 19:08:51 +00:00
nesquena-hermes
04ce7599e3 stage-batch11: #3330 pinned-scroll + #3311 inline-math currency
#3330 Fix pinned chat scroll after message rebuild
Co-authored-by: jianongHe <jianongHe@users.noreply.github.com>

#3311 fix: reject inline math when $ is followed by a digit (currency)
Co-authored-by: toanalien <toanalien@users.noreply.github.com>
2026-06-01 19:08:30 +00:00
nesquena-hermes
9cad8980ff Merge pull request #3350 from nesquena/release/stage-batch10
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.198: custom-provider reasoning model-id normalize + profile skill counts + run-adapter RFC slice
2026-06-01 11:41:21 -07:00
nesquena-hermes
76da1d144a fix(profiles): TOCTOU-safe _SKILLS_STATS_CACHE read via .get() (Opus pre-release nit on #3341) 2026-06-01 18:38:39 +00:00
nesquena-hermes
49fce9af16 docs: v0.51.198 release header (stage-batch10 — #3327 #3334 #3341) 2026-06-01 18:24:25 +00:00
nesquena-hermes
de22c607bc stage-batch10: #3327 model-id normalize + #3334 RFC slice doc + #3341 profile skill counts
#3327 fix(reasoning): normalize custom-provider model ids for fallback heuristics
Co-authored-by: Carry00 <Carry00@users.noreply.github.com>

#3334 docs(rfc): mark run-adapter Slice 4f shipped, define Slice 4g gate
Co-authored-by: Michaelyklam <Michaelyklam@users.noreply.github.com>

#3341 fix(profiles): show enabled vs compatible skill counts
Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-06-01 18:24:05 +00:00
nesquena-hermes
c5aed7654c Merge pull request #3349 from nesquena/release/stage-batch9
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.197: stop agent replaying edited/undone messages (#3102)
2026-06-01 11:21:09 -07:00
nesquena-hermes
bce1f9430b fix(#3102): don't let persisted watermark permanently drop future state.db recovery rows
Codex regression-gate finding: since Session.save() no longer auto-clears the
truncation_watermark, the unconditional 'timestamp > watermark' skip in
merge_session_messages_append_only became a permanent ceiling — a genuine future
state.db-only row (recovery/compaction, missed by the sidecar) would be silently
dropped from /api/session and model-context reconstruction forever. Only apply the
above-watermark skip while the sidecar has NOT advanced past the watermark. Preserves
the #2914 deleted-tail filtering (revert-verified). Adds 2 regression tests.
2026-06-01 18:14:48 +00:00
nesquena-hermes
a755786314 docs: v0.51.197 release header (stage-batch9 — #3102 only; #3268 + #3300 dropped pending fixes) 2026-06-01 18:05:17 +00:00
nesquena-hermes
778652713c Fix #2914: stop agent replaying edited/undone messages; sync context_messages truncation (#3102)
Co-authored-by: AlexeyDsov <AlexeyDsov@users.noreply.github.com>
2026-06-01 18:05:02 +00:00
nesquena-hermes
65d1728c81 Merge pull request #3348 from nesquena/release/stage-batch8
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.196: file-manager external sessions + artifacts tool metadata + edge-toggle icon + type hints
2026-06-01 10:34:48 -07:00
nesquena-hermes
1c4365ce2a fix(artifacts): guard malformed tool_calls entries in collectSessionArtifacts (#3329)
Codex regression-gate finding: the OpenAI tool_calls loop dereferenced
tc.function with no null/type guard, so a persisted message.tool_calls
array containing a null or non-object entry would throw and abort artifact
collection. Mirror the existing tool_use-block guard. Adds a node-driven
regression test.

Co-authored-by: mysoul12138 <mysoul12138@users.noreply.github.com>
2026-06-01 17:27:02 +00:00
nesquena-hermes
89a72c2e3b docs: v0.51.196 release header (stage-batch8 — #3314 #3329 #3318 #3323) 2026-06-01 17:20:17 +00:00
kuishou68
23c51e8e64 fix: use Optional[T] for default None parameters in state_sync.py
Fixes type annotations where non-optional types (str, int) are used with
None defaults. All affected parameters now use Optional[T] = None.

Closes #3322
2026-06-01 17:20:17 +00:00
Sanjay Santhanam
92bddca3c7 fix: file manager falls back to state.db for external Telegram/CLI sessions (#3280) 2026-06-01 17:19:48 +00:00
mysoul12138
fae5ada40d fix: Artifacts tab cannot open files when messages carry structured tool metadata
Two bugs prevented clicking Artifacts entries from opening files:

1. collectSessionArtifacts() only read S.toolCalls, but
   _syncToolCallsForLoadedMessages clears it when messages carry
   their own tool_calls/tool_use metadata. Fix: also scan messages'
   structured tool data (OpenAI tool_calls array + Anthropic
   tool_use content blocks).

2. openArtifactPath() only stripped ~/ and ./ prefixes. When artifact
   paths were absolute (e.g. /mnt/.../workspace/file.js), /api/list
   received the full absolute path and returned 404. Fix: strip the
   session workspace prefix before calling _workspacePathExists.

Bonus: renderSessionArtifacts() now displays workspace-relative paths
instead of cluttered absolute paths in the artifact list.
2026-06-01 17:19:48 +00:00
xz-dev
2303aa1023 Fix workspace panel edge arrow direction 2026-06-01 17:19:48 +00:00
nesquena-hermes
1fcd81e38e Merge pull request #3310 from nesquena/release/stage-batch7
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.195: hide attachment path markers in chat UI (#3296)
2026-05-31 22:40:22 -07:00
nesquena-hermes
c1a3c3ef28 docs: v0.51.195 release header (stage-batch7 — #3296 hide attachment path markers) 2026-06-01 05:32:33 +00:00
AJV20
ec704356ac fix: hide attachment path markers in chat UI 2026-06-01 05:31:53 +00:00
nesquena-hermes
36fac9d0a3 Merge pull request #3309 from nesquena/release/stage-batch6
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.194: profiles config-import-cycle fix (#3303)
2026-05-31 22:13:10 -07:00
nesquena-hermes
1cf6460cd9 docs: v0.51.194 release header (stage-batch6 — #3303 only; #3300 dropped pending compaction-marker fix) 2026-06-01 05:05:29 +00:00
AJV20
d8471a7d87 fix(profiles): avoid config import cycle 2026-06-01 05:05:16 +00:00
nesquena-hermes
4591f53b0e Merge pull request #3308 from nesquena/release/stage-batch5
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.193: ctl dotenv opt-out + workspace inline-open + gateway reply polish (3 PRs)
2026-05-31 21:53:32 -07:00
nesquena-hermes
7003acc036 docs: v0.51.193 release header (stage-batch5) 2026-06-01 04:42:42 +00:00
AJV20
ca99d12e04 fix: keep WebUI reply guidance language-neutral 2026-06-01 04:42:23 +00:00
AJV20
7a1b2bfe72 fix: carry WebUI reply polish into gateway chat 2026-06-01 04:42:23 +00:00
xz-dev
239f913485 Fix workspace open in browser inline sandbox 2026-06-01 04:42:05 +00:00
AJV20
18eea5b2ab test(ctl): allow dotenv-free launches 2026-06-01 04:42:05 +00:00
nesquena-hermes
763756ca55 Merge pull request #3302 from nesquena/docs/markdown-refresh
docs: refresh markdown, regenerate contributor tally (137→194), README IA pass
2026-05-31 19:40:01 -07:00
nesquena-hermes
099dcf52fe test: point gateway_chat doc-contract test at docs/advanced-chat-setup.md
The README IA pass moved the Gateway-backed-chat operator docs out of the README
into docs/advanced-chat-setup.md (niche self-hosted feature). The contract test
test_gateway_chat_health_payload_is_documented_as_operator_diagnostic_only
asserted those phrases live in README.md — update it to follow the content to its
new home (CHANGELOG entry unchanged). Contract test moves with the content per
docs/CONTRACTS.md. Full suite: 7138 passed, 0 failed.
2026-06-01 02:36:25 +00:00
nesquena-hermes
ec168b3c67 docs(readme): re-sequence IA — pull Features up, consolidate access, extract niche docs
Information-architecture pass on the 840-line README so the most important
things come first and secondary/niche content is linked rather than inline:

- Reorder: Why -> Quick start -> FEATURES (was at line 502, now right after
  Quick start) -> Configuration & access -> Docker -> Running tests ->
  Architecture -> Docs -> Contributors. Readers see what it does before the
  deployment minutiae.
- Consolidate the scattered access sections (start.sh discovery, overrides,
  remote/SSH, Tailscale, manual launch) under one '## Configuration & access'
  H2 with H3 subsections.
- Extract two genuinely-niche blocks to new linked docs (nothing deleted):
  - docs/advanced-chat-setup.md — dynamic recall-prefill + Gateway-backed chat
  - docs/remote-access.md — SSH tunnel + Tailscale + ARM64-Android field report
  Quick start keeps a one-line pointer to each.
- Update Contents TOC + Docs index for the new order and new files.

README 840 -> 705 lines; content preserved (verified moved-not-dropped); all
internal links + new docs verified to resolve; docs/*.md gitignore-allowlisted.
2026-06-01 02:26:18 +00:00
nesquena-hermes
9d1b7b1d52 docs: freshen ROADMAP + ARCHITECTURE content + README notable-contrib counts
- ROADMAP.md: 8-themes -> 11-skins in status snapshot; replace stale per-file
  LOC architecture table with role descriptions + CI gate list (ruff/shard/
  browser/docker); note LOC drifts.
- ARCHITECTURE.md: refresh File Inventory — drop chronically-stale per-file LOC,
  add missing modules (state_sync.py, updates.py, i18n/login/icons/sw.js,
  pyproject.toml, ctl.sh, CONTRIBUTORS.md), refresh test count to ~7,150.
- README.md: update the 8 'Notable contributions' blurb headers (PR counts +
  release ranges) to match the regenerated tally.
2026-06-01 02:16:57 +00:00
nesquena-hermes
f14efc4368 docs: regenerate contributor tally (137->194, union, nobody dropped)
Regenerate CONTRIBUTORS.md + README contributors section from a verified
3-source union: GitHub merged-PR list, CHANGELOG.md attribution lines, and
Co-authored-by trailers on master commits (the canonical signal for a CLOSED
contributor PR whose commits were cherry-picked/absorbed and attributed).

- New tally: 194 contributors / 843 PR credits (was a stale 137 / 646).
- The increase: ~135 releases since the v0.51.58 pin PLUS newly-detected
  absorbed-CLOSED PRs the prior hand-count missed (e.g. franksong2702 148 =
  129 merged + 19 cherry-picked-and-attributed).
- UNION with the existing hand-curated file as a floor: 27 old contributors
  had no machine-readable signal (very old closed PRs) — preserved, ZERO dropped.
- Refreshed special-thanks PR counts to match.
- Generator committed to the maintainer workspace as scripts/regen_contributors.py
  (--merge-existing keeps it safe for all future refreshes).

Verified: every one of the original 137 logins still present (+57 new).
2026-06-01 02:14:01 +00:00
nesquena-hermes
31fcdfa39a docs: refresh + reorganize README and key markdown files
README:
- Add a Contents table of contents for navigability (800-line doc).
- Freshen stale snapshots: test count 5303/488 files -> ~7,150/~700 files;
  reframe contributor counts to point at CONTRIBUTORS.md as the live source.
- Rebuild the Architecture section: drop per-file exact LOC (drifts every
  release -> chronic staleness) in favor of a stable backend/frontend role map;
  add pyproject.toml + the ruff/browser/docker CI gates.
- Reorganize the Docs index by purpose (Start here / Using / Deploying /
  Contributing & design / Release history); add missing CONTRIBUTING.md,
  DESIGN.md, docs/workspace-git.md; convert bare paths to working links.

ARCHITECTURE.md: header v0.51.54/5303 -> v0.51.192/~7,150 + note that the
  numbers are a periodic snapshot (authoritative source = git tag + collect-only).

TESTING.md: header + footer test counts refreshed; drop the stale
  'through v0.50.21' framing; note the ruff/browser/docker gates.

ROADMAP.md: refresh the 'Last updated' stamp (v0.51.31/5028 -> v0.51.192/~7,150)
  with recent themes.

Markdown-only; all internal links verified to resolve.
2026-06-01 01:42:20 +00:00
nesquena-hermes
4c7c65192c Merge pull request #3298 from nesquena/release/stage-batch4
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.192: per-model context_length default-only guard (#3263, closes #3256) (stage-batch4)
2026-05-31 18:28:07 -07:00
nesquena-hermes
7e9f674edf fix(#3263): provider-compatible default match — reject same-bare-name on different providers (Codex over-match MUST-FIX)
Prior round stripped provider prefixes from both sides and matched bare-only,
which over-matched: openai/gpt-4o would match default openrouter/gpt-4o. Now the
matcher compares bare model ids AND rejects when both sides identify DIFFERENT
providers (from provider/ prefix, @provider: qualifier, or the explicit provider
arg). Same-provider / unknown-session-provider still match. Added cross-provider
rejection regression tests.

Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
2026-06-01 01:19:49 +00:00
nesquena-hermes
4147e7f3df fix(#3263): provider-aware default-model match (Codex final-gate MUST-FIX)
The default-only context_length guard compared model.default to the session
model with exact string equality. But model.default and the session model can
be stored in equivalent-but-different shapes (bare 'claude-opus-4.8',
provider-prefixed 'anthropic/claude-opus-4.8', or '@anthropic:claude-opus-4.8').
An exact compare wrongly treats the actual default model as non-default and
drops its configured context_length cap for provider-prefixed configs.

Add api/routes._model_matches_configured_default(session_model, cfg_default,
provider) that normalizes all three shapes, and use it at all 6 guard sites
(routes resolver + the 5 api/streaming.py sites: live-usage snapshot, persistence
_skip_cc_cl, persistence fallback _apply_cfg_ctx, SSE-done _dropped_stale_cap_sse,
SSE fallback _apply_cfg_ctx). Imported function-scoped in streaming to avoid the
routes<->streaming module-level circular import. 10 helper unit tests + a
behavioral test that a prefixed default still receives its cap.

Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
2026-06-01 01:14:22 +00:00
nesquena-hermes
17e7794923 fix(#3263): init _skip_cc_cl/_cc_cl before compressor block (UnboundLocalError on no-compressor path) + relax brittle #1318 source-assertion
The threshold-rescale block runs unconditionally after the fallback and
references _skip_cc_cl/_cc_cl, which were only defined inside 'if _cc_for_save:'.
On the no-compressor path (fresh agent / interrupted stream) that raised
UnboundLocalError (caught by test_issue1857_usage_overwrite). Hoist both inits
above the block (no-op rescale when no compressor). Also widen the #1318
source-assertion test to accept the widened fallback gate (still asserts the
falsy-check invariant).

Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
2026-06-01 00:14:13 +00:00
nesquena-hermes
18a459726b fix(#3263): apply Codex dual-gate MUST-FIX — recompute persisted stale cap + rescale terminal threshold
Codex regression gate (+Opus, both independently) found the default-only guard
dropped the stale compressor cap but two sibling paths stayed inconsistent:
1. Per-turn persistence: fallback resolver only ran when context_length was
   falsy, so a previously-persisted stale 232K survived forever on non-default
   sessions. Now also runs when _skip_cc_cl, and rescales threshold_tokens to
   the recomputed real cap (or clears it).
2. Terminal SSE 'done' payload: re-emitted the stale compressor threshold, so
   messages.js overwrote S.lastUsage and the indicator reverted on stream end.
   Now rescales threshold to the resolved window when the stale cap was dropped.
Added 3 source-structure regression tests pinning both fixes; bumped the brittle
test_pr1341 distance limit 13000→15000 (+ noted it should become structural).

Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
2026-06-01 00:09:21 +00:00
nesquena-hermes
7a734462b0 docs(changelog): v0.51.192 Release FL (stage-batch4) 2026-05-31 23:52:28 +00:00
nesquena-hermes
5768a4bfc8 test(#3263): bump brittle save-distance limit + add default-only context_length guard regression test
- test_pr1341 distance limit 9000→13000 (the PR legitimately added the
  default-only pre-save guard block; the test is designed to be bumped when a
  new pre-save mutation block lands — this was the only CI-red cause on shard 1).
- new tests/test_issue3256_context_length_default_only_guard.py: verifies the
  global model.context_length cap applies ONLY to model.default (revert-fix-
  verified — fails on master, passes with the fix).

Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
2026-05-31 23:51:49 +00:00
allenliang2022
cba69cd415 fix(context): rescale threshold_tokens to real cap in live usage snapshot
The default-only guard corrected context_length to the real per-model cap
(e.g. 1M for claude-opus-4.7-1m) but left threshold_tokens pointing at the
ContextCompressor's stale value (computed from the global 232K cap → 197.2k
@ 85%). UI then showed 'auto-compress at 197.2k / 1M' which is misleading.

Rescale threshold_tokens by the real/orig ratio so the displayed trigger
reflects the actual window (e.g. ~850k @ 1M).

NOTE: this only corrects the SSE display payload. The real auto-compress
trigger lives inside ContextCompressor in hermes-agent (agent_init.py:1446
constructs it with the global cap). A full fix requires a parallel change
upstream — tracked separately.
2026-05-31 23:49:41 +00:00
allenliang2022
1263cf03cf perf(context): cache real per-model context_length in live usage snapshot
_live_usage_snapshot() runs on every metering tick (~10x/sec while streaming).
The #3256 default-only guard recomputed get_model_context_length() there on
every tick for non-default models, which does a config read + potential
metadata/network probe — freezing claude-opus-4.7-1m streams while the default
model (4.8) stayed fast (guard not triggered for it). Resolve the real cap at
most once per stream via _real_ctx_cache. Backend-only, no frontend changes.
2026-05-31 23:49:41 +00:00
allenliang2022
4785aa542a fix(context): re-apply default-only context_length guard after v0.51.185 upgrade
Re-applies cb0065eb + b34311b3 (context_length default-only guard) which were
dropped by the upgrade reset to v0.51.185. Fixes 4.7-1m context window showing
as stale global cap (232K) instead of real 1M metadata. Backend-only: touches
api/routes.py + api/streaming.py, zero frontend/render changes.
2026-05-31 23:49:40 +00:00
nesquena-hermes
0488adc695 Merge pull request #3292 from nesquena/release/stage-batch3
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.191: skills-detail markdown styling (#3284) + launchd duplicate-start guard (#3291) (stage-batch3)
2026-05-31 16:20:44 -07:00
nesquena-hermes
e86488072a fix(#3291): make launchd duplicate-start guard port-aware (Codex gate MUST-FIX)
Codex regression gate found the launchd guard blocked ANY ctl.sh start while a
launchd job was live — including a legitimate second instance on a different
port (HERMES_WEBUI_PORT=8788). Now _launchd_webui_pid only treats the launchd
job as a conflict when its PID is actually listening on the requested CTL_PORT
(via a new best-effort _pid_listens_on_port helper using lsof); a different-port
start is allowed. When port ownership can't be determined (no lsof), falls back
to guarding only the default 8787 port so non-default ports are never wrongly
blocked. Adds a different-port-allowed regression test + makes the existing
block test deterministic. Co-authored-by preserved.
2026-05-31 23:08:52 +00:00
nesquena-hermes
15fddb84b6 docs(changelog): v0.51.191 Release FK (stage-batch3) 2026-05-31 23:02:52 +00:00
Andy Kang
91717b7e47 fix: block duplicate webui start when launchd owns 8787 2026-05-31 23:02:23 +00:00
Pamnard
f24d633189 Fix skills detail markdown styling with preview-md wrapper
Skill detail and linked markdown files now use the same preview-md
pipeline as Memory/Notes, with code highlighting and KaTeX enhancement.
2026-05-31 23:02:23 +00:00
nesquena-hermes
e16f699313 Merge pull request #3287 from nesquena/add-github-funding
chore: add GitHub Sponsors funding config
2026-05-31 15:40:19 -07:00
nesquena-hermes
717f4688d3 chore: add GitHub Sponsors funding config
Adds .github/FUNDING.yml so GitHub displays a "Sponsor" button on the
repository, linking to GitHub Sponsors for @nesquena.
2026-05-31 22:26:35 +00:00
nesquena-hermes
e459acd81a Merge pull request #3282 from nesquena/release/stage-batch2
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.190: Windows upgrade state-stranding hotfix (#2905) + gateway banner (#3194) + quiet tool previews (stage-batch2)
2026-05-31 12:44:46 -07:00
nesquena-hermes
1aed605fb6 fix(#3267): harden collapsed tool-preview secret filter (Codex gate MUST-FIX)
Codex regression gate found the exact-name hidden-key set leaked secret-shaped
args (apiKey/access_token/clientSecret/Authorization/cookie/...) into the
always-visible collapsed tool-card header. Replace with a normalized
case-insensitive _toolArgPreviewKeyIsHidden() predicate matching secret-bearing
substrings + camelCase variants. Adds 22 parametrized regression tests pinning
the secret-key denial + a legit-key-still-shown guard. Co-authored-by preserved.
2026-05-31 19:21:15 +00:00
nesquena-hermes
639a88e937 docs(changelog): v0.51.190 Release FJ (stage-batch2) 2026-05-31 19:12:56 +00:00
ai-ag2026
69072ac34d fix: keep collapsed tool previews quiet 2026-05-31 19:12:31 +00:00
nesquena-hermes
5742a23836 fix: apply Opus+Codex gate findings — stale-stopped not configured (#1944), narrow #2905 markers, scope except
Both pre-release reviewers (Opus advisor + Codex regression gate) converged on
the same MUST-FIX:

- #3194: treating gateway_stale_stopped_state as 'configured' contradicted
  #1944 (a stopped root gateway should read like 'not configured' so the
  banner doesn't nag). Now ONLY stale-RUNNING metadata
  (reason=gateway_stale_running_state or gateway_state=='running') flips
  configured=True; stale-stopped falls through to bool(identity_map) like the
  genuinely-unconfigured case. Updated the test accordingly + added a
  stale-stopped no-regression test.

Opus follow-ups also applied:
- #2905: narrowed the populated-home markers to WebUI-only artifacts
  (webui/, webui/sessions, webui/settings.json), dropping config.yaml/auth.json
  so a long-time agent user installing WebUI fresh isn't wrongly diverted to
  the legacy %USERPROFILE%\.hermes (auth.json predates #2897 there).
- profiles._resolve_base_hermes_home(): narrowed except Exception -> ImportError
  so a real bug in the config helper still surfaces.

Adjacent suites green: #2840, #1879, gateway_status_agent_health (66 tests).
2026-05-31 19:12:30 +00:00
nesquena-hermes
5dc11ef977 fix: Windows upgrade state stranding (#2905) + gateway-configured banner on first deploy (#3194)
Two urgent breaking bugs that had no PR, combined into one hotfix.

#2905 (data-loss-class): v0.51.134 moved the Windows default Hermes home
from %USERPROFILE%\.hermes to %LOCALAPPDATA%\hermes (PR #2897) with no
migration, so upgrading Windows users opened the app to empty
sessions/pins/settings (data intact on disk, at an address the new build
no longer read). _platform_default_hermes_home() now prefers the populated
legacy home ONLY when the new location is not yet established —
non-destructive (no file moves) and self-healing on next launch.
profiles._resolve_base_hermes_home() delegates to the same config helper so
the active-profile pointer can never drift from STATE_DIR.

#3194: GET /api/gateway/status reported 'Gateway not configured' on a fresh
two-container Docker deploy because an alive=None + gateway_stale_running_state
health payload with an empty identity_map fell through to
configured=bool(identity_map)=False. The alive=None branch now treats a
payload carrying gateway metadata (gateway_state detail, or a stale-running/
stale-stopped reason) as configured.

Tests: +17 regression tests (11 for #2905 incl. full truth table + non-destructive
guard + POSIX no-op; 6 for #3194 incl. 5 no-regression guards). Full suite
7090 passed, 0 failed.

Closes #2905
Closes #3194
2026-05-31 19:12:30 +00:00
nesquena-hermes
1d4d40b16d Merge pull request #3277 from nesquena/release/stage-batch1
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.189: ruff lint gate + SSE refresh dedupe + tooltip i18n (stage-batch1)
2026-05-31 11:39:37 -07:00
nesquena-hermes
a92c23c4fa docs(changelog): v0.51.189 Release FI (stage-batch1) 2026-05-31 18:27:00 +00:00
nesquena-hermes
e58fb6a201 ci: forward-looking ruff lint gate (E9+F+B, new-code-only) (#3275)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-05-31 18:26:37 +00:00
ai-ag2026
e34c632236 fix: localize WebUI tooltip quick wins 2026-05-31 18:26:31 +00:00
PINKIIILQWQ
aeda6add2b fix: suppress phantom sidebar refresh on gateway SSE reconnect
Shift from backend mtime-based detection to frontend SSE deduplication.

Backend: Revert gateway_watcher.py to original pure hash-based polling.
Remove _get_db_mtime, _detect_gateway_restart, and mtime tracking.
This is a no-op in behavior — the original was already hash-only.

Frontend: Add deduplication at the SSE event handler level.
- _gatewaySessionSnapshotKey(sessions): deterministic key from
  session_id + updated_at + message_count (same fields as backend hash)
- _isGatewaySessionForSnapshot(session): classify non-webui sessions
- _isDuplicateGatewaySessionSnapshot(sessions): compare SSE payload
  against current _allSessions, filtered to gateway subset
- SSE sessions_changed handler wraps renderSessionList() in dedupe:
  identical data → skip refresh

This directly addresses the real root cause: the SSE reconnect snapshot
(routes.py:7735) unconditionally pushes an initial snapshot, and the
frontend always re-renders. After this fix, a reconnect with unchanged
session data is correctly detected and the redundant redraw is skipped.

Previously submitted as #3259 (backend mtime approach, now closed per
maintainer review).
2026-05-31 18:26:30 +00:00
nesquena-hermes
78994c6a49 Merge pull request #3274 from nesquena/release/stage-batchH
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.188: configured runner-client boundary, default-off (batchH, #3073)
2026-05-31 10:55:47 -07:00
nesquena-hermes
ba7ae5786d harden: runner client rejects non-http(s) base_url + no redirect-follow (#3073)
Defense-in-depth flagged by both pre-release reviewers (Opus + Codex), both
non-blocking but cheap on a credential-handling surface:
- reject any non-http(s) HERMES_WEBUI_RUNNER_BASE_URL scheme at construction
  (a misconfigured file:// / ftp:// can never reach urlopen);
- route requests through an opener that does NOT follow 3xx redirects, so a
  misbehaving/compromised runner cannot smuggle the Bearer token to another host.
Both operator-misconfiguration-only (not user-reachable). +2 regression tests.

Co-authored-by: AJV20 <AJV20@users.noreply.github.com>
2026-05-31 17:49:26 +00:00
nesquena-hermes
fd01d80ca6 docs(changelog): v0.51.188 Release FH (stage-batchH) 2026-05-31 17:37:39 +00:00
nesquena-hermes
33fbd9fc6d feat: add configured runner client boundary (#3073)
Opt-in HTTP runner-client boundary for the runner-local runtime adapter
(RFC hermes-run-adapter-contract / #1925, Slice 4c/4d). Default-OFF:
without HERMES_WEBUI_RUNNER_BASE_URL the factory preserves the bounded
'not configured' path; when set, WebUI acts only as a JSON HTTP client +
SSE bridge for start/observe/status/controls without owning runner maps.
New api/runner_client.py + additive _runner_* helpers in api/routes.py;
no change to the live _run_agent_streaming legacy path.

Co-authored-by: AJV20 <AJV20@users.noreply.github.com>
2026-05-31 17:37:11 +00:00
nesquena-hermes
556f5d1c15 Merge pull request #3271 from nesquena/release/stage-batchG
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.187: workspace-preview persistence + scroll-intent window (batchG)
2026-05-31 10:10:46 -07:00
nesquena-hermes
d46d3a1411 fix: canonicalize ./ and ~/ prefixes in _normalizeArtifactPath (#3262)
Pre-release Codex regression gate caught that _normalizeArtifactPath()
did not strip ./ or ~/ prefixes, so a tool arg recorded as ./foo.md did
not match a file-tree-opened foo.md in _turnMutatedPreviewPaths — the
open preview was left stale after an agent edit via a ./-prefixed path.
Strip ~/ and leading ./ before ignore/membership checks. Node-driven
regression test pins foo.md == ./foo.md == ~/foo.md and confirms the
existing ignore-dir / URL / empty rejections still hold.

Co-authored-by: Pamnard <pamnard@users.noreply.github.com>
2026-05-31 17:03:07 +00:00
nesquena-hermes
16bb1df44b test: pin #3250 scroll-intent window + fix #2823 openFile anchor for #3262
- Pin MESSAGE_UPWARD_INTENT_MS>=2000ms + intent-helper-uses-constant +
  downward-repin-independence (#3250, co-authored emanon312).
- Update the #2823 large-markdown-preview test's openFile() signature
  anchor for #3262's openFile(path, opts={}) extension.

Co-authored-by: emanon312 <emanon312@users.noreply.github.com>
2026-05-31 16:54:55 +00:00
Pamnard
ee414144d3 Reload open workspace preview when agent mutates that file
Track write/edit tool paths per turn, refresh the open preview on
tool_complete and after preservePreview loadDir on stream done, without
closing preview for unrelated responses or wiping unsaved local edits.
2026-05-31 16:54:22 +00:00
Pamnard
9365f2d219 Fix workspace preview closing on chat stream done
Background file-tree refresh after a response must not call clearPreview();
preserve the open preview while still reloading the directory listing.
2026-05-31 16:53:57 +00:00
emanon312
e24ca105d7 fix: extend upward scroll intent timeout to prevent streaming scroll snap-back
Increase MESSAGE_UPWARD_INTENT_MS from 450ms to 2000ms to fix a race
condition where the user scrolls up during streaming, pauses to read
for >450ms, and then gets snapped back to the bottom.

The root cause: after the 450ms upward-intent window expires, DOM layout
changes from the streaming markdown parser (smd), tool card insertions,
or code re-highlighting can trigger scroll events that the handler no
longer recognizes as user-initiated. When the resulting position lands
inside the 250px near-bottom zone for two consecutive samples, the
hysteresis counter re-pins (_scrollPinned=true) and the next streaming
token's scrollIfPinned() call forces scrollTop to the bottom.

With a 2-second window, the user's upward intent persists through typical
streaming DOM churn. Downward scrolling and the scroll-to-bottom button
are unaffected — movedUp requires top < _lastScrollTop-2 which is false
for downward movement regardless of the intent timeout.

Refs: #1360 (macOS momentum protection), #1731 (direction-aware unpin)
2026-05-31 16:53:57 +00:00
nesquena-hermes
e09bd013d9 Merge pull request #3269 from nesquena/release/stage-batchF
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.186: update-checker ff-reachability fall-through + utf-8 git-output test coverage (batchF)
2026-05-31 09:33:52 -07:00
nesquena-hermes
02bdd214c1 docs(changelog): v0.51.186 Release FF (stage-batchF) 2026-05-31 16:18:31 +00:00
Pamnard
3a5cf6cda6 fix(updates): fall through when release tag is not ff-reachable
When agent checkouts track main past an older tag but the newest published
tag is on a divergent side branch, stop advertising tag-based updates and
route check/apply through the upstream branch instead.
2026-05-31 16:18:06 +00:00
zapabob
f17f582fc1 test: cover utf-8 git output decoding 2026-05-31 16:18:06 +00:00
nesquena-hermes
d4d87a901b Merge pull request #3251 from nesquena/release/stage-batchE
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.185 — Release FE (stage-batchE): clarify-card bug-fix batch
2026-05-30 23:58:51 -07:00
nesquena-hermes
907b00a449 docs(changelog): v0.51.185 Release FE (stage-batchE) 2026-05-31 06:50:40 +00:00
nesquena-hermes
997eff58ae Merge PR #3249 into stage-batchE 2026-05-31 06:50:11 +00:00
nesquena-hermes
86a1ddc3a7 Merge PR #3247 into stage-batchE 2026-05-31 06:50:10 +00:00
nesquena-hermes
584a3f0bf1 Merge PR #3245 into stage-batchE 2026-05-31 06:50:10 +00:00
allenliang2022
eddb17028d fix(updates): prevent startup crash on non-UTF-8 (GBK) locales
_run_git used subprocess.run(text=True) without an explicit encoding, so on
Chinese Windows (and other non-UTF-8 codepages) git stdout was decoded with the
locale codepage. _dirty_suffix() runs `git diff --binary HEAD`, whose binary
bytes are not valid GBK, raising UnicodeDecodeError in the subprocess reader
thread. That left r.stdout = None, so `r.stdout.strip()` raised AttributeError
during module import of api.updates, crashing server.py before it could bind
its port.

Force UTF-8 decoding with errors=replace and guard against None defensively.
2026-05-31 14:32:48 +08:00
mysoul12138
f9ff6df883 fix: prevent browser autofill on clarify input (#clarify-autofill)
Chrome's password manager aggressively autofills the clarify card's
input field with saved credentials (e.g. provider base URLs) despite
autocomplete='off'. This causes two bugs:

1. 'Clarification closed. Your draft was kept in the composer.' appears
   on every session completion because _stashClarifyDraft reads the
   autofilled value and treats it as a user draft.

2. The autofilled URL gets injected into the main composer, confusing
   the user.

Fix: add readonly attribute to the clarify input element so Chrome's
autofill ignores it. When showClarifyCard() makes the card visible,
readonly is removed programmatically so the user can type normally.

Both the static HTML (index.html) and the dynamic DOM creation
(_ensureClarifyCardDom in messages.js) are patched.
2026-05-31 13:54:02 +08:00
Andy Kang
18e9a6b9c9 fix: distinguish identical clarify prompts by id 2026-05-31 14:12:15 +09:00
nesquena-hermes
13c7f7157b Merge pull request #3244 from nesquena/release/stage-batchD
Some checks failed
Release & Docker / release (push) Has been cancelled
Release stage-batchD → v0.51.184 (raw audio mode #3169 + scroll-preserve #3237 + non-POSIX test skip #3235)
2026-05-30 22:12:02 -07:00
nesquena-hermes
0cceb3bebf fix(#3169): pin capture backend at mic start (_activeCaptureMode) — toggle-mid-record safety (Codex review #2)
Codex round-2: _stopMic and mediaRecorder.onstop read the CURRENT _rawAudioMode
to choose backend/dispatch, but the recording was started on the OLD mode — so
toggling Settings→Sound mid-recording could stop the wrong backend (orphaning
the other) or dispatch raw-vs-transcribe wrongly. Pin _activeCaptureMode
(speech | media-raw | media-transcribe) at start; _stopMic + onstop use it.
Adds front-end source-invariant regression tests.

Co-authored-by: lucasrc <lrclucas@gmail.com>
2026-05-31 04:54:56 +00:00
nesquena-hermes
c74a019a8a fix(#3169): honor explicit Send-click (_micPendingSend) in raw-audio mode (Codex review)
Codex pre-release gate: clicking Send while raw-audio recording with text in the
composer attached the audio but never sent — btnSend sets _micPendingSend=true
before _stopMic, but _sendRawAudio only called send() when the textarea was empty.
Mirror the transcribe path: if _micPendingSend is set, clear it and send()
regardless of composer text; otherwise keep the empty-composer auto-send + toast.

Co-authored-by: lucasrc <lrclucas@gmail.com>
2026-05-31 04:47:04 +00:00
nesquena-hermes
6a7e8a7b94 docs(CHANGELOG): v0.51.184 — raw audio mode (#3169) + scroll-preserve (#3237) + non-POSIX test skip (#3235) 2026-05-31 04:34:49 +00:00
Lucas Coutinho
038bf9924f fix: _stopMic skips recognition.stop() when raw audio mode is active
Bug: when raw audio mode is enabled, SpeechRecognition is
skipped and MediaRecorder is used instead. But _stopMic()
checked for a truthy 'recognition' reference (which exists
even when unused) and called recognition.stop() — a no-op
since speech recognition was never started — never reaching
the mediaRecorder.stop() branch. Recording could never be
stopped by the mic button.

Fix: add !_rawAudioMode guard to the recognition.stop() branch.
2026-05-31 04:34:17 +00:00
Lucas Coutinho
5ebf98479d fix: rename _updateMicBadge → _updateMicTooltip, use monkeypatch in tests
- 'Badge' naming was misleading since the visual badge was removed
- Use monkeypatch.setitem with pytest fixture instead of manual
  import sys + del sys.modules (safer cleanup, no inline import)
- All 9 tests pass
2026-05-31 04:33:52 +00:00
Lucas Coutinho
6258afbaef fix: tooltip conflict with RAW badge — use span instead of ::after
The .has-tooltip CSS uses ::after to show the tooltip text.
Using ::after for the RAW badge overwrote the tooltip content.
Fixed by adding a <span class="mic-raw-badge"> child element via JS.

Also: use _setButtonTooltipAndKey for consistent i18n tooltip management.
2026-05-31 04:33:52 +00:00
Lucas Coutinho
a1e703203d chore: remove stale plan files from previous branch 2026-05-31 04:33:52 +00:00
Lucas Coutinho
f6b658c7a2 fix: use _setButtonTooltipAndKey for mic tooltip to update data-i18n-title
The global _setButtonTooltip only sets data-tooltip, not data-i18n-title.
The i18n system reads data-i18n-title for locale-aware tooltip updates.
Switched to _setButtonTooltipAndKey which manages both attributes.
2026-05-31 04:33:52 +00:00
Lucas Coutinho
41a8b1deb0 feat: add raw audio upload mode — send audio as attachment instead of transcribing
- Settings toggle in Sound section (after voice mode button)
- Mic button shows 'RAW' badge when raw audio mode is active
- Raw mode: record → pending file → send() (auto-send if textarea empty)
- Dictation mode: unchanged (transcribe → textarea)
- 12 locales with full i18n keys and translations
- 4 backend tests for upload + transcribe regression

PR: Thinking Path
- Hermes WebUI has dictation via Web Speech API / MediaRecorder → /api/transcribe
- Telegram sends raw audio as attachment, agent decides what to do
- Raw audio mode enables external STT, emotion/noise analysis, multimodal models

What Changed
- static/index.html: settingsRawAudio checkbox in Sound section
- static/boot.js: raw audio preference, send raw blob as pending file
- static/style.css: badge RAW on mic button
- static/i18n.js: 12 locales with translations
- tests/test_raw_audio_upload.py: 4 tests for upload + regressions

AI Usage
- Provider: opencode-go
- Model: deepseek-v4-flash
2026-05-31 04:33:52 +00:00
Andy Kang
6091428791 fix: preserve scroll on CLI import refresh 2026-05-31 04:33:12 +00:00
zapabob
3b852bd98a test: skip terminal cleanup collection on non-posix 2026-05-31 04:33:11 +00:00
nesquena-hermes
83a45b6fe1 Merge pull request #3240 from nesquena/release/stage-batchC
Some checks failed
Release & Docker / release (push) Has been cancelled
Release stage-batchC → v0.51.183 (#3219 inline file:// media + #3234 /api/media secret-file confinement)
2026-05-30 21:21:15 -07:00
nesquena-hermes
d3b69ab98b fix(security #3234): deny per-profile webui_state state subdirs (Codex review #11)
Per-profile WebUI state lives at <root>/webui_state (api/workspace.py), so
<base>/profiles/<name>/webui_state/sessions/*.json was reachable — it is not a
direct child of the profile root, so the prior deny-subdir loop missed it. Add
<root>/webui_state/<state-subdir> to the deny dirs for every Hermes root. Adds a
regression assertion (profile webui_state/sessions/*.json → 403).
2026-05-31 04:11:35 +00:00
nesquena-hermes
a62d35a0b3 fix(security #3234): deny atomic-write temp auth/state files under Hermes roots (Codex review #10)
auth.py/passkeys.py write via tmp*.<name>.tmp sidecars then rename; deny those
suffixes (.sessions.tmp, .login_attempts.tmp, .passkeys.tmp, .passkey_challenges.tmp)
under Hermes roots so a momentary temp file can't be fetched via /api/media.
2026-05-31 04:05:47 +00:00
nesquena-hermes
d1645ff803 fix(security #3234): deny passkeys.json + .passkey_challenges.json + .login_attempts.json (Codex review #9)
Codex direct probe found three more auth-state basenames under STATE_DIR that
/api/media still served: passkeys.json + .passkey_challenges.json (api/passkeys.py)
and .login_attempts.json (api/auth.py). Add them to _DENY_FILENAMES.
2026-05-31 03:58:19 +00:00
nesquena-hermes
ce7a2c14db fix(security #3234): enumerate named-profile roots, drop wholesale profiles deny (Codex review #8)
Denying the whole <root>/profiles tree 403'd legitimate named-profile workspace
media (<base>/profiles/p1/workspace/shot.png). Fix: remove 'profiles' from
_DENY_SUBDIRS and instead enumerate each <root>/profiles/<name> directory as its
own Hermes root — so each profile's sensitive subdirs (sessions/memories/cron/
logs/checkpoints/backups) + secret filenames are denied, while that profile's
workspace/ is allowed via the carve-out. Adds a regression test: named-profile
workspace media serves, profile + sibling-profile auth.json stay 403.
2026-05-31 03:48:59 +00:00
nesquena-hermes
124044dc11 fix(security #3234): do not deny STATE_DIR wholesale — keep STATE_DIR/workspace media (Codex review #7)
The default workspace lives at STATE_DIR/workspace, so denying STATE_DIR itself
403'd legitimate workspace media. STATE_DIR is already in _hermes_roots, so its
sensitive subdirs (STATE_DIR/sessions, /memories, /profiles, etc.) are still
covered by the per-root subdir loop; direct sensitive files are still caught by
the filename denies. Drop the wholesale _state_dir deny. Adds a regression test
proving STATE_DIR/workspace/shot.png serves while STATE_DIR/sessions/*.json 403s.
2026-05-31 03:41:21 +00:00
nesquena-hermes
4fa051ea14 fix(security/#3234 + #3219): unify case-folded path checks, fix workspace over-block, protect raw <pre> (Codex review #6)
1. routes.py: hoist a single case-folded path helper (_norm/_within_ci/_equal_ci)
   used for ALL deny + carve-out comparisons (consistent macOS/Windows safety).
2. routes.py: split the deny into (a) dir-based denies that ALWAYS fire (even
   inside the active workspace — so a workspace overlapping a state dir cannot
   expose sessions/memories), and (b) filename denies relaxed only by the
   carve-out. Fix the over-block: a workspace that is a proper DESCENDANT of a
   Hermes root (e.g. STATE_DIR/workspace) is a legit project workspace and keeps
   the carve-out; only a root-itself / ancestor / $HOME / profiles / state-subdir
   workspace disables it.
3. ui.js: move the bare file:// media-stash pass after the raw-<pre> stash too,
   so file:// inside a raw <pre> block stays literal (not just fenced/inline code).
2026-05-31 03:24:35 +00:00
nesquena-hermes
5758d4a827 fix(security/#3234 + #3219): case-fold deny checks + make bare file:// rewrite code-fence-aware (Codex review #5)
1. api/routes.py: case-fold /api/media deny filename + dir containment checks
   (os.path.normcase + casefold) so STATE.DB / Sessions/ cannot bypass the
   state/secret deny on case-insensitive filesystems (macOS/Windows).
2. static/ui.js: move the bare file:// media-stash pass to run AFTER fenced-block
   and inline-code stashing, so a file:// inside a code block / backtick span
   stays literal text instead of becoming an auto-loaded <img>. The MEDIA: stash
   keeps its first-position precedent.
Adds behavioral renderer tests (real renderMd via node) for fenced + inline code
file:// staying literal, bare file:// becoming media, and anchors keeping the
link path. Closes the last Codex review items for #3234.
2026-05-31 03:17:10 +00:00
nesquena-hermes
26305c2ebb fix(security #3234): gate active-workspace carve-out against broad/internal roots (Codex review #4)
Codex round-4: the carve-out could re-open the hole if the active workspace is
pathologically set to a broad/internal root ($HOME, ~/.hermes, a profile root)
— get_last_workspace only checks is_dir(), so workspace=~/.hermes would serve
state.db. Gate the carve-out: disable it when the active workspace IS, CONTAINS,
or is CONTAINED BY any Hermes root, or is $HOME / a */profiles dir / a named
profile root / an internal state subdir. Adds a unit test proving state.db stays
403 when the active workspace is the Hermes home. Widen CSP-slice test window.
2026-05-31 03:08:10 +00:00
nesquena-hermes
343bad7263 fix(security #3234): redesign /api/media deny with active-workspace carve-out (Codex review #3)
Codex round-3 found the prior multi-profile hardening OVER-blocked: denying
STATE_DIR + base/profiles wholesale 403'd legitimate active-workspace media.
Redesign around a single principle: the ACTIVE WORKSPACE is the user's own
content (never deny), Hermes INTERNAL STATE lives outside any workspace (deny).
If target is inside the active workspace -> allow; else deny known secret/config
basenames + internal state subdirs across all Hermes roots. Also folds in Opus
defense-in-depth: adds cron/logs/checkpoints/backups subdirs +
gateway_state.json/channel_directory.json/jobs.json basenames. Adds an
over-block regression test (a /tmp artifact named settings_* still serves 200).
2026-05-31 02:55:53 +00:00
nesquena-hermes
c514b32f61 fix(security #3234): cover ALL Hermes roots in /api/media deny-list (Codex review #2)
Under a named profile, process HERMES_HOME is ~/.hermes/profiles/<name> but the
allowlist still grants base ~/.hermes — so the prior deny (anchored only on the
active-profile root + STATE_DIR) left ~/.hermes/state.db and sibling-profile
secrets (~/.hermes/profiles/other/auth.json) reachable. Build deny roots from
every Hermes state root the allowlist accepts: active HERMES_HOME, base ~/.hermes,
api.profiles._DEFAULT_HERMES_HOME, and STATE_DIR; apply the state-subdir dir-denies
under each. Widen the CSP-slice structural test window to match.
2026-05-31 02:45:17 +00:00
nesquena-hermes
68a6099eaf fix(security #3234): harden /api/media deny-list (Codex review)
- Add state.db-wal / state.db-shm (SQLite sidecars carry the same data as state.db)
- Add google_token.json / google_client_secret.json (OAuth creds)
- Scope filename-based denies to files under HERMES_HOME / STATE_DIR so a
  legitimate workspace or /tmp media artifact named settings.json / config.yaml
  is not wrongly blocked.
Dir-based denies (state subdirs) remain unconditional.
2026-05-31 02:39:46 +00:00
nesquena-hermes
c36fda23f7 test: widen _handle_media CSP-slice window to 7000 after #3234 deny-list insertion 2026-05-31 02:32:16 +00:00
nesquena-hermes
63bb2f9884 fix(security #3234): /api/media hard-denies WebUI state + secret/config files
Pre-release dual-gate (Codex + Opus) on #3219 surfaced that /api/media serves
files under the allowlisted Hermes home, including settings.json / state.db /
auth.json / config.yaml. #3219 makes this materially worse: pre-#3219 a bare
file:// URL in agent output rendered as inert text, but #3219 turns it into an
auto-loading <img src=/api/media?path=...> that fetches on render. Rather than
weaken #3219, harden the boundary at the route: hard-deny known secret/config
filenames and the WebUI state subdirs (sessions/memories/profiles + STATE_DIR)
before the allow/serve decision, covering every entry path (bare file://,
markdown anchors, MEDIA: tokens, session-token grants). Adds a live-server
regression test. Closes #3234.

Co-authored-by: AJV20 <24819659+AJV20@users.noreply.github.com>
2026-05-31 02:27:15 +00:00
AJV20
aae3b418f0 fix: render file artifact links as media 2026-05-31 02:24:55 +00:00
nesquena-hermes
0405fefb5e Merge pull request #3233 from nesquena/release/stage-batchB2
Some checks failed
Release & Docker / release (push) Has been cancelled
Release stage-batchB2 → v0.51.182 (consolidated client-disconnect handling #3210)
2026-05-30 19:06:43 -07:00
nesquena-hermes
dca4a2a7af fix(#3210 review): convert bare urlopen TimeoutError to ValueError in _joplin_api_get
Codex+Opus pre-release gate both flagged: TimeoutError is now in the
consolidated _CLIENT_DISCONNECT_ERRORS dispatch set, so a bare socket-connect
TimeoutError from Joplins urlopen(timeout=8) — which is NOT always URLError-
wrapped — would escape _handle_notes_search and be swallowed by the dispatch
disconnect handler as a fake client disconnect (silent empty response, no log).
Catch (URLError, TimeoutError) at the route so it surfaces as a clean
"not reachable" ValueError -> JSON error. Adds a regression test.

Co-authored-by: someaka <someaka@users.noreply.github.com>
2026-05-31 01:57:29 +00:00
nesquena-hermes
c4d845c9d5 docs(CHANGELOG): v0.51.182 — consolidated client-disconnect handling (#3210) 2026-05-31 01:57:29 +00:00
Ed
79f526b1b2 fix(helpers): use ssl.SSLError instead of broad OSError in disconnect tuple
OSError is too broad — it masks real errors like file-not-found.
ssl.SSLError specifically catches SSL-level disconnects without
swallowing unrelated OSError subtypes.

Closes the test_excludes_broad_oserror CI failure.
2026-05-31 01:56:39 +00:00
Ed
78652ecdea fix: consolidate _CLIENT_DISCONNECT_ERRORS, remove scratch file
Address review feedback from @nesquena-hermes on PR #3210:

1. Deduplicate _CLIENT_DISCONNECT_ERRORS:
   - Single authoritative definition in api/helpers.py
   - api/routes.py now imports from api.helpers instead of defining
     its own copy with different membership
   - Unified tuple uses OSError (covers ssl.SSLError since it
     subclasses OSError) — broad socket-level disconnect coverage

2. Remove github-search-report.md:
   - Research scratch output that doesn't belong in the repo root
   - Content belongs in PR description or a gist

3. Docstring improvement:
   - Added comment explaining why OSError covers ssl.SSLError
   - Documents the errno-level socket errors caught by OSError
2026-05-31 01:56:39 +00:00
Ed
59b7a3ac94 fix: eliminate silent failures in client disconnect handling
- api/helpers.py: _safe_write() now logs disconnects at debug level
  instead of silently passing. No more invisible errors.

- server.py: Restructure exception handlers to catch
  _CLIENT_DISCONNECT_ERRORS first, then Exception. Remove the
  isinstance() filter inside except Exception (LBYL anti-pattern).
  The 500-response fallback now catches _CLIENT_DISCONNECT_ERRORS
  separately (expected) and logs unexpected failures via
  traceback.print_exc() instead of bare except Exception: pass.

- tests/test_broken_pipe_cascade.py: Add coverage for SSL/Timeout
  disconnect routing and 500-response safety (both disconnect
  survival and unexpected error logging).
2026-05-31 01:56:39 +00:00
someaka
e531a05e60 fix(server): catch client disconnect errors in response write path 2026-05-31 01:56:39 +00:00
someaka
2cee44b1b2 fix(helpers): catch client disconnect errors in response write path
Extract _safe_write() helper that wraps end_headers() + wfile.write()
in try/except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError,
TimeoutError, ssl.SSLError).  Both j() and t() now use _safe_write()
instead of raw wfile calls.

Fixes cascading BrokenPipeError + SSL BAD_LENGTH crash when a client
disconnects mid-response and the error handler tries to write a 500
status through the same broken socket.
2026-05-31 01:56:39 +00:00
Ed
5915d675a0 docs: add GitHub search report — upstream issues/PRs related to fork commits 2026-05-31 01:56:39 +00:00
nesquena-hermes
5b2cbe1323 ci: add headless browser smoke test (runtime brick-class gate) (#3231)
Boots the real server.py agent-free and loads the key pages in headless
Chromium, failing on any console error or uncaught JS exception. Catches the
runtime-JS brick class (const-reassign #3162, function/window collision
#2715/#2771) that node --check, ESLint, and the mocked pytest suite cannot see
because they only manifest when a real browser executes the page.

Credential-free: strips *_API_KEY from the env, drives no real model, needs no
secrets. Runs on every PR + push via .github/workflows/browser-smoke.yml.

Co-authored-by: nesquena-hermes <[email protected]>
2026-05-30 18:48:25 -07:00
nesquena-hermes
82fe933b5a Merge pull request #3221 from nesquena/release/stage-batchA
Some checks failed
Release & Docker / release (push) Has been cancelled
Release stage-batchA → v0.51.181 (agent-cache eviction teardown #3218 + streaming finalize race #3201)
2026-05-30 17:31:34 -07:00
nesquena-hermes
634a70bebf test(#3218): extend cache-eviction lock-discipline guard to the self-heal site (Opus NIT)
Opus advisor noted the static guard covered 4 of the 5 identity-mismatch
eviction sites; add the credential-self-heal pop/close pair so a future
re-lock of that path is caught by the gate too.
2026-05-31 00:26:59 +00:00
nesquena-hermes
5134d896ba absorb #3201: set _streamFinalized immediately in SSE done handler (closes #3195)
Keeps the messages.js fix from @mysoul12138 (set _streamFinalized=true right
after the early-return guard, before the fade window, so a stream_end arriving
mid-fade can't call _restoreSettledSession and overwrite live messages with a
stale server snapshot). Drops the contributor's dead empty `else {}` block in
ui.js (no behavior). Adds a regression test pinning the immediate-finalize order.

Co-authored-by: mysoul12138 <mysoul12138@users.noreply.github.com>
2026-05-31 00:07:39 +00:00
nesquena-hermes
7607f3f100 Merge #3218: route identity-mismatch agent-cache evictions through session-boundary teardown (closes #3215) 2026-05-31 00:06:13 +00:00
AJV20
6ecc32be00 Close evicted cached agents on identity mismatch 2026-05-30 19:25:51 -04:00
nesquena-hermes
82f92eb78d Merge pull request #3216 from nesquena/release/stage-batch62
Some checks failed
Release & Docker / release (push) Has been cancelled
Release stage-batch62 → v0.51.180 (session/agent cache ownership hardening — #3191 + #3166)
2026-05-30 15:21:06 -07:00
nesquena-hermes
22e628be07 Merge #3191: guard session cache ownership across compression
# Conflicts:
#	CHANGELOG.md
2026-05-30 21:57:48 +00:00
nesquena-hermes
bc50e95ec7 Merge #3166: close evicted agents from WebUI cache (commit memory + shutdown provider + close db) 2026-05-30 21:57:13 +00:00
nesquena-hermes
f50aac68b2 Merge pull request #3213 from nesquena/release/stage-batch61
Some checks failed
Release & Docker / release (push) Has been cancelled
Release stage-batch61 → v0.51.179 (custom-provider reasoning efforts #3202 + clearer sidebar tooltips #3203)
2026-05-30 14:54:15 -07:00
nesquena-hermes
d6d74b5354 polish(#3203): preserve localized 'Forked from' base in fork tooltip
Opus advisor SHOULD-FIX: the tooltip rework dropped t('forked_from') in favor
of a hardcoded English string, a real i18n regression (the catalog key exists
in all locales). Restore the localized base while keeping the clearer
'<base>: <parent>' format. Adds a regression test.

The new lineage/child explanatory suffixes (additive English) and the
read-only title hint are deferred to a small follow-up — they need new locale
keys and the read-only state is still surfaced by the existing meta chip.

Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-30 21:51:17 +00:00
nesquena-hermes
cceeca89d0 polish(#3203): keep localized attention tooltip precedence over generic state tooltip
The merged hunk set state.title=_sessionStateTooltip(...) unconditionally,
two lines after assigning the localized attention.title (pending approval/
clarify, from #3190). That clobbered the attention tooltip and, for a
needs-attention session not currently streaming, blanked it to '' since
_sessionStateTooltip returns '' when neither streaming nor unread.

Make the attention title win, and apply the state tooltip only when non-empty.
Adds a regression test pinning the precedence.

Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-30 21:46:01 +00:00
nesquena-hermes
5cd2aef0d5 Merge #3203: clarify sidebar session tooltips
# Conflicts:
#	CHANGELOG.md
2026-05-30 21:43:03 +00:00
nesquena-hermes
2a60aa048c Merge #3202: reasoning effort for bare/dot-separated custom-provider model names 2026-05-30 21:42:26 +00:00
nesquena-hermes
e41fee1dfd Merge pull request #3212 from nesquena/release/stage-batch60
Some checks failed
Release & Docker / release (push) Has been cancelled
Release stage-batch60 → v0.51.178 (parallel sharded CI + shard-safe test suite, completes #3197)
2026-05-30 14:12:18 -07:00
nesquena-hermes
f9acf464e9 ci: run test suite in 3 parallel shards + make suite shard-safe
Completes the test-sharding half of #3197 (Docker-cache half shipped v0.51.177).
Adds pytest-shard 3-way split to tests.yml (3 shards x 3 Python = 9 jobs,
fail-fast: false). pytest-shard is 0-indexed so the matrix uses [0,1,2] — the
original #3197 used [1,2,3] which would have crashed the out-of-range job and
silently skipped shard 0's tests.

Made the suite shard-safe by fixing 4 cross-test state-pollution bugs that
passed sequentially but failed when sharded:
- test_onboarding_mvp: reset onboarding_completed flag (settings.json) in the
  autouse fixture; the config-cleanup only cleared config.yaml/.env.
- test_issue693_system_health_panel: invalidate the process-wide password-hash
  cache before/after so a prior test's "no password" cache doesn't defeat the
  auth-gate assertion.
- test_auth_session_persistence: assert against auth._SESSIONS_FILE (where auth
  actually writes) instead of a local _TEST_STATE path that only matched under a
  lucky import order.
- test_profile_env_isolation (root cause of the worst leak): stop deleting +
  re-importing api.profiles under a temp HERMES_BASE_HOME — that swapped the
  module object and poisoned the cached _DEFAULT_HERMES_HOME for every later
  test (broke test_title_aux_routing's load_config). Now points the cached path
  via monkeypatch.setattr (auto-restored, no module swap).
- conftest: autouse fixture restores HERMES_HOME/HERMES_BASE_HOME after each
  test as defense-in-depth against future switch_profile leaks.

Verified: all 3 shards green (6912 passed, 0 failed); full sequential run still
green (6957 passed, 0 failed). Slowest shard ~70s vs ~180s sequential.
2026-05-30 21:09:25 +00:00
nesquena-hermes
113c9791f8 Merge pull request #3207 from nesquena/release/stage-batch59
Some checks failed
Release & Docker / release (push) Has been cancelled
Release stage-batch59 → v0.51.177 (Docker smoke-test layer caching, #3197 docker half)
2026-05-30 13:14:31 -07:00
nesquena-hermes
f4772ff081 ci(docker): cache smoke-test image layers via GHA cache (#3197 docker half)
Build the Docker image once in a build-image job, cache layers via type=gha,
restore from cache in each smoke variant instead of rebuilding. Variant restore
uses cache-from only (build-image is the sole cache writer).

Co-authored-by: hayriodabas <hayriodabas@users.noreply.github.com>
2026-05-30 20:10:27 +00:00
nesquena-hermes
f8f1052d53 Merge pull request #3206 from nesquena/release/stage-batch58
Some checks failed
Release & Docker / release (push) Has been cancelled
Release stage-batch58 → v0.51.176 (sidebar attention indicators)
2026-05-30 13:07:44 -07:00
nesquena-hermes
4ef544cfa4 Merge branch 'pr-3190' into release/stage-batch58
# Conflicts:
#	CHANGELOG.md
2026-05-30 19:56:01 +00:00
Hermes Agent
01c1239fc9 refactor(sessions): simplify attention indicator to a color-coded dot
Per maintainer UX direction: drop the text badge pill ("APPROVAL" /
"N QUESTIONS") in favor of color-coding the existing right-side status dot
plus the colored left rail. Red dot (--error) for pending approvals, amber
dot (--warning) for pending clarifies; theme-driven, visible even when the
session is not streaming/unread. Approval dot gently pulses (disabled under
prefers-reduced-motion). Quieter and consistent with the existing pin/unread
dot family — the row no longer needs to truncate its title to fit a pill.

Updated test_session_attention_badges to assert the dot classes
(is-attention-approval/clarify) instead of the removed badge element/styles.

Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
2026-05-30 19:54:51 +00:00
ai-ag2026
00e6d953c2 feat: add distinct attention request sound
Adds a distinct two-tone attention sound (880->660Hz) for approval and
clarify prompts so they are not confused with the existing completion sound,
plus sidebar attention badges + colored rails driven by `attention` metadata
on /api/sessions. Includes lock-safety note: the in-lock
publish_session_list_changed() calls in clarify.py are safe because publish()
only takes the leaf _SESSION_EVENTS_LOCK and never re-acquires clarify._lock
(verified by Opus advisor review).

Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
2026-05-30 19:40:40 +00:00
nesquena-hermes
0560a3fa8b Merge pull request #3205 from nesquena/release/stage-batch57
Some checks failed
Release & Docker / release (push) Has been cancelled
Release stage-batch57 → v0.51.175 (internal conversation links)
2026-05-30 12:23:12 -07:00
ai-ag2026
d2aa066862 feat: surface session attention badges 2026-05-30 19:18:13 +00:00
nesquena-hermes
9e38b0ff90 docs(changelog): v0.51.175 release header (stage-batch57) 2026-05-30 19:16:12 +00:00
ai-ag2026
8e44dc8609 feat: resolve session links in conversation filter 2026-05-30 19:13:49 +00:00
ai-ag2026
ce4026660e test: harden session link sanitization 2026-05-30 19:13:11 +00:00
ai-ag2026
d3ee17793a feat: add internal conversation links 2026-05-30 19:13:11 +00:00
nesquena-hermes
05122e263d Merge pull request #3204 from nesquena/release/stage-batch56
Some checks failed
Release & Docker / release (push) Has been cancelled
Release stage-batch56 → v0.51.174 (CLI/gateway session usage in Insights, double-count fix)
2026-05-30 12:11:37 -07:00
nesquena-hermes
7ec16ba30c docs(changelog): v0.51.174 release header (stage-batch56) 2026-05-30 19:08:08 +00:00
Hermes Agent
06842f6e9f fix(insights): exclude source='webui' from CLI session pass + add tests
The new CLI/gateway insights pass counted ALL state.db sessions, but
WebUI-native sessions are persisted to state.db with source='webui' AND
already counted from the sidecar _index.json first pass — double-counting
them in totals, model breakdown, and daily charts. Add
`AND COALESCE(source, '') != 'webui'` so only CLI/gateway/cron/tui rows are
added by the second pass.

Adds regression tests proving (a) CLI + Telegram sessions appear in totals
and (b) a webui-source state.db row is not double-counted against its
_index.json entry.

Co-authored-by: wind-chant <wind-chant@users.noreply.github.com>
2026-05-30 18:58:20 +00:00
ai-ag2026
c26e25414e fix: clarify sidebar session tooltips 2026-05-30 20:56:59 +02:00
wind-chant
1048d2cb0c feat: include CLI session usage in WebUI Insights
The Insights page () previously only counted WebUI-native
sessions from its own session index. This adds a query to the Hermes
state.db so CLI and gateway sessions (Telegram, Discord, etc.) are also
included in the aggregated token counts, costs, model breakdown, and
daily activity charts.

The state.db query is best-effort: if the file is missing or
unreadable, the existing WebUI-only data is returned as before.
2026-05-30 18:55:50 +00:00
carryzuo00
d6c1b696f5 fix(reasoning): expose effort levels for bare/dot-separated model names on custom providers
Custom API aggregators (New API, One API, etc.) route requests using their
own naming conventions — bare names like ``deepseek-v4-flash`` or dot-separated
names like ``moonshotai.kimi-k2.5`` — rather than the OpenRouter-style
``vendor/model`` slash format that ``_heuristic_reasoning_efforts``'s prefix
list was written for.

Because none of these names match the slash-prefixed patterns, the function
returned ``[]``, hiding the reasoning effort selector in the WebUI even for
models that fully support thinking/reasoning.

Fix: add a secondary check in ``_heuristic_reasoning_efforts`` that strips an
optional dot-vendor prefix and matches the remaining model name against a list
of known thinking-capable bare-name prefixes (``deepseek-v4``, ``deepseek-r1``,
``kimi-k2``, ``qwen3``, ``claude-3/4``, ``o1/o3/o4``), plus a keyword catch
for model names containing ``thinking`` or ``reasoning``.

The slash-prefix path and all provider-specific fast paths (copilot, lmstudio,
models.dev metadata) are unchanged; this block only runs as a final fallback.

Regression tests added in ``tests/test_custom_provider_bare_model_reasoning.py``.
2026-05-31 02:51:26 +08:00
nesquena-hermes
9f775d3f63 Merge pull request #3200 from nesquena/release/stage-batch55
Some checks failed
Release & Docker / release (push) Has been cancelled
Release stage-batch55 → v0.51.173 (Windows safety + pin-quota snapshot + tool-card paging anchor + sidebar dedupe + quieter tool cards)
2026-05-30 11:47:10 -07:00
nesquena-hermes
76d052875d docs(changelog): v0.51.173 release header (stage-batch55) 2026-05-30 18:46:41 +00:00
nesquena-hermes
025dd738b9 Merge branch 'pr-3178' into release/stage-batch55
# Conflicts:
#	CHANGELOG.md
2026-05-30 18:25:55 +00:00
nesquena-hermes
91f33a50cd Merge branch 'pr-3192' into release/stage-batch55
# Conflicts:
#	CHANGELOG.md
2026-05-30 18:24:30 +00:00
nesquena-hermes
db952ac561 Merge branch 'pr-3187' into release/stage-batch55
# Conflicts:
#	CHANGELOG.md
2026-05-30 18:24:29 +00:00
nesquena-hermes
58aec3e3f8 Merge branch 'pr-3184' into release/stage-batch55
# Conflicts:
#	CHANGELOG.md
2026-05-30 18:24:29 +00:00
nesquena-hermes
6d1ae11522 Merge branch 'pr-3180' into release/stage-batch55
# Conflicts:
#	CHANGELOG.md
2026-05-30 18:24:28 +00:00
nesquena-hermes
79145c0a39 Merge pull request #3198 from nesquena/release/stage-batch54
Some checks failed
Release & Docker / release (push) Has been cancelled
Release stage-batch54 → v0.51.172 (model-label fallback + dev cache-bust + tilde completion + cron project chips)
2026-05-30 11:11:32 -07:00
nesquena-hermes
cb193a9768 docs(changelog): v0.51.172 release header (stage-batch54) 2026-05-30 18:11:02 +00:00
nesquena-hermes
9e48a2a417 Merge branch 'pr-3183' into release/stage-batch54
# Conflicts:
#	CHANGELOG.md
2026-05-30 17:52:53 +00:00
nesquena-hermes
637ae65383 Merge branch 'pr-3182' into release/stage-batch54 2026-05-30 17:52:53 +00:00
nesquena-hermes
141f27d933 Merge branch 'pr-3188' into release/stage-batch54 2026-05-30 17:52:53 +00:00
ai-ag2026
9458df6f4d fix: dedupe sidebar completion session ids 2026-05-30 18:19:34 +02:00
ai-ag2026
9932899b88 fix: guard session cache ownership across compression 2026-05-30 18:19:31 +02:00
mysoul12138
71e6eabb29 fix: let cron sessions bypass CLI_VISIBLE_SESSION_LIMIT for project chips (#3172)
When state.db has many non-cron sessions, the normal sidebar query caps
at CLI_VISIBLE_SESSION_LIMIT (20) rows ordered by latest activity. Older
cron runs get squeezed out before _include_project_hidden_background_sidebar_sessions
can rescue them, making them invisible under their project chip.

Add a second-pass cron-only query with a higher cap (CRON_PROJECT_CHIP_LIMIT=200)
that merges into the CLI session list.  The project-chip rescue layer then
marks them default_hidden so they stay addressable without polluting the
default sidebar window.

Verification: regression test seeds 25+ newer non-cron sessions and asserts
the older messageful cron session still appears with project_id set.
2026-05-30 23:25:41 +08:00
ai-ag2026
4639d355ee test: cover hidden snapshot pin quota route 2026-05-30 16:31:24 +02:00
ai-ag2026
b891e4fe77 fix: avoid double rebasing legacy tool cards 2026-05-30 16:10:52 +02:00
ai-ag2026
d57854807a fix: keep tool cards anchored during history paging 2026-05-30 14:57:00 +02:00
ai-ag2026
09d2f4be2f fix: exclude hidden snapshots from pin quota 2026-05-30 11:48:57 +02:00
ai-ag2026
cb01e041fd fix: preserve tilde workspace suggestions 2026-05-30 11:35:05 +02:00
ai-ag2026
000e1dc9b6 fix: hash dirty WebUI dev builds 2026-05-30 11:25:33 +02:00
ai-ag2026
fb7fa5bcac fix: handle Windows media and turn journal edge cases 2026-05-30 11:15:35 +02:00
ai-ag2026
b3483d943c fix: close evicted agents from WebUI cache 2026-05-30 10:43:13 +02:00
ai-ag2026
97dde9a48e fix: restore legacy tool card visuals 2026-05-30 10:31:35 +02:00
mysoul12138
1a991041f1 fix: use getModelLabel() in _formatSessionModelWithGateway fallback
When gateway_routing is absent, _formatGatewayModelLabel returns '' and
the fallback path returns raw s.model — which may contain an @provider:
prefix (e.g. @token-plan-cn.xiaomimimo.com:mimo-v2.5-pro).

getModelLabel() already handles stripping @provider: prefixes (ui.js:2855)
but was never called in this fallback path. Route through it so the
session list always displays a clean model name.

Closes #3172
2026-05-30 15:13:03 +08:00
nesquena-hermes
7dc4273a21 Merge pull request #3168 from nesquena/release/stage-batch53
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.171 — Release EQ (stage-batch53: tool-output badge #2867 + Neon opt-in skin)
2026-05-29 21:38:36 -07:00
nesquena-hermes
5d73101c8e test: make .send-btn / .app-titlebar global-selector finders skin-variant-aware (#3164 Neon uses :root.dark[data-skin=]) 2026-05-30 04:32:18 +00:00
Hermes Agent
a7c871d45e feat: add Neon cyberpunk skin
Cyberpunk-inspired dark-first skin with neon purple (#B347FF) accent
and cyan (#00DDFF) highlights. Full palette for both light and dark
modes with component-level overrides (glow effects, purple scrollbars,
tool cards, tree viewer, session badges, MCP status indicators).

- static/style.css: 37 CSS rules (light + dark palette + components)
- static/boot.js: Neon entry in _SKINS array
- static/index.html: neon:1 in skin allowlist

Follows the existing skin pattern (modeled after Nous).
2026-05-30 04:24:15 +00:00
AJV20
d34d11821b fix: label tool output cards persistently 2026-05-30 04:24:15 +00:00
nesquena-hermes
0fc38cecf8 Merge pull request #3167 from nesquena/release/stage-batch52
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.170 — Release EP (stage-batch52: run-aware SSE replay cursors #3124)
2026-05-29 21:16:41 -07:00
AJV20
5cb82f9da4 fix: make SSE replay cursors run-aware 2026-05-30 04:09:20 +00:00
nesquena-hermes
d37e57f1ea Merge pull request #3165 from nesquena/release/stage-batch51
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.169 — Release EO (stage-batch51: skill-toggle profile scoping #3066 + update-tag filter #3140 + Docker docs)
2026-05-29 20:54:34 -07:00
nesquena-hermes
d032653a97 test(#3066): mark profile-skill tests requires_agent_modules (skill_utils import unavailable in CI) 2026-05-30 03:51:13 +00:00
AJV20
fbc0742621 fix: ignore release tags already contained by head 2026-05-30 03:37:12 +00:00
AJV20
7efb4c5acb fix: scope skill toggles to active profile 2026-05-30 03:36:09 +00:00
AJV20
f50763b3d2 docs: clarify docker host and home paths 2026-05-30 03:35:24 +00:00
nesquena-hermes
e8e93ad98c Merge pull request #3163 from nesquena/fix-3162
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.168 — Release EN (hotfix #3162: mobile 'Failed to load conversation messages' + JS runtime-lint guard)
2026-05-29 20:31:14 -07:00
nesquena-hermes
e5b31caee1 fix(#3162): const→let in _ensureMessagesLoaded (brick-class mobile TypeError) + ESLint runtime-error guard
The #3018 carry-forward reassigns msgs but it was declared const, throwing a
TypeError that surfaced as 'Failed to load conversation messages' on every mobile
message (v0.51.161-166). Change to let.

Adds a static JS runtime-error lint guard (eslint.runtime-guard.config.mjs +
tests/test_static_js_runtime_lint.py) using no-const-assign/no-import-assign — the
exact class node --check and source-presence tests miss. Dev-only dependency; app
stays pure Python + vanilla JS.
2026-05-30 03:24:41 +00:00
nesquena-hermes
9cd7729915 Merge pull request #3161 from nesquena/release/stage-batch49
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.167 — Release EM (stage-batch49: iOS-style swipe actions for touch devices #2252)
2026-05-29 20:10:17 -07:00
nesquena-hermes
6702805527 stage-batch49: CHANGELOG for v0.51.167 (Release EM) 2026-05-30 02:57:02 +00:00
nesquena-hermes
359a86cc4f Merge PR #2252 (swipe actions for touch devices + session list FLIP) into stage-batch49 2026-05-30 02:56:40 +00:00
nesquena-hermes
6791b1d5d8 Merge PR #2252 (swipe actions for touch devices + session list FLIP) onto master — stripped out-of-scope version-badge/_dirty_suffix changes 2026-05-30 02:33:18 +00:00
nesquena-hermes
abe87b9b3e Merge pull request #3156 from nesquena/release/stage-batch48
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.166 — Release EL (stage-batch48: shared OpenCode runtime key #3145 + cron project-chip sessions #3134)
2026-05-29 19:26:02 -07:00
nesquena-hermes
c2663f827a Merge PR #3152 (keep agent cron sessions in project chips, #3134) into stage-batch48
# Conflicts:
#	CHANGELOG.md
2026-05-30 02:19:28 +00:00
nesquena-hermes
798ca584c6 Merge PR #3150 (honor shared OpenCode key at runtime, closes #3145) into stage-batch48 2026-05-30 02:19:06 +00:00
AJV20
e68c670bba fix: keep agent cron sessions in project chips 2026-05-30 02:18:51 +00:00
AJV20
d2686eaedb fix: honor shared OpenCode key at runtime 2026-05-30 02:18:06 +00:00
nesquena-hermes
3005811200 Merge pull request #3153 from nesquena/release/stage-batch47
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.165 — Release EK (stage-batch47: stop EventSource reconnect storm #3103)
2026-05-29 19:15:40 -07:00
nesquena-hermes
5f0d7534db stage-batch47: CHANGELOG for v0.51.165 (Release EK) 2026-05-30 02:09:01 +00:00
nesquena-hermes
728bf42efd Merge PR #3128 (stop Connection: close on long-lived SSE streams) into stage-batch47 2026-05-30 02:08:37 +00:00
nesquena-hermes
2fc692f68b test: pin #3103 — SSE event-stream handlers must not emit Connection: close (keep it for finite responses) 2026-05-30 02:08:37 +00:00
Sanjays2402
ab4de3bb64 Fix #3103: stop emitting Connection: close on long-lived SSE streams
The gateway SSE handler (`_handle_gateway_sse_stream`) and session-events
SSE handler (`_handle_session_events_stream`) both set
`Connection: close` on the response. On long-lived SSE streams, browsers
(Chrome, Firefox, Safari) interpret that header as 'this is a one-shot
response, the EventSource lifecycle is over the moment the body ends'
and trigger an immediate reconnect when the server-side worker rotates.

The result, with EventSource's auto-reconnect on top, is a tight loop of
connect -> sessions_changed snapshot -> reconnect every ~1s that pegs
the worker, thrashes the session list, and prevents the in-app session
list from ever settling.

Removing the header lets the python BaseHTTPServer close the socket
naturally after the stream ends, which is what EventSource expects.

Regression introduced in 598fd4ff.
2026-05-30 02:02:40 +00:00
nesquena-hermes
832cf8e4be Merge pull request #3151 from nesquena/release/stage-batch46
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.164 — Release EJ (stage-batch46: passive performance hardening)
2026-05-29 19:01:22 -07:00
nesquena-hermes
a7848c884e stage-batch46: CHANGELOG for v0.51.164 (Release EJ) 2026-05-30 01:54:52 +00:00
nesquena-hermes
938a58b0e1 Merge PR #3105 (passive performance hardening + bounded restart-safety wait) into stage-batch46 2026-05-30 01:53:39 +00:00
nesquena-hermes
694fdd1096 review-3105: bound _wait_until_restart_safe with 300s max-wait + execv fallback (Opus review — avoid self-update soft-jam on long runs) 2026-05-30 01:53:38 +00:00
AJV20
b53bc1a2ea fix: harden webui passive performance paths 2026-05-30 01:39:54 +00:00
nesquena-hermes
e90ebd2394 Merge pull request #3149 from nesquena/release/stage-batch45
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.163 — Release EI (stage-batch45: session duplicate/branch field propagation)
2026-05-29 18:37:35 -07:00
nesquena-hermes
315cca2ec7 stage-batch45: CHANGELOG for v0.51.163 (Release EI) 2026-05-30 01:26:21 +00:00
nesquena-hermes
71d0b1cb5e Merge PR #3101 (session duplicate/branch field propagation) into stage-batch45 2026-05-30 01:26:00 +00:00
AlexeyDsov
da9211bf43 fix(session): copy all critical fields on duplicate and branch to prevent state loss 2026-05-30 01:19:50 +00:00
nesquena-hermes
24a266841a Merge pull request #3148 from nesquena/release/stage-batch44
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.162 — Release EH (stage-batch44: conversation-filter clear button + title-lang regression coverage)
2026-05-29 18:13:04 -07:00
nesquena-hermes
ed12948301 stage-batch44: CHANGELOG for v0.51.162 (Release EH) 2026-05-30 01:06:24 +00:00
ai-ag2026
e0c27174b3 test: cover code-only title language fallback 2026-05-30 01:06:04 +00:00
nesquena-hermes
01c63be969 Merge PR #3026 (clear button on conversation filter) into stage-batch44 2026-05-30 01:05:58 +00:00
george-andraws
28498aa988 fix: scope conversation filter clear search CSS 2026-05-30 00:52:29 +00:00
george-andraws
317d828cff Add clear button to conversation filter 2026-05-30 00:52:29 +00:00
nesquena-hermes
cbc74e33b8 Merge pull request #3147 from nesquena/release/stage-batch43
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.161 — Release EG (stage-batch43: 3-PR live-display fixes)
2026-05-29 17:50:01 -07:00
nesquena-hermes
3119ea8200 test: update rotate-url-sync completion_marker for #3018 _finishDone carry-forward 2026-05-30 00:43:37 +00:00
nesquena-hermes
7edd43549d stage-batch43: extend #3018 carry-forward to _finishDone completion path (Opus review — most-frequent reproducer) 2026-05-30 00:39:54 +00:00
nesquena-hermes
49a16e5fc3 stage-batch43: CHANGELOG for v0.51.161 (Release EG) 2026-05-30 00:21:35 +00:00
nesquena-hermes
9059b8f5c3 Merge PR #3116 (stale unread + compression timer + tool-card dedup) into stage-batch43 2026-05-30 00:21:04 +00:00
nesquena-hermes
86d5d63593 Merge PR #3131 (preserve per-turn ephemeral fields) into stage-batch43 2026-05-30 00:20:58 +00:00
nesquena-hermes
02e8a6ca8d Merge PR #3127 (jump-to-question on intermediate assistant msgs) into stage-batch43 2026-05-30 00:20:58 +00:00
mysoul12138
5b3771012a Fix tool card duplication: skip detail when snippet equals preview
When tc.snippet === tc.preview (common for no-progress tools where
both are set to the same result_snippet), the detail block would show
identical content as the header. Skip the detail block in this case.

This also handles the reload-path where derived entries get snippet
populated but no preview, so displaySnippet falls back to the snippet
content for the header — same deduplication applies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 00:20:49 +00:00
mysoul12138
0002cda23d fix: stale unread dot, compression timer leak, tool card duplication (#3020, #2973)
#3020: Sync viewed-count in the polling path for actively-viewed sessions
so navigating away doesn't show a stale unread dot. Defensive clear of
completion-unread marker in _setSessionViewedCount.

#2973: Clear elapsed-timer attributes and interval when a live compression
card transitions from running to done, preventing the orphan timer from
overwriting the completed card state. Guarded by active-session check.

Tool card duplication: Route tool_complete result to tc.snippet (detail)
instead of tc.preview (header) to prevent identical content appearing in
both the card header and expanded detail section.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 00:20:49 +00:00
nesquena-hermes
6d56280e94 test: update session-rotate-url-sync marker for #3018 split assignment (behavior preserved) 2026-05-30 00:16:32 +00:00
Sanjays2402
01c27806d4 Fix #3018: preserve per-turn ephemeral fields across session refreshes
`_turnUsage` (and `_turnDuration`, `_turnTps`, `_gatewayRouting`,
`_statusCard`) are computed client-side in `_finishDone()` and attached
to the last assistant message in `S.messages`. Three code paths replace
`S.messages` wholesale with fresh API data that lacks these fields:

  1. `_restoreSettledSession()` after a late `stream_end` or SSE error.
  2. The late-restore branch after `done` (messages.js ~L2247).
  3. `loadSession()` for active-session external refresh / focus-change.

When any of these fire after `done`, the badge/duration/gateway-pill
flashes for ~1s and disappears, never returning until the next message
or page reload.

Add `_carryForwardEphemeralTurnFields(prev, next)` that matches messages
by `(role, timestamp, content prefix)` — the same identity the renderer
already uses — and copies forward the ephemeral fields when the server
payload is missing them. Wire it into all three replace sites. The fix
is conservative: it only fills slots that are `null` on the new message,
so an authoritative server-side value (if/when the API ever surfaces
per-turn usage) wins automatically.

Picked Option A from the bug report (preserve on the client side) over
Option B (synthesize from `S.lastUsage`) because `lastUsage` is a
session-level aggregate; reconstructing per-turn breakdowns from it is
lossy. Option C (set `_streamFinalized` earlier) would suppress legit
late-arriving server data on transient errors.
2026-05-30 00:11:50 +00:00
nesquena-hermes
d45bc32c8d test: update #2246 question-jump assertions for #3114 (button on all assistant msgs with resolvable target) 2026-05-30 00:10:29 +00:00
Sanjays2402
24b175045f Fix #3114: keep jump-to-question button on intermediate assistant messages
In multi-step turns (assistant -> tool_call -> assistant -> tool_call ->
final assistant), only the turn-final assistant bubble was rendering the
'jump to question' navigation button because the gate keyed on
isTurnFinalAssistant. Intermediate assistant bubbles that *do* have a
resolvable question raw-index lost the affordance entirely.

Switch the gate to 'show whenever questionRawIdxByAssistantRawIdx has a
target for this rawIdx', which is the actual precondition for the button
being meaningful. Turn-finality was a proxy for 'has a question target'
that under-covered multi-step turns.

No template/CSS change needed; _questionJumpButtonHtml already handles
the rawIdx-or-undefined contract.
2026-05-30 00:09:13 +00:00
nesquena-hermes
c8afb37ae7 Merge pull request #3146 from nesquena/release/stage-batch42
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.160 — Release EF (stage-batch42: 3-PR low-risk cleanup)
2026-05-29 16:55:24 -07:00
nesquena-hermes
cb7360e11c stage-batch42: CHANGELOG for v0.51.160 (Release EF) + skills/session-index entries 2026-05-29 23:48:51 +00:00
nesquena-hermes
bd0e915a5d Merge PR #3142 (session-index metadata refresh perf) into stage-batch42 2026-05-29 23:48:29 +00:00
nesquena-hermes
8933fe9dbe Merge PR #3112 (skills disabled read-path) into stage-batch42 2026-05-29 23:48:24 +00:00
nesquena-hermes
dc2ed59aaa Merge PR #3136 (OpenCode shared key) into stage-batch42 2026-05-29 23:48:24 +00:00
nesquena-hermes
23f5ee15f7 review-3142: align load_metadata_only comment with sidecar-first logic 2026-05-29 23:46:51 +00:00
zapabob
1f51ef14d0 fix: detect shared OpenCode API key 2026-05-29 23:22:15 +00:00
Harlan Zhou
39f09a5b7a fix: read skills disabled state from active profile 2026-05-29 23:21:48 +00:00
ai-ag2026
017179b189 fix: speed up session index metadata refresh 2026-05-29 23:21:48 +00:00
nesquena-hermes
95cee09c31 Merge pull request #3144 from nesquena/release/stage-batch41
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.159 — Release EE (stage-batch41: 5-PR low-risk cleanup)
2026-05-29 15:28:50 -07:00
nesquena-hermes
6d61fbdd17 stage-batch41: CHANGELOG for v0.51.159 (Release EE) 2026-05-29 22:25:37 +00:00
nesquena-hermes
4e382e4f36 stage-batch41: update test_issue1824 snippet-limit assertion 200->4000 (follows #3117) 2026-05-29 22:22:05 +00:00
nesquena-hermes
11ba36beb9 Merge PR #3135 into stage-batch41
# Conflicts:
#	CHANGELOG.md
#	api/routes.py
2026-05-29 22:15:46 +00:00
nesquena-hermes
111e7a11b1 Merge PR #3118 into stage-batch41
# Conflicts:
#	CHANGELOG.md
#	static/boot.js
2026-05-29 22:14:16 +00:00
nesquena-hermes
de90ca3abe Merge PR #3117 into stage-batch41 2026-05-29 22:13:08 +00:00
nesquena-hermes
5c96764e3d Merge PR #3099 into stage-batch41
# Conflicts:
#	CHANGELOG.md
2026-05-29 22:13:08 +00:00
nesquena-hermes
9ca0bdccc8 Merge PR #3098 into stage-batch41
# Conflicts:
#	CHANGELOG.md
2026-05-29 22:12:43 +00:00
nesquena-hermes
1ebd1601d6 Merge pull request #3143 from nesquena/release/stage-batch40
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.158 — Release ED (stage-batch40: 5-PR low-risk cleanup)
2026-05-29 15:11:11 -07:00
nesquena-hermes
edd80751fc stage-batch40: CHANGELOG for v0.51.158 (Release ED) 2026-05-29 22:01:47 +00:00
nesquena-hermes
ff78a422ca Merge PR #3139 into stage-batch40 2026-05-29 22:01:06 +00:00
nesquena-hermes
3206aec3f3 Merge PR #3138 into stage-batch40
# Conflicts:
#	CHANGELOG.md
2026-05-29 22:01:06 +00:00
nesquena-hermes
9aff2a16d4 Merge PR #3137 into stage-batch40 2026-05-29 21:59:24 +00:00
nesquena-hermes
d072039237 Merge PR #3130 into stage-batch40 2026-05-29 21:59:24 +00:00
nesquena-hermes
649f64b374 Merge PR #3129 into stage-batch40 2026-05-29 21:59:24 +00:00
Philippe Le Rohellec
e6aa9271c2 fix(models): preserve provider-qualified model selections in the picker
- Stop provider-qualified or slash-qualified model inputs from fuzzy-matching a
  sibling catalog entry when the exact requested model is missing from the
  curated picker list.
- Preserve the raw typed selection so uncatalogued provider-routed models
  fall through to a temporary custom option instead of silently snapping to a
  nearby curated model.
- Add generalized regression coverage for provider-qualified uncatalogued
  picker selections.
2026-05-29 10:24:44 -07:00
AJV20
3c2f98eb23 fix: polish WebUI assistant replies 2026-05-29 13:01:14 -04:00
AJV20
ce82ea3fdf fix: add joplin search auth compatibility 2026-05-29 12:36:27 -04:00
AJV20
35f89c4e5b fix: harden external notes guardrails 2026-05-29 12:30:04 -04:00
Sanjays2402
e09c6aa6bd Fix #3076: don't force mobile send-on-Enter when a real keyboard is attached
A touch-primary device (`matchMedia('(pointer:coarse)')` is true) can
still have a physical keyboard available — Android tablet + Bluetooth
keyboard, detachable Surface, iPad + Magic Keyboard. The existing
`_mobileDefault` gate flipped Enter to newline on every such device the
moment the visual-viewport heuristic *thought* the soft keyboard was
open, which it often did when the on-screen IME hadn't actually come up
because the user is typing on the hardware keys. Result: Shift+Enter and
Ctrl+Enter never sent and the user could not submit at all.

Add `_hasFinePointerCoexisting()` (`(any-pointer:fine)`) and short-
circuit the mobile-default path when ANY fine pointer is present. That
flag is true whenever a real mouse/trackpad/stylus is paired, which is
the strongest browser signal we have for 'there is a hardware input rig
in the picture too'. Pure-touch phones/tablets are unaffected.
2026-05-29 03:26:12 -07:00
Sanjays2402
7c3d85d6c5 Fix #3107: throttle active-session external-refresh poll from 5s to 30s
The active-session 'is it externally updated?' fallback poll fires every
5 s. On long sessions this causes visible scroll churn (the rendered
message list is rebuilt and the scrollTop is restored on a 5-second
cadence) and a measurable network/CPU floor even when the user is just
reading.

This poll is a *fallback* for the case where the SSE session-events
stream is unavailable; SSE already invalidates the active session in
real time. Pushing it to 30 s keeps the safety net for SSE-broken
environments without it acting as a primary refresh path.

Regression introduced in 467ef33a.
2026-05-29 03:25:49 -07:00
mysoul12138
7f63a1ce71 Add regression test for JS/Python snippet limit parity
Prevents the JS slice(0,N) and Python _TOOL_RESULT_SNIPPET_MAX from
drifting apart again.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 16:12:05 +08:00
Dennis Soong
f5dc9477ff fix: submit composer on numpad enter 2026-05-29 13:00:15 +08:00
mysoul12138
b476126cb9 fix(ui): match tool result snippet limit to backend (200 -> 4000 chars)
_cliToolResultSnippet truncated to 200 chars while the backend's
_tool_result_snippet uses 4000. This caused tool card details to be
more aggressively truncated after session reload than during live
streaming.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 12:34:10 +08:00
ai-ag2026
53f16c4ee6 fix: log WebUI shutdown diagnostics 2026-05-28 23:22:31 +02:00
AJV20
d8a48ee5f8 Merge remote-tracking branch 'origin/master' into fix/webui-gateway-tool-activity
# Conflicts:
#	CHANGELOG.md
#	tests/test_webui_gateway_chat_backend.py
2026-05-28 16:11:34 -04:00
nesquena-hermes
cf003ae986 Merge pull request #3096 from nesquena/release/stage-batch39
Some checks failed
Release & Docker / release (push) Has been cancelled
stage-batch39: v0.51.157 / Release EC — 5-PR mixed-risk cleanup
2026-05-28 13:08:40 -07:00
AJV20
b76a6dfedb fix: forward gateway tool activity to webui 2026-05-28 16:03:16 -04:00
nesquena-hermes
0459f60b5c stage-batch39: stamp v0.51.157 / Release EC
5-PR mixed-risk cleanup:
- #3090 gateway-chat forwards prefill+session-context into delegated Gateway turns
- #3091 sidebar keeps newer compressed continuation visible; lineage-aware state-db dedupe; session-arc marker exclusion
- #3092 browser-transcript memory guidance prompt addition
- #3093 reasoning-effort max parity with hermes_constants
- #3094 webui_prefill_context_max_chars budget (default 12000) for prefill payloads
2026-05-28 19:52:56 +00:00
nesquena-hermes
65dc5f7cf1 stage-batch39: add CHANGELOG entry for #3091 (no entry in original PR) 2026-05-28 19:52:10 +00:00
nesquena-hermes
c1a7668bdf Merge PR #3091 2026-05-28 19:51:29 +00:00
nesquena-hermes
6719f35930 Merge PR #3094
# Conflicts:
#	CHANGELOG.md
#	api/streaming.py
2026-05-28 19:51:29 +00:00
nesquena-hermes
20f8bc3290 Merge PR #3090
# Conflicts:
#	CHANGELOG.md
2026-05-28 19:50:10 +00:00
nesquena-hermes
1a6409764a Merge PR #3093
# Conflicts:
#	CHANGELOG.md
2026-05-28 19:49:34 +00:00
nesquena-hermes
22db5bbe67 Merge PR #3092
# Conflicts:
#	CHANGELOG.md
2026-05-28 19:48:56 +00:00
nesquena-hermes
5ec136af3b Merge pull request #3095 from nesquena/release/stage-batch38
Some checks failed
Release & Docker / release (push) Has been cancelled
stage-batch38: v0.51.156 / Release EB — 2-PR Tier B cleanup
2026-05-28 12:45:09 -07:00
nesquena-hermes
993e251435 stage-batch38: stamp v0.51.156 / Release EB
2-PR Tier B cleanup:
- #3084 harden WebUI request/session/runtime edges (auth.py thread-safety + body validation + frontend storage guards + sw.js vendor precache + i18n key fills + workspace_git test default-branch)
- #3087 model_provider fallback only when same model — fixes fresh-session model_provider=null bug
2026-05-28 19:38:29 +00:00
nesquena-hermes
f7b4c6b0f4 Merge PR #3087
# Conflicts:
#	CHANGELOG.md
2026-05-28 19:38:10 +00:00
Hermes Agent
b92204a7b7 fix(sidebar): keep newer continuation visible over older snapshot 2026-05-28 13:38:06 -06:00
nesquena-hermes
d2265aaca3 Merge PR #3084 2026-05-28 19:37:11 +00:00
AJV20
d539d5c079 fix: budget webui prefill context 2026-05-28 15:26:35 -04:00
theseussss
b554fd8078 fix(reasoning): 推理强度补齐 max 档,与 hermes_constants 对齐
WebUI 镜像的 VALID_REASONING_EFFORTS 此前止于 xhigh,漏了底层
hermes_constants 已有的 max 档。补齐后端常量、前端命令/下拉/帮助文本,
并同步快照测试。支持 max thinking 的 provider(如 Anthropic)现可在
composer 下拉和 /reasoning 命令中选到 Max。
2026-05-29 03:05:02 +08:00
dobby-d-elf
1aa352eb80 Merge remote-tracking branch 'origin/master' into fix/session-list-touch-actions
# Conflicts:
#	api/updates.py
#	static/sessions.js
2026-05-28 13:03:55 -06:00
AJV20
d5b36267f7 Merge remote-tracking branch 'origin/master' into fix/selective-durable-writeback
# Conflicts:
#	CHANGELOG.md
2026-05-28 14:59:12 -04:00
AJV20
79fd4b64c2 test: make gateway context test self-contained 2026-05-28 14:56:28 -04:00
AJV20
c494e41220 Merge remote-tracking branch 'origin/master' into fix/webui-gateway-context-prefill
# Conflicts:
#	CHANGELOG.md
2026-05-28 14:52:15 -04:00
AJV20
2cb3c9f10b Merge remote-tracking branch 'origin/master' into HEAD
# Conflicts:
#	CHANGELOG.md
#	tests/test_workspace_git.py
2026-05-28 14:50:33 -04:00
AJV20
00f4b11227 fix: limit webui durable memory guidance 2026-05-28 14:48:30 -04:00
dobby-d-elf
b253d345cf refine swipe action reveal timing 2026-05-28 12:47:55 -06:00
nesquena-hermes
20f8c7c02d Merge pull request #3089 from nesquena/release/stage-batch37
Some checks failed
Release & Docker / release (push) Has been cancelled
stage-batch37: v0.51.155 / Release EA — 3-PR very low-risk cleanup
2026-05-28 11:47:06 -07:00
AJV20
609a0c887b fix: carry WebUI context through gateway chat 2026-05-28 14:33:32 -04:00
nesquena-hermes
2893f87e19 stage-batch37: stamp v0.51.155 / Release EA
3-PR very low-risk cleanup:
- #3039 api(): timeoutToast:false opt-in for passive pollers
- #3085 _merged_session_messages_for_display: preserve sidecar order when longer
- #3086 subsecond timestamps for gateway turns + compaction batches
2026-05-28 18:28:04 +00:00
nesquena-hermes
ad9b38c945 Merge PR #3086
# Conflicts:
#	CHANGELOG.md
2026-05-28 18:27:03 +00:00
nesquena-hermes
88bdc99a49 Merge PR #3085
# Conflicts:
#	CHANGELOG.md
2026-05-28 18:27:03 +00:00
nesquena-hermes
2f73eb0f8d Merge PR #3039
# Conflicts:
#	CHANGELOG.md
2026-05-28 18:27:03 +00:00
nesquena-hermes
6267716ba4 Merge pull request #3088 from nesquena/release/stage-batch36
Some checks failed
Release & Docker / release (push) Has been cancelled
stage-batch36: v0.51.154 / Release DZ — 9-PR medium-risk cleanup
2026-05-28 11:24:31 -07:00
nesquena-hermes
0a2dabc730 stage-batch36: tighten #3064 MEDIA: token gate to non-user-role messages
Per Opus advisor on stage-batch36: skip role='user' messages in
_session_media_token_allows_image_path so a user-injected MEDIA: token
cannot mint an allow-list entry for the user's own request. Preserves
the original use case (assistant/tool emitted artifacts outside the
active workspace) while making the implicit threat model explicit.

Defense-in-depth — the single-user WebUI scope means same-origin user
input already had the same effective access, but multi-user / shared
WebUI deployments would benefit from the restriction.
2026-05-28 18:20:25 +00:00
george-andraws
0f6eab301c Fix browser chat provider fallback on fresh sessions 2026-05-28 11:10:14 -07:00
nesquena-hermes
371f77c9b9 stage-batch36: stamp v0.51.154 / Release DZ
9-PR medium-risk cleanup:
- #3037 routes.py: argv-style prefill hook + env-var override for notes drawer
- #3046 models.py: compression parent not repaired as stale interrupted turn
- #3048 session_discoverability.py: --repair-safe CLI with default dry-run
- #3053 ui.js: streaming KaTeX guard for parser-owned equations
- #3059 models.py: empty partial activity rows excluded from sidebar recency
- #3060 profiles.py: API key writes to .env (chmod 600), not config.yaml
- #3064 routes.py: MEDIA: image tokens allow exact session-referenced paths
- #3069 models.py: cron sessions with project_id surface via Cron Jobs chip
- #3077 gateway_chat.py: HTTP 401 maps to gateway_auth_error event
2026-05-28 18:04:24 +00:00
ai-ag2026
07aed6b7ff fix(session): preserve subsecond message timestamp order 2026-05-28 19:58:18 +02:00
ai-ag2026
1cd58f6f5a fix(session): preserve sidecar order in display merges 2026-05-28 19:58:17 +02:00
AJV20
cf6e3111c4 Merge remote-tracking branch 'origin/master' into fix/webui-bugfix-sweep
# Conflicts:
#	CHANGELOG.md
2026-05-28 13:56:00 -04:00
nesquena-hermes
a3fc305aeb Merge PR #3077
# Conflicts:
#	CHANGELOG.md
#	tests/test_webui_gateway_chat_backend.py
2026-05-28 17:47:56 +00:00
nesquena-hermes
1c89c7d327 Merge PR #3064
# Conflicts:
#	CHANGELOG.md
2026-05-28 17:47:35 +00:00
nesquena-hermes
4412aea9e8 Merge PR #3059
# Conflicts:
#	CHANGELOG.md
2026-05-28 17:47:34 +00:00
nesquena-hermes
921b94a287 Merge PR #3046
# Conflicts:
#	CHANGELOG.md
2026-05-28 17:47:34 +00:00
nesquena-hermes
c642c1e438 Merge PR #3069
# Conflicts:
#	CHANGELOG.md
2026-05-28 17:47:34 +00:00
nesquena-hermes
83f8080103 Merge PR #3053
# Conflicts:
#	CHANGELOG.md
2026-05-28 17:47:34 +00:00
nesquena-hermes
11ea6c3023 Merge PR #3060 2026-05-28 17:47:33 +00:00
nesquena-hermes
007ba46c3f Merge PR #3048
# Conflicts:
#	CHANGELOG.md
2026-05-28 17:47:33 +00:00
nesquena-hermes
dc5b4b1697 Merge PR #3037
# Conflicts:
#	CHANGELOG.md
2026-05-28 17:47:33 +00:00
nesquena-hermes
b103f4ad68 Merge pull request #3081 from nesquena/release/stage-batch35
Some checks failed
Release & Docker / release (push) Has been cancelled
stage-batch35: v0.51.153 / Release DY — 11-PR low-risk cleanup
2026-05-28 10:46:25 -07:00
AJV20
60d4b2d990 fix: harden WebUI bugfix sweep 2026-05-28 13:38:50 -04:00
dobby-d-elf
354fec90bd speed up swipe action reveal 2026-05-28 11:09:26 -06:00
nesquena-hermes
95aa69f951 stage-batch35: stamp v0.51.153 / Release DY
11-PR low-risk cleanup:
- #3043 openai-codex models.dev reasoning passes xhigh
- #3044 reset _messagesTruncated on new session
- #3047 discoverability: api lineage representative for stale CLI flag
- #3049 title-language detection threshold + English false-positive fix
- #3051 docker docs: sudo compose + Linux host-gateway
- #3054 SSE reconnect: visible-but-unfocused current pane
- #3055 fallback title: drop German-only Session Bilder case
- #3056 title prompt: language-neutral instruction
- #3070 /api/upload reports actual stored filename
- #3071 clarify SSE fallback preserves owner session id
- #3072 gateway-chat forwards image attachments as image_url parts
2026-05-28 16:13:58 +00:00
nesquena-hermes
34d7585bb7 Merge PR #3051
# Conflicts:
#	CHANGELOG.md
2026-05-28 16:11:17 +00:00
nesquena-hermes
4058c741b6 Merge PR #3072
# Conflicts:
#	CHANGELOG.md
2026-05-28 16:11:16 +00:00
nesquena-hermes
2107160bf7 Merge PR #3054
# Conflicts:
#	CHANGELOG.md
2026-05-28 16:11:16 +00:00
nesquena-hermes
50a0d254a3 Merge PR #3047
# Conflicts:
#	CHANGELOG.md
2026-05-28 16:11:16 +00:00
nesquena-hermes
167ed85e8e Merge PR #3070
# Conflicts:
#	CHANGELOG.md
2026-05-28 16:11:16 +00:00
nesquena-hermes
2443db60b6 Merge PR #3071
# Conflicts:
#	CHANGELOG.md
2026-05-28 16:11:15 +00:00
nesquena-hermes
d446a6c304 Merge PR #3049
# Conflicts:
#	CHANGELOG.md
2026-05-28 16:10:49 +00:00
nesquena-hermes
0fd12b2365 Merge PR #3056
# Conflicts:
#	CHANGELOG.md
2026-05-28 16:10:12 +00:00
nesquena-hermes
0147f05c0d Merge PR #3055 2026-05-28 16:09:47 +00:00
nesquena-hermes
b77398abbf Merge PR #3043 2026-05-28 16:09:45 +00:00
nesquena-hermes
fa34c7220d Merge PR #3044 2026-05-28 16:09:44 +00:00
AJV20
923b719ed1 fix: surface gateway auth errors in browser 2026-05-28 11:12:58 -04:00
AJV20
790fc70e87 test: keep bare git fixtures on master 2026-05-28 10:37:38 -04:00
AJV20
8e6ed66815 fix: clarify gateway chat auth errors 2026-05-28 09:59:35 -04:00
AJV20
04e0f905dd test: force master in git workspace fixtures 2026-05-28 09:50:07 -04:00
AJV20
cbd3704a7f fix: preserve literal prefill script paths 2026-05-28 09:31:07 -04:00
AJV20
e4ef50a0da test: cover provider-neutral notes sources 2026-05-28 09:26:09 -04:00
ai-ag2026
3469a2f898 fix: avoid interruption marker for completed journal runs 2026-05-28 15:19:09 +02:00
AJV20
3f22902423 fix: forward gateway image attachments 2026-05-28 08:40:51 -04:00
AJV20
0f26b99a11 fix: preserve clarify fallback ownership 2026-05-28 08:35:26 -04:00
AJV20
0458a0a065 fix: report stored upload filenames 2026-05-28 08:33:50 -04:00
AJV20
9e69db9920 fix: show cron sessions in project filter 2026-05-28 08:10:15 -04:00
AJV20
1b5e6f6fae fix: mirror WebUI prefill env for AI-recent notes 2026-05-28 07:19:31 -04:00
Frank Song
10573ab8aa Fix session media image rendering 2026-05-28 18:05:01 +08:00
gavinssr
d77e8f0445 test: update _write_endpoint_to_config tests for api_key→.env migration
- test_writes_api_key: now asserts no-op (no config.yaml created)
  since api_key-only is no longer a valid use case
- test_writes_both: asserts api_key is NOT written to config.yaml
2026-05-28 16:07:13 +08:00
ai-ag2026
821d4a7fa4 test: keep redaction fixture visible in session index 2026-05-28 09:52:42 +02:00
gavinssr
9e5403994c fix(profiles): write API key to .env instead of config.yaml on profile creation
When a user creates a profile through the WebUI and supplies an API key,
the key was written to config.yaml under model.api_key. However, Hermes
Agent's provider layer reads keys from environment variables (.env), not
from config.yaml — making the key invisible to the actual LLM provider.

Additionally, hermes profile show reports .env: not configured when no
.env file exists, regardless of config.yaml contents, giving users the
false impression that their API key was not saved.

Changes:
- Add _PROVIDER_ENV_MAP to resolve provider IDs to .env variable names
  (kimi-coding → KIMI_API_KEY, deepseek → DEEPSEEK_API_KEY, etc.)
- Add _write_api_key_to_dotenv() that writes the key to the profile's
  .env file under the correct provider-specific variable
- Add _upsert_dotenv_line() helper for idempotent KEY=value writes
- Remove api_key writing from _write_endpoint_to_config()
- Wire _write_api_key_to_dotenv() into create_profile_api()

Fixes: profile created via WebUI shows .env: not configured despite
        correct API key being entered in the form.
2026-05-28 15:45:17 +08:00
ai-ag2026
ce59e7ca20 fix: defer stale stream repair for active workers 2026-05-28 09:33:40 +02:00
Frank Song
9190ab4449 Fix empty partial activity tail recency 2026-05-28 15:30:49 +08:00
ai-ag2026
62efbfb13f fix: use generic title language prompt 2026-05-28 09:14:46 +02:00
ai-ag2026
b046cb42c3 fix: remove German-only fallback title override 2026-05-28 09:09:59 +02:00
ai-ag2026
c197e0c091 fix: allow current-pane SSE reconnect when unfocused 2026-05-28 09:02:06 +02:00
ai-ag2026
2ee249112a fix: defer streaming KaTeX for pending equations 2026-05-28 08:56:34 +02:00
ai-ag2026
eb8ecb2e61 docs: clarify Docker localhost and sudo compose setup 2026-05-28 08:39:59 +02:00
ai-ag2026
2aeebf56ac fix: tighten title language detection 2026-05-28 08:21:58 +02:00
ai-ag2026
f879fd6bc3 fix: add dry-run discoverability safe repair 2026-05-28 08:19:49 +02:00
ai-ag2026
bd8fd22d81 fix: show lineage representative in discoverability audit 2026-05-28 08:13:35 +02:00
ai-ag2026
5f42e87aa9 fix: skip stale repair for compression parents 2026-05-28 08:02:24 +02:00
AJV20
571bb1010d Merge remote-tracking branch 'origin/master' into webui-context-prefill-status
# Conflicts:
#	CHANGELOG.md
2026-05-28 00:57:34 -04:00
Hermes Agent
07f61b72fb test: add codex metadata=False case for reasoning efforts
- Covers the openai-codex + supports_reasoning=False path now that
  Codex routes through _models_dev_reasoning_efforts.
- Mirrors the existing false-suppression test for symmetry.
- 9/9 tests pass.
2026-05-27 22:26:24 -06:00
Frank Song
9ddf36f510 fix: reset _messagesTruncated flag on new session creation
newSession() did not reset _messagesTruncated or _oldestIdx, unlike
loadSession() which resets both at line 590. When a user switched from
a long session (messages > _INITIAL_MSG_LIMIT) to a new session, the
stale _messagesTruncated=true caused renderMessages() to show the
'Scroll up or click to load older messages' indicator on a fresh
conversation with only 1 message.

Add the same reset that loadSession() already performs so newSession()
starts with clean pagination state.
2026-05-28 12:13:30 +08:00
nesquena-hermes
5528e2c579 Merge pull request #3041 from nesquena/release/stage-batch34
Some checks failed
Release & Docker / release (push) Has been cancelled
stage-batch34: v0.51.152 / Release DX — optional gateway-backed browser chat
2026-05-27 20:40:43 -07:00
Hermes Agent
b2220a54b5 fix: route openai-codex through models.dev so GPT-5.5 exposes xhigh
- Remove openai-codex special case that called github_model_reasoning_efforts()
- Codex now falls through to _models_dev_reasoning_efforts() (full efforts)
- GitHub/Copilot still use the GitHub helper (caps at high)
- Added regression tests for both behaviors
2026-05-27 21:38:06 -06:00
nesquena-hermes
bd7576b303 stage-batch34: stamp v0.51.152 / Release DX
Single-PR optional gateway-backed browser chat (#3021)
2026-05-28 03:27:25 +00:00
AJV20
83cc12b0bf feat: add optional gateway-backed WebUI chat (#3021)
Squash-merged pr-3021 into stage-batch34. Default-off bridge to Hermes Gateway API server via HERMES_WEBUI_CHAT_BACKEND=gateway.
2026-05-28 03:27:07 +00:00
Dennis Soong
5f901f579a fix: suppress timeout toasts for passive pollers 2026-05-28 11:13:00 +08:00
nesquena-hermes
4d6269eae6 Merge pull request #3038 from nesquena/release/stage-batch33
Some checks failed
Release & Docker / release (push) Has been cancelled
stage-batch33: v0.51.151 / Release DW — SSE reattach + title-lang + composer cap
2026-05-27 20:10:49 -07:00
nesquena-hermes
7cbc5c1d89 test(stage-batch33): scope test_issue2540 urlopen recorder to broken.example
Pre-existing failure on master: the test assumed only the broken-proxy /v1/models call would route through fake_urlopen, but get_available_models() also probes the Copilot internal v2 token endpoint (10.0s timeout) and OpenRouter free-tier discovery (8.0s), which now pollute the recorded timeout list.

Fix narrows the recorder to the broken-proxy endpoint only. The contract being pinned (broken-proxy probe uses CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS, not the urllib default 10) is unchanged.

Caught by stage-batch33 sequential pytest gate.
2026-05-28 02:57:04 +00:00
nesquena-hermes
6ea2363d02 stage-batch33: stamp v0.51.151 / Release DW
3-PR mid-risk batch: SSE reattach + title-lang + composer cap (#2925, #2984, #2946)
2026-05-28 02:49:14 +00:00
Florian Krause
8408a3dd22 fix: reattach SSE on session-switch return + close leaked stream connections (#2925)
Squash-merged pr-2925 into stage-batch33. Closes #2924.
2026-05-28 02:48:17 +00:00
ai-ag2026
ad564f2cde fix: keep generated titles in conversation language (#2984)
Squash-merged pr-2984 into stage-batch33.
2026-05-28 02:47:40 +00:00
Dustin
afc8aea4b4 ux(composer): cap composer-box max-width at 1600px on ultrawide viewports (#2946)
Squash-merged pr-2946 into stage-batch33.
2026-05-28 02:47:30 +00:00
AJV20
8757a8fbf6 fix: align AI-recent notes with WebUI prefill hook 2026-05-27 22:44:55 -04:00
nesquena-hermes
5bc3cdb3dd Merge pull request #3036 from nesquena/release/stage-batch32
Some checks failed
Release & Docker / release (push) Has been cancelled
stage-batch32: v0.51.150 / Release DV — single-PR reasoning-effort agent metadata
2026-05-27 19:23:36 -07:00
nesquena-hermes
b8e4dfe57d stage-batch32: stamp v0.51.150 / Release DV 2026-05-28 02:19:47 +00:00
nesquena-hermes
424a55036f Merge pull request #3017 2026-05-28 02:16:34 +00:00
nesquena-hermes
1082a4108f Merge pull request #3034 from nesquena/release/stage-batch31
Some checks failed
Release & Docker / release (push) Has been cancelled
stage-batch31: v0.51.149 / Release DU — hyphenated session ids + prefill role consistency
2026-05-27 19:15:24 -07:00
nesquena-hermes
63a60cd39e stage-batch31: stamp v0.51.149 / Release DU 2026-05-28 02:12:06 +00:00
nesquena-hermes
c1942a1cd8 fix(sessions): widen #3023 to all 5 session-id validators via shared is_safe_session_id helper
PR #3023 only updated Session.load() and Session.load_metadata_only(), leaving
three sibling validators (Session-internal _repair_stale_pending and the
/api/session/worktree/remove + /api/session/delete route handlers) still
gated on the old lowercase-only character set.  That would have shipped a
confusing UX where api-* and reachy-voice-* sessions could be loaded into
the sidebar but rejected with HTTP 400 on delete or worktree removal.

This commit factors the validation into a single is_safe_session_id helper
in api.models and updates all five call sites to use it.  Adds regression
coverage in tests/test_issue3023_safe_session_id_validators.py for both
the helper itself and a repo-wide guarantee that no narrow lowercase-only
magic string survives.

Closes the follow-up flagged by the parallel reviewer agent on #3023.
2026-05-28 02:09:05 +00:00
nesquena-hermes
d76e23a9f2 Merge pull request #3009
# Conflicts:
#	CHANGELOG.md
2026-05-28 02:06:21 +00:00
nesquena-hermes
3cb2bd08fb Merge pull request #3023 2026-05-28 01:58:34 +00:00
nesquena-hermes
1d1daeb6c1 Merge pull request #3033 from nesquena/release/stage-batch30
Some checks failed
Release & Docker / release (push) Has been cancelled
stage-batch30: v0.51.148 / Release DT — single-PR Insights skill-usage reader
2026-05-27 18:50:31 -07:00
nesquena-hermes
0657f9b2cc stage-batch30: stamp v0.51.148 / Release DT 2026-05-28 01:46:32 +00:00
nesquena-hermes
7006bb5c7a Merge pull request #3008 2026-05-28 01:43:22 +00:00
nesquena-hermes
d9766d9e56 Merge pull request #3032 from nesquena/release/stage-batch29
Some checks failed
Release & Docker / release (push) Has been cancelled
stage-batch29: v0.51.147 / Release DS — single-PR streaming ownership-cleanup follow-up
2026-05-27 18:41:31 -07:00
nesquena-hermes
6228ceb5ac stage-batch29: stamp v0.51.147 / Release DS 2026-05-28 01:38:21 +00:00
AJV20
25f8e100ec Merge origin/master into prefill context fix 2026-05-27 21:30:22 -04:00
nesquena-hermes
0614ca63d8 Merge pull request #3010 2026-05-28 01:24:17 +00:00
nesquena-hermes
5edc7d15ae Merge pull request #3031 from nesquena/release/stage-batch28
Some checks failed
Release & Docker / release (push) Has been cancelled
stage-batch28: v0.51.146 / Release DR — 6-PR low-risk safety+contrast batch
2026-05-27 18:20:51 -07:00
nesquena-hermes
8d0f1beff3 stage-batch28: stamp v0.51.146 / Release DR 2026-05-28 01:17:07 +00:00
nesquena-hermes
64c83e4932 Merge pull request #3029 2026-05-28 01:02:58 +00:00
nesquena-hermes
d75bbfc90d Merge pull request #3028 2026-05-28 01:02:58 +00:00
nesquena-hermes
3560f37c8d Merge pull request #3027 2026-05-28 01:02:58 +00:00
nesquena-hermes
0c492e4eb1 Merge pull request #3003 2026-05-28 01:02:58 +00:00
nesquena-hermes
43cdd7cc37 Merge pull request #3025
# Conflicts:
#	CHANGELOG.md
2026-05-28 01:02:57 +00:00
nesquena-hermes
0db506f3f9 Merge pull request #3022 2026-05-28 01:02:33 +00:00
ai-ag2026
0af58f2b82 feat: add session discoverability audit 2026-05-28 00:51:32 +02:00
ai-ag2026
9650b387fd fix: keep webui mirrored sessions out of cli filter 2026-05-28 00:51:12 +02:00
ai-ag2026
38905f335c fix: preserve messageful sidebar discoverability 2026-05-27 23:42:24 +02:00
AJV20
fb6dd28ac3 fix: cap custom models endpoint probes 2026-05-27 12:19:42 -04:00
ai-ag2026
3b93345487 fix(sessions): allow hyphenated session ids 2026-05-27 16:18:41 +02:00
sunilkumarvalmiki
b14ef7735f Fix workspace save icon contrast 2026-05-27 19:17:46 +05:30
BonyFish
dc1e369f89 fix: remove webui-side writer to avoid conflict with agent
The .usage.json file is owned by hermes-agent (tools/skill_usage.py).
This change removes the webui-side increment logic to avoid:

1. File ownership conflict - both writing to same file
2. Schema mismatch - agent uses ISO strings, webui used floats
3. Concurrency issues - agent uses fcntl locks, webui had no locking
4. Double-counting - agent already increments counters server-side

Changes:
- api/skill_usage.py: keep only read_skill_usage(), remove increment functions
- api/streaming.py: remove skill usage counter hook
- api/routes.py: adapt response to pass through agent's format as-is,
  with defensive coercion for None values and metadata preservation
- tests/test_skill_usage.py: remove increment tests (17→7 cases)
2026-05-27 20:57:32 +08:00
Hermes Agent
74fe79ddc6 fix(reasoning): use agent metadata for effort support 2026-05-27 03:54:45 -06:00
ai-ag2026
ffd9f337b1 fix(chat): scope settled stream cleanup to source 2026-05-27 09:10:43 +02:00
AJV20
2a96b2acc2 fix: wrap WebUI script prefill text as user context 2026-05-27 03:09:01 -04:00
BonyFish
9141a55ae0 fix test error 2026-05-27 15:05:47 +08:00
BonyFish
3a676b704a feat: add skill usage counter to insights page
- Add api/skill_usage.py for .usage.json read/write with atomic updates
- Track skill_view (view_count), skill_manage (use_count), skill_patch
  (patch_count) invocations per SSE stream completion
- Add GET /api/skills/usage endpoint returning per-skill usage stats
- Render Skill Usage card in Insights panel after LLM Wiki card
- Add i18n keys for en, zh, zh-Hant, ja, it locales
- Add 17 tests covering read/increment logic, API route, and streaming hook
2026-05-27 14:44:28 +08:00
nesquena-hermes
329debcd33 Merge pull request #3004 from nesquena/release/stage-batch27
Some checks failed
Release & Docker / release (push) Has been cancelled
Release DQ — stage-batch27 — sidebar running-state preservation (v0.51.145)
2026-05-26 22:13:57 -07:00
nesquena-hermes
cc96acd7da stage-batch27: stamp v0.51.145 / Release DQ 2026-05-27 05:08:46 +00:00
george-andraws
541c064a72 fix(session): route messaging metadata loads through display merge
Use _merged_session_messages_for_display for is_messaging_session even in the
metadata-only (messages=0) path. This ensures message_count and last_message_at
match the full load path for Telegram / external messaging sessions that have
stitched or duplicate rows in state.db + sidecar.

Prevents spurious refresh loops, scroll resets, and open panel closures when
resuming cross-surface sessions in the WebUI.

No impact on CLI, non-messaging, or full-message paths. All 580 session tests pass.

Fixes the root cause identified in the SessionDB / render interaction changes.
2026-05-26 22:05:58 -07:00
nesquena-hermes
716a93a77d Merge pull request #3001
# Conflicts:
#	CHANGELOG.md
2026-05-27 04:56:49 +00:00
nesquena-hermes
3f22e54700 Merge pull request #3002 from nesquena/release/stage-batch26
Some checks failed
Release & Docker / release (push) Has been cancelled
Release DP — stage-batch26 — terminal supervisor hardening (v0.51.144)
2026-05-26 21:54:43 -07:00
nesquena-hermes
58cbc11df2 stage-batch26: stamp v0.51.144 / Release DP 2026-05-27 04:51:22 +00:00
nesquena-hermes
a8e2279d73 stage-batch26: skip test_terminal_survives_short_lived_request_thread (timing flaky)
The 7 other tests in this file deterministically cover the supervisor invariants
(concurrent spawn, timeout-race reap, Popen-failure recovery, supervisor singleton).
This 1 test depends on bash prompt rendering + printf execution echoing through
the PTY within a 1s deadline, which is timing-flaky in non-tty CI.
2026-05-27 04:48:21 +00:00
nesquena-hermes
2d69c18226 stage-batch26: backfill CHANGELOG entry for #2880 2026-05-27 04:40:26 +00:00
nesquena-hermes
744c5d60c4 Merge pull request #2880 2026-05-27 04:40:11 +00:00
Frank Song
8ab9235e00 Preserve server running state in sidebar merge 2026-05-27 12:29:57 +08:00
nesquena-hermes
875ce4169a Merge pull request #3000 from nesquena/release/stage-batch25
Some checks failed
Release & Docker / release (push) Has been cancelled
Release DO — stage-batch25 — workspace:// markdown scheme (v0.51.143)
2026-05-26 21:28:39 -07:00
nesquena-hermes
78bcd6f48f stage-batch25: stamp v0.51.143 / Release DO 2026-05-27 04:25:06 +00:00
nesquena-hermes
540993ccaf Merge pull request #2938
# Conflicts:
#	CHANGELOG.md
2026-05-27 04:21:45 +00:00
nesquena-hermes
620696b4a4 Merge pull request #2997 from nesquena/release/stage-batch24
Some checks failed
Release & Docker / release (push) Has been cancelled
Release DN — stage-batch24 — 4-PR fresh-today batch (v0.51.142)
2026-05-26 20:42:03 -07:00
nesquena-hermes
c3016a0470 stage-batch24: stamp v0.51.142 / Release DN 2026-05-27 03:38:47 +00:00
nesquena-hermes
7f66f72514 Merge pull request #2993
# Conflicts:
#	CHANGELOG.md
2026-05-27 03:25:04 +00:00
nesquena-hermes
c208b710a4 Merge pull request #2996 2026-05-27 03:24:43 +00:00
nesquena-hermes
75cb7662d7 Merge pull request #2995 2026-05-27 03:24:43 +00:00
nesquena-hermes
9be6dce9ad Merge pull request #2994 2026-05-27 03:24:43 +00:00
BonyFish
ea3d4ec0b3 feat(cron): dynamic delivery options from API instead of hardcoded select
Replace the hardcoded 4-option deliver dropdown (local/discord/telegram/slack)
with a dynamic select populated from a new GET /api/crons/delivery-options
endpoint that reads _KNOWN_DELIVERY_PLATFORMS from hermes-agent.

Key changes:
- Add GET /api/crons/delivery-options endpoint returning all known platforms
- Frontend loads options asynchronously on first cron form open, with caching
- Enable deliver editing for existing jobs (was previously disabled)
- Include deliver in update payload when editing cron jobs
- Fallback to local-only if API unavailable
- Custom deliver values (e.g. feishu:oc_xxx) shown with * suffix
- Add cron_deliver_custom i18n key to all 12 locales
- Add 5 integration tests for the new endpoint
2026-05-27 10:58:49 +08:00
Frank Song
0ce1f9e8ed Update SessionDB injection invariant 2026-05-27 10:38:40 +08:00
Frank Song
3a9948d6cf Fix streaming SessionDB profile path 2026-05-27 10:29:31 +08:00
Isla-Liu
20f49e7bda docs(config): address PR #2964 review nits
* Comment alongside _AUTH_FINGERPRINT_VOLATILE_KEYS notes client_secret
  is treated as rotation-only on purpose, not a model-cache
  differentiator (maintainer §Concerns 3).
* _write_auth docstring at tests/test_issue_t16551f61_auth_token_churn_
  fingerprint.py:108 now describes actual behavior (writes + monkeypatch,
  no sleep+restat) — Copilot r3302471228.

Refs #2242. PR #2964 review.
2026-05-27 09:51:32 +08:00
george-andraws
f1b04be8f7 Fix WebUI fallback provider chain merging 2026-05-26 17:51:49 -07:00
nesquena-hermes
08e9ce3d8a Merge pull request #2992 from nesquena/release/stage-batch23
Some checks failed
Release & Docker / release (push) Has been cancelled
Release DM — stage-batch23 — 4-PR second hold-bucket pass (v0.51.141)
2026-05-26 17:46:00 -07:00
nesquena-hermes
3a32660739 stage-batch23: stamp v0.51.141 / Release DM 2026-05-27 00:42:38 +00:00
nesquena-hermes
2d7d2cb31d Merge pull request #2958
# Conflicts:
#	CHANGELOG.md
2026-05-27 00:38:58 +00:00
nesquena-hermes
ad4a4e2d62 Merge pull request #2506
# Conflicts:
#	CHANGELOG.md
2026-05-27 00:38:40 +00:00
nesquena-hermes
aa6cd48318 Merge pull request #2888
# Conflicts:
#	CHANGELOG.md
#	tests/test_webui_prefill_context.py
2026-05-27 00:38:24 +00:00
nesquena-hermes
e0dfa0c586 Merge pull request #2792 2026-05-27 00:37:48 +00:00
nesquena-hermes
83862e67aa Merge pull request #2990 from nesquena/release/stage-batch22
Some checks failed
Release & Docker / release (push) Has been cancelled
Release DL — stage-batch22 — 5-PR hold-bucket reassessment (v0.51.140)
2026-05-26 17:27:19 -07:00
nesquena-hermes
8b505e8ece stage-batch22: stamp v0.51.140 / Release DL 2026-05-27 00:23:54 +00:00
nesquena-hermes
b5856f76ec stage-batch22: backfill CHANGELOG entries for #2964, #2986, #2899, #2970 2026-05-27 00:20:03 +00:00
nesquena-hermes
6c619f9e81 Merge pull request #2970 2026-05-27 00:19:20 +00:00
nesquena-hermes
858a250319 Merge pull request #2899 2026-05-27 00:19:20 +00:00
nesquena-hermes
52e785a2d8 Merge pull request #2902
# Conflicts:
#	CHANGELOG.md
2026-05-27 00:19:20 +00:00
nesquena-hermes
244258aa89 Merge pull request #2986 2026-05-27 00:19:00 +00:00
nesquena-hermes
2db584969c Merge pull request #2964 2026-05-27 00:19:00 +00:00
nesquena-hermes
8bc3404932 Merge pull request #2989 from nesquena/release/stage-batch21
Some checks failed
Release & Docker / release (push) Has been cancelled
Release DK — stage-batch21 — 5-PR tier-2 batch (v0.51.139)
2026-05-26 17:10:22 -07:00
nesquena-hermes
eeff76bec7 stage-batch21: stamp v0.51.139 / Release DK 2026-05-27 00:07:03 +00:00
nesquena-hermes
485ad85203 stage-batch21: backfill CHANGELOG entry for #2954 (session index churn) 2026-05-27 00:03:31 +00:00
nesquena-hermes
80a9b0fa68 Merge pull request #2985
# Conflicts:
#	CHANGELOG.md
2026-05-27 00:03:02 +00:00
nesquena-hermes
f3fbb49ce4 Merge pull request #2983
# Conflicts:
#	CHANGELOG.md
2026-05-27 00:02:47 +00:00
nesquena-hermes
f061733c91 Merge pull request #2969
# Conflicts:
#	CHANGELOG.md
2026-05-27 00:02:32 +00:00
nesquena-hermes
825288a590 Merge pull request #2967
# Conflicts:
#	CHANGELOG.md
2026-05-27 00:02:09 +00:00
nesquena-hermes
272bfa9612 Merge pull request #2954 2026-05-27 00:01:54 +00:00
nesquena-hermes
a6c65de2e8 Merge pull request #2988 from nesquena/release/stage-batch20
Some checks failed
Release & Docker / release (push) Has been cancelled
Release DJ — stage-batch20 — 7-PR ultra-safe batch (v0.51.138)
2026-05-26 16:58:47 -07:00
nesquena-hermes
56ae08d9f2 stage-batch20: stamp v0.51.138 / Release DJ 2026-05-26 23:55:38 +00:00
nesquena-hermes
68abe36050 stage-batch20: backfill CHANGELOG entries for #2975 and #2982 2026-05-26 23:52:00 +00:00
nesquena-hermes
c7e7029ab4 Merge pull request #2950
# Conflicts:
#	CHANGELOG.md
2026-05-26 23:51:39 +00:00
nesquena-hermes
12fb9cac7e Merge pull request #2948
# Conflicts:
#	CHANGELOG.md
2026-05-26 23:51:25 +00:00
nesquena-hermes
cdc197aeec Merge pull request #2982 2026-05-26 23:51:10 +00:00
nesquena-hermes
9dc1c87cd8 Merge pull request #2949
# Conflicts:
#	CHANGELOG.md
2026-05-26 23:51:10 +00:00
nesquena-hermes
de2ab4f5d3 Merge pull request #2975 2026-05-26 23:50:51 +00:00
nesquena-hermes
ac64f5c7fc Merge pull request #2972
# Conflicts:
#	CHANGELOG.md
2026-05-26 23:50:51 +00:00
nesquena-hermes
f89aa4458a Merge pull request #2960 2026-05-26 23:50:28 +00:00
ai-ag2026
815df9de76 fix: vendor math and yaml assets for CSP 2026-05-26 23:49:02 +02:00
ai-ag2026
fceb255252 fix(session): anchor message windows on renderable tail rows 2026-05-26 23:37:08 +02:00
george-andraws
65cae8f1a5 Improve sidebar chat search highlighting 2026-05-26 14:17:59 -07:00
xolom
e210c4855c fix: include client addresses in webui request logs 2026-05-26 21:45:27 +02:00
Ed
9bb5842530 chore: address audit findings — module docstring + consistent section separators 2026-05-26 16:56:02 +02:00
Ed
38db6199b8 chore: clean up test_issue1894_provider_overlap — remove vestigial noqa, add docstrings 2026-05-26 16:56:02 +02:00
Ed
e46072c699 fix: update opencode-go base_url in provider overlap tests
api.opencode.ai/v1 -> opencode.ai/zen/go/v1 (canonical per hermes_cli/auth.py)
2026-05-26 16:56:02 +02:00
dobby-d-elf
b74df67726 Make session index pruning explicit 2026-05-26 07:43:16 -06:00
weiwei83
d0235b7f5a fix: remove dead #composerAttach selector (actual ID is #attachTray) 2026-05-26 21:29:18 +08:00
weiwei83
1c7dfc85b3 refactor: code review cleanup
- Rename _escHandler to _keyHandler (now handles nav keys too)
- Store counter reference (lb._counterEl) to avoid DOM query on every nav
- Remove dead 'let counter = null' and 'hasNav' closure variable
- Use lb._navImages directly in keyboard handler for consistency
- Add null guard on lb.querySelector('img') in _navigateLightbox
- Inline _updateLightboxCounter one-liner
- Fix CSS section comment 'Image lightbox close' → 'Image lightbox'
- Fix CHANGELOG placeholder (#PR → #2967)
2026-05-26 21:28:38 +08:00
weiwei83
84d0d56f53 refactor: address PR review — fix event listener churn and dead code
- _navigateLightbox now reads lb._navIndex / lb._navImages directly
  instead of receiving a closure-captured index and rebuilding the
  keyboard handler on every navigation. No more removeEventListener /
  addEventListener churn.
- Button onclick handlers also read the live lb._navIndex.
- Removed dead backward-compat string-type shim and its unused oldEl
  querySelector.
- Composer attach-tray chips now open single-image lightboxes (no
  sibling detection across staged uploads).
2026-05-26 21:21:30 +08:00
Frank Song
2ff73c9954 Document truncation watermark metadata intent 2026-05-26 20:13:01 +08:00
Michael Lam
1b05d6031e docs(runtime): define runner client backend gate 2026-05-26 04:06:12 -07:00
Frank Song
9db0d6869a fix: keep session switch metadata non-blocking 2026-05-26 16:40:35 +08:00
AlexeyDsov
08277fcd9c perf(ui): cache visWithIdx to skip O(N) scan on render window expansion 2026-05-26 11:24:23 +03:00
AlexeyDsov
f0dfe116ca perf(ui): limit renderMessages O(N) passes to visible window 2026-05-26 11:24:11 +03:00
AlexeyDsov
755ecb94cd perf(ui): cache renderMd output and lazy Prism.js highlighting 2026-05-26 11:23:50 +03:00
weiwei83
ecf7ca7c60 feat: add prev/next navigation to image lightbox
When multiple images appear in the same message, clicking any image
now opens a lightbox with prev/next navigation buttons (‹ / ›) and
keyboard support (← / →). An image counter (e.g. '3 / 5') is shown at
the bottom of the overlay.

- _openImgLightbox now receives the clicked <img> element to find
  sibling images within the same message container
- New _openImgLightboxWithNav, _navigateLightbox, _updateLightboxCounter
- CSS: .img-lightbox-nav (prev/next buttons), .img-lightbox-counter
- Close button (×), Escape key, and click-outside-to-close preserved
2026-05-26 15:16:03 +08:00
Sanjay Santhanam
cf94f2f7c4 docs(docker): promote scheduled-jobs section to H2 + runnable verify command (review feedback from @nesquena-hermes) 2026-05-25 23:11:59 -07:00
Sanjay Santhanam
0211e1e012 fix(providers): pass through server CSRF reason instead of one-size message (review feedback from @nesquena-hermes) 2026-05-25 23:11:11 -07:00
Sanjay Santhanam
6dd6bced67 fix(transcript): preserve subagent accent over tool-card border-left (review feedback from @nesquena-hermes) 2026-05-25 23:10:39 -07:00
Isla-Liu
ecfa580660 fix(config): exclude credential-rotation fields from models-cache auth.json fingerprint
auth.json is rewritten by credential-pool/OAuth token refresh roughly every
14 minutes. _models_cache_source_fingerprint() hashed it via mtime/size
(#1699 _models_cache_file_fingerprint), so every token refresh churned the
fingerprint and the 24h /api/models cache was effectively dead -- the hot
GET /api/session?resolve_model=1 path paid a cold ~11.5s rebuild every few
minutes (RCA t_d127953d residual #2, t_16551f61).

Add _auth_store_semantic_fingerprint(): content-hash auth.json with a
DENY-list of known credential-rotation-only keys (access/refresh token,
expiry, per-credential status/telemetry, request_count, save updated_at)
stripped. Deny-list (not allow-list) is deliberate -- any unknown field, or
a real provider/endpoint/model-set change (active_provider, a new
credential_pool entry, base_url, source, label, auth_type, the providers{}
block, ...) stays in the fingerprint and still correctly busts the cache.
Conservative fallbacks: missing file -> marked; unreadable/corrupt ->
stat-based fallback (never less safe than pre-fix). config.yaml keeps the
cheap stat fingerprint (deliberate edits, no timer churn).

Bidirectional invariant regression test (non-tautological -- the
end-to-end churn test flips RED when the auth_json axis is reverted to
stat-based): token-only churn keeps fingerprint byte-identical AND keeps a
valid disk cache loadable; active_provider change / new credential_pool
entry / changed base_url each flip the fingerprint AND reject the stale
disk cache. Measured: 5/5 cold rebuilds per 5 refresh cycles -> 0/5.

Tests: 9 new pass; 28 adjacent (#1699/#1633/display-resolver) pass;
54 models_cache/fingerprint suite pass.
2026-05-26 12:46:59 +08:00
Frank Song
a9ea56040f Tighten interim progress activity boundaries 2026-05-26 11:32:34 +08:00
Frank Song
fb2b1ebd19 Clarify contract routing coverage boundary 2026-05-26 11:32:34 +08:00
Frank Song
8f152a005e docs: require contract change routing 2026-05-26 10:06:50 +08:00
Frank Song
26fb71839e fix(chat): keep visible interim progress in timeline 2026-05-26 09:53:55 +08:00
Frank Song
7a52dec35d feat(chat): open workspace links in preview 2026-05-26 08:57:31 +08:00
dobby-d-elf
ca9e821b5e Reduce session index churn on chat start 2026-05-25 16:25:23 -06:00
dobby-d-elf
594627806c adjust delete/archive/restore text color 2026-05-25 16:04:41 -06:00
Sanjay Santhanam
655b5f9101 docs(docker): point gateway-not-configured banner at a real fix
Scheduled cron jobs created in the Tasks panel never tick on a
single-container Docker install because the WebUI doesn't run the
gateway daemon itself. The maintainer's analysis on #2785 spells this
out: the gateway ticks the scheduler every 60s, and without it
'Gateway not configured' just sits there.

The Tasks panel already shows a banner explaining this, but doesn't
give the user anywhere to go. Two small docs-shaped changes:

1. Add a 'Scheduled jobs require a gateway daemon' section to
   docs/docker.md under 'What goes wrong' with the two-container
   compose command and a verify step. Cross-linked from the existing
   short paragraph higher up so both entry points land on the same
   fix.

2. Append a 'How to enable scheduled jobs in Docker' link to the
   cron panel banner (loadCronGatewayNotice) pointing at the new
   docs anchor when the gateway is unconfigured. The banner text
   itself is unchanged.

Verified locally by serving the WebUI without a gateway, opening
Tasks, and confirming the banner now shows the new link; clicked it
and confirmed it lands on the new docs section. With the gateway
running the banner stays hidden as before.

Refs #2785
2026-05-25 11:35:24 -07:00
Sanjay Santhanam
51f333fef0 fix(providers): clearer toast on 403 when removing a provider key
The Remove button under Settings -> Providers calls
POST /api/providers/delete, which runs through _check_csrf. When the
CSRF cookie/header pair has drifted (typically a tab opened before the
most recent login or cookie rotation), the server returns 403 with the
string 'Cross-origin request rejected'. That string reads like a
reverse-proxy deployment problem and gives the user no next step (#2572).

Surface a recovery-shaped toast on 403 from this endpoint:
'Session expired. Reload the page and try again.' The underlying
server response is unchanged so logs/diagnostics still see the original
string; only the user-facing toast is replaced for this code path.

Verified locally by patching _check_csrf to return False, clicking
Remove on a provider card, and confirming the toast now reads the new
message instead of the raw cross-origin string.

Refs #2572
2026-05-25 11:28:14 -07:00
Sanjay Santhanam
d86e841f0b fix(transcript): make tool cards visually distinct without hover
The tool-card border-subtle was so faint that the cards visually melted
into the surrounding prose once the cursor left the conversation. Bumps
the resting border to --border-muted and adds a 2px left edge so a tool
output row reads as metadata at a glance, even on light skins where
border-subtle is barely visible. Hover still escalates to --border2.

Verified by loading a session with mixed tool calls and assistant prose
on the light theme and confirming the tool cards are now identifiable
without mousing over them.

Refs #2867
2026-05-25 11:22:35 -07:00
nesquena-hermes
48a2e79224 Merge pull request #2947 from nesquena/release/stage-batch19
Some checks failed
Release & Docker / release (push) Has been cancelled
Release DI — stage-batch19 — 6-PR medium-risk batch (v0.51.137)
2026-05-25 10:56:06 -07:00
nesquena-hermes
4d3a59d72c stage-batch19: stamp v0.51.137 / Release DI 2026-05-25 17:52:38 +00:00
nesquena-hermes
f13433b7d3 stage-batch19: backfill CHANGELOG entry for #2915 (marker-based anchor) 2026-05-25 17:48:43 +00:00
george-andraws
0f388de09c fix duplicate chat upload filenames 2026-05-25 17:48:19 +00:00
nesquena-hermes
0c6af12723 Merge pull request #2933
# Conflicts:
#	CHANGELOG.md
2026-05-25 17:48:05 +00:00
nesquena-hermes
7c666d8e4d Merge pull request #2913
# Conflicts:
#	CHANGELOG.md
2026-05-25 17:47:51 +00:00
nesquena-hermes
fb9d67865b Merge pull request #2937
# Conflicts:
#	CHANGELOG.md
2026-05-25 17:47:36 +00:00
nesquena-hermes
c6869806f0 Merge pull request #2923
# Conflicts:
#	CHANGELOG.md
2026-05-25 17:47:23 +00:00
nesquena-hermes
c2fc390723 Merge pull request #2915 2026-05-25 17:46:56 +00:00
nesquena-hermes
2577432f14 Merge pull request #2945 from nesquena/release/stage-batch18
Some checks failed
Release & Docker / release (push) Has been cancelled
Release DH — stage-batch18 — 5-PR streaming + session index batch (v0.51.136)
2026-05-25 10:25:05 -07:00
nesquena-hermes
785b3eebb5 stage-batch18: stamp v0.51.136 / Release DH 2026-05-25 17:21:47 +00:00
nesquena-hermes
668cdeade0 stage-batch18: backfill CHANGELOG entry for #2907 2026-05-25 17:18:17 +00:00
nesquena-hermes
4a8b29db9e Merge pull request #2928
# Conflicts:
#	CHANGELOG.md
2026-05-25 17:18:02 +00:00
nesquena-hermes
f6dc7aad6f Merge pull request #2907 2026-05-25 17:17:44 +00:00
nesquena-hermes
68afa0e44a Merge pull request #2932
# Conflicts:
#	CHANGELOG.md
2026-05-25 17:17:44 +00:00
nesquena-hermes
0be9874549 Merge pull request #2918
# Conflicts:
#	CHANGELOG.md
2026-05-25 17:17:31 +00:00
nesquena-hermes
6eaddada93 Merge pull request #2934
# Conflicts:
#	CHANGELOG.md
2026-05-25 17:17:06 +00:00
nesquena-hermes
a0ab1687b1 Merge pull request #2944 from nesquena/release/stage-batch17
Some checks failed
Release & Docker / release (push) Has been cancelled
Release DG — stage-batch17 — 9-PR small-fix batch (v0.51.135)
2026-05-25 10:14:51 -07:00
nesquena-hermes
fe9d95e22b stage-batch17: skip #2927 test file when hermes-agent module not available (CI fix) 2026-05-25 17:11:59 +00:00
nesquena-hermes
41e424b3d5 stage-batch17: stamp v0.51.135 / Release DG 2026-05-25 17:06:27 +00:00
nesquena-hermes
fa9881445a stage-batch17: backfill CHANGELOG entries for #2906 and #2940 2026-05-25 17:03:00 +00:00
nesquena-hermes
f7ff8b5297 Merge pull request #2919
# Conflicts:
#	CHANGELOG.md
2026-05-25 17:02:33 +00:00
nesquena-hermes
bf28f400ec Merge pull request #2921
# Conflicts:
#	CHANGELOG.md
2026-05-25 17:02:16 +00:00
nesquena-hermes
67b41b2b54 Merge pull request #2927 2026-05-25 17:02:02 +00:00
nesquena-hermes
fdb0ccb392 Merge pull request #2940 2026-05-25 17:02:02 +00:00
nesquena-hermes
84218ce5a9 Merge pull request #2936
# Conflicts:
#	CHANGELOG.md
2026-05-25 17:02:02 +00:00
nesquena-hermes
d86aa6013f Merge pull request #2906 2026-05-25 17:01:47 +00:00
nesquena-hermes
da25aad946 Merge pull request #2912
# Conflicts:
#	CHANGELOG.md
2026-05-25 17:01:47 +00:00
nesquena-hermes
c876724ff4 Merge pull request #2922 2026-05-25 17:01:32 +00:00
nesquena-hermes
6b80df8252 Merge pull request #2917 2026-05-25 17:01:27 +00:00
george-andraws
fe597c1cdf fix chat upload attachment paths 2026-05-25 09:12:14 -07:00
Rehan Khan
0db8f5ded4 fix(terminal): harden supervisor lifecycle and eliminate timeout race conditions
- Split timeout state from spawn completion using timed_out flag
- Ensure request.done represents only spawn completion
- Add per-request locking to enforce atomic lifecycle resolution
- Prevent late Popen results from mutating timed-out requests
- Reap abandoned subprocesses before terminal registry assignment
- Strengthen supervisor stability under repeated failure and mixed workloads
- Add regression coverage for timeout + race + failure scenarios
2026-05-25 20:45:17 +05:00
Frank Song
3ee0173cd3 feat(server): allow extra CSP connect sources 2026-05-25 21:48:52 +08:00
Frank Song
aa6daf82f2 fix(gateway): treat active sessions as configured 2026-05-25 21:48:51 +08:00
Frank Song
524ed6cce3 fix(streaming): preserve profile env for checkpoints 2026-05-25 21:29:06 +08:00
Frank Song
e265389116 perf(sessions): prime missing index in background 2026-05-25 21:21:20 +08:00
Frank Song
459286830b fix(session): preserve sidecar truncation boundary 2026-05-25 21:21:15 +08:00
ai-ag2026
47f6648905 fix(chat): keep one live SSE source per stream 2026-05-25 13:14:55 +02:00
carryzuo00
2881fcec89 fix(agent_health): detect profile-scoped gateway.pid for correct status
_gateway_root_pid_path() unconditionally returned <hermes_root>/gateway.pid.
Profile-scoped gateways (started with --profile <name> or via active_profile)
write their runtime files under <hermes_root>/profiles/<name>/ instead of the
root, so the root-level path never existed.

build_agent_health_payload() therefore always received a non-existent pid_path,
fell through to the stale root-level gateway_state.json, and returned alive=None.
This caused the cron/scheduled-jobs page to display "Gateway not configured" even
when a gateway was actively running.

Fix: after failing to find a root-level gateway.pid, fall back to the active
profile directory via get_active_hermes_home(). Root-level wins when it exists,
so deployments that do write there are unaffected. Errors from profile lookup are
swallowed and the root path is returned, preserving the previous safe default.

Adds five focused unit tests covering the new fallback, the priority rule, and
the error-handling path.
2026-05-25 10:35:37 +00:00
MinhoJJang
d920d4222a Trim paginated session tool call payloads 2026-05-25 18:28:43 +09:00
ai-ag2026
5e1b51d16f docs: add canonical session resolution contract 2026-05-25 11:14:15 +02:00
Frank Song
cfca26f2e8 Repaint sidebar after session archive or delete 2026-05-25 16:31:15 +08:00
Frank Song
10463158f8 docs: add clarify dialog screenshot evidence 2026-05-25 16:25:11 +08:00
Frank Song
89d8c3a94c fix: cap live tool prompt estimate per turn 2026-05-25 16:04:40 +08:00
Frank Song
85e13a6121 fix: reserve space for clarify dialogs 2026-05-25 15:58:27 +08:00
Frank Song
21e981a166 test: cover metadata-only profile routing 2026-05-25 15:50:47 +08:00
fxd-jason
5b6e1e1477 fix: restore messages.js from upstream to fix inflight stream tests 2026-05-25 15:27:57 +08:00
fxd-jason
dd09e1c2a8 Merge remote-tracking branch 'origin/master' into fix/compression-anchor-marker-based
# Conflicts:
#	tests/test_issue2028_compression_anchor_helpers.py
2026-05-25 15:23:17 +08:00
fxd-jason
90dfbf2f2d fix: marker-based compression anchor calculation
Instead of using len(visible_after)-1 (which points to the last visible
message and gets pushed behind the render window as more turns accumulate),
find the last [CONTEXT COMPACTION] marker in s.messages and compute the
anchor from visible messages before it.

This keeps the compression reference card at the correct boundary even
after 50+ subsequent turns have scrolled the render window past the old
anchor position.

Fixes a bug where the assistant's output message appeared to disappear
after automatic context compression because the reference card was placed
at the wrong position.
2026-05-25 15:16:26 +08:00
ai-ag2026
9e74072bf3 fix(chat): resolve stale compression parent routes 2026-05-25 08:54:46 +02:00
ai-ag2026
3009c0bf71 fix(chat): keep compression tip selected in sidebar 2026-05-25 08:28:12 +02:00
ai-ag2026
21655af09b fix(chat): accept session_id URL parameter 2026-05-25 07:59:21 +02:00
ai-ag2026
5b9484b816 fix(streaming): suppress visible progress echoes 2026-05-25 06:18:06 +02:00
ai-ag2026
17233293ac fix(chat): dedupe uploaded-file pending turns 2026-05-25 06:11:22 +02:00
Simonas Jakubonis
7aae822872 fix(compression): ignore tool output for compaction cards 2026-05-25 11:27:15 +08:00
nesquena-hermes
4ea762ae0d Merge pull request #2903 from nesquena/release/stage-batch16
Some checks failed
Release & Docker / release (push) Has been cancelled
Release DF / v0.51.134 — stage-batch16 (Windows path defaults align with agent)
2026-05-24 19:07:57 -07:00
nesquena-hermes
cc8a79cec4 Stamp CHANGELOG for v0.51.134 (Release DF / stage-batch16)
Single-PR Windows-paths align fix:
- PR #2897 (chouzz) — align WebUI default state/config paths with Hermes Agent's
  %LOCALAPPDATA%\hermes on Windows. POSIX behavior unchanged.

Cherry-picked clean from contributor tip to dodge stale-base trap (net master→PR
delta was -2184 LOC due to ~2-week-old base).

Gates passed:
- Pre-Opus: Python ast.parse on api/config.py, api/profiles.py, new test file
- Stale-base check: cherry-picked diff matches contributor's actual change (6 files, +55/-17)
- Opus advisor: SHIP-AS-IS, Linux no-op invariant verified
- Full pytest sequential: 6540 passed, 6 skipped, 3 xpassed, 0 failures (179s)

Closes #2840.
2026-05-25 02:04:15 +00:00
nesquena-hermes
0645cfe7d2 chore(changelog): add Unreleased entry for #2897 Windows paths fix 2026-05-25 02:03:26 +00:00
george-andraws
e8d4a15d19 fix: keep underscores literal while streaming 2026-05-24 19:02:58 -07:00
Harlan Zhou
e8b426d825 test: avoid global env-coupled defaults regression 2026-05-25 01:58:32 +00:00
Harlan Zhou
f8a7726e09 fix(windows): align WebUI defaults with Hermes Agent home path 2026-05-25 01:58:32 +00:00
nesquena-hermes
75e0e67af2 Merge pull request #2900 from nesquena/release/stage-batch15
Some checks failed
Release & Docker / release (push) Has been cancelled
Release DE / v0.51.133 — stage-batch15 (6 low-risk contributor PRs)
2026-05-24 18:57:01 -07:00
nesquena-hermes
36cd8acb98 Stamp CHANGELOG for v0.51.133 (Release DE / stage-batch15)
Six-PR low-risk contributor batch:
- PR #2891 (franksong2702) — validate auxiliary model task slots
- PR #2892 (franksong2702) — gate workspace artifact mutation paths
- PR #2893 (franksong2702) — reject update apply with no targets
- PR #2895 (franksong2702) — preserve cached agent prefill context
- PR #2894 (franksong2702) — Joplin token in Authorization header
- PR #2896 (franksong2702) — localize third-party notes drawer copy

Pre-Opus gates: Python/JS syntax OK, no merge markers, all entries present.
Opus advisor: SHIP-AS-IS (no MUST-FIX, no SHOULD-FIX).
Full pytest sequential: 6545 passed, 6 skipped, 3 xpassed, 0 failures (174s).
2026-05-25 01:53:51 +00:00
nesquena-hermes
ba4f1e58ed chore(changelog): add missing #2892 Unreleased entry 2026-05-25 01:47:49 +00:00
nesquena-hermes
18c94ad324 Merge pull request #2896 — localize third-party notes drawer copy
# Conflicts:
#	CHANGELOG.md
2026-05-25 01:47:24 +00:00
nesquena-hermes
7e22c4edd5 Merge pull request #2894 — send Joplin token in Authorization header
# Conflicts:
#	CHANGELOG.md
2026-05-25 01:47:23 +00:00
nesquena-hermes
15dc7373c1 Merge pull request #2895 — preserve cached agent prefill context
# Conflicts:
#	CHANGELOG.md
2026-05-25 01:47:23 +00:00
nesquena-hermes
e8ab7410df Merge pull request #2893 — reject update apply with no selected target
# Conflicts:
#	CHANGELOG.md
2026-05-25 01:47:23 +00:00
nesquena-hermes
16983d3ab8 Merge pull request #2892 — gate workspace artifact read-only tool paths
# Conflicts:
#	CHANGELOG.md
2026-05-25 01:47:23 +00:00
nesquena-hermes
d513c4cd45 Merge pull request #2891 — validate auxiliary task slots 2026-05-25 01:46:49 +00:00
dobby-d-elf
5c21968158 adjust animation scaling 2026-05-24 19:09:31 -06:00
AJV20
894ad9ed90 Merge origin/master into feat/webui-prefill-script 2026-05-24 21:04:27 -04:00
Qi
7bd85405c9 perf(session): cumulative tail-window load for older history
_loadOlderMessages() previously fetched older messages with the legacy
index-cursor page (msg_before=_oldestIdx&msg_limit=30) and prepended
the page to S.messages. After #2716 the backend always runs the full
append-only merge for /api/session?messages=1 — the same merge as a
larger msg_limit on the same call — so we can ask for a larger
authoritative tail window directly instead of stitching pages on the
client.

Behavior

* Default request shape becomes msg_limit=currentLoaded+30. The newly
  exposed head of the response is what the user sees as 'older
  messages'. No new query parameters.
* msg_before remains supported by the backend and is retained in the
  client as a race-fallback path: if the returned tail no longer has
  the currently displayed messages as a suffix (because the session
  appended new messages mid-flight, or merge filtered something), the
  client issues the legacy msg_before page and prepends it instead.
  This preserves correctness under concurrent appends.
* Suffix-continuity uses the existing _sameTranscriptMessage helper,
  which tolerates timestamp drift and content-array reshapes.
* Existing race guards (loadingSessionId, S.session.session_id, and
  the _messagesGeneration snapshot from #1937) are reapplied after
  the fallback await.

Tests

Updated four static-string assertions in the existing scroll/viewport
tests to track the new mutation site (S.messages = nextMessages) and
the new msg_limit=requestedLimit shape, while still asserting that
msg_before remains in the body for the race-fallback path.

  pytest -q
    tests/test_older_history_viewport_preservation.py
    tests/test_parallel_session_switch.py
    tests/test_issue1937_endless_scroll_jumpstart_race.py
    tests/test_session_tail_payload.py
  -> 52 passed
  node --check static/sessions.js -> ok

Notes

Originally part of PR #2835. That PR was closed because of an
architectural conflict with #2716 on a different file (api/models.py
metadata-only path). #2716 left static/sessions.js untouched — this
change applies cleanly on post-#2716 master with no rebase work.
2026-05-25 01:01:45 +00:00
AJV20
d0a9d3e1ac docs(chat): clarify prefill as retrieval router 2026-05-24 21:00:49 -04:00
Frank Song
d2e4dfabb4 fix: localize external notes drawer copy 2026-05-25 08:51:44 +08:00
Frank Song
dc86841547 fix: send joplin token in auth header 2026-05-25 08:51:44 +08:00
Frank Song
535c238285 fix: preserve cached agent prefill context 2026-05-25 08:51:44 +08:00
Frank Song
3836b5eee0 fix: block update apply without targets 2026-05-25 08:51:44 +08:00
Frank Song
3c9b72f75a fix: gate workspace artifact mutation paths 2026-05-25 08:51:44 +08:00
Frank Song
826e719a29 fix: validate auxiliary model task slots 2026-05-25 08:51:44 +08:00
Roberto Villegas
4c4922a0d5 fix(webui): harden reasoning chip provider coverage 2026-05-24 18:42:44 -06:00
Roberto Villegas
d1471fbed7 fix(webui): resolve reasoning efforts for routed codex models 2026-05-24 18:42:44 -06:00
Roberto Villegas
91976a8fae fix(ui): re-sync reasoning chip on model change with provider context
Model picker onchange now calls syncReasoningChip after session model/
provider updates, and dropdown selections pass providerId so duplicate
bare model ids resolve to the correct backend capabilities.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 18:42:44 -06:00
Roberto Villegas
a9ce2889af fix(ui): hide reasoning chip when model lacks effort levels
Resolve supported reasoning efforts per active model/provider and pass
that context through /api/reasoning so Composer and other non-configurable
models no longer show a misleading effort picker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 18:42:31 -06:00
Roberto Villegas
ef5eafcceb fix(cursor-acp): route slash models and honor picker on new chat
Ensure cursor/composer IDs always resolve via @cursor-acp:, carry the
visible picker selection into POST /api/session/new, persist model
changes before a session exists, and evict cached agents on model switch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 18:42:20 -06:00
Roberto Villegas
aee376323f feat(cursor-acp): add cursor-acp to WebUI model picker
- Add cursor-acp to _PROVIDER_DISPLAY with label 'Cursor ACP'
- Add cursor-acp static model list to _PROVIDER_MODELS
- composer-2.5, composer-2, default, cursor-acp
2026-05-24 18:42:04 -06:00
nesquena-hermes
4132085e0c Merge pull request #2889 from nesquena/release/stage-batch14
Some checks failed
Release & Docker / release (push) Has been cancelled
Release/stage-batch14
2026-05-24 17:39:33 -07:00
nesquena-hermes
a1e5f89ce8 Stamp CHANGELOG for v0.51.132 (Release DD / stage-batch14)
4-PR low-risk batch:
- #2685 LumenYoung: replayed-context dedup fix + live tool prompt cap
- #2739 ai-ag2026: interrupted-response classification + SSE diagnostics
  (rate-limited 30/60s, 4KB body cap, payload sanitized)
- #2824 gavinssr: shutdown server affordance — relocated to Settings
- #2859 AJV20: passkey/WebAuthn sign-in (opt-in default-off behind
  HERMES_WEBUI_PASSKEY / webui_passkey_enabled feature flag)

Opus Advisor verdict: SHIP-WITH-SHOULD-FIXES applied.
Zero MUST-FIX. 4/5 SHOULD-FIX patched inline. 1 deferred to follow-up
(live tool metering cumulative cap).
2026-05-25 00:27:30 +00:00
nesquena-hermes
1c2d574882 Stage-batch14: Opus advisor SHOULD-FIX patches (UX + defense-in-depth)
Inline fixes for 4 of 5 Opus SHOULD-FIX items before tag:

1. /api/auth/status now gates passkeys_enabled / passwordless_enabled on
   _passkey_feature_flag_enabled() — when flag is off, status reports
   no credentials even if passkeys.json has legacy entries. New
   passkey_feature_flag field added to the response for the frontend.

2. Settings → System Passkeys block (passkeysSettingsBlock) now starts
   display:none and loadPasskeys() reveals it only when the server
   confirms passkey_feature_flag === true AND /api/auth/passkeys
   doesn't return {disabled: true}. Stops the broken-affordance trap
   where users would see Add passkey → click → 404.

3. /api/settings/save now refuses to set passwordless mode when the
   passkey feature flag is off. Closes the auth-bypass path Opus flagged:
   user goes passwordless while flag on → admin unsets flag → restart
   serves the WebUI fully unauthenticated.

4. CHANGELOG entries added for PR #2685 (replayed-context dedup +
   per-turn metering cap) and PR #2824 (Stop server affordance,
   relocated to Settings) — both PRs had functional changes but no
   release-notes entries. Also enriched the rate-limit detail on the
   #2739 entry (30 events / 60s / 4KB body cap).

Deferred to follow-up issue (#5 in Opus review):
- Live tool metering cumulative cap across many tool calls — non-trivial
  refactor of _bump_live_prompt_estimate, will be a separate PR
2026-05-25 00:26:40 +00:00
nesquena-hermes
8c170b50ac Stage-batch14: i18n parity for new shutdown keys
Adds the 7 shutdown-related i18n keys to all 10 non-en/tr locales
(it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr) with proper translations.

Resolves test_*_locale.py::test_*_locale_covers_english_keys failures
that were caught by full sequential pytest. Locale parity is enforced
because untranslated keys would surface in non-en deployments as
English fallback text in the Stop Server affordance.

Italian + Portuguese translations use \' to escape apostrophes inside
the single-quoted JS string literals.
2026-05-25 00:22:15 +00:00
AJV20
befee0e035 test(chat): harden WebUI prefill script hook 2026-05-24 20:20:28 -04:00
nesquena-hermes
19c6e5d5f2 Stage-batch14: update passkey test for HERMES_WEBUI_PASSKEY feature flag
test_passwordless_mode_keeps_auth_enabled_with_passkeys now sets
HERMES_WEBUI_PASSKEY=1 via monkeypatch since are_passkeys_enabled()
gates on the feature flag.

Adds 2 new tests:
- test_passkey_feature_flag_off_disables_passkeys_even_with_credentials
- test_passkey_feature_flag_via_config
2026-05-25 00:17:11 +00:00
nesquena-hermes
46ed70bfde Stage-batch14: add HERMES_WEBUI_PASSKEY feature flag for #2859 passkey support
Per the stage-batch14 ship plan, passkey/WebAuthn support is shipped
opt-in default-off behind an explicit feature flag so deployments can
disable the entire surface (UI + endpoints + credential storage) without
needing to delete code.

Enable via either:
  - HERMES_WEBUI_PASSKEY=1 environment variable, OR
  - webui_passkey_enabled: true in config.yaml

With the flag off:
  - are_passkeys_enabled() returns False even if credentials exist
  - is_auth_enabled() falls back to password-only checking
  - /login renders password-only (no passkey button)
  - All 6 /api/auth/passkey/* endpoints return 404 with a clear message
  - Settings → System → Passkeys section is hidden

Mirrors the #2527 notes-drawer flag shape (env-or-config, truthy parse).
Auth is high-stakes; opt-in lets us land the code while keeping default
deployments on the well-tested password-only path.

Touches: api/auth.py (new _passkey_feature_flag_enabled helper, gated
are_passkeys_enabled), api/routes.py (6 endpoint guards).
2026-05-25 00:16:12 +00:00
AJV20
1b48643f63 feat: support passkey-only auth 2026-05-25 00:14:38 +00:00
AJV20
7c257ae8f9 fix: avoid prompt in passkey registration 2026-05-25 00:14:38 +00:00
AJV20
c60ff543b5 feat: add passkey sign-in 2026-05-25 00:14:38 +00:00
nesquena-hermes
15c5f4cf05 Stage-batch14: relocate #2824 shutdown from title bar to Settings → System
Per project deep-UX standards (default-hidden for niche destructive
actions). The title bar is shared real estate where always-visible
chrome competes with the title text and reload button — adding a
prominent destructive button there fails the 'kid clicks it' test even
with a confirmation modal. Moved to Settings → System where the user
who actively wants to stop the server can still find it, while everyone
else doesn't have to look at it.

Changes:
- Removed app-titlebar-shutdown button from <header> in index.html
- Removed dead .app-titlebar-shutdown CSS rule
- Added Settings → System → Stop server affordance (label + description + button)
- shutdownServer() and _showServerStopped() now use i18n keys
- Added 8 new locale keys to en + tr blocks (settings_label_shutdown,
  settings_desc_shutdown, settings_btn_shutdown, settings_shutdown_confirm_*,
  settings_shutdown_stopped_message). Other 9 locales fall back to English
  via the existing locale fallthrough — follow-up issue tracked separately.

Preserves all of gavinssr's backend work (/api/shutdown route after CSRF
gate, BroadcastChannel for multi-tab signaling, app dialog with danger
styling) — only the placement is changed.
2026-05-25 00:13:47 +00:00
gavinssr
0ab3ad3bb2 fix: place shutdown route after CSRF gate
Move POST /api/shutdown routing after the CSRF check so drive-by
cross-origin requests cannot bring down a dev server with auth off.
Also replace os._exit(0) with os.kill(os.getpid(), signal.SIGINT)
so atexit handlers and pending session writes run during shutdown.
2026-05-25 00:10:52 +00:00
gavinssr
c361089658 fix: use app dialog instead of confirm, danger 2026-05-25 00:10:52 +00:00
gavinssr
39121650d4 feat: add shutdown button to WebUI title bar
Add a power button (⏻) in the title bar that gracefully stops the
WebUI server process from the browser.

- api/routes.py: POST /api/shutdown endpoint with threaded os._exit(0)
- static/boot.js: shutdownServer() with confirm prompt, BroadcastChannel
  cross-tab notification, and _showServerStopped() placeholder UI
- static/index.html: shutdown button HTML in title bar (after reload btn)
- static/style.css: .app-titlebar-shutdown styles, hover turns red
2026-05-25 00:10:52 +00:00
nesquena-hermes
fe6558efac Stage-batch14: drop pr-artifacts/ scratchpad from #2685 cherry-picks
The contributor used pr-artifacts/ as a working scratchpad during PR
development. The real test count and failure-mode docs are already
covered by inline test comments and CHANGELOG entries; this directory
adds nothing for upstream readers.
2026-05-25 00:10:39 +00:00
Lumen Yang
d0992730a9 fix: preserve repeated state rows in replay delta 2026-05-25 00:10:27 +00:00
Lumen Yang
32658978a9 docs: refresh context replay test count 2026-05-25 00:10:27 +00:00
Lumen Yang
5934c2fe8a fix: address context replay review feedback 2026-05-25 00:10:27 +00:00
Lumen Yang
3740df5302 docs: add context replay PR body 2026-05-25 00:10:27 +00:00
Lumen Yang
c616c8e788 fix: cap live tool prompt estimates 2026-05-25 00:10:27 +00:00
Lumen Yang
50c69713cc fix: reconcile state db delta after context 2026-05-25 00:10:27 +00:00
Lumen Yang
15cde132f3 fix: dedupe replayed context summaries 2026-05-25 00:10:27 +00:00
nesquena-hermes
74b3ca9920 Merge pull request #2739 from ai-ag2026/fix/interrupted-response-cause-diagnostics
fix(chat): classify interrupted responses and log SSE diagnostics
2026-05-25 00:09:52 +00:00
ai-ag2026
8a2f11c770 fix(chat): log sanitized client sse diagnostics
(cherry picked from commit 749ca6e18c5e307fbf7e7fb5fffce97249545017)
2026-05-25 02:06:42 +02:00
ai-ag2026
2f1ca959f1 fix(chat): classify interrupted response causes
(cherry picked from commit 5c1e802cd6ee8565da74c7ffe57e6407fe21bf02)
2026-05-25 02:06:42 +02:00
ai-ag2026
efe3d7c296 fix(chat): avoid false restart wording for interrupted responses
(cherry picked from commit ef8fd879682aeb729a7b7afa1e7c46478ca5ebb6)
2026-05-25 02:06:42 +02:00
AJV20
fa57868431 feat(chat): add WebUI prefill script hook 2026-05-24 20:05:20 -04:00
dobby-d-elf
e2bff58964 Refine iOS-style session swipe actions 2026-05-24 17:58:26 -06:00
nesquena-hermes
e50d6ccc8c Merge pull request #2884 from nesquena/release/stage-batch13
Some checks failed
Release & Docker / release (push) Has been cancelled
Release/stage-batch13
2026-05-24 16:43:13 -07:00
nesquena-hermes
d012436cb4 Stamp CHANGELOG for v0.51.131 (Release DC / stage-batch13 / 6-PR notes-drawer + context-parity + PWA-swipe + locale polish)
Opus Advisor verdict: SHIP-AS-IS. Zero MUST-FIX, three SHOULD-FIX
filed as follow-up issues:
- Notes drawer: 10 non-en locales ship English fallback (default-off so user impact = 0)
- _joplin_api_get URL-token defense-in-depth (move to Authorization header)
- prefill_messages setattr cache-reuse safety on older agent builds

6,503 pytest passed (sequential mode — xdist not supported by test infra).
2026-05-24 23:42:37 +00:00
nesquena-hermes
e5533ea0e4 Merge pull request #2547 from AJV20/fix/webui-context-parity
fix(chat): align WebUI context with messaging sessions
2026-05-24 23:12:27 +00:00
nesquena-hermes
767a9cd06d Merge pull request #2527 from AJV20/feat/webui-notes-sources
feat(memory): show third-party notes sources
2026-05-24 23:11:54 +00:00
nesquena-hermes
376fb78906 Stage-batch13: CHANGELOG for #2873 launcher env
PR was fork-PR-style with no CHANGELOG entry; added an entry describing
the launcher-env-preserve behavior change.
2026-05-24 23:09:48 +00:00
nesquena-hermes
d42253bca3 Merge pull request #2873 from Charanis/codex/webui-launcher-env-pr
[1/7] Preserve WebUI launcher environment
2026-05-24 23:09:28 +00:00
nesquena-hermes
1ec0bbc9e0 Stage-batch13: PR #2882 polish — fix zh-TW indent + CHANGELOG entry
The cherry-pick of #2882 brought in an accidental two-space indent on a
zh-TW key. Restored the existing two-space indentation level so the
zh-CN clarification stays the only behavioural change.
2026-05-24 23:09:23 +00:00
john
0279f1b6df Apply zh-CN session-time label clarifications from #2882 (ycj)
PR #2882 was based on stale master (66de2367, pre-stage-batch7); naive
merge would delete 5,627 lines of subsequent work. Extracted the actual
zh-CN diff and applied it on top of fresh stage.

Co-authored-by: john <yuanchangjun@gmail.com>
2026-05-24 23:08:58 +00:00
nesquena-hermes
6f94a44745 Merge pull request #2868 from AJV20/feat/pwa-sidebar-swipe
feat: add PWA sidebar edge swipe
2026-05-24 23:08:30 +00:00
nesquena-hermes
2d803afa3d Merge pull request #2865 from AJV20/fix/session-personality-default
fix: avoid stamping display personality on sessions (#2845)
2026-05-24 23:08:04 +00:00
dobby-d-elf
b4fa00b793 Merge branch 'master' into fix/session-list-touch-actions 2026-05-24 16:27:41 -06:00
AJV20
7c460ef7b1 fix(i18n): add Turkish notes-source strings 2026-05-24 17:54:14 -04:00
AJV20
d7b98d87cd Merge remote-tracking branch 'origin/master' into maint/pr-2547
# Conflicts:
#	CHANGELOG.md
2026-05-24 17:48:09 -04:00
AJV20
24979c8af1 Merge remote-tracking branch 'origin/master' into maint/pr-2527
# Conflicts:
#	CHANGELOG.md
2026-05-24 17:48:09 -04:00
AJV20
cf92aa5cc1 Merge remote-tracking branch 'origin/master' into maint/pr-2868
# Conflicts:
#	CHANGELOG.md
2026-05-24 17:48:07 -04:00
AJV20
6e2991f45a Merge remote-tracking branch 'origin/master' into maint/pr-2865
# Conflicts:
#	CHANGELOG.md
2026-05-24 17:48:06 -04:00
Charanis
f0b0854773 fix: preserve webui launcher environment
(cherry picked from commit 2297ab4db854b52b20cdd34731cd82e8cc5bdb72)
2026-05-24 21:49:21 +02:00
nesquena-hermes
5977567035 Merge pull request #2869 from nesquena/release/stage-batch12
Some checks failed
Release & Docker / release (push) Has been cancelled
Release DB — v0.51.130 (stage-batch12, 3-PR profile-isolation + boot-precedence + workspace Artifacts tab)
2026-05-24 12:43:25 -07:00
hermes-agent
7796f74ca3 Stage 406: Opus MUST-FIX — parity-fix sync_session_usage profile= at api/routes.py:9007
Opus pre-release advisor caught a #2762 parity gap. api/streaming.py:5078
(_run_agent_streaming worker, background thread) correctly passes
profile= to sync_session_usage post-#2827. But the SECOND production
call site at api/routes.py:9007 (_handle_chat_sync, HTTP thread) does
not. Safe TODAY (HTTP thread sets TLS correctly), but it's a
defense-in-depth gap: anyone wrapping that handler in a worker pool
later silently regresses the fix. Closes the parity gap so the
threat-model invariant holds regardless of future threading changes.
2026-05-24 19:39:28 +00:00
hermes-agent
cb6780e170 Stage 406: fix conflict-resolution bug — restore sessionModelState definition in _hydrateBootModelDropdown
My earlier conflict resolution between #2716 master and #2726 PR
dropped the 'const sessionModelState=...' assignment that the
.then() callback body uses on 6 different lines (1596, 1600, 1601,
1607, 1608, 1610). Without it boot.js would ReferenceError on every
boot. Caught by tests/test_new_chat_default_model_frontend.py::test_boot_model_hydration_prefers_active_session_over_persisted_model
which I'd missed in the initial touched-tests gate. Adds the
assignment back at the top of the .then() callback — semantically
matches the original #2716 master shape (S.session.model → wrap in
{model,model_provider} object, else null).
2026-05-24 19:21:43 +00:00
hermes-agent
c9bc21f394 Stage 406: in-stage test fixes + CHANGELOG for v0.51.130
- Patch tests/test_issue2762_state_sync_profile_kwarg.py::_read_session
  helper to query the real state.db schema (sessions.id PRIMARY KEY,
  not sessions.session_id). Was always broken — the test never matched
  any actual schema. Fix: SELECT id AS session_id + WHERE id = ?
- Patch tests/test_session_metadata_fast_path.py::test_failed_boot_model_catalog_prime_is_retryable
  to accept both populateModelDropdown() and populateModelDropdown({preferProfileDefaultOnFreshBoot:true})
  signatures (sibling-collision with #2726).
- Patch tests/test_model_default_boot_precedence.py::test_boot_model_dropdown_explicitly_requests_profile_default_precedence
  to accept either the original allowBootSavedModelOverride variable
  name OR the post-#2716-cherry-pick stateToApply equivalent
  (!window._defaultModel?savedState:null gate).
- Stamp CHANGELOG for v0.51.130 (Release DB).
2026-05-24 19:17:59 +00:00
AJV20
b0f7a7bdff feat: add PWA sidebar edge swipe 2026-05-24 15:14:28 -04:00
AJV20
7af7370be6 Merge remote-tracking branch 'origin/master' into fix/session-personality-default
# Conflicts:
#	CHANGELOG.md
2026-05-24 15:05:29 -04:00
hermes-agent
7a84c81dda Stage 406: PR #2673 — Add scoped workspace Artifacts tab by @AJV20 (closes #2655) 2026-05-24 18:58:59 +00:00
hermes-agent
c4ee97f41f Stage 406: PR #2726 — fix(model): keep boot default precedence non-destructive by @starship-s 2026-05-24 18:58:49 +00:00
hermes-agent
4f20cbd6ca Stage 406: PR #2673 — Add scoped workspace Artifacts tab by @AJV20 (closes #2655)
Cherry-picked via 3-way apply onto stage HEAD.
Resolved workspace.js conflict: kept master's #2716 sessionId-capture
stale-session guard (closure-scoped sessionId check after await), AND
added PR's renderSessionArtifacts() call to refresh the new Artifacts
tab when the file tree updates. Wrapped in typeof check for defense.

Co-authored-by: AJV20 <abdielvc@me.com>
2026-05-24 18:58:37 +00:00
hermes-agent
d9b2dd5019 Stage 406: PR #2726 — fix(model): keep boot default precedence non-destructive by @starship-s
Cherry-picked via 3-way apply onto stage HEAD (post-Release-A/B/C1).
Resolved boot.js conflict: took PR's parameterized
populateModelDropdown({preferProfileDefaultOnFreshBoot:true}) call
(the whole point of #2726) on top of master's #2716 boot path.

Co-authored-by: starship-s <starship-s@github.users.noreply.github.com>
2026-05-24 18:58:37 +00:00
hermes-agent
b788badea1 Stage 406: PR #2827 — fix(state-sync): pass profile explicitly so background-thread DB writes hit the right state.db (#2762) by @Koraji95-coder 2026-05-24 18:57:40 +00:00
hermes-agent
fd1c4eaeaf Stage 406: PR #2827 — fix(state-sync): pass profile explicitly so background-thread DB writes hit the right state.db (#2762) by @Koraji95-coder 2026-05-24 18:57:40 +00:00
AJV20
9bd595de40 fix: avoid stamping display personality on sessions 2026-05-24 14:57:37 -04:00
nesquena-hermes
3a98700a6b Merge pull request #2866 from nesquena/release/stage-batch11
Some checks failed
Release & Docker / release (push) Has been cancelled
Release DA — v0.51.129 (stage-batch11, 4-PR feature + perf: HTTP/1.1 keep-alive + Aux Models card + PWA startup polish + runtime adapter harness)
2026-05-24 11:56:08 -07:00
hermes-agent
7222095413 Stage 405: Opus MUST-FIX + Stamp CHANGELOG for v0.51.129 (Release DA / stage-batch11 / 4-PR feature + perf batch)
Opus pre-release advisor MUST-FIX patched inline:
- api/routes.py:7290-7308 _handle_folder_download: add Connection: close
  header before end_headers() to satisfy HTTP/1.1 framing on the on-the-fly
  ZIP stream. Without it, post-#2836 protocol_version bump leaves clients
  hanging waiting for the next pipelined response after central-directory
  bytes finish. Opus verified this is the ONLY streaming response #2836
  missed — all other paths (j/t helpers, 12 hand-written responses, 8 SSE
  endpoints, auth flow) are already correctly framed.
2026-05-24 18:52:54 +00:00
hermes-agent
2b6ed07c95 Stage 405: i18n parity + brittle-counter fixes for sibling-PR collisions
- Add Turkish translations for 16 settings_aux_* / settings_label/desc_auxiliary_models
  keys that #2680 added against the 10-locale set (pre-#2772 Turkish baseline).
- Bump test_auxiliary_models_settings.py::test_all_locales_have_auxiliary_keys
  from count == 11 to count == 12 (one per locale, now including tr).
2026-05-24 18:36:13 +00:00
nesquena-hermes
db87012f37 Stage 405: PR #2842 — feat: polish installed PWA startup by @AJV20 2026-05-24 18:28:53 +00:00
hermes-agent
c15148f925 Stage 405: PR #2842 — feat: polish installed PWA startup by @AJV20
Cherry-picked via 3-way apply of net delta against stage HEAD. All 8 files
applied cleanly including the new static/pwa-startup.js.

Co-authored-by: AJV20 <abdielvc@me.com>
2026-05-24 18:28:52 +00:00
nesquena-hermes
cefde78043 Stage 405: PR #2680 — feat: add Auxiliary Models settings card by @mccxj 2026-05-24 18:28:26 +00:00
hermes-agent
a86b378036 Stage 405: PR #2680 — feat: add Auxiliary Models settings card by @mccxj
Cherry-picked via 3-way apply (rebase had failed on static/index.html
conflict when applied via rebase commit chain; 3-way of the net delta
against stage HEAD applied cleanly).

Co-authored-by: mccxj <mccxj@github.users.noreply.github.com>
2026-05-24 18:28:26 +00:00
hermes-agent
e1a407f0ec Stage 405: PR #2836 — perf(http): enable HTTP/1.1 keep-alive by @v2psv 2026-05-24 18:26:56 +00:00
Qi
598fd4ff83 perf(http): enable HTTP/1.1 keep-alive
Enable HTTP/1.1 on the WebUI server so browsers can reuse TCP
connections across normal API/static requests. Tighten response framing
by adding Content-Length to short manual responses and marking
SSE/streaming responses as Connection: close, keeping HTTP/1.1 message
boundaries unambiguous.

Verified:
- python3 -m py_compile server.py api/auth.py api/routes.py api/kanban_bridge.py
- pytest tests/test_auth_*.py tests/test_*sse*.py tests/test_pr1350_*.py
        tests/test_pr1355_sse_handler_no_deadlock.py tests/test_kanban_bridge.py
        tests/test_logs_ui_static.py tests/test_onboarding_static.py
        tests/test_regressions.py tests/test_1038_pwa_auth_redirect.py
        tests/test_issue1623_sse_heartbeat_alignment.py
  → 239 passed, 1 skipped
2026-05-24 18:26:56 +00:00
hermes-agent
01f01b9cbe Stage 405: PR #2794 — feat(runtime): wire runner route selection harness by @Michaelyklam 2026-05-24 18:26:55 +00:00
Michael Lam
dd7648d56c feat(runtime): wire runner route selection harness 2026-05-24 18:26:55 +00:00
nesquena-hermes
9ad8737252 Merge pull request #2862 from nesquena/release/stage-batch10
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CZ — v0.51.128 (stage-batch10, 2-PR perf + correctness: #2716 perf optimizations + #2830 pin state authoritative)
2026-05-24 11:22:17 -07:00
hermes-agent
324df1c4af Stamp CHANGELOG for v0.51.128 (Release CZ / stage-batch10 / 2-PR perf + correctness batch) 2026-05-24 18:18:49 +00:00
hermes-agent
7401aef193 Stage 404: PR #2830 — fix(sessions): keep pin state authoritative by @franksong2702 (closes #2821) 2026-05-24 18:08:42 +00:00
hermes-agent
2419b3a0a2 Stage 404: PR #2830 — fix(sessions): keep pin state authoritative by @franksong2702 (closes #2821)
Agent reviewer 'LGTM. Ship it.'
- Bug A fix: _session_field helper handles dict-vs-object snapshot in pin-limit check
- Bug B fix: removed stale client-side pinLimitReached short-circuit
- Bug C recovery: renderSessionList() on pin/unpin failure refreshes from server

Co-authored-by: franksong2702 <146128127+franksong2702@users.noreply.github.com>
2026-05-24 18:08:42 +00:00
hermes-agent
d39229ae75 Stage 404: PR #2716 — Performance optimizations by @dobby-d-elf (nesquena APPROVED) 2026-05-24 18:08:41 +00:00
hermes-agent
9d95ba0b92 Stage 404: PR #2716 — Performance optimizations by @dobby-d-elf
nesquena APPROVED 2026-05-22. Cherry-picked onto post-v0.51.127
master via 3-way apply. Resolved api/routes.py conflict: master had
the inline correctness fix from the deep-review iteration; PR
refactors it into _metadata_only_message_summary() helper. Took the
helper AND added profile= threading (post-#2827 master adds
profile-aware state.db reads). Kept master's pre-existing
test_api_session_reload_drops_stale_cached_user_tail_after_saved_assistant
alongside the PR's new test_metadata_fast_path_matches_reconciliation_for_restamped_replays.

Co-authored-by: dobby-d-elf <dobby.the.agent@gmail.com>
2026-05-24 18:08:41 +00:00
nesquena-hermes
f5aa3755a4 Merge pull request #2860 from nesquena/release/stage-batch9
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CY — v0.51.127 (stage-batch9, 7-PR low-risk batch: brick-class Linux + brick-class update + composer wide-screen + Turkish + MCP toggle + SSE + Windows CI)
2026-05-24 10:55:31 -07:00
hermes-agent
d84f8b29cb Stamp CHANGELOG for v0.51.127 (Release CY / stage-batch9 / 7-PR low-risk batch) 2026-05-24 17:51:54 +00:00
hermes-agent
130be3db1d Stage 403: Opus pre-release fixes (1 MUST-FIX + 3 SHOULD-FIX)
MUST-FIX:
- tests/test_2735_open_in_vscode.py: bump expected open_in_vscode locale
  counter from 10 to 11 (Turkish locale added in #2772). The bump fell
  out of an in-rebase test edit but never got committed; tagging without
  this would have shipped a failing test in the release commit.

SHOULD-FIX inline:
- api/updates.py: case-D drift in _select_apply_compare_ref. The original
  #2855 fix used latest_tag in the past-tag predicate; the check side
  uses current_tag (HEAD's nearest reachable tag) plus a 'behind == 0'
  gate. They drift when HEAD is on an OLDER release tag with commits on
  top AND a NEWER tag exists ('case D'): check correctly suggests
  advancing to the newer tag, but apply fell through to origin/<branch>.
  Mirror the check-side predicate exactly. Adds regression test
  test_select_apply_compare_ref_case_d_older_tag_with_commits_and_newer_tag_exists.
- static/messages.js: post-await race guard in _restoreSettledSession.
  stream_end without preceding 'done' enters the settlement path, awaits
  /api/session, then sets _streamFinalized=true. If a late 'done' event
  arrives during that await, it sees _streamFinalized still false and
  double-runs the finalize. The guard returns early when done won the
  race, avoiding double renderMessages() + double notification.
- server.py: CORS preflight Access-Control-Allow-Methods now includes PUT.
  #2776 wired PUT into the router for /api/mcp/servers/{name} but didn't
  update the OPTIONS response. Same-origin only in practice, but cosmetic
  completeness for CORS-aware deployments.

Opus advisor verdict: all 5 risk areas reviewed, 1 MUST-FIX + 3 SHOULD-FIX
all addressed inline. Net: +69/-9, no new architecture, no behavior risk.
2026-05-24 17:42:06 +00:00
hermes-agent
f92eff573a Stage 403: i18n parity — Turkish translations for 9 MCP/VS-Code/ignore-agent-updates keys
Sibling-PR collision between #2772 (Turkish locale baseline)
and #2776 (MCP enable/disable toggle) plus already-shipped
master additions for open_in_vscode and ignore_agent_updates.
Add Turkish translations for the 9 missing keys to restore
locale-parity invariant:

  mcp_enable_server, mcp_disable_server, mcp_enabled_toast,
  mcp_disabled_toast, mcp_toggle_failed, open_in_vscode,
  open_in_vscode_failed, settings_label_ignore_agent_updates,
  settings_desc_ignore_agent_updates
2026-05-24 17:15:45 +00:00
nesquena-hermes
4fb5749492 Stage 403: PR #2772 — feat(i18n): add Turkish (tr) locale by @vaur94 2026-05-24 17:13:34 +00:00
Uğur Murat Altıntas
d4603b096d fix(i18n): correct double-escaped ellipsis in Turkish locale
Replace \\u2026 with \u2026 (and fix \\u2192/\\u2713) in the tr block
so ellipsis renders as U+2026 instead of literal backslash-u text.
Add a regression test guarding against double-escaped unicode sequences.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 17:13:34 +00:00
Uğur Murat Altıntas
6c811dcef5 fix(i18n): address Turkish locale review feedback
Fix Copilot review issues in the tr locale: Korean string leaks,
placeholder order, stray quotes, broken {provider} tags, duplicate
English voice keys overriding translations, and remaining TODO strings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 17:13:34 +00:00
Uğur Murat Altıntas
c77936ff81 feat(i18n): add Turkish (tr) locale support
Add a complete Turkish locale to the WebUI and login page so users can
select Türkçe in Settings, with speech recognition via tr-TR.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 17:13:34 +00:00
nesquena-hermes
af1d26abd0 Stage 403: PR #2776 — feat: PATCH /api/mcp/servers/{name} enable/disable toggle by @roryford 2026-05-24 17:13:33 +00:00
nesquena-hermes
a290af61b8 Stage 403: PR #2855 — fix(updates): apply path must follow check-side fall-through past the latest tag (#2846) by @nesquena-hermes 2026-05-24 17:13:32 +00:00
Rory Ford
7be9a26018 feat: PATCH /api/mcp/servers/{name} — enable/disable toggle
Add `PATCH /api/mcp/servers/{name}` endpoint that accepts `{"enabled": bool}`,
updates `mcp_servers.<name>.enabled` in config.yaml, and calls `reload_config()`.
Mirrors the existing DELETE pattern.

Also wire the previously-defined-but-unrouted `_handle_mcp_server_delete` into
`handle_delete`, and `_handle_mcp_server_update` into a new `handle_put` +
`do_PUT` in server.py — fixing a pre-existing bug where those handlers existed
but were never reachable over HTTP.

UI: add a toggle button in each MCP server row in the system settings panel
(panels.js). Clicking it calls PATCH and reloads the list. Toggle button is
styled with `.mcp-toggle-enabled` / `.mcp-toggle-disabled` CSS classes. The
`toggle_supported` flag in the list response is now `True`.

i18n: add 5 new keys (`mcp_enable_server`, `mcp_disable_server`,
`mcp_enabled_toast`, `mcp_disabled_toast`, `mcp_toggle_failed`) to all 9
non-English locales (English values as placeholder translations).

Tests: add `TestMcpToggle` class with 7 tests covering disable, enable,
404-not-found, empty name, missing field, response payload, and URL-encoded name.
Update `test_empty_config` and visibility panel assertions to reflect
`toggle_supported: True` and the new toggle button in panels.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:13:32 +00:00
nesquena-hermes
5d0d2bd0bf fix(updates): apply path must follow check-side fall-through past the latest tag
Fixes #2846. After PR #2758 (the #2653 fix) the update check correctly
falls through to the branch comparison when HEAD has moved past the
latest `v*` tag — so the banner reports the real commit count against
`origin/<branch>`. But `_select_apply_compare_ref` was never updated to
mirror that decision: as long as any `v*` tag exists, it returns
`tags[0]`, even when HEAD is far past it.

Result for everyone running hermes-agent past `v2026.5.16` (i.e. anyone
on agent master between tagged releases):

1. Banner: `Agent (origin/main): 254 updates available` ← correct
2. User clicks Update Now
3. `_select_apply_compare_ref` picks `v2026.5.16` because tags exist
4. `git pull --ff-only origin v2026.5.16` — no-op (HEAD is already past it)
5. `_schedule_restart()` fires anyway, server bounces
6. Next check still reports 254 behind — banner reappears unchanged

`apply_force_update` had the same bug, except worse: `git reset --hard
v2026.5.16` would have actively rewound the user's checkout 254 commits.

The root cause is the same bug class as #2653 — two parallel paths
(`_check_repo_release` and `_select_apply_compare_ref`) that should make
the same decision but didn't. Pre-fix, the "is HEAD past the latest
tag?" predicate lived inline inside `_check_repo_release` only.

Fix
---

Extract `_head_is_past_latest_tag(path, current_tag)` and have both
paths consult it. When HEAD is past the latest tag:

- check path:  release check returns None → branch check runs (#2653,
  unchanged behaviour, just refactored)
- apply path:  falls through to upstream / `origin/<branch>`, never the
  stale tag (#2846, new behaviour)

Tests
-----

- `test_select_apply_compare_ref_uses_tag_when_head_is_on_tag` —
  unchanged behaviour pinned: HEAD exactly on tag → advance to tag.
- `test_select_apply_compare_ref_falls_through_when_head_is_past_tag` —
  the #2846 repro: HEAD = v2026.5.16 + 608 commits → advance to
  `origin/main`, not the tag.
- `test_select_apply_compare_ref_no_tags_uses_upstream` — unchanged.
- `test_select_apply_compare_ref_no_tags_no_upstream_uses_default_branch`
  — unchanged.
- `test_check_and_apply_paths_agree_when_head_is_past_tag` — symmetry
  test, ensures the two paths can't drift apart again.

All 21 tests in `tests/test_updates.py` pass locally (16 existing + 5
new).

Refs #2846, #2653.
2026-05-24 17:13:32 +00:00
nesquena-hermes
4d8a80b54f Stage 403: PR #2854 — fix(terminal): drop PR_SET_PDEATHSIG preexec_fn that killed every Linux shell (#2853) by @nesquena-hermes 2026-05-24 17:13:31 +00:00
nesquena-hermes
71ba863ce5 fix(terminal): drop PR_SET_PDEATHSIG preexec_fn that killed every Linux shell
Fixes #2853. The `_terminal_shell_preexec_fn` added in `71d8a8fb` called
`prctl(PR_SET_PDEATHSIG, SIGTERM)` so orphaned PTY shells would die when
the WebUI process crashed. But that signal is **per-thread**, not
per-process, and WebUI runs `ThreadingHTTPServer`: every HTTP request is
handled in its own short-lived worker thread.

Flow that broke every Linux user:

1. User clicks the terminal toggle → frontend hits `POST /api/terminal/start`.
2. ThreadingHTTPServer spins up a worker thread to handle that one request.
3. The worker thread calls `subprocess.Popen(..., preexec_fn=...)`.
4. The shell calls `prctl(PR_SET_PDEATHSIG, SIGTERM)` in its preexec_fn.
   Its registered "parent" is now the WebUI worker thread that called Popen.
5. The handler returns its JSON response and the worker thread exits.
6. The kernel sees the pdeathsig-parent thread has died and sends SIGTERM
   to the PTY shell. The shell dies within ~10 ms of being created.
7. The reader loop sees EIO on the master FD, emits `terminal_closed`, and
   the frontend writes `[terminal closed]`.

macOS users were unaffected because `libc.prctl` doesn't exist there —
`ctypes.CDLL(None)` returns a libc handle, `libc.prctl` raises
`AttributeError`, the bare-`except` swallows it, and the shell starts
with no pdeathsig configured.

Empirical verification on this Linux host (real PTY + `subprocess.Popen`
inside a `threading.Thread` that joins immediately):

  with    preexec_fn → proc.poll() == -15 (SIGTERM), master FD returns EIO
  without preexec_fn → proc.poll() == None (alive), master FD returns "HELLO\\r\\n"

Same shell, same PTY, same threading topology as WebUI.

Fix
---

Drop the `preexec_fn` entirely. The orphan-shell-on-crash case the original
PR was navigating is rare for self-hosted single-user installs, and the
existing `atexit.register(close_all_terminals)` + explicit `close_terminal`
paths cover graceful shutdown. A future fix (option B in the issue) can
re-introduce pdeathsig pinned to a long-lived supervisor thread, but that
is a follow-up — this PR is the smallest unbricks-Linux-today change.

Tests
-----

- Invert `test_terminal_shell_uses_parent_death_signal_preexec` →
  `test_terminal_shell_does_not_use_pdeathsig_preexec`: asserts
  `preexec_fn` is NOT in the Popen kwargs.
- Add `test_pty_shell_survives_when_spawning_thread_exits`: spawns a
  real PTY shell via `start_terminal` from a worker thread, waits for
  the worker to join, asserts the shell is still alive after a half-second
  grace window. This is the contract the original tests never exercised.
- Update `test_terminal_module_registers_graceful_shutdown_reaper` to
  refuse re-introduction of the preexec_fn or the `libc.prctl(1, SIGTERM)`
  call (treats either as a regression).

All 27 terminal-related tests pass locally.

Refs #2853
2026-05-24 17:13:31 +00:00
nesquena-hermes
a5c937e766 Stage 403: PR #2812 — style(composer): clamp composer-box max-width on wide displays by @Koraji95-coder 2026-05-24 17:13:30 +00:00
Dustin
029d95ada9 style(composer): address Copilot review on PR #2812
Three small fixes from Copilot's review:

1. static/style.css:1354 - removed spaces inside `clamp(...)` args to
   match the file's existing compact style (no spaces after commas in
   neighboring declarations like `transition:border-color .2s,box-shadow .2s`).

2. CHANGELOG.md - wrapped the long single-line entry across multiple lines
   with standard Markdown continuation indentation for cleaner diffs.

3. CHANGELOG.md - normalized `~1300 px` to `~1300px` for unit-formatting
   consistency.

No behavior change. Same one-line CSS rule, just tightened formatting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:13:30 +00:00
Dustin
2c9fc4cfc0 style(composer): responsive composer-box max-width via clamp()
`.composer-box` had a hardcoded `max-width: 780px` since the early
v0.50.x layout pass. On wide displays (1440p+, 2880px ultrawides)
this leaves significant unused horizontal space AND squeezes the
composer-footer chips (workspace, model, reasoning, context %)
against each other inside the 780px box.

When the context-percentage ring appears (active token usage), the
workspace chip truncates to "Fou..." instead of showing the full
workspace name. Model + reasoning chips also lose room. The chip
strip horizontally-scrolls inside .composer-left, so the rightmost
chips effectively hide behind context %.

The constraint isn't "Reading flow looks better at 780px" — the
textarea is min-height:64px, max-height:200px and wraps naturally,
so users on wide displays get the SAME readable text wrap regardless
of box width. Only the footer chips suffer.

Fix: clamp(780px, 60vw, 1100px). Preserves the 780px floor (no
regression on viewports < 780px since clamp's first arg is the
minimum) while letting wider viewports use up to 1100px (60% of
viewport width, capped). 1100px gives ~40% more horizontal room for
the footer chips without filling the entire screen at extreme widths.

Per-viewport behavior:
  <= 780 px → 780 px (hard floor) — zero change vs current
  1280  px  → 60vw = 768 → floored to 780 — zero change
  1440  px  → 60vw = 864 — +84 px room
  1920  px  → 60vw = 1152 → capped at 1100 — +320 px room
  2880  px  → 60vw = 1728 → capped at 1100 — +320 px room

One line in static/style.css. CHANGELOG entry. No JS. No new deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:13:30 +00:00
nesquena-hermes
3aae462048 Stage 403: PR #2811 — ci(windows): add native-Windows startup E2E workflow by @Koraji95-coder 2026-05-24 17:10:02 +00:00
Dustin
ae6b6b1b72 ci(windows): make taskkill no-op when server.py already exited
The path-discovery step succeeds on the first run, but the cleanup
step exits non-zero because `taskkill /PID 5560 /T /F` returns 128
("process not found") when server.py has already exited on the mock
hermes_cli stub. That's the expected steady state for this mock-only
workflow, not a failure.

Two-line fix: reset `$global:LASTEXITCODE = 0` after the taskkill
call, and explicit `exit 0` at the end of the step so any other
external-command exit codes don't bubble up. The try/catch wrapper
didn't help because taskkill writes its diagnostic to stderr without
raising a PowerShell exception — `catch` never fired.

Run 26352805510 on this branch shows the failure shape: "OK: start.ps1
path discovery - all guards passed." in the verify step, then
"ERROR: The process '5560' not found." in the cleanup step. Path
discovery is what this workflow exists to validate; cleanup just has
to not fail the job.
2026-05-24 17:10:02 +00:00
Dustin
145a442f61 ci(windows): rework #2811 with mock hermes_cli (maintainer ask, option 1)
Per @nesquena-hermes review on #2811: hermes-agent isn't published to
PyPI, so `pip install hermes-agent` finds nothing and start.ps1's
hermes_cli guard correctly bails out — leaving the previous workflow
unable to self-validate against release/stage-batch6.

This rework adopts option 1 from the review: drop the pip install,
stub a hermes_cli/ directory with a minimal __init__.py next to the
sibling hermes-agent/ folder, then run start.ps1 for 8 seconds and
assert that none of its own Write-Error guards (no Python, no agent
dir, bad port, missing hermes_cli, missing server.py) appeared in
stderr. /health is no longer probed — the server cannot boot on a
stub, and full-boot regressions stay covered by the Linux jobs and
docker-smoke.yml.

Scope intentionally narrower than the original: this workflow
validates start.ps1's PowerShell syntax + path discovery only. The
exact bug class PR #2805 caught (WOW64 ProgramFiles redirect) would
now light up red here pre-merge, which is the reason this gate exists.

Paths filter trimmed to `start.ps1` + the workflow itself; the broader
list (requirements.txt / bootstrap.py / server.py) was inherited from
the original full-boot scoping and isn't relevant for a path-discovery-
only run.

Verification: workflow runs on this PR via its own pull_request trigger.
The first CI run on this branch IS the verification.

CHANGELOG updated under [Unreleased] with a single bullet sized to the
surrounding density.
2026-05-24 17:10:02 +00:00
nesquena-hermes
bb9698e883 Stage 403: PR #2852 — fix(chat): settle stream_end without done by @ai-ag2026 2026-05-24 17:10:01 +00:00
ai-ag2026
a34d5e26c2 fix(chat): settle stream_end without done 2026-05-24 17:10:01 +00:00
nesquena-hermes
d450ef8a60 Merge pull request #2851 from nesquena/release/stage-batch8
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CX: stage-batch8 — 2-PR batch (v0.51.126) — kanban markdown + live activity timeline
2026-05-24 09:22:05 -07:00
nesquena-hermes
68af4399a6 Stamp CHANGELOG for v0.51.126 (Release CX / stage-batch8 / 2-PR batch)
Cherry-picked PRs:
- #2819 (humayunak) — kanban markdown full GFM rendering
- #2847 (AJV20) — live activity timeline observable telemetry (squashed from 2)
2026-05-24 16:14:04 +00:00
AJV20
237bab753a feat: surface live activity timeline (#2847)
Squashed from 2 author commits:
- d2237e23 feat: surface live activity timeline
- eee57ec0 fix: satisfy activity timeline CI guards

Frontend-only telemetry from existing stream events. Replaces empty
Thinking… placeholder with observable run status (Waiting on model /
Waiting on tool result / Working for …). New CSS, new test file.
2026-05-24 16:13:00 +00:00
humayunak
7983e025c4 kanban: full markdown rendering for task description and comments
- Rewrote _kanbanRenderMarkdown() from basic paragraph wrapper to a
  line-by-line block processor supporting headings, code blocks, lists,
  task lists, tables, blockquotes, horizontal rules, and strikethrough.
- Added CSS for all new elements (table borders, code blocks, checkboxes,
  blockquote accent, heading sizing, etc.).
- Dropped white-space: pre-wrap from .kanban-task-preview-body and
  .kanban-detail-row-main since markdown now handles layout.
- Applied _kanbanRenderMarkdown() to task description (was esc()) and
  comment body (was esc()) in the task detail view.
2026-05-24 16:12:34 +00:00
nesquena-hermes
073bd3e1e2 Merge pull request #2849 from nesquena/release/stage-batch7
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CW: stage-batch7 — 10-PR low-risk batch (v0.51.125) — UI polish + bug fixes + diagnostics
2026-05-24 09:10:17 -07:00
nesquena-hermes
ded516754b Stamp CHANGELOG for v0.51.125 (Release CW / stage-batch7 / 10-PR low-risk batch)
Cherry-picked PRs:
- #2839 (tn801534) — kanban worker log URL double query param fix
- #2832 (franksong2702) — tolerate malformed request logging
- #2818 (humayunak) — prevent focus theft by approval/clarify cards
- #2820 (tangerine-fan) — echo clarify user choice as visible message
- #2826 (Koraji95-coder) — chip wrap overlap fix at narrow widths (closes #2740)
- #2843 (AJV20) — Settings option to ignore Agent updates
- #2837 (franksong2702) — clarify CSRF rejection diagnostics
- #2838 (franksong2702) — surface gateway scheduling guidance in Tasks panel
- #2834 (franksong2702) — render mailto:/tel: links + sandbox HTML preview links
- #2829 (franksong2702) — large markdown preview falls back to plain text (closes #2823, supersedes #2828)
2026-05-24 15:55:03 +00:00
Frank Song
70402f96f1 fix(workspace): fall back for large markdown previews 2026-05-24 15:52:54 +00:00
Frank Song
99c886c199 fix(workspace): open rendered preview links correctly 2026-05-24 15:52:35 +00:00
Frank Song
f1586daa3b fix(cron): surface gateway scheduling guidance 2026-05-24 15:52:35 +00:00
Frank Song
67a204773e fix(csrf): clarify rejection diagnostics 2026-05-24 15:52:34 +00:00
AJV20
b6f7412b53 Add option to ignore agent updates 2026-05-24 15:52:34 +00:00
Koraji95-coder
7a3ceacffe fix(composer): stop chip wraps from compressing past their content (#2740)
Squashed from 2 author commits:
- a1017d02 initial fix: flex:0 0 auto on all 5 chip wraps
- bf54ba50 Copilot review fix-up: consolidate into single rule

Closes #2740. CSS-only, no JS changes. Default-width layout unchanged,
only affects narrow-viewport overflow regime via composer-left's existing
overflow-x:auto.
2026-05-24 15:52:26 +00:00
tangerine-fan
9a5973a6b5 feat: echo clarify user choice as visible message in conversation
After the user responds to a clarify prompt, insert a synthetic user
message into the conversation showing their choice. This makes the
clarify interaction visible in the chat history, which was previously
only shown in the transient clarify dialog card.

The message is marked with _clarify_response: true so downstream
consumers can distinguish it from regular user messages if needed.
2026-05-24 15:52:00 +00:00
humayunak
32df5546b4 fix(webui): prevent approval and clarify cards stealing focus from composer textarea
When tool approval or clarification cards appear during streaming,
they unconditionally call focus() on their input elements via setTimeout,
stealing focus from the composer (#msg) if the user is actively typing.
This silently drops keystrokes mid-type.

Add a guard: only move focus to the card if the composer textarea does
not already have focus. The document.activeElement check matches the
pattern already used upstream in other focus-sensitive components.

Fixes: #
2026-05-24 15:52:00 +00:00
Frank Song
618e1a5da8 fix(server): tolerate malformed request logging 2026-05-24 15:52:00 +00:00
tn801534
2e876ea229 fix: kanban worker log URL double query param on non-default boards 2026-05-24 15:52:00 +00:00
nesquena-hermes
66de2367a9 Merge pull request #2817 from nesquena/release/stage-batch6
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CV: stage-batch6 — 3-PR Windows-only stack (v0.51.124) — start.ps1 paths/docs/hardening
2026-05-23 21:53:47 -07:00
nesquena-hermes
fe0a1cea94 Stamp CHANGELOG for v0.51.124 (Release CV / stage-batch6 / 4-PR Windows-only stack)
Cherry-picked PRs (all by @Koraji95-coder):
- #2805 — expand hermes-agent candidate paths for Windows installers
- #2806 — clarify native Windows venv path; remove WSL2-venv-portability claim
- #2807 — TryParse HERMES_WEBUI_PORT + exit AFTER try/finally cleanup
- #2811 — native-Windows startup E2E CI workflow

All 4 PRs were branched off #2783 (now shipped in v0.51.121). Squash-merged
each PR's unique changes onto current master with conflict resolution.
Authorship preserved on every commit. Zero impact on Linux/macOS runtime —
file scope is start.ps1, README.md (Windows section), and a new Windows-CI
workflow that only runs on PRs touching start.ps1/requirements.txt/etc.
2026-05-24 04:47:53 +00:00
Koraji95-coder
055f9b3cd0 harden(start.ps1): TryParse HERMES_WEBUI_PORT + exit AFTER try/finally cleanup (#2807)
Squashed from 2 author commits onto current master (3 base commits from
already-shipped #2783 were filtered out by the squash):
- f53b9308 fix(start.ps1): TryParse HERMES_WEBUI_PORT + exit AFTER try/finally cleanup
- 7b6e0722 fix(start.ps1): drop non-functional @args splat under [CmdletBinding()]

Authorship preserved. CHANGELOG entry merged into batch stamp commit.
2026-05-24 04:37:18 +00:00
Koraji95-coder
9db6be99e1 docs(start.ps1+README): clarify native Windows venv path; remove misleading WSL2-venv-portability claim (#2806)
Squashed from 3 author commits onto current master (3 base commits from
already-shipped #2783 were filtered out by the squash). #2805's expanded
candidate-path discovery + PathType Container check preserved from prior
stage commit.

Authorship preserved. CHANGELOG entry merged into batch stamp commit.
2026-05-24 04:37:18 +00:00
Koraji95-coder
ed9a163731 feat(start.ps1): expand hermes-agent candidate paths for Windows installers (#2805)
Squashed from 3 author commits onto current master (the 3 base commits from
already-shipped #2783 were filtered out by the squash):
- 6822cbbb feat: expand hermes-agent candidate paths
- 6f423538 Copilot review: PathType+null-guard+changelog
- dbebbedd handle WOW64 ProgramFiles redirection

Authorship preserved. CHANGELOG entry merged into batch stamp commit.
2026-05-24 04:37:18 +00:00
nesquena-hermes
cb270f351f Merge pull request #2816 from nesquena/release/stage-batch5
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CU: stage-batch5 — 2-PR low-risk batch (v0.51.123) — gzip+ETag static caching / Open in VS Code
2026-05-23 21:36:33 -07:00
nesquena-hermes
79a3ac3724 Stamp CHANGELOG for v0.51.123 (Release CU / stage-batch5 / 2-PR low-risk batch)
Cherry-picked PRs:
- #2779 (v2psv) — gzip + ETag/304 + immutable cache for fingerprinted /static/*
- #2787 (munim) — Open in VS Code workspace file browser action (closes #2735)

Conflict resolution: kept ours CHANGELOG (3 PRs all added Unreleased entries).
For #2779's _serve_static overhaul, took theirs (PR replaces function body).
Folded missing 'import gzip' into the #2779 commit via fixup+autosquash.
2026-05-24 04:27:34 +00:00
Abdul Munim
7999d1c75a feat(workspace): add Open in VS Code action for files and folders (#2735)
Right-click any workspace file, folder, or root now shows
'Open in VS Code' alongside the existing Reveal in File Manager action.

- POST /api/file/open-vscode: resolves path via safe_resolve, finds VS
  Code via shutil.which() with fallbacks for macOS (/usr/local/bin/code,
  app bundle CLI), Linux (/usr/bin/code, /snap/bin/code), and Windows
  (%LOCALAPPDATA% and %PROGRAMFILES% user/system installs). Returns a
  descriptive error if not found rather than a bare OS error.
- Optional vscode block in config.yaml: command (default: code),
  host_path_prefix + container_path_prefix for Docker path mapping.
- i18n: open_in_vscode and open_in_vscode_failed translated in all 10
  locales (it, ja, ru, es, de, zh-CN, zh-TW, pt, ko).
- 26 tests in tests/test_2735_open_in_vscode.py covering source wiring,
  command resolution, i18n completeness, and live endpoint error paths.
2026-05-24 04:26:46 +00:00
Qi
d20da832b3 fix(static): tighten cache validators and 304 headers 2026-05-24 04:26:46 +00:00
nesquena-hermes
904cdec1a6 Merge pull request #2815 from nesquena/release/stage-batch4
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CT: stage-batch4 — 4-PR low-risk batch (v0.51.122) — stale cache tail / inflight UI / segment flush / reasoning accumulator
2026-05-23 21:17:39 -07:00
nesquena-hermes
6bfded9e02 Stamp CHANGELOG for v0.51.122 (Release CT / stage-batch4 / 4-PR low-risk batch)
Cherry-picked PRs:
- #2802 (ai-ag2026) — drop stale cached user tail (supersedes held #2733)
- #2796 (ai-ag2026) — clear stale inflight UI state (5-commit squash)
- #2777 (b3nw) — flush pending render at segment boundaries
- #2778 (b3nw) — reset reasoning accumulator per turn + prefer reasoning_content
2026-05-24 04:09:45 +00:00
b3nw
160cd03c18 fix(chat): reset reasoning accumulator per turn and prefer reasoning_content (closes #2565)
Two confirmed bugs in the thinking/reasoning display:

1. reasoningText was initialized once when the SSE stream opened and never
   reset between turns. On the done event, the last assistant message
   received the union of every turn's reasoning. Now reset at both turn
   boundaries: tool (alongside existing liveReasoningText reset) and
   interim_assistant (the other turn boundary where prior reasoning closes).

2. ui.js renderMessages preferred m.reasoning (which could be corrupted by
   bug 1) over m.reasoning_content (the clean per-turn value from the
   backend). The fallback now reads m.reasoning_content || m.reasoning.

Both fixes are needed: bug 2 alone cannot cover providers that stream
reasoning events without populating reasoning_content on the final API
message.

Updated test_streaming_race_fix.py to scope its reconnect-accumulator
guard to the _wireSSE preamble only, since turn-boundary resets inside
event listeners are intentional and correct.

9 new regression tests in test_issue2565_reasoning_accumulation.py.
2026-05-24 04:08:40 +00:00
b3nw
1f56fad73f fix(chat): flush pending render before segment reset at tool/interim boundaries
Fixes #2713 — live assistant text can truncate at tool-call segment
boundaries during streaming.

Before _resetAssistantSegment() in the tool and interim_assistant SSE
handlers, synchronously flush any pending rAF render work so tokens that
arrived during the 66ms throttle window are written to the DOM before
assistantBody is cleared. Without this flush, the pending _doRender
callback fires after assistantBody is null and skips the write silently,
causing the tail of the pre-tool segment to disappear from the live view.

Implementation:
- Extract _flushPendingSegmentRender() helper (guarded by assistantBody
  && _renderPending) that cancels the pending rAF and synchronously
  writes via smd/renderMd/esc — same cascade as _doRender.
- Call the helper from both the tool and interim_assistant handlers
  before their respective _resetAssistantSegment() calls.
- Normal cases where the rAF has already fired are unaffected (guard
  skips immediately).

Completed transcripts were never affected (renderMessages rebuilds from
the full assistantText accumulator on done).

Adds tests/test_issue2713_streaming_segment_flush.py with 11 static
analysis regression tests pinning the helper shape and call-site
ordering.
2026-05-24 04:08:33 +00:00
ai-ag2026
39242c586c fix: clear stale inflight UI state (closes #2795, squashed from 5 commits)
Cherry-pick of PR #2796 by @ai-ag2026, squashed from 5 author commits onto current master:
- dcee0563 fix: drop stale optimistic sidebar rows
- 3a73400d fix: clear stale busy state before send
- 46c3b902 fix: preserve server idle rows during optimistic merge
- de51d271 fix: let chat start survive pre-start UI errors
- d2f5c906 fix: hide nonfatal pre-start send warnings

Authorship preserved via --author. Code-only squash (no CHANGELOG).
2026-05-24 04:08:25 +00:00
ai-ag2026
225ea78604 fix: drop stale cached user tail after saved assistant 2026-05-24 04:06:45 +00:00
nesquena-hermes
798e5714ec Merge pull request #2813 from nesquena/release/stage-batch3
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CS: stage-batch3 — 4-PR low-risk batch (v0.51.121) — state.db merge / display counts / compression marker / Windows launcher
2026-05-23 21:03:41 -07:00
nesquena-hermes
10838ae8fa Stamp CHANGELOG for v0.51.121 (Release CS / stage-batch3 / 4-PR low-risk batch)
Cherry-picked PRs:
- #2788 (Carry00) — state.db merge: include id column + per-profile reads
- #2797 (ai-ag2026) — align messaging session display counts (raw->merged)
- #2803 (simjak) — compression marker strict predicate (no tool output)
- #2783 (Koraji95-coder) — native Windows start.ps1 + README community link
2026-05-24 03:55:42 +00:00
Koraji95-coder
49f340d939 feat(windows): native Windows community-guide link + start.ps1 launcher (#1952)
PR #2783 by @Koraji95-coder — squashed from 3 commits (initial PR + Copilot review fixes + agent-dir validation). CHANGELOG entry merged into stamp commit.
2026-05-24 03:55:42 +00:00
Simonas Jakubonis
35c55e1268 fix(compression): ignore tool output for compaction cards 2026-05-24 03:55:42 +00:00
ai-ag2026
cd029d801a fix: align messaging session display counts 2026-05-24 03:55:42 +00:00
carryzuo00
ee672df463 fix: prevent state.db messages being silently dropped during sidecar merge
Two bugs combined to cause historical messages to vanish from the WebUI
after a session was continued in a later conversation.

**Bug 1 — missing `id` in state.db SELECT (models.py)**
`get_state_db_session_messages()` did not include the `id` column in its
SELECT, so every row got a `("legacy", ...)` merge key instead of
`("message_id", ...)`.  The timestamp gate in
`merge_session_messages_append_only()` explicitly exempts `message_id`-keyed
rows from its "skip if older than newest sidecar message" rule, but
legacy-keyed rows are unconditionally dropped.  With a session that has any
new sidecar messages (max_sidecar_timestamp == today), all older state.db
rows were silently discarded.

Fix: include `id` when the column is present so rows get proper
`("message_id", ...)` keys and survive the timestamp filter.

**Bug 2 — always reads active profile's state.db, not the session's (models.py + routes.py)**
`get_state_db_session_messages()` always called `_active_state_db_path()`,
which returns the currently-active profile's database.  Sessions belonging to
a different profile (e.g. `jump`) were read from the wrong state.db, returning
either no rows or unrelated ones.

Fix: add an optional `profile` parameter; when supplied, resolve the path via
`_get_profile_home(profile)` with a fallback to the active path if the
profile-specific db does not exist.  The call-site in `routes.py` now reads
`session.profile` and passes it through.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 03:55:42 +00:00
nesquena-hermes
465b97a9f5 Merge pull request #2810 from nesquena/release/stage-batch2
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CR: stage-batch2 — 3-PR low-risk batch (v0.51.120) — Bedrock provider / update past-tag / CORS preflight
2026-05-23 20:49:09 -07:00
nesquena-hermes
60eb6f5349 Stamp CHANGELOG for v0.51.120 (Release CR / stage-batch2 / 3-PR low-risk batch)
Cherry-picked PRs:
- #2786 (munim) — surface bedrock provider in WebUI model picker
- #2789 (munim) — update check falls through when HEAD is past latest tag
- #2790 (weidzhou) — do_OPTIONS handler for CORS preflight (minimal resubmit of closed #2750)

No surface overlap between the 3 PRs.
2026-05-24 03:43:14 +00:00
wdzhou
acda74e557 fix: add do_OPTIONS handler for CORS preflight requests 2026-05-24 03:42:13 +00:00
Abdul Munim
d04805b0d7 fix(updates): fall through to branch check when HEAD is past latest tag
When current_tag == latest_tag, _check_repo_release returned behind=0
and reported 'Up to date' even if master had moved hundreds of commits
past the tag.  This was visible as Agent: v2026.5.16-593-gedb2d9105
alongside a green 'Up to date' pill in Settings.

Run 'git describe --tags --always' after computing behind==0.  If the
output includes a -N-gSHA suffix the tag is not at HEAD; return None so
_check_repo_branch runs and counts the real commit gap via rev-list.

When HEAD is exactly on the latest tag the new branch is never taken and
behaviour is unchanged.

Fixes #2653.
2026-05-24 03:42:13 +00:00
Abdul Munim
d7f1514d96 fix(models): surface bedrock provider in WebUI model picker (#2720)
Bedrock was silently dropped from the picker because:
1. 'bedrock' absent from _PROVIDER_DISPLAY — group header fell back to
   title-cased id; more critically the group fell to the else branch
2. 'bedrock' absent from _PROVIDER_MODELS — else branch has no
   auto-detected models, so the group was never appended
3. Fallback env-var detection (hermes_cli unavailable) never checked
   AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY

Fix:
- Add 'bedrock': 'AWS Bedrock' to _PROVIDER_DISPLAY
- Add static fallback model list to _PROVIDER_MODELS['bedrock'] with
  global Anthropic Claude 4.x cross-region inference profile IDs;
  live discovery via hermes_cli.models.provider_model_ids('bedrock')
  is used first (existing _read_live_provider_model_ids machinery)
- Detect bedrock in env fallback path when both AWS_ACCESS_KEY_ID and
  AWS_SECRET_ACCESS_KEY are present

Tests: tests/test_issue2720_bedrock_model_picker.py (5 new tests)
2026-05-24 03:42:13 +00:00
nesquena-hermes
01e4159818 Merge pull request #2809 from nesquena/release/stage-batch1
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CQ: stage-batch1 — 3-PR low-risk batch (v0.51.119) — tool cards / 404 recovery / Hepburn skin
2026-05-23 20:38:58 -07:00
nesquena-hermes
1ffac74a8b Stamp CHANGELOG for v0.51.119 (Release CQ / stage-batch1 / 3-PR low-risk batch)
Cherry-picked PRs:
- #2801 (ai-ag2026) — preserve settled tool cards after stream completion
- #2808 (chouzz) — recover from boot-time /session/{id} 404
- #2799 (gavinssr) — Hepburn skin (magenta-rose palette)

All UI-only, additive or behaviorally-narrow. No api/ changes.
2026-05-24 03:26:45 +00:00
gavinssr
75fdadd477 feat: add Hepburn skin (magenta-rose palette)
Add Hepburn skin with full light/dark palette derived from the
Hepburn TUI theme. Brand color #c6246a with pink-magenta accents.

- Light: soft pink surfaces (#fff3f7 / #fbe4ed)
- Dark: deep aubergine (#110a0f / #1e0f19)
- Accent: #d44a7a (light) / #f278ad (dark)
- Styled: send button, new chat button, tool cards, session indicator

Also fix settings panel skin picker to prioritize localStorage
over server defaults, so newly selected skins reflect correctly
in the dropdown.
2026-05-24 03:03:32 +00:00
Harlan Zhou
21481e85fd fix(ui): recover from stale /session/{id} on boot-time 404 2026-05-24 03:03:31 +00:00
ai-ag2026
1e5f20f56d fix: preserve settled tool cards after stream completion 2026-05-24 03:03:31 +00:00
dobby-d-elf
cf2eb9214c Merge branch 'master' into fix/session-list-touch-actions 2026-05-23 11:21:26 -06:00
AJV20
a263abdf05 Merge remote-tracking branch 'origin/master' into fix/webui-context-parity
# Conflicts:
#	CHANGELOG.md
2026-05-22 21:38:32 -04:00
AJV20
b140c53e1c Merge remote-tracking branch 'origin/master' into feat/webui-notes-sources
# Conflicts:
#	CHANGELOG.md
2026-05-22 21:38:31 -04:00
nesquena-hermes
e091e65d56 Merge pull request #2774 from nesquena/release/stage-pr2773
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.118 (Release CP / stage-pr2773 / 1-PR hotfix — v0.51.117 brick fix)
2026-05-22 16:25:41 -07:00
nesquena-hermes
c9dfa33e39 Stamp CHANGELOG for v0.51.118 (Release CP / stage-pr2773 / 1-PR hotfix) 2026-05-22 23:22:27 +00:00
nesquena-hermes
3adc59874d Stage pr2773: PR #2773 — fix(chat): rename _inflightStateLimits() to fix v0.51.117 collision (closes #2771) 2026-05-22 23:21:33 +00:00
nesquena-hermes
d8b82e274a docs: stamp PR #2773 in CHANGELOG entry 2026-05-22 23:17:47 +00:00
nesquena-hermes
12becd1f4b fix(chat): rename _inflightStateLimits() to _getInflightStateLimits() to fix v0.51.117 collision
Closes #2771.

v0.51.117 (PR #2766) introduced a top-level function _inflightStateLimits()
in static/ui.js that collided with the window._inflightStateLimits config
object set in static/boot.js. Because top-level function declarations in
classic (non-module) scripts attach to window, boot.js's assignment
overwrote the function reference, and every later _inflightStateLimits()
call threw TypeError. _compactInflightState() runs on every send(), so
no new chat session could be created — v0.51.117 is effectively unusable.

Reported by @jahilldev, with multiple users (@isma3iloiso, @theDanielJLewis,
@JHVenn) confirming the bug or reverting to v0.51.116.

Fix: rename the function to _getInflightStateLimits() — the window-attached
config key stays under its original name (unchanged for any downstream
code that reads it). Updates all 4 call sites in static/ui.js.

Tests:

  - Update tests/test_inflight_storage_quota.py — the existing test
    asserted 'function _inflightStateLimits()' in UI_JS as a positive
    presence check, which certified the bug. Now asserts the renamed
    function name is present AND the old colliding name is absent AND
    no stale call sites remain.
  - Add tests/test_window_function_collision.py — generalized regression
    that scans every static JS file for top-level function declarations
    whose name also appears as the target of 'window.X = {...}' or
    'window.X = <number>'. This is the exact shape that broke #2715
    (_pinnedSessionsLimit in v0.51.106) and #2771. Test fails with a
    precise diagnostic naming the file and symbol if the bug class
    returns. Confirmed test FAILS on current master (unfixed) and PASSES
    on this branch.

Verified end-to-end against the live browser before commit:
  - typeof window._inflightStateLimits === 'object' (config preserved)
  - typeof window._getInflightStateLimits === 'function'
  - _getInflightStateLimits() returns the limits object
  - saveInflightState() persists to localStorage without throwing

Full pytest suite: 6308 passed, 6 skipped, 3 xpassed, 8 subtests passed.
Opus advisor: SHIP.
2026-05-22 23:17:00 +00:00
AJV20
86b5a8fcff Merge remote-tracking branch 'origin/master' into feat/webui-notes-sources
# Conflicts:
#	CHANGELOG.md
2026-05-22 17:35:18 -04:00
AJV20
d9e1a9c4fd Merge remote-tracking branch 'origin/master' into fix/webui-context-parity
# Conflicts:
#	CHANGELOG.md
2026-05-22 17:35:15 -04:00
nesquena-hermes
f930260157 Merge pull request #2770 from nesquena/release/stage-pr2766
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CO: v0.51.117 (stage-pr2766 — 1-PR — in-flight recovery storage quota-safe)
2026-05-22 12:56:45 -07:00
nesquena-hermes
aa0c7b7144 Stamp CHANGELOG for v0.51.117 (Release CO / stage-pr2766 / 1-PR) 2026-05-22 19:53:05 +00:00
george-andraws
b2477974c5 fix: make in-flight recovery storage quota-safe 2026-05-22 19:49:20 +00:00
nesquena-hermes
a9acafb918 Merge pull request #2769 from nesquena/release/stage-pr2676
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CN: v0.51.116 (stage-pr2676 — 1-PR — per-skill enable/disable toggle in Skills panel, CLI-parity, Telegram-approved)
2026-05-22 12:47:44 -07:00
nesquena-hermes
e42e3e59b2 Stamp CHANGELOG for v0.51.116 (Release CN / stage-pr2676 / 1-PR) 2026-05-22 19:43:27 +00:00
lucasrc
35adc3a473 feat: add per-skill enable/disable toggle in Skills panel
Original PR: #2676 by @lucasrc

Adds POST /api/skills/toggle endpoint that flips skills.disabled in
config.yaml, and a UI toggle in the Skills panel that shows all skills
(including disabled ones) with a per-skill on/off control.

- Backend: new endpoint validates skill exists in filesystem before
  toggling. Read-modify-write wrapped in _cfg_lock for thread safety.
  Writes through to platform_disabled.webui when present.
- Frontend: each skill-item now has a toggle switch; disabled skills
  appear muted but still listed (previously they were filtered out).
- i18n: new toggle keys translated across all 9 non-English locales.
- Tests: round-trip test for disabled list normalization + toggle
  endpoint behavior.

Squash-merged from contributor's branch (19 commits + 1 merge commit)
onto current master via the cherry-pick-stale-contributor-prs procedure.
2026-05-22 19:43:00 +00:00
AJV20
7305d470b9 feat(memory): gate third-party notes drawer 2026-05-22 14:54:41 -04:00
AJV20
50195c229b fix(chat): keep WebUI prefill file-only 2026-05-22 14:46:17 -04:00
AJV20
171d93aecf Merge remote-tracking branch 'origin/master' into fix/webui-context-parity
# Conflicts:
#	CHANGELOG.md
2026-05-22 14:25:47 -04:00
AJV20
42a6cf38ea Merge remote-tracking branch 'origin/master' into feat/webui-notes-sources
# Conflicts:
#	CHANGELOG.md
2026-05-22 14:25:24 -04:00
nesquena-hermes
d833e11fda Merge pull request #2765 from nesquena/release/stage-pr2731
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CM: v0.51.115 (stage-pr2731 — 1-PR — clarify prompt collapse/expand with chevron-icon polish, Telegram-approved)
2026-05-22 11:21:51 -07:00
nesquena-hermes
56b2f58259 Stamp CHANGELOG for v0.51.115 (Release CM / stage-pr2731 / 1-PR) 2026-05-22 18:15:39 +00:00
nesquena-hermes
057ae7da53 Polish: chevron icon toggle + fix collapsed-card edge clip
- Replace text 'Collapse'/'Expand' button labels with Lucide chevron SVG
  icons (chevron-down expanded → click to collapse, chevron-up collapsed
  → click to expand). Matches the iconographic design language of the
  rest of the chrome (composer buttons, sidebar controls).
  ARIA label + title attributes carry the same semantics for assistive
  tech, so no accessibility regression vs. the text labels.

- Fix collapsed-card edge clipping at viewport bottom. Original
  .clarify-card { bottom: -24px } was sized for the expanded card
  (300-420px tall); adding a 72px collapsed variant pushed the header
  below the parent's visible region. Override bottom to 8px and reduce
  inner padding for the collapsed state so the entire header sits cleanly
  inside the viewport at both desktop and mobile sizes (verified card
  fits with ~115px margin desktop / ~125px margin mobile).

Per Nathan's 2026-05-22 UX feedback on the screenshot package.
2026-05-22 18:14:48 +00:00
AJV20
e794b54e18 Merge remote-tracking branch 'origin/master' into feat/webui-notes-sources
# Conflicts:
#	CHANGELOG.md
2026-05-22 14:14:15 -04:00
AJV20
19565b6022 Merge remote-tracking branch 'origin/master' into fix/webui-context-parity
# Conflicts:
#	CHANGELOG.md
2026-05-22 14:13:42 -04:00
Michael Lam
581da3da2b feat: make clarify prompt collapsible 2026-05-22 17:45:40 +00:00
nesquena-hermes
e08eecefb1 Merge pull request #2764 from nesquena/release/stage-407
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CL: v0.51.114 (stage-407 — 1-PR — update-check recovery from remote re-tags)
2026-05-22 10:38:18 -07:00
nesquena-hermes
60f146afe6 Stamp CHANGELOG for v0.51.114 (Release CL / stage-407 / 1-PR) 2026-05-22 17:32:26 +00:00
nesquena-hermes
15afabf849 docs: stamp PR #2758 in CHANGELOG entry 2026-05-22 17:25:54 +00:00
nesquena-hermes
0703a07654 fix(updates): pass --force to git fetch --tags to recover from remote re-tags
Without --force, git fetch origin --tags refuses to overwrite divergent
local tags and returns 'would clobber existing tag', jamming the entire
WebUI update path indefinitely. The WebUI is a release-tracking consumer
that never pushes tags, so it should always defer to whatever the remote
says a release tag points to. Add --force to all three fetch-tag call
sites:

  - _check_repo (the 'Check now' button + periodic check)
  - apply_force_update (force-reset to remote HEAD)
  - apply_update (stash + pull --ff-only)

Tests:

  - Updated 3 existing tests in test_updates.py whose fake_git mocks
    asserted the exact ['fetch', 'origin', '--tags'] args list.
  - Updated 1 existing test in test_update_banner_fixes.py that asserted
    the same shape for apply_update.
  - Added 4 new regression tests:
      - test_check_repo_fetches_tags_with_force
      - test_apply_force_update_fetches_tags_with_force
      - test_apply_update_fetches_tags_with_force
      - test_check_repo_recovers_from_remote_retag (end-to-end,
        proves the bare --tags fetch shape is no longer used)

Closes #2756.
2026-05-22 17:25:54 +00:00
nesquena-hermes
92d144ba00 Merge pull request #2763 from nesquena/release/stage-406
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CK: v0.51.113 (stage-406 — 1-PR — composer model picker lag fix + hard-refresh recovery)
2026-05-22 10:18:17 -07:00
nesquena-hermes
aaa06c1574 Stamp CHANGELOG for v0.51.113 (Release CK / stage-406 / 1-PR) 2026-05-22 17:13:44 +00:00
Frank Song
53f294dc8d Fix composer model picker opening lag 2026-05-22 16:58:55 +00:00
nesquena-hermes
73fe8f24c9 Merge pull request #2760 from nesquena/release/stage-405
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CJ: v0.51.112 (stage-405 — 1-PR — session model authoritative across restore)
2026-05-22 09:57:40 -07:00
nesquena-hermes
d56cd6a312 Stamp CHANGELOG for v0.51.112 (Release CJ / stage-405 / 1-PR) 2026-05-22 16:54:23 +00:00
ai-ag2026
ef9fe1dfdb fix(chat): keep model fallback guard before session option repair 2026-05-22 16:50:17 +00:00
ai-ag2026
ebcf0dabb5 fix(chat): keep restored session model visible on hard refresh 2026-05-22 16:50:17 +00:00
ai-ag2026
765e5aa091 fix(chat): hydrate restored session model on boot 2026-05-22 16:50:17 +00:00
ai-ag2026
6bcc9689aa fix(chat): keep new session model authoritative 2026-05-22 16:50:17 +00:00
ai-ag2026
af1ee81f06 fix(chat): resolve session model before activating 2026-05-22 16:50:17 +00:00
nesquena-hermes
a323f49f14 Merge pull request #2759 from nesquena/release/stage-404
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CI: v0.51.111 (stage-404 — 1-PR — keep state.db replays out of sidecar tail)
2026-05-22 09:49:28 -07:00
nesquena-hermes
9f4c7adbb1 Stamp CHANGELOG for v0.51.111 (Release CI / stage-404 / 1-PR) 2026-05-22 16:46:12 +00:00
ai-ag2026
dd07334d6c fix(session): keep state db replays out of sidecar tail 2026-05-22 16:25:10 +00:00
nesquena-hermes
0de3eccdc3 Merge pull request #2757 from nesquena/release/stage-403
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CH: v0.51.110 (stage-403 — 2-PR batch — default personality from config + sort configured providers to top)
2026-05-22 09:24:28 -07:00
nesquena-hermes
4da2a8e21a Stamp CHANGELOG for v0.51.110 (Release CH / stage-403 / 2-PR batch) 2026-05-22 16:20:41 +00:00
fxd-jason
84ef8a63a6 fix: remove xai from has_key test (CI env has no XAI_API_KEY) 2026-05-22 16:13:46 +00:00
fxd-jason
7e556614f9 test: add sort order tests for providers and model picker 2026-05-22 16:13:46 +00:00
fxd-jason
56575bd393 feat: sort configured/custom providers to top in model picker and settings 2026-05-22 16:13:46 +00:00
s010mn
4153a47d0f feat: new_session() reads display.personality from config as default
When display.personality is set in config.yaml (e.g. personality: taleb),
new sessions now inherit it automatically instead of starting with
personality=None and requiring an explicit /personality command.

This makes the selected personality sticky across new conversations rather
than requiring per-session activation.

Behavior:
- display.personality values 'none', 'default', 'neutral', '' are treated
  as no personality (personality=None), matching TUI gateway semantics.
- Config read is wrapped in try/except — if it fails, personality falls
  back to None (no crash, no regression).
- Case-insensitive: 'Taleb' normalizes to 'taleb'.

The /personality slash command still works for per-session overrides as
before; this change only affects the initial default.
2026-05-22 16:13:33 +00:00
nesquena-hermes
6498043b5e Merge pull request #2755 from nesquena/release/stage-402
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CG: v0.51.109 (stage-402 — 2-PR batch — sidebar action menu click stability + chat panel sidebar resync after navigation)
2026-05-22 09:07:30 -07:00
nesquena-hermes
0a1e22d0a2 Stamp CHANGELOG for v0.51.109 (Release CG / stage-402 / 2-PR batch) 2026-05-22 15:55:12 +00:00
nesquena-hermes
cab60bcd3b Stage 402: PR #2736 2026-05-22 15:51:12 +00:00
nesquena-hermes
78233e91dc Stage 402: PR #2741
# Conflicts:
#	CHANGELOG.md
2026-05-22 15:51:09 +00:00
nesquena-hermes
05c63b5611 Merge pull request #2754 from nesquena/release/stage-401
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CF: v0.51.108 (stage-401 — 4-PR batch — session-index dedup + update-check redaction + handoff-summary sqlite leak + RuntimeAdapter Slice 4d gate docs)
2026-05-22 08:39:18 -07:00
nesquena-hermes
46661ede93 Stamp CHANGELOG for v0.51.108 (Release CF / stage-401 / 4-PR batch) 2026-05-22 15:29:02 +00:00
nesquena-hermes
bbaa4f83e3 Stage 401: PR #2744
# Conflicts:
#	CHANGELOG.md
2026-05-22 15:22:30 +00:00
nesquena-hermes
d71b8977d6 Stage 401: PR #2742 2026-05-22 15:22:01 +00:00
nesquena-hermes
742c9cbd9b Stage 401: PR #2730 2026-05-22 15:21:32 +00:00
nesquena-hermes
cc2d41d7c7 Stage 401: PR #2738 2026-05-22 15:21:32 +00:00
Michael Lam
bda91a6edd docs(runtime): define runner route gate 2026-05-22 04:08:59 -07:00
Isla-Liu
5b41f03a92 fix(webui): close sqlite3 connections in handoff-summary path (#2233)
Two functions on the /api/session/handoff-summary hot path were opening
sqlite3.connect(...) inside a bare `with` statement, which commits the
transaction at scope exit but does NOT close the connection. Per-turn
invocations accumulated state.db / state.db-wal file descriptors and
CPython heap pages on long-lived worker threads, surfacing as the
multi-GB VmRSS and 6x duplicated state.db fds observed on the live
instance (D0 pre-restart baseline: VmRSS 1,334,248 kB, 55 fds; cold
baseline after restart: VmRSS 136,668 kB, 10 fds).

Wrap both call sites with contextlib.closing(...) (already imported and
used at seven other sites in the same files) so the connection is
closed deterministically:

  - api/models.py :: count_conversation_rounds
  - api/routes.py :: _persist_handoff_summary_to_state_db

Regression test:
  tests/test_issue2233_sqlite_connection_leak.py loops both functions
  20 times against a tmp state.db and asserts /proc/<pid>/fd count
  does not grow more than 2. Linux-only via sys.platform skip.

D1 live soak against a freshly-built worktree server (port 8799,
isolated HERMES_HOME / HERMES_WEBUI_STATE_DIR) hitting
/api/session/handoff-summary 20 times:

  fd_before      = 5
  fd_after       = 5     (growth 0, threshold < 5)
  vmrss_before   = 52636 kB
  vmrss_after    = 52636 kB  (growth 0 kB, threshold < 30 MB)

The patched fix curve trends below the leak curve.

Rollback: single git revert <this-sha> reverts both file edits.

Refs #2233.
2026-05-22 18:34:06 +08:00
ai-ag2026
dabe7b0ea5 fix(session): keep conversation actions menu clickable 2026-05-22 12:21:10 +02:00
wdzhou
a4e6ffccd9 fix(session): deduplicate _write_session_index full rebuild entries by session_id
The full rebuild path scans SESSION_DIR via glob('*.json') and appends every loaded session to a plain list without deduplicating by session_id. When old-format session_*.json files coexist alongside WebUI-format xxx.json files (both sharing session_id), the index gets duplicate entries, causing frontend Vue key crashes.

Fix: use dict[session_id -> compact_entry] to naturally deduplicate.
2026-05-22 18:02:49 +08:00
wdzhou
16f9887846 fix(session): deduplicate _write_session_index full rebuild by session_id
The full rebuild path of _write_session_index scans SESSION_DIR via
glob('*.json') and appends every loaded session to a plain list without
deduplicating by session_id. When old-format session_*.json files coexist
alongside WebUI-format xxx.json files (both sharing the same session_id),
the same session appears multiple times in the index, causing frontend
Vue key collisions and a blank page.

Fix: use dict[session_id -> compact_entry] to naturally deduplicate.
Prefer the entry with the larger message_count when conflicts arise.
2026-05-22 16:13:42 +08:00
ai-ag2026
77b0ca6e02 fix(chat): preserve session actions after panel resync 2026-05-22 09:25:15 +02:00
ai-ag2026
f81d24ec4c fix(chat): resync sidebar after panel switch 2026-05-22 09:25:15 +02:00
Ashish Vaja
b63bdae09b fix: redact update-check git diagnostics 2026-05-22 02:28:17 +00:00
nesquena-hermes
5abd142f34 Merge pull request #2725 from nesquena/release/stage-400
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CE — stage-400 — v0.51.107 — 8-PR low-risk batch (pin-cap getter rename + upload-turn dedup + active-run repair + KaTeX incremental + profile default model + French locale + update-check error surfacing + update apply path)
2026-05-21 16:18:12 -07:00
Hermes Agent
9d10390852 Stamp CHANGELOG for v0.51.107 (Release CE / stage-400 / 8-PR batch) 2026-05-21 23:13:31 +00:00
Hermes Agent
0718770fe1 Stage 400: PR #2719 — fix: apply release update target (clear sessionStorage check-once stamp + force banner re-evaluation on user click)
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-21 22:59:56 +00:00
Hermes Agent
b14aae4ee5 Stage 400: PR #2717 — fix: surface update check fetch errors instead of failing silently
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-21 22:59:54 +00:00
Hermes Agent
d1f09a9e9b Stage 400: PR #2722 — i18n: complete French (fr) locale (+93 missing keys, 88.8% → 96.7% coverage)
Co-authored-by: victorwhale <victorwhale@users.noreply.github.com>
2026-05-21 22:59:51 +00:00
Hermes Agent
80356c3a47 Stage 400: PR #2709 — fix(model): prefer profile default model on fresh boot when localStorage has no persisted pick
Co-authored-by: starship-s <starship-s@users.noreply.github.com>
2026-05-21 22:59:48 +00:00
Hermes Agent
cc36711b9f Stage 400: PR #2710 — fix: render streamed math incrementally (no flash when delta completes a KaTeX expression)
Co-authored-by: Michaelyklam <Michaelyklam@users.noreply.github.com>
2026-05-21 22:59:46 +00:00
Hermes Agent
654f62e0bd Stage 400: PR #2721 — fix(session): treat active runs as live during repair (skip restart-stale prune for sessions with live streams)
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-21 22:59:43 +00:00
Hermes Agent
0fae9cbdf1 Stage 400: PR #2723 — fix(chat): dedupe uploaded-file optimistic user turns
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-21 22:59:40 +00:00
Hermes Agent
4f3eb9f7f8 Stage 400: PR #2718 — fix: rename _pinnedSessionsLimit() to _getPinnedSessionsLimit() (follow-up to v0.51.105 #2700, matches sessions.js getter convention)
Co-authored-by: eslicarrillo <eslicarrillo@users.noreply.github.com>
2026-05-21 22:59:37 +00:00
nesquena-hermes
79af205fb8 Merge pull request #2711 from nesquena/release/stage-399
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CD — stage-399 — v0.51.106 — 3-PR runtime-context batch (restamped state.db replay dedupe + context_messages dedupe so agent doesn't see duplicates + empty _partial bloat fix)
2026-05-21 11:06:10 -07:00
Hermes Agent
af76e3ec0b Stamp CHANGELOG for v0.51.106 (Release CD / stage-399 / 3-PR batch) 2026-05-21 17:57:22 +00:00
Hermes Agent
3874781a57 Stage 399: PR #2704 — fix(streaming): prevent exponential empty _partial accumulation across reasoning-only cancellations
Co-authored-by: wirtsi <wirtsi@users.noreply.github.com>
2026-05-21 17:56:46 +00:00
Hermes Agent
7152da2756 Stage 399: PR #2705 — fix(api): dedupe replayed context_messages so the agent doesn't see duplicates (follow-up to v0.51.96 #2620)
Co-authored-by: AlexeyDsov <AlexeyDsov@users.noreply.github.com>
2026-05-21 17:56:43 +00:00
Hermes Agent
4db8df5e29 Stage 399: PR #2686 — fix(session): dedupe restamped state.db replay rows in /api/session display merge
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-21 17:56:40 +00:00
nesquena-hermes
bcf73084ad Merge pull request #2708 from nesquena/release/stage-398
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CC — stage-398 — v0.51.105 — 4-PR low-risk batch (hide suggestions + Docker version detection + runner adapter slice 4c + configurable pin cap)
2026-05-21 10:53:15 -07:00
Hermes Agent
37bc59c99a Stamp CHANGELOG for v0.51.105 (Release CC / stage-398 / 4-PR batch) 2026-05-21 17:49:34 +00:00
Hermes Agent
905b3eba5e Stage 398: PR #2700 — feat: make pinned session limit configurable (builds on shipped #2614 3-cap)
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-21 17:43:56 +00:00
Hermes Agent
f563d37244 Stage 398: PR #2696 — feat(runtime): add runner-local adapter selection (RuntimeAdapter slice 4c, feature-flagged)
Co-authored-by: Michaelyklam <Michaelyklam@users.noreply.github.com>
2026-05-21 17:43:54 +00:00
Hermes Agent
6864739e55 Stage 398: PR #2703 — fix: detect agent version from copied source (Docker two-container System panel) (closes #2691)
Closes #2691

Co-authored-by: Michaelyklam <Michaelyklam@users.noreply.github.com>
2026-05-21 17:43:51 +00:00
Hermes Agent
7d3013245a Stage 398: PR #2687 — feat: hide suggestions preference (closes #2679)
Closes #2679

Co-authored-by: Michaelyklam <Michaelyklam@users.noreply.github.com>
2026-05-21 17:43:48 +00:00
nesquena-hermes
c70ec3ba19 Merge pull request #2707 from nesquena/release/stage-397
Some checks failed
Release & Docker / release (push) Has been cancelled
Release CB — stage-397 — v0.51.104 — 9-PR low-risk batch (i18n + geist polish + tablet kbd + Codex slash repair + SSE jitter + inline cron + inflight race + model picker resync + sidebar clamp + transcript cache)
2026-05-21 10:41:17 -07:00
Hermes Agent
e6d51c2c48 Stamp CHANGELOG for v0.51.104 (Release CB / stage-397 / 9-PR batch) 2026-05-21 17:37:47 +00:00
Hermes Agent
8e1ac89baa Stage 397: PR #2692 — fix(ui): invalidate transcript cache on same-count content changes
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-21 17:14:37 +00:00
Hermes Agent
e2338f696f Stage 397: PR #2688 — fix: resync virtualized sidebar after scroll clamp
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-21 17:14:36 +00:00
Hermes Agent
d5dcd60981 Stage 397: PR #2674 — fix: new-chat default model provider sync when model id matches but provider differs
Co-authored-by: AJV20 <AJV20@users.noreply.github.com>
2026-05-21 17:14:34 +00:00
Hermes Agent
3dd2ace4e1 Stage 397: PR #2689 — fix(chat): preserve inflight send state during start race
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-21 17:14:33 +00:00
Hermes Agent
12a92dd50a Stage 397: PR #2671 — fix: SSE reconnect jitter + cron run row inline expansion
Closes #2629
Closes #2661

Co-authored-by: AJV20 <AJV20@users.noreply.github.com>
2026-05-21 17:13:59 +00:00
Hermes Agent
92f1896754 Stage 397: PR #2684 — fix: repair stale Codex OpenAI slash-qualified model state
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-21 17:13:56 +00:00
Hermes Agent
345762cf70 Stage 397: PR #2706 — fix: tablet hardware keyboard Enter sends (treat iPad with attached keyboard like desktop)
Co-authored-by: dobby-d-elf <dobby-d-elf@users.noreply.github.com>
2026-05-21 17:13:52 +00:00
Hermes Agent
958762bcfe Stage 397: PR #2701 — fix: geist-contrast skin composer UI improvements (light-mode text, scrollbar, send button color)
Co-authored-by: jasonjcwu <jasonjcwu@users.noreply.github.com>
2026-05-21 17:13:49 +00:00
Hermes Agent
2752e23b84 Stage 397: PR #2690 — i18n: correct zh-CN/zh-TW cron_status_active translations (运行中→已启用 / 活躍中→已啟用)
Co-authored-by: laiaman <laiaman@users.noreply.github.com>
2026-05-21 17:13:46 +00:00
fxd-jason
59ffe4fca5 fix: geist-contrast skin composer UI improvements
- Light mode: override white user-bubble-text so textarea text is black (#111)
- Remove scrollbar from textarea (scrollbar-width:none + webkit)
- Remove double border on focus: split composer-box:focus-within from
  textarea:focus to prevent stacking box-shadows
- Remove composer-box border (border:none) to eliminate double-border ring
2026-05-22 00:46:57 +08:00
dobby-d-elf
ad88c92155 clean up & refactor 2026-05-21 08:25:04 -06:00
dobby-d-elf
13fa0de891 refined iOS enter animations 2026-05-21 07:59:16 -06:00
dobby-d-elf
c4fffff205 refine archive/restore animations 2026-05-21 07:52:42 -06:00
dobby-d-elf
a277bbd42c refactor 2026-05-21 07:41:08 -06:00
dobby-d-elf
1d8d0bfeb7 optimize performance 2026-05-21 07:30:41 -06:00
dobby-d-elf
03266c2644 fix: preserve touch swipe exit animations 2026-05-21 06:52:33 -06:00
dobby-d-elf
6c96423231 v4 effects save 2026-05-21 06:35:07 -06:00
dobby-d-elf
ca6736407e Checkpoint polished session swipe interactions 2026-05-20 21:24:35 -06:00
nesquena-hermes
7057c94277 Merge pull request #2667 from nesquena/release/stage-396
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.103 (Release CA / stage-396 / 1-PR follow-on)
2026-05-20 17:38:18 -07:00
nesquena-hermes
85d2e80cfe Stage-396 locale fix: add plugins_active_provider + plugins_provider_no_hooks to all 10 non-English locales
The original PR #2663 added 2 new English i18n keys but didn't extend them
to the 10 non-English locale blocks. Five locale-coverage tests
(zh/ja/ko/ru/es) failed because they verify every English key has a paired
entry in their locale. Added the keys to all 10 locales:
  - zh + zh-Hant: actual translations
  - it/ja/ru/es/de/pt/ko/fr: English + // TODO: translate markers (the
    repo's established pattern for these locales)
2026-05-21 00:35:22 +00:00
nesquena-hermes
32c006a6fd Stamp CHANGELOG for v0.51.103 (Release CA / stage-396 / 1-PR follow-on) 2026-05-21 00:27:07 +00:00
nesquena-hermes
c6587091a2 Stage 396: PR #2663 2026-05-21 00:26:54 +00:00
nesquena-hermes
78be555460 Merge pull request #2666 from nesquena/release/stage-395
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.102 (Release BZ / stage-395 / 1-PR follow-on)
2026-05-20 17:19:03 -07:00
nesquena-hermes
70fe39a09f Stamp CHANGELOG for v0.51.102 (Release BZ / stage-395 / 1-PR follow-on) 2026-05-21 00:15:04 +00:00
nesquena-hermes
f867b4520b Stage 395: PR #2662 2026-05-21 00:14:45 +00:00
nesquena-hermes
4cae511d15 Merge pull request #2665 from nesquena/release/stage-394
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.101 (Release BY / stage-394 / 2-PR deep-review batch)
2026-05-20 17:09:59 -07:00
nesquena-hermes
7479af462b Stage-394 test fix: set destructive env in test_git_commit_route_rejects_active_stream
The test was calling _handle_git_commit() directly in-process, but the
HERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE=1 env var was only being set on the
test_server subprocess (via conftest.py L539). In-process the destructive
gate (returns 403) fires before the active-stream gate (returns 409), so
the test never reached the assertion it was trying to verify.

monkeypatch.setenv() restores the test's intent: confirm that when
destructive mode IS enabled, an active stream still blocks mutations with
the more specific 409 code.
2026-05-21 00:06:53 +00:00
nesquena-hermes
0774235987 Stamp CHANGELOG for v0.51.101 (Release BY / stage-394 / 2-PR deep-review batch) 2026-05-20 23:54:00 +00:00
nesquena-hermes
cc5f6e3a78 Stage 394: PR #2636 2026-05-20 23:53:04 +00:00
nesquena-hermes
45c7a693af Stage 394: PR #2625 2026-05-20 23:53:04 +00:00
nesquena-hermes
ea8305d5e2 Stage-394 chip CSS contrast: dark text on filled chips for light-theme readability
Light-theme review revealed white text on gold chips (color: var(--bg-page)) was
washed out and hard to read. Switched to fixed dark text #1a1a1a with font-weight
600 so the on-state reads clearly on the gold accent in both light and dark
themes. Off-state unchanged (muted text on transparent).
2026-05-20 23:38:51 +00:00
dobby-d-elf
1084afd37f Merge branch 'master' into fix/session-list-touch-actions
# Conflicts:
#	static/sessions.js
#	static/style.css
2026-05-20 17:20:42 -06:00
nesquena-hermes
38933b288d Stage-394 follow-up: profile-switch reconciliation + a11y switch role + server-side chat/settings filter
Per deep-review verdict SHIP-WITH-FIXES on PR #2636:

1. Profile-switch reconciliation: _refreshProfileSwitchBackground now re-fetches
   /api/settings and re-applies hidden_tabs for the new profile. Without this,
   Profile A's hidden-tabs choice stayed in effect under Profile B until the
   user opened Settings → Appearance.

2. A11y: switched chips from role=button + aria-pressed to role=switch +
   aria-checked. The pressed/not-pressed wording confused screen-reader users
   because chip-off looks like the off state. Added role=group +
   aria-labelledby on the container, and a :focus-visible style on the chips.

3. Server-side belt-and-suspenders: api/config.py now strips 'chat' and
   'settings' from hidden_tabs at validation time, matching the client's apply-
   time filter. A tampered POST can no longer persist the forbidden values.

3 new regression tests added (chat/settings rejection, profile-switch wiring,
chip a11y attributes).

Co-authored-by: FrancescoFarinola <francesco.farinola@example.com>
2026-05-20 23:05:19 +00:00
Francesco Farinola
5491a54285 fix: address PR review feedback on sidebar tab visibility
Three tweaks from reviewer:

1. Harden _applyTabVisibility to skip always-visible panels even if
   they appear in hidden_tabs (localStorage tampering, stale server
   data). Forces shouldHide=false so stale nav-tab-hidden classes
   on chat/settings get removed, not just skipped.

2. Add synchronous inline <script> flash-prevention after sidebar-nav
   in index.html. On slow networks, defer scripts run after the
   browser incrementally renders the DOM, causing hidden tabs to
   flash visible before JS executes. The inline script reads
   hermes-webui-hidden-tabs from localStorage and applies
   nav-tab-hidden classes before first paint, mirroring the existing
   theme/skin/font-size pattern. The boot.js IIFE becomes a secondary
   fallback (comment updated).

3. Remove _settingsHiddenTabsOnOpen dead state. It was tracked but
   never read for revert — _revertSettingsPreview is intentionally
   a no-op for appearance autosave. Removing the tracking makes
   the code honest about what it actually does. Also removes the
   test_settings_session_tracking test that validated this dead code.
2026-05-20 22:57:36 +00:00
Francesco Farinola
7f1feca3fe feat: sidebar tab visibility toggle in Settings > Appearance
Add chip row in Settings > Appearance that lets users toggle individual
sidebar/rail tabs on or off. Chat and Settings are always visible.

- Backend: hidden_tabs list setting with validation (no bool coerce)
- Frontend: pill chips that scan rail buttons, autosave via appearance
- Boot: _restoreTabVisibility IIFE applies hidden tabs before first paint
- i18n: 11 locales (label + description)
- Tests: 5 regression tests covering backend, frontend contracts,
  boot restore, i18n coverage, and settings session tracking
2026-05-20 22:57:36 +00:00
Michael Lam
f17d4e204f fix: keep resumed CLI sessions in sidebar cap 2026-05-20 15:54:44 -07:00
nesquena-hermes
f4a7989d4f Merge pull request #2658 from nesquena/release/stage-393
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.100 (Release BX / stage-393 / 3-PR deep-review batch)
2026-05-20 15:45:47 -07:00
nesquena-hermes
abdaf014ce Stamp CHANGELOG for v0.51.100 (Release BX / stage-393 / 3-PR deep-review batch) 2026-05-20 22:32:58 +00:00
nesquena-hermes
2e7cdc4b13 Stage 393 follow-up: HTTP integration tests for /api/sessions/events (Opus advisor PR #2637) 2026-05-20 22:27:23 +00:00
nesquena-hermes
feb35893b9 Stage 393: PR #2637
# Conflicts:
#	static/sessions.js
2026-05-20 22:24:40 +00:00
nesquena-hermes
4d8b8d0ffe Stage 393: PR #2633
# Conflicts:
#	CHANGELOG.md
2026-05-20 22:23:53 +00:00
nesquena-hermes
e35c94bf55 Stage 393: PR #2615 2026-05-20 22:23:53 +00:00
nesquena-hermes
f3b8d57c99 Merge pull request #2652 from nesquena/release/stage-392
Release v0.51.99 (Release BW / stage-392 / 5-PR batch)
2026-05-20 15:07:40 -07:00
nesquena-hermes
7c7ae8ead2 Stamp CHANGELOG for v0.51.99 (Release BW / stage-392 / 5-PR batch) 2026-05-20 21:48:56 +00:00
nesquena-hermes
aaf30b7b0a Stage 392: PR #2643 2026-05-20 21:48:04 +00:00
nesquena-hermes
fa459aa01e Stage 392: PR #2651 2026-05-20 21:48:04 +00:00
nesquena-hermes
b4a00b5aae Stage 392: PR #2650 2026-05-20 21:48:04 +00:00
nesquena-hermes
dc0c833744 Stage 392: PR #2647 2026-05-20 21:48:04 +00:00
nesquena-hermes
6ed66daac2 Stage 392: PR #2638 2026-05-20 21:48:04 +00:00
Lumen Yang
71fbc796b2 fix: dedupe replayed context tail after compression 2026-05-20 23:15:54 +02:00
nesquena-hermes
329a7fa6f3 Merge pull request #2649 from nesquena/release/stage-391
Release v0.51.98 (Release BV / stage-391 / 1-PR follow-on)
2026-05-20 13:43:53 -07:00
nesquena-hermes
1bf905a0cc Stamp CHANGELOG for v0.51.98 (Release BV / stage-391 / 1-PR follow-on) 2026-05-20 20:40:48 +00:00
nesquena-hermes
2403e7cd2b Stage 391: PR #2640 2026-05-20 20:40:30 +00:00
dobby-d-elf
6278222596 tighten session refresh invalidation 2026-05-20 14:40:13 -06:00
starship-s
153e035d12 fix: forward title generation api key 2026-05-20 14:39:38 -06:00
dobby-d-elf
14dd5aa00d address session event review 2026-05-20 14:33:36 -06:00
Colin Chang
9c3e37d2ee fix: custom_providers models allowlist takes priority over live /v1/models fetch
Custom providers that have a curated models: list in config.yaml
(e.g. ZenMux gateways) should show ONLY those configured models in
the picker dropdown, not the full /v1/models catalog.

Before this fix, _named_custom_groups unconditionally called
_read_custom_endpoint_models() which would pull hundreds of models
from aggregator gateways and overwrite the user's curated list.

Now the build checks if the custom_provider entry has a non-empty
models dict/list in config.yaml — if so, it skips the live fetch
and uses only the configured models (same behavior as hermes-agent
model_switch.py Section 4 patch).

Closes: configure-model-list-should-be-authoritative
2026-05-20 20:22:11 +00:00
nesquena-hermes
ba0b4c367f Merge pull request #2648 from nesquena/release/stage-390
Release v0.51.97 (Release BU / stage-390 / 3-PR batch)
2026-05-20 13:20:32 -07:00
dobby-d-elf
87527ff4f6 Fix state db legacy dedup repeat preservation 2026-05-20 14:18:47 -06:00
nesquena-hermes
6301b0e87b Stamp CHANGELOG for v0.51.97 (Release BU / stage-390 / 2-PR batch) 2026-05-20 20:16:50 +00:00
nesquena-hermes
1e3ca07575 Stage 390: PR #2634
# Conflicts:
#	CHANGELOG.md
2026-05-20 20:16:30 +00:00
nesquena-hermes
495991c2db Stage 390: PR #2642 2026-05-20 20:16:30 +00:00
dobby-d-elf
7742b83062 Merge remote-tracking branch 'origin/master' into tool-tooltip-fix 2026-05-20 14:12:29 -06:00
Arsh Kumar Singh
2253cf5a32 chore: address review notes — dedup comment and 409-path clarification 2026-05-20 19:57:20 +00:00
Michael Lam
6e64068f0f fix: cap CLI session sidebar state scans 2026-05-20 12:47:03 -07:00
nesquena-hermes
6c60925a54 Merge pull request #2644 from nesquena/release/stage-389
Release v0.51.96 (Release BT / stage-389 / 8-PR batch)
2026-05-20 11:25:15 -07:00
nesquena-hermes
7c2d56c920 Stage 389 follow-up: close TOCTOU race in pin-cap (Opus advisor #2614) 2026-05-20 18:12:38 +00:00
dobby-d-elf
58259f6edb Avoid duplicate session list boot animation 2026-05-20 11:03:37 -06:00
dobby-d-elf
19ad20afff Fix new chats using profile default model 2026-05-20 10:57:04 -06:00
nesquena-hermes
2b5a960df2 Stamp CHANGELOG for v0.51.96 (Release BT / stage-389 / 8-PR batch) 2026-05-20 16:43:15 +00:00
nesquena-hermes
360a57164a Stage 389: PR #2627
# Conflicts:
#	CHANGELOG.md
2026-05-20 16:41:45 +00:00
nesquena-hermes
dd36d09f89 Stage 389: PR #2626
# Conflicts:
#	CHANGELOG.md
2026-05-20 16:41:45 +00:00
nesquena-hermes
3d34eef02d Stage 389: PR #2620 2026-05-20 16:41:45 +00:00
nesquena-hermes
84f6bf5323 Stage 389: PR #2619
# Conflicts:
#	CHANGELOG.md
2026-05-20 16:41:45 +00:00
nesquena-hermes
4d8e1ccc10 Stage 389: PR #2618 2026-05-20 16:41:44 +00:00
nesquena-hermes
eaff4d0b8e Stage 389: PR #2614
# Conflicts:
#	CHANGELOG.md
2026-05-20 16:41:44 +00:00
nesquena-hermes
3bcd81b79f Stage 389: PR #2612
# Conflicts:
#	CHANGELOG.md
2026-05-20 16:41:44 +00:00
nesquena-hermes
9c564ccc1b Stage 389: PR #2610 2026-05-20 16:40:42 +00:00
Arsh Kumar Singh
d385db69d5 fix(clarify): require stable clarify_id and wait for backend ack so stale responses are rejected
The WebUI clarification popup had a response-delivery failure: users
submitted answers in the popup, but the agent still fell through to the
timeout fallback message.  Three bugs conspired:

1. No stable clarify_id — _ClarifyEntry had no unique identifier, so
   the frontend could not reference a specific pending prompt.  The
   backend used FIFO resolution which silently failed for stale/late
   responses.

2. Frontend hid the card before confirmation — respondClarify() called
   hideClarifyCard(true, 'sent') BEFORE the API call completed.  If the
   backend rejected the response, the card was already gone and the
   user's draft was discarded.

3. Backend lied about success — _resolve_clarify_legacy() returned
   bool(resolved) or not bool(clarify_id).  Since the frontend never
   sent clarify_id, the backend always reported ok:true even when
   nothing was resolved.

Changes:

api/clarify.py:
- _ClarifyEntry now auto-generates a stable clarify_id (uuid4.hex[:12])
- submit_pending() injects clarify_id into the data dict visible to the
  frontend via SSE and polling
- New resolve_clarify_by_id() for O(1) lookup by id instead of FIFO pop

api/routes.py:
- _resolve_clarify_legacy() uses resolve_clarify_by_id when clarify_id
  is provided; returns actual bool result (no more unconditional True)
- _handle_clarify_respond() returns HTTP 409 + {ok:false, stale:true}
  when resolution fails

static/messages.js:
- respondClarify() now sends clarify_id in the POST body
- Waits for a positive backend acknowledgement before hiding the card
- Saves a draft copy before POST and restores it on failure
- On 409/network error: re-enables controls, shows error toast
- Guards against parallel-SSE race where clearing the cache after a
  successful response could erase a newly queued next prompt (codex P1)

tests:
- Updated test_sprint30.py for new ack-before-hide behaviour
- Updated test_clarify_unblock.py for 409 on stale responses

Closes #2639.
2026-05-20 16:35:15 +00:00
Michael Lam
6eb5d939d7 test: allow custom provider settings filter 2026-05-20 09:33:51 -07:00
dobby-d-elf
dc57d358eb fix test 2026-05-20 10:02:46 -06:00
dobby-d-elf
439a574cf4 address PR comments & bugs from real testing 2026-05-20 09:59:36 -06:00
Dennis Soong
cec435a833 fix(session): rebuild missing startup index 2026-05-20 23:43:30 +08:00
dobby-d-elf
fd7212b014 Optimize profile switching and session list loading 2026-05-20 08:47:49 -06:00
dobby-d-elf
4c8914304b fix: keep compact tool activity grouped
Compact tool activity regressed into separate Activity rows and standalone Thinking blurbs when interim assistant text retired the current live activity group and Thinking rendered outside the disclosure.

Render Compact-mode Thinking inside the shared Activity body for live and settled turns, keep interim assistant text from splitting the current Activity group, and remove the now-unused stream-local activity-close path. This restores the intended single compact disclosure without adding new functionality.
2026-05-20 08:29:46 -06:00
dobby-d-elf
5e378d3b38 sync session list from server events 2026-05-20 08:18:56 -06:00
Michael Lam
8ef8fae831 fix: show config-managed custom providers 2026-05-20 06:27:00 -07:00
Isla Liu
98106c809b docs(session): clarify lazy retry trigger for metadata-only polling 2026-05-20 20:55:08 +08:00
Isla Liu
37c3e84ad2 test(session): cover lazy journal retry give-up paths 2026-05-20 20:55:08 +08:00
Isla Liu
2a303de2a3 fix(session): preserve retry budget while journal is still arriving 2026-05-20 20:55:07 +08:00
Isla Liu
d5a185d9c6 fix(session): serialize lazy journal retry per session 2026-05-20 20:48:38 +08:00
Michael Lam
680d0cbc92 docs(runtime): define runner backend harness gate 2026-05-20 04:05:36 -07:00
stocky789
9ac94d3ef6 fix(workspace): tighten git subprocess trust boundary 2026-05-20 11:02:45 +00:00
Michael Lam
c3eafa34f8 fix: surface custom provider model endpoint errors 2026-05-20 03:12:33 -07:00
stocky789
898e15a899 fix(workspace): restore branch changes on switch 2026-05-20 08:14:30 +00:00
manji
ff0aa69d5f fix(session): use second-level timestamp granularity in legacy dedup key
The _normalized_message_timestamp_for_key helper was preserving
microsecond precision (%.6f). When the same message is persisted by
both the WebUI sidecar JSON writer and the Hermes agent state.db
writer, their timestamps can differ by a few microseconds, causing
_session_message_merge_key to produce different keys for the same
logical message and letting both copies survive the dedup pass in
merge_session_messages_append_only.

Truncating to second-level granularity collapses sub-second drift to
the same key, so the duplicate is suppressed correctly.

Fixes #2616
2026-05-20 07:13:55 +00:00
Michael Lam
471b75d762 docs: move Hermes overview out of agent context root 2026-05-19 23:55:58 -07:00
stocky789
0f9c64b780 fix: classify CRLF-only git status noise
Distinguish CRLF-only working tree changes from filemode-only noise when the ignored-CR diff path set is empty on GitHub Actions.
2026-05-20 05:43:17 +00:00
Lumen Yang
b2c6af12f1 fix(webui): prefer sidecar counts over stale session index 2026-05-20 05:42:55 +00:00
Isla Liu
1957785332 fix(session): address Copilot round-2 review — correct stale comment and drop unused fixture arg
Two non-functional cleanups from the second Copilot pass:

1. The inline comment in `test_error_marker_no_preserved_as_draft`
   said the legacy "user message above was preserved" wording was used
   for the post-retry-give-up case.  The actual implementation demotes
   give-up markers to a different neutral wording ("Partial output may
   have been lost.").  Comment rewritten to match the contract.

2. The regression test `test_lost_response_recovered_on_second_read`
   declared a `monkeypatch` parameter it never used.  Dropped.
2026-05-20 13:08:08 +08:00
stocky789
5fc7aee781 feat(workspace): add backend Git operations 2026-05-20 04:51:41 +00:00
Isla Liu
9870e8f111 fix(session): address Copilot review — scope tool-card dedupe by stream id + tighten docs
Four code-review comments from the automated Copilot reviewer on this PR:

1. `_journal_tool_already_present` dedupe was session-wide, so a
   legitimately-repeated tool (e.g. a second `terminal: ls` in an
   earlier turn) could cause the retry path to falsely skip
   materializing the recovered tool card.  The helper now takes a
   keyword `stream_id` argument; when supplied, a tool card whose
   `_recovered_stream_id` is set AND differs from the candidate is no
   longer treated as a duplicate.  Untagged tool cards (live tools, or
   tool cards carried over from a pre-tagging core transcript) still
   match, preserving the existing 'core transcript already has this
   tool, don't duplicate' invariant.  Two new tests in
   `TestJournalToolDedupeScoping` cover both legs of the rule.

2./3. The troubleshooting FAQ pointed at `~/.hermes/webui/sessions/session_<sid>.json`
   and `~/.hermes/_run_journal/...`.  The actual sidecar filename has
   no `session_` prefix and the run-journal lives under the WebUI
   sessions dir (`~/.hermes/webui/sessions/_run_journal/<sid>/<stream>.jsonl`,
   default).  Both paths fixed and an explicit note added about
   `HERMES_WEBUI_STATE_DIR` overriding the state root.

4. Drop unused `json` / `queue` / `Path` imports from
   `tests/test_session_lost_response_regression.py` so the file stops
   carrying noise that future linting would flag.
2026-05-20 12:18:03 +08:00
Mark Baker
a2ce4e81b5 fix(plugins): distinguish exclusive/provider activation in Settings panel
The Settings → Plugins panel keyed off `loaded.enabled` and the four
agentic visibility hooks, both of which are False/empty for exclusive
plugins (memory.provider, model-provider, etc.). Those plugins were
mislabeled as "Disabled" with "No registered lifecycle hooks" even when
fully functional as the active provider for their category.

Surface `manifest.kind` and a derived `activation` field
("enabled" | "disabled" | "exclusive" | "provider") in /api/plugins.
The card render picks a third badge state ("Active (provider)") and a
dedicated empty-hooks line for those rows. `enabled` is preserved in
the payload so older clients still work; new clients should prefer
`activation`.

Fixes #2659
2026-05-20 00:01:02 -04:00
Isla Liu
66b6d8f019 docs(session): CHANGELOG entry + troubleshooting FAQ for the lost-response self-heal
CHANGELOG: append an Unreleased / Fixed entry describing the user-visible
behaviour change (interrupted-turn marker now self-heals on the next
session read; gives up gracefully after 12 retries or 24h).

docs/troubleshooting.md: add a 'Symptom → Why → Diagnostic → Fix →
Caps → When to file a bug' entry for the
'no agent output was recovered' marker so users who hit the lost-response
shape on WSL2 / network FS can recognise it, verify the run-journal on
disk, and know that reloading the session is enough.
2026-05-20 11:59:06 +08:00
Isla Liu
2387720068 test(session): unit + backwards-compat + WSL race coverage for lazy-retry path
Adds five test classes that together pin down the contract added in the
previous commit and protect pre-fix session shapes:

  * `TestInterruptedRecoveryMarker` — pure-function tests for the new
    `pending_retry=True` keyword and the mutual-exclusion rule between
    `recovered_output=True` and `pending_retry=True`.
  * `TestRetryJournalRecoveryInPlace` — promote-on-success,
    increment-on-failure, demote-after-max-attempts,
    demote-after-giveup-seconds, no-op when no pending marker, and the
    `_session_has_pending_journal_retry` short-circuit (which stops at
    the most recent normal assistant turn).
  * `TestGetSessionLazyRetryHook` — both `get_session()` entry paths
    (cache-hit and cold-load) trigger the helper when a pending marker
    is present; the short-circuit avoids the helper when nothing is
    pending; and `metadata_only=True` skips the helper to keep sidebar
    refresh free.
  * `TestLazyRetryBackwardsCompat` — pre-fix sessions whose markers
    use the legacy "no agent output" wording (no flag) are not touched
    by `get_session()`. The four retry-meta keys round-trip cleanly
    through `Session.save()` / `Session.load()`.
  * `TestWslPageCacheRace` — covers the WSL2 / network-FS shape: a
    first `read_run_events` raising IOError followed by a successful
    read; a journal that grows visible tokens between sidecar repair
    and retry; and two concurrent `get_session(sid)` calls converging
    on a single promoted marker with a single recovered body
    (deduped by `dedupe_existing=True`).

Two pre-existing assertions had to be relaxed because they encoded the
buggy contract (permanent "no agent output was recovered" / "user
message above was preserved" wording in the journal-empty + stream-id
known case). Both tests now accept either the legacy wording or the
new "Recovering the partial output…" wording with the pending-retry
flag, reflecting the broader fact that the old wording was the bug.
2026-05-20 11:58:54 +08:00
Isla Liu
e8cd0bcc66 test(session): end-to-end regression for lost-response self-heal
Reproduces the production failure mode:

1. Stage 1 — sidecar repair runs while the run-journal for the dead
   stream is empty on disk. Assert the marker arms the lazy-retry
   hook (`_pending_journal_recovery=True`,
   `_journal_retry_stream_id`, `_journal_retry_attempts=0`,
   `_journal_retry_first_seen_ts`) and does NOT carry the legacy
   "no agent output was recovered" wording. Pending sidecar fields
   are cleared regardless.

2. Stage 2 — journaled token / tool / tool_complete / token events
   appear on disk. Call `get_session(sid)` and assert the marker
   self-heals: wording promotes to "recovered from the run journal",
   journaled assistant rows + tool card land above the marker in
   chronological order, all retry meta is stripped.

Without the lazy-retry path this test fails at the very first
assertion (marker still carries the legacy no-output wording).
2026-05-20 11:58:37 +08:00
Isla Liu
75a26174aa fix(session): lazily retry run-journal recovery so the interrupted-turn marker self-heals
When the WebUI process restarts mid-stream and sidecar repair runs while
the run-journal for the dead stream is not yet visible on disk (WSL2 9p
/ DrvFs page-cache loss, un-fsynced journal tail on network FS, …),
`_append_journaled_partial_output()` returns False and the marker is
permanently baked with the "no agent output was recovered" wording even
though the journaled tokens appear on disk shortly afterwards.

This commit reframes the recovery contract so the read side can
self-heal:

  * `_interrupted_recovery_marker` gains a `pending_retry=True` mode
    that produces a third wording ("Recovering the partial output …
    reload this session to retry.") and stamps a
    `_pending_journal_recovery` flag.
  * `_apply_core_sync_or_error_marker` now writes that pending-retry
    marker (with `_journal_retry_stream_id`,
    `_journal_retry_attempts`, `_journal_retry_first_seen_ts` meta)
    whenever it cannot recover visible output AND the stream id is
    known. The legacy "no output" wording is reserved for the
    no-stream-id case. The core-sync branch leaves marker emission to
    the existing visible-output check (the core transcript itself is the
    canonical history in that branch).
  * A new `_retry_journal_recovery_in_place(session)` helper re-runs
    `_append_journaled_partial_output(…, dedupe_existing=True)` for the
    latest pending marker. On success the marker is promoted in place to
    the recovered-output wording, the journaled rows are reordered to
    sit above the marker (preserving chronological order), and all
    retry meta is stripped. On failure attempts is incremented; after
    _JOURNAL_RETRY_MAX_ATTEMPTS (12) or _JOURNAL_RETRY_GIVEUP_SECONDS
    (24h) the marker is demoted to a neutral "Partial output may have
    been lost." wording.
  * `get_session()` cheaply short-circuits via
    `_session_has_pending_journal_retry()` and invokes the helper on
    both cache-hit and cold-load paths when a pending marker is found.
    `metadata_only=True` skips the helper to keep sidebar refresh
    cheap. The retry call runs OUTSIDE the SESSIONS LOCK to avoid a
    deadlock with `session.save()` write paths.

No streaming write path or run_journal fsync behaviour is changed — the
fix is read-side only.
2026-05-20 11:58:26 +08:00
Michael Lam
9ca846eb8f feat: cap pinned sessions from sidebar 2026-05-19 20:50:26 -07:00
AJV20
8109bcb7e4 fix: honor configured title generation model 2026-05-19 22:35:11 -04:00
AJV20
f966e99231 fix: preserve IPv6 dashboard link brackets 2026-05-19 21:35:10 -04:00
nesquena-hermes
9c983e693a Merge pull request #2608 from nesquena/release/stage-388
release: v0.51.95 (Release BS / stage-388 / 5-PR batch — live tool dedup + browser dashboard links + messaging dedup + Geist Contrast skin + SSE diagnostics)
2026-05-19 17:40:44 -07:00
nesquena-hermes
7eccff4fed Stamp CHANGELOG for v0.51.95 (Release BS / stage-388 / 5-PR batch) 2026-05-20 00:37:35 +00:00
nesquena-hermes
fc5639db15 test: exempt skin-specific scoped overrides from session-title color assertion
PR #2521 (Geist Contrast skin) adds a scoped
`:root[data-skin="geist-contrast"] .session-item.active .session-title` rule
that legitimately uses its own palette values. The existing assertion in
test_sprint40_ui_polish.py matched on any line containing the
`.session-item.active .session-title` substring, picking up the skin-scoped
override and asserting against its palette.

Exclude lines containing `:root[data-skin=` from the base-rule scan so
skin-scoped overrides are free to use their own design tokens, while the
global rule still enforces var(--gold) / var(--accent-text).
2026-05-20 00:26:11 +00:00
nesquena-hermes
1a8c7b65c9 test: anchor picker-active-state assertions to global #mainSettings rule
PR #2521 (Geist Contrast skin) legitimately adds a scoped
`:root[data-skin="geist-contrast"] .theme-pick-btn.active` override that
appears earlier in style.css than the global `#mainSettings .theme-pick-btn.active`
rule. The naive substring search in tests/test_1059_settings_picker_active_state.py
found the skin-specific override first (which correctly uses --border2 for its
palette), failing the global assertion that wanted --accent.

Tighten both assertions to anchor on the `#mainSettings` selector prefix so
they always match the global rule regardless of how many skin-specific
overrides land in the file.
2026-05-20 00:23:01 +00:00
nesquena-hermes
b2c9bddadf Stamp CHANGELOG for stage-388 (Release BS — 5-PR batch)
Unreleased section now reflects:
- PR #2598 live tool event dedup (AJV20)
- PR #2533 browser dashboard links (AJV20)
- PR #2607 messaging transcript dedup (AJV20)
- PR #2521 Geist Contrast skin (intellectronica)
- PR #2524 SSE runtime diagnostics endpoint (AJV20)

Removed merge markers and consolidated stray entries that leaked into the v0.51.94 release block.
2026-05-20 00:20:55 +00:00
nesquena-hermes
ed6ee3e067 Stage 388: PR #2607
# Conflicts:
#	CHANGELOG.md
2026-05-20 00:17:48 +00:00
nesquena-hermes
a201401236 Stage 388: PR #2524 2026-05-20 00:17:48 +00:00
nesquena-hermes
bd819f5e68 Stage 388: PR #2533 2026-05-20 00:17:47 +00:00
nesquena-hermes
7c3dcce1d0 Stage 388: PR #2598 2026-05-20 00:17:47 +00:00
nesquena-hermes
a9e8ab221f Stage 388: PR #2521 2026-05-20 00:17:47 +00:00
Eleanor Berger
86d43751ad docs: include Geist Contrast in contracts index 2026-05-20 00:09:06 +00:00
Eleanor Berger
b05fe9843d docs: avoid hard-coded skin count 2026-05-20 00:09:06 +00:00
Eleanor Berger
2e91c0f81e fix: honour skin value in theme command 2026-05-20 00:09:06 +00:00
Eleanor Berger
4598adfd04 feat: add Geist Contrast skin 2026-05-20 00:09:06 +00:00
AJV20
cb0850208d fix(session): dedupe messaging transcript timestamps 2026-05-19 19:17:43 -04:00
AJV20
6e9f70904c fix(memory): cover notes source locale keys 2026-05-19 18:59:17 -04:00
AJV20
35da27bc53 Merge remote-tracking branch 'origin/master' into fix/webui-context-parity
# Conflicts:
#	CHANGELOG.md
2026-05-19 18:54:38 -04:00
AJV20
6805b3d5dd Merge remote-tracking branch 'origin/master' into audit-2527
# Conflicts:
#	CHANGELOG.md
2026-05-19 18:54:35 -04:00
AJV20
54b6c38578 feat(health): expose WebUI stream runtime diagnostics 2026-05-19 22:48:10 +00:00
AJV20
f14138659f test: Sanitize dashboard URL fixture 2026-05-19 22:47:55 +00:00
AJV20
739c948e74 fix(system): allow browser-only dashboard links 2026-05-19 22:47:55 +00:00
AJV20
612fcd30fe fix: avoid duplicate live tool events 2026-05-19 18:41:08 -04:00
nesquena-hermes
c8896ac1b6 Merge pull request #2606 from nesquena/stage-387
Stage 387 / v0.51.94 — Release BR — 10-PR full sweep batch
2026-05-19 15:20:48 -07:00
nesquena-hermes
7ae97c551a Stamp CHANGELOG for v0.51.94 (Release BR / stage-387 / 10-PR full sweep batch) 2026-05-19 22:11:44 +00:00
nesquena-hermes
6d43116794 Stage 387: PR #2573 2026-05-19 22:10:20 +00:00
nesquena-hermes
cc8ef201be Stage 387: PR #2600 2026-05-19 22:10:20 +00:00
nesquena-hermes
93727897b6 Stage 387: PR #2605
# Conflicts:
#	api/routes.py
2026-05-19 22:10:20 +00:00
nesquena-hermes
1ddb18264e Stage 387: PR #2604
# Conflicts:
#	CHANGELOG.md
2026-05-19 22:08:56 +00:00
nesquena-hermes
4bb60d9b10 Stage 387: PR #2601 2026-05-19 22:08:56 +00:00
nesquena-hermes
3a404874aa Stage 387: PR #2603 2026-05-19 22:08:56 +00:00
nesquena-hermes
c3fd395bd6 Stage 387: PR #2597 2026-05-19 22:08:56 +00:00
nesquena-hermes
e63de7c15f Stage 387: PR #2593
# Conflicts:
#	CHANGELOG.md
2026-05-19 22:08:56 +00:00
nesquena-hermes
536a8b7636 Stage 387: PR #2566 2026-05-19 22:08:55 +00:00
nesquena-hermes
7dd20de132 Stage 387: PR #2599 2026-05-19 22:08:55 +00:00
Lumen Yang
dc5c8168d1 fix(webui): refresh active session on external sidecar updates 2026-05-19 21:34:08 +00:00
Michael Lam
1ebfbf3527 fix: reconcile session metadata counts 2026-05-19 14:28:20 -07:00
keyos
ada59d73e6 fix(approval): simplify gateway_keys expression and document race window
Drop the redundant 'if gw_data else []' guard — gw_data is already
guaranteed to be a dict by the 'or {}' fallback above.

Add a one-line comment explaining the peek-without-pop race window:
a concurrent resolver may pop a different gateway entry, but
approve_session is idempotent over the session key set so the
outcome is the same regardless.
2026-05-19 20:56:22 +00:00
starship-s
692ea22f9e fix(streaming): finish auto-compression card after rotation 2026-05-19 14:35:11 -06:00
keyos
729ed415ff fix(approval): peek _gateway_queues for session-level approval when _pending is empty
During active streaming, dangerous-command approvals go through the
gateway path and are stored in _gateway_queues as _ApprovalEntry
objects, not in _pending. The _resolve_approval_legacy helper only
looked at _pending, so 'Allow for this session' never called
approve_session() — the user clicked Allow, the card vanished, but
the next dangerous command asked again.

Now when _pending has no matching entry, the helper peeks into
_gateway_queues to extract pattern_keys, calls approve_session(),
and marks found_target=True so resolve_gateway_approval also fires.

This commit is re-scoped to peek-only (no agent_session_key round-trip,
no state_db metadata changes).

Includes:
- Import + fallback for _gateway_queues
- Null-safe key filtering in all_keys
- Source-contract test (static) + functional test with
  @requires_agent_modules skip marker for CI
- All comments and docstrings in English
2026-05-19 20:24:05 +00:00
starship-s
37df7d76a4 fix(webui): prevent composer draft rollback on refresh 2026-05-19 13:31:12 -06:00
Michael Lam
5770323188 feat(runtime): add runner adapter facade 2026-05-19 12:06:57 -07:00
AJV20
ebb4dffc7d fix: stream live tool callback events 2026-05-19 14:55:19 -04:00
Lumen Yang
8d2b9d4a16 feat(webui): render indexed context metadata 2026-05-19 18:52:50 +00:00
Bryan Bartley
94ceb66c17 docs: clarify folder-zip cap bounds wall-clock/bandwidth not RSS
Per reviewer note: because the zip streams straight into handler.wfile
(no io.BytesIO buffering), peak memory is bounded by zipfile's per-file
read buffer, not the HERMES_WEBUI_FOLDER_ZIP_MAX_MB cap. Adds a comment
so the next reader doesn't have to trace it to learn the cap's actual
shape.
2026-05-19 13:44:56 -05:00
nesquena-hermes
0310fcc466 Merge pull request #2596 from nesquena/stage-386
Stage 386 / v0.51.93 — Release BQ — 10-PR full sweep batch
2026-05-19 11:42:46 -07:00
Dennis Soong
acd1df1112 fix: time out hung browser api requests 2026-05-20 02:41:00 +08:00
Bryan Bartley
b1b93f9c97 fix(i18n): add download_folder key to all non-en locales
CI parity tests enforce that every key in the English locale block exists
in zh, ja, ko, ru, and es. The PR introducing download_folder added it to
en only, which broke the 5 hard-parity test files. Adds the English
fallback to all 10 non-en blocks (it/ja/ru/es/de/zh/zh-Hant/pt/ko/fr) with
the project's // TODO: translate marker so translators can refine later.

Tests: tests/test_chinese_locale.py, test_japanese_locale.py,
test_korean_locale.py, test_russian_locale.py, test_spanish_locale.py —
26/26 passing locally.
2026-05-19 13:40:29 -05:00
nesquena-hermes
cf014f3c30 Stamp CHANGELOG for v0.51.93 (Release BQ / stage-386 / 10-PR full sweep batch) 2026-05-19 18:21:49 +00:00
nesquena-hermes
6c0f864b10 Stage 386: PR #2587
# Conflicts:
#	CHANGELOG.md
2026-05-19 18:20:47 +00:00
nesquena-hermes
96cb4a556a Stage 386: PR #2584 2026-05-19 18:20:47 +00:00
nesquena-hermes
86f52f67b8 Stage 386: PR #2581
# Conflicts:
#	api/streaming.py
2026-05-19 18:20:47 +00:00
nesquena-hermes
0585881511 Stage 386: PR #2583 2026-05-19 18:20:07 +00:00
nesquena-hermes
7675f2f139 Stage 386: PR #2588
# Conflicts:
#	CHANGELOG.md
2026-05-19 18:20:07 +00:00
nesquena-hermes
9a512194d5 Stage 386: PR #2582
# Conflicts:
#	CHANGELOG.md
2026-05-19 18:20:07 +00:00
nesquena-hermes
42c2eda0fc Stage 386: PR #2579 2026-05-19 18:20:06 +00:00
nesquena-hermes
4b72539b3c Stage 386: PR #2576 2026-05-19 18:20:06 +00:00
nesquena-hermes
05de68f5b3 Stage 386: PR #2580 2026-05-19 18:20:06 +00:00
nesquena-hermes
629ebf45f7 Stage 386: PR #2575 2026-05-19 18:20:06 +00:00
Michael Lam
0736e45485 fix: dedupe tool-only partial recovery markers 2026-05-19 11:16:21 -07:00
Michael Lam
bc7648271f fix: preserve provider for configured model picker selections 2026-05-19 08:05:52 -07:00
Lumen Yang
a8d429775c fix(webui): preserve casual chat compaction guard 2026-05-19 14:34:58 +00:00
AJV20
d3a07b8df6 test: Use generic redaction fixture 2026-05-19 10:28:00 -04:00
AJV20
d43de57180 test: Use generic redaction fixture 2026-05-19 10:27:58 -04:00
AJV20
f93e288214 Fix stale stream recovery writeback race 2026-05-19 10:26:45 -04:00
Florian Krause
646f18c696 fix: prevent queued follow-up message from draining into wrong chat
When a queued message was waiting for the active stream to finish,
the 120ms setTimeout drain in setBusy(false) would write the queued
text to the shared #msg composer and call send(), which reads
S.session.session_id at call time. If the user switched to a different
chat during the 120ms window, the queued message was sent to the
wrong session.

Two fixes:

1. setBusy(false) drain: guard the setTimeout callback — if the
   currently viewed session no longer matches the drain session,
   put the message back into the original session's queue instead
   of sending it.

2. _sendInProgress re-queue: track _sendInProgressSid alongside
   _sendInProgress so that when a concurrent send() is caught by the
   guard, the re-queued message targets the in-flight session rather
   than the currently viewed one.
2026-05-19 15:50:12 +02:00
dobby-d-elf
2a95c1e482 Fix profile-aware assistant display names 2026-05-19 07:17:11 -06:00
Michael Lam
71d8a8fb1b fix: reap terminal shells on shutdown 2026-05-19 04:57:51 -07:00
Michael Lam
79652935d3 fix: centralize workspace tree toggle width 2026-05-19 01:49:52 -07:00
starship-s
2e9ca283dc fix: display canonical cache hit percentage 2026-05-19 02:27:12 -06:00
Lumen Yang
600bb48970 fix(webui): use active state db for metadata summary 2026-05-19 08:02:43 +00:00
Lumen Yang
6ca63e5815 perf(webui): keep external refresh metadata cheap 2026-05-19 08:02:43 +00:00
Lumen Yang
a63ab310b5 fix(webui): preserve reconciled session invariants 2026-05-19 08:02:43 +00:00
Lumen Yang
f12fef280d fix(webui): clear stale prompts on external refresh
Force same-session external refreshes to dismiss stale approval and clarification prompts immediately so completed state.db updates do not leave the composer blocked.
2026-05-19 08:02:43 +00:00
Lumen Yang
467ef33a24 feat(webui): reconcile external session updates
When API server runs append messages directly to state.db, reconcile WebUI sidecar sessions with those canonical rows across API responses, model-facing streaming context, and active browser refresh.

Add append-only state.db merge helpers, metadata-only counts for refresh polling, and regression coverage for API visibility, context incorporation, and frontend refresh behavior.
2026-05-19 08:02:43 +00:00
dobby-d-elf
11e1e9a342 Fix settled rendering for file markdown links 2026-05-18 22:32:20 -06:00
dobby-d-elf
ebf60ba7f9 Merge remote-tracking branch 'origin/master' into fix/session-list-touch-actions 2026-05-18 22:22:35 -06:00
Michael Lam
960c95cfe3 docs(runtime): define runner sidecar gate 2026-05-18 21:06:05 -07:00
Michael Lam
209b8928c4 Merge origin/master into CLI sessions PR 2026-05-18 20:56:38 -07:00
nesquena-hermes
71c70352c1 Merge pull request #2570 from nesquena/stage-385
Stage 385 / v0.51.92 — Release BP — 7-PR full sweep batch
2026-05-18 20:37:24 -07:00
nesquena-hermes
a978612b76 Update brittle-source-string assertions for widened compressed-event guard 2026-05-19 03:21:26 +00:00
nesquena-hermes
e5831043a1 Stamp CHANGELOG for v0.51.92 (Release BP / stage-385 / 7-PR full sweep batch) 2026-05-19 03:14:54 +00:00
nesquena-hermes
3da564a9c3 Move PR #2548 screenshots into docs/pr-media/2548 (project convention) 2026-05-19 03:14:04 +00:00
nesquena-hermes
d880e9c4b3 Stage 385: PR #2548 2026-05-19 03:13:47 +00:00
nesquena-hermes
54875f2110 Stage 385: PR #2550 2026-05-19 03:13:47 +00:00
nesquena-hermes
d92e44ef5a Stage 385: PR #2568
# Conflicts:
#	CHANGELOG.md
2026-05-19 03:13:47 +00:00
nesquena-hermes
e9079548ab Stage 385: PR #2567
# Conflicts:
#	CHANGELOG.md
2026-05-19 03:13:47 +00:00
nesquena-hermes
975928ffb7 Stage 385: PR #2561 2026-05-19 03:13:46 +00:00
nesquena-hermes
ed4e7e67ce Stage 385: PR #2563
# Conflicts:
#	CHANGELOG.md
2026-05-19 03:13:46 +00:00
nesquena-hermes
dfddf25233 Stage 385: PR #2560 2026-05-19 03:13:46 +00:00
Michael Lam
1827ea3efd fix: add Grok OAuth provider catalog support 2026-05-18 19:51:01 -07:00
Dennis Soong
ea978a1989 fix: surface auto-compression handoff 2026-05-19 10:45:43 +08:00
Bryan Bartley
6caf86ba96 feat(workspace): download folder as zip via /api/folder/download
Adds a "Download Folder" item to the workspace file-tree right-click
menu and a GET /api/folder/download endpoint that streams the
directory as a zip with Content-Disposition: attachment.

Configurable caps:
  HERMES_WEBUI_FOLDER_ZIP_MAX_MB    (default 1024)
  HERMES_WEBUI_FOLDER_ZIP_MAX_FILES (default 50000)

Pre-flights the walk so cap-exceeded returns 413 + JSON BEFORE any
zip bytes are sent. Symlinks resolving outside the workspace are
skipped. Mirrors the existing _handle_file_raw shape (session_id
resolution, safe_resolve, RFC 5987 filename via
_content_disposition_value). Stdlib zipfile only; no new dependencies.

Tests: 11 static-inspection tests matching the style of
tests/test_issue1867_upload_size_preflight.py. All passing on
Python 3.11/3.12/3.13.
2026-05-18 21:40:02 -05:00
Michael Lam
b473b21a2f fix: align workspace tree file icons 2026-05-18 18:38:13 -07:00
AJV20
ac19de9526 Merge remote-tracking branch 'origin/master' into fix/webui-context-parity
# Conflicts:
#	CHANGELOG.md
2026-05-18 21:37:52 -04:00
Michael Lam
a48e47dd1c feat: separate CLI sessions in sidebar 2026-05-18 17:29:51 -07:00
Nanook Claw
b6b9deca4c fix: refresh project move cache 2026-05-19 00:08:07 +00:00
keyos
a5a0de2ad6 screenshots: PWA viewport gate evidence for PR #2548 2026-05-18 23:42:39 +00:00
Michael Lam
11bcf812e0 docs(runtime): clarify queue adapter staging 2026-05-18 16:34:16 -07:00
nesquena-hermes
718a4c7615 Merge pull request #2557 from nesquena/stage-384
Stage 384 / v0.51.91 — Release BO — 5-PR full sweep batch
2026-05-18 15:54:07 -07:00
nesquena-hermes
0bb8fde586 Mark ControlResult unsafe_hash=False with explainer (Opus advisor followup) 2026-05-18 22:50:45 +00:00
nesquena-hermes
4a49226439 Stamp CHANGELOG for v0.51.91 (Release BO / stage-384 / 5-PR full sweep batch) 2026-05-18 22:45:57 +00:00
nesquena-hermes
4f90fc5339 Stage 384: PR #2544 2026-05-18 22:44:02 +00:00
nesquena-hermes
589e0bf490 Stage 384: PR #2556
# Conflicts:
#	CHANGELOG.md
2026-05-18 22:44:02 +00:00
nesquena-hermes
91099051c6 Stage 384: PR #2505
# Conflicts:
#	CHANGELOG.md
2026-05-18 22:44:02 +00:00
nesquena-hermes
2675b6266d Stage 384: PR #2520
# Conflicts:
#	CHANGELOG.md
2026-05-18 22:44:02 +00:00
nesquena-hermes
c13f15b2fc Stage 384: PR #2536 2026-05-18 22:43:52 +00:00
Michael Lam
6917b9a0e7 fix: sanitize custom provider env hints 2026-05-18 15:18:20 -07:00
Ruslan
6d8e5031fc Merge branch 'master' into fix/archive-extract-respects-attachment-dir 2026-05-18 23:01:23 +03:00
keyos
b2e1bac149 fix(config): keep anonymous custom endpoints in picker when /v1/models probe fails
When an anonymous custom endpoint (bare base_url, not a named custom_providers[] entry) fails its /v1/models probe, the provider group was silently dropped from the model picker entirely. This made the endpoint unusable even when /v1/chat/completions would work fine.

The fix adds an elif branch: if pid == 'custom', a cfg_base_url is configured, but no models were returned by the probe, the group is still added with an empty model list. Users can then select the Custom group and type a model ID manually in the picker's free-form input.

Closes #2542.
2026-05-18 19:28:46 +00:00
Michael Lam
6a68bab114 fix(runtime): clarify goal adapter seam semantics 2026-05-18 12:10:17 -07:00
AJV20
56a8c6d21a fix(chat): harden WebUI prefill scripts 2026-05-18 14:56:28 -04:00
r.kulbaev
2fe0ece991 fix(upload): scope archive extraction to per-session attachment dir
handle_upload_extract() used Path(s.workspace) as the extraction root,
bypassing HERMES_WEBUI_ATTACHMENT_DIR entirely. Route through
_session_attachment_dir(session_id) so archives land alongside
single-file uploads and session cleanup covers them.

Add tests and CHANGELOG entry.

Ref #2247
2026-05-18 21:22:02 +03:00
keyos
c7d0c6343c feat(ui): add PWA standalone reload button and pull-to-refresh gesture
Adds a reload button to the app titlebar visible only in PWA standalone
or fullscreen mode, and a pull-to-refresh gesture on the messages container
that smooth-scrolls to the top before activating.

The reload button sits next to the message count label and provides a
one-tap refresh for users who installed the WebUI as a home-screen app
where browser navigation controls are unavailable.

The pull-to-refresh gesture detects downward drag at the top of the
message list, shows a visual indicator ('Pull to refresh' / 'Release to refresh'),
and reloads on release past the 80px threshold. When triggered mid-conversation,
it smooth-scrolls to the top first.
2026-05-18 18:10:20 +00:00
AJV20
540292a7cd fix(chat): align WebUI context with messaging sessions 2026-05-18 14:07:06 -04:00
AJV20
5c1161f84f feat(chat): load WebUI prefill context 2026-05-18 14:04:49 -04:00
AJV20
8e65ad3063 fix(chat): add WebUI surface context to agent turns 2026-05-18 14:04:17 -04:00
Michael Lam
e94827f460 fix: stop replaying reasoning-only history 2026-05-18 10:50:42 -07:00
Michael Lam
b23fb6ccaa feat(runtime): route goal through adapter seam 2026-05-18 10:30:26 -07:00
Hermes Agent
42b97d15f6 fix: clear fallback streaming warnings 2026-05-18 12:21:59 -05:00
nesquena-hermes
4589dbec30 Merge pull request #2538 from nesquena/stage-383
Stage 383 / v0.51.90 — Release BN — 10-PR full sweep batch
2026-05-18 10:02:57 -07:00
nesquena-hermes
c28fa63cc9 Stamp removal-required comment on Slice 3c gate test (Opus advisor followup #3) 2026-05-18 16:57:40 +00:00
nesquena-hermes
26718cdfc7 Stamp CHANGELOG for v0.51.90 (Release BN / stage-383 / 10-PR full sweep batch) 2026-05-18 16:46:53 +00:00
nesquena-hermes
63d75df818 Stage 383: PR #2530
# Conflicts:
#	CHANGELOG.md
2026-05-18 16:44:36 +00:00
nesquena-hermes
d161e9a074 Stage 383: PR #2528
# Conflicts:
#	CHANGELOG.md
2026-05-18 16:44:36 +00:00
nesquena-hermes
69b2289671 Stage 383: PR #2512
# Conflicts:
#	CHANGELOG.md
2026-05-18 16:44:36 +00:00
nesquena-hermes
c7badae039 Stage 383: PR #2515
# Conflicts:
#	CHANGELOG.md
2026-05-18 16:44:35 +00:00
nesquena-hermes
75f6d2a03c Stage 383: PR #2522
# Conflicts:
#	CHANGELOG.md
2026-05-18 16:44:35 +00:00
nesquena-hermes
3bf2b10a68 Stage 383: PR #2509
# Conflicts:
#	CHANGELOG.md
2026-05-18 16:44:35 +00:00
nesquena-hermes
cd7ee68e02 Stage 383: PR #2511
# Conflicts:
#	CHANGELOG.md
2026-05-18 16:44:35 +00:00
nesquena-hermes
c713b80197 Stage 383: PR #2459 2026-05-18 16:44:26 +00:00
nesquena-hermes
8286061fb0 Stage 383: PR #2294
# Conflicts:
#	tests/test_gateway_sync.py
2026-05-18 16:44:26 +00:00
nesquena-hermes
52d54b58c8 Stage 383: PR #2286 2026-05-18 16:43:10 +00:00
AJV20
372d090c6c fix(memory): match notes search button styling 2026-05-18 10:31:36 -04:00
AJV20
8c08acec5a feat(memory): show AI-used Joplin notes 2026-05-18 10:24:05 -04:00
Frank Song
0cd62ab474 fix: keep kanban drag drop from opening detail 2026-05-18 22:13:04 +08:00
AJV20
54ca6bf2e3 feat(memory): browse Joplin notes from notes drawer 2026-05-18 10:10:48 -04:00
AJV20
2f7883580f fix(memory): infer configured notes source tools 2026-05-18 10:04:03 -04:00
AJV20
faf1160ca9 fix(memory): show configured notes sources without tools 2026-05-18 09:57:45 -04:00
Michael Lam
b76d698a79 fix: guard new conversation cold-start clicks 2026-05-18 06:48:05 -07:00
AJV20
1f3b7aa2c3 feat(memory): show third-party notes sources 2026-05-18 09:28:11 -04:00
Michael Lam
f3f9f3ed40 fix: allow keyless named custom endpoints 2026-05-18 04:27:31 -07:00
Michael Lam
037652308d fix: load remote models for named custom providers 2026-05-18 01:08:09 -07:00
Frank Song
9d6eb5f0df Fix board selector label overlap 2026-05-18 16:02:56 +08:00
Dennis Soong
d72b3382fd fix: clarify compression elapsed cap 2026-05-18 14:22:29 +08:00
Michael Lam
9f1ff51950 docs(runtime): tighten queue goal gate 2026-05-17 23:17:46 -07:00
Dennis Soong
516d2a588c fix: show auto-compression elapsed time 2026-05-18 13:08:38 +08:00
Frank Song
46029e0b1c docs: align uiux demo theme controls 2026-05-18 12:50:25 +08:00
Nathan Esquenazi
e8dbf94a6f fix(i18n+tests): complete locale parity + bump test windows for new menu action
PR #2294 added the show_previous_messaging_sessions setting and a "Hide
from list" menu action for external sessions, but tripped 8 tests:

  - 4 locale-parity tests (tests/test_{japanese,russian,spanish,chinese}_locale.py)
    demand every en key be defined in ja/ru/es/zh blocks. The contributor
    only added the 5 new keys to en + ko, leaving ja/ru/es/zh/it/de/zh-TW/pt/fr
    missing them. tests/test_provider_quota_status.py also requires the two
    settings_{label,desc}_previous_messaging_sessions keys in ALL 11 locales.

  - tests/test_1466_sidebar_cancel_clarify.py read the first 5200 chars of
    _openSessionActionMenu to find cancelSessionStream/delete actions; the
    new "Hide from list" branch (17 lines for external sessions) pushed
    those past the read window.

  - tests/test_issue1611_session_profile_filtering.py grep'd for the
    literal string `_keep_latest_messaging_session_per_source(scoped)`,
    which no longer exists after the call was rewritten as a multi-line
    keyword-arg form.

Fixes:

1. Translations for the 5 new i18n keys added to all 9 missing locales
   (it, ja, ru, es, de, zh-CN, zh-TW, pt, fr):
   - session_hide_external
   - session_hide_external_desc
   - session_hidden
   - settings_label_previous_messaging_sessions
   - settings_desc_previous_messaging_sessions

   Where the locale already used the English fallback for related keys
   (ru/es/de session_archive), I provided localized translations for the
   new keys to match the project's general direction. Native-script
   quality, not machine-translation.

2. test_1466 window bumped 5200 → 6400 with a comment explaining the
   bump (mirrors the existing 3200→4400→5200 history annotations).

3. test_1611 dedupe-position check loosened to match the function name
   without the `(scoped)` suffix so it tolerates both single-line and
   keyword-arg call shapes.

Tests: full suite 5828 passed / 63 skipped / 0 failed (was 8 failed).
Behavioral harness verifies the toggle's claimed behavior — off (default)
hides reset/compression segments, on shows all rows in timestamp order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:38:06 -07:00
junjunjunbong
3a53592107 Add previous messaging session controls 2026-05-17 21:27:32 -07:00
junjunjunbong
98f2814ecd Fix empty gateway session hiding messaging history 2026-05-17 21:13:24 -07:00
Michael Lam
2a3c4abc60 docs(runtime): define queue goal control gate 2026-05-17 21:06:13 -07:00
nesquena-hermes
e6be01c4dd Merge pull request #2507 from nesquena/stage-382
stage-382 — 6-PR release batch (4 contributor + 1 agent + 1 docs)
2026-05-17 20:59:02 -07:00
nesquena-hermes
04e08391c4 Stamp CHANGELOG for v0.51.89 (Release BM / stage-382 / 6-PR full sweep batch) 2026-05-18 03:56:54 +00:00
nesquena-hermes
715017924c Stage 382: PR #2503 2026-05-18 03:45:42 +00:00
nesquena-hermes
cec1e87d04 Stage 382: PR #2500 (with whitespace polish on panels.js + ARCHITECTURE.md) 2026-05-18 03:45:02 +00:00
nesquena-hermes
42c677b223 Stage 382: PR #2496 2026-05-18 03:43:59 +00:00
nesquena-hermes
ea1261d03b Stage 382: PR #2501 2026-05-18 03:43:26 +00:00
nesquena-hermes
f731f1fa43 Stage 382: PR #2499 2026-05-18 03:43:01 +00:00
nesquena-hermes
b170980522 Stage 382: PR #2504 2026-05-18 03:43:01 +00:00
nesquena-hermes
d9a26d26af Merge pull request #2497 from nesquena/stage-381
Release BL — v0.51.88 (stage-381 — 3-PR security + UX + lineage batch — CSRF + quoted-reply + lineage collapse)
2026-05-17 18:50:48 -07:00
nesquena-hermes
e919da8705 Stamp CHANGELOG for v0.51.88 (Release BL / stage-381 / 3-PR security + UX + lineage batch) 2026-05-18 01:44:41 +00:00
nesquena-hermes
00fc4ccc02 Stage 381: PR #2493 2026-05-18 01:44:05 +00:00
nesquena-hermes
db048fade5 Stage 381: PR #2485 2026-05-18 01:32:24 +00:00
nesquena-hermes
eef47ea27b Stage 381: PR #2484 2026-05-18 01:32:21 +00:00
nesquena-hermes
a27f8c8c24 Merge pull request #2487 from Michaelyklam/docs/issue-1925-approval-clarify-gate
docs(runtime): define approval clarify control gate
2026-05-17 18:22:59 -07:00
nesquena-hermes
adcdc261b4 Merge pull request #2495 from nesquena/stage-380
Release BK (stage-380): v0.51.87 — 2-PR Docker hygiene + CI gate — read-only mount tmpfs staging + Docker runtime smoke workflow + agent-source boundary inventory + writable-mount startup warning
2026-05-17 18:22:12 -07:00
nesquena-hermes
944c634f97 Stamp CHANGELOG for v0.51.87 (Release BK / stage-380, also restore v0.51.86 block clobbered during rebase) 2026-05-18 01:18:53 +00:00
nesquena-hermes
669e815a73 Stage 380: PR #2482
# Conflicts:
#	CHANGELOG.md
#	docker_init.bash
2026-05-18 01:16:19 +00:00
nesquena-hermes
c1671d1e5e Stage 380: PR #2490
# Conflicts:
#	CHANGELOG.md
2026-05-18 01:14:28 +00:00
Michaelyklam
d9e6dcf3ef docs(runtime): define approval clarify control gate 2026-05-18 01:06:26 +00:00
nesquena-hermes
7ed9117929 Merge pull request #2494 from nesquena/docs/2483-android-avf-rebased
docs: note Android AVF ARM64 field report (refs #2364, supersedes #2483)
2026-05-17 17:58:25 -07:00
Frank Song
144aac28b9 docs: note Android AVF ARM64 field report (refs #2364)
Add a narrow README note for the community ARM64 Android AVF field
report: Hermes Agent + WebUI running inside a Debian 12 VM on a
mid-range Android phone with cloud-hosted inference.

The note frames the report as a compatibility signal rather than an
official support baseline or provider/model benchmark, and records
practical mobile caveats around first-install compile time, Android
tab reloads, and battery optimization.

Refs #2364
Closes nesquena/hermes-webui#2483

Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-05-18 00:51:41 +00:00
Dennis Soong
9b65e2440b fix: collapse WebUI compression continuations in sidebar 2026-05-18 08:35:38 +08:00
Nathan Esquenazi
64590cb6b9 harden(docker-smoke): catch !!ERROR/!!Exiting + tighten egg_info test
Two non-blocking observations from the review, both addressed:

1. The bad-pattern grep listed `error_exit` as a literal token, but the
   `error_exit()` function at docker_init.bash:5-10 only echoes the
   strings `"!! ERROR: "` and `"!! Exiting script (ID: $$)"` — the
   function name itself never appears in container logs. So
   `grep -E -i "error_exit"` would only fire on stray debug prints of
   the name, not on actual failures. The other patterns
   (`Failed to set (UID|GID|...)`, `groupmod: cannot`, etc.) DO catch
   real error_exit output, so this wasn't a coverage gap — just a dead
   token.

   Add `!! ERROR` and `!! Exiting script` to the bad-pattern set so the
   grep actually matches the function's output. Keep the literal
   `error_exit` token as belt-and-suspenders for any debug/echo of the
   name.

2. `test_docker_init_excludes_egg_info_during_staging` was a single
   `assert "egg-info" in src` check. That passes if any occurrence
   appears — including the explanatory comment block above the staging
   logic. A maintainer removing the `--exclude='*.egg-info'` from
   rsync but keeping the comment would slip past the test.

   Tighten to:
   - scope to the staging block (between `_stage_src=` and the
     `uv pip install` line) so comments outside that window can't
     satisfy the assertion;
   - require the literal `--exclude='*.egg-info'` rsync flag;
   - require `*.egg-info` in the block so the cp-fallback cleanup is
     also pinned;
   - additionally require `--exclude='build'`, `--exclude='dist'`,
     `--exclude='__pycache__'` so all four setuptools-touchable
     artifact dirs stay excluded.

Verified:
- tests/test_docker_docs_and_readonly.py — 11/11 pass.
- YAML parses cleanly via `yaml.safe_load`.
- Full suite: 5770 passed, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:34:46 -07:00
nesquena-hermes
70f371c8b9 fix(docker): stage agent source to writable build dir before pip install
The Docker smoke gate added in this same PR caught a real production
regression on its very first CI run. v0.51.84 (PR #2470) mounted
hermes-agent-src read-only on the WebUI side and widened the chown
prune to keep the read-only walk happy, but missed that the WebUI's
startup also runs:

    uv pip install "$_agent_src[all]"

against the same now-read-only mount. setuptools' egg_info step writes
hermes_agent.egg-info/ inside the source tree even under PEP 517 build
isolation (this is by design -- PEP 517 isolates the BUILD environment,
not the source tree's metadata directory). On a :ro mount this returns
EROFS, the install fails, error_exit fires, and every multi-container
deploy dies at startup. The smoke gate flagged it on both the
two-container and three-container variants.

The fix
-------
Stage the agent source into a writable build dir under /tmp BEFORE
invoking pip install, then point pip at the staged copy.

  _stage_src="/tmp/hermes-agent-build"
  rm -rf "$_stage_src" && mkdir -p "$_stage_src"
  rsync -a --exclude='*.egg-info' --exclude='build' --exclude='dist' \
        --exclude='__pycache__' --exclude='.git' \
        "$_agent_src"/ "$_stage_src"/
  uv pip install "$_stage_src[all]" ...
  rm -rf "$_stage_src"

The exclusion list matters: when setuptools sees a pre-baked *.egg-info,
build, or dist directory, it takes a timestamp-update code path that
also reads/writes inside that directory -- which itself fails on a :ro
source. Excluding them keeps the build on the fresh-build path
unconditionally.

rsync is in the production image (Dockerfile line 41-44). For users
running custom WebUI images without rsync, the script falls back to
cp -a + post-copy rm -rf of the same artifacts.

Tests
-----
Two new source-level invariants in tests/test_docker_docs_and_readonly.py:

  test_docker_init_stages_agent_source_for_writable_install
    -- asserts _stage_src=... is declared
    -- asserts every `uv pip install ...[all]` line uses _stage_src,
       NOT raw $_agent_src

  test_docker_init_excludes_egg_info_during_staging
    -- asserts the staging path excludes *.egg-info (rsync exclude
       form or cp-fallback's explicit rm -rf both pass)

These would have caught the v0.51.84 regression at the source level
(once written; they're new). The Docker runtime smoke gate is the
durable defence for the broader class of :ro x init-script
interactions, since source-level invariants only catch what they're
written to catch.

Verification
------------
- pytest tests/test_docker_docs_and_readonly.py: 11 passed (9 existing
  + 2 new)
- pytest tests/ -q --timeout=60: 5891 passed, 6 skipped (was 5889;
  delta is exactly the 2 new tests)
- bash -n docker_init.bash: clean

Once this lands, the Docker smoke gate's two/three-container variants
should go green, completing the self-validating loop.
2026-05-18 00:21:31 +00:00
nesquena-hermes
5b6f69c884 ci(docker): runtime smoke gate for Docker init logic
Closes the source-only-test gap that let v0.51.84's :ro-mount x chown -h
{} + startup regression reach review with 5800+ green pytests. Adds a
new GitHub Actions workflow .github/workflows/docker-smoke.yml that
actually runs 'docker compose up' against each compose variant.

Triggers
--------
Path-filtered on pull_request + push to master:
  Dockerfile, docker_init.bash, docker-compose*.yml, .dockerignore,
  .env.docker.example, .github/workflows/docker-smoke.yml itself.
Also workflow_dispatch for manual runs.

Jobs
----
1. compose-config -- preflight that 'docker compose config' parses each
   of the three compose files. Cheap, fast, catches schema/interpolation
   drift in parallel before any container starts.

2. smoke (matrix: single / two-container / three-container) -- for each
   variant:
   a. Reap any leftover hermes-smoke-* containers/volumes/networks from
      prior runs (defence-in-depth on self-hosted runners; hosted runners
      are fresh).
   b. docker build -t ghcr.io/nesquena/hermes-webui:latest .
      Critical: the multi-container compose files reference the GHCR
      image. Without this retag, multi-container smoke would test the
      previously-released image, NOT the PR's docker_init.bash / Dockerfile
      changes. With the retag, Compose's default pull_policy=missing keeps
      the local build in place and the PR is genuinely exercised.
   c. mktemp -d for ephemeral HERMES_HOME + HERMES_WORKSPACE so the
      runner's host filesystem is never touched.
   d. docker compose up -d --wait --wait-timeout 120 (Dockerfile carries a
      HEALTHCHECK so --wait blocks on 'healthy', not just 'running').
   e. curl /health probe with a 30-attempt x 2s poll loop as headroom for
      the multi-container variants' Python dep install phase.
   f. grep startup logs for known-bad signatures:
        EROFS | Read-only file system | Traceback | PermissionError |
        error_exit | groupmod: cannot | usermod: cannot |
        Failed to set (UID|GID|owner|permissions|ownership)
      These are the exact patterns that would have flagged #2470 in real
      time. Failed-to-set is anchored to specific objects to avoid false
      positives on benign locale/library bootstrap warnings.
   g. trap on EXIT: docker compose down -v --remove-orphans + rm -rf the
      ephemeral host paths, regardless of how the job exited.

Safety
------
- permissions: contents: read only -- no GITHUB_TOKEN write scope.
- Fork PRs run with no secrets (standard pull_request, not
  pull_request_target).
- No host bind mounts; no ~/.hermes exposure; no network egress beyond
  what compose itself needs to pull the agent image.
- timeout-minutes: 15 on the smoke job as a hard ceiling against a
  hung docker build.
- Per-run COMPOSE_PROJECT name (hermes-smoke-VARIANT-RUNID-ATTEMPT)
  so concurrent runs or reruns can't clobber each other.

Out of scope for v1 (per design review)
---------------------------------------
- HERMES_WEBUI_SMOKE_TEST env flag in docker_init.bash -- production-code
  footgun that would let any leaked env var silently exit before
  serving traffic.
- --user 60000:60000 -- incompatible with the image's root-init phase
  and would skip the very chown branch we are guarding against.
- Local-runnable scripts/docker-smoke-test.sh -- defer until CI gating
  ships and we see what contributors actually trip over.
- Hadolint / yamllint -- separate lint workflow, follow-up PR.
- Podman runtime smoke -- defer until a podman-specific bug ships.

Pre-merge verification
----------------------
- actionlint: clean
- YAML parse: clean (3 triggers, 2 jobs, 3-variant matrix)
- bash -n on all 6 run-blocks: clean
- pytest tests/ -q --timeout=60: 5889 passed, 6 skipped (no test impact;
  workflow-only change)
- Opus design review on the brief (REVISE -> minimum scope adopted)
- Opus implementation review on this workflow (APPROVE)
2026-05-18 00:09:41 +00:00
nesquena-hermes
2927702596 Merge pull request #2486 from nesquena/stage-379
Release BJ (stage-379): v0.51.86 — 4-PR review-bypass batch — WebUI memory-provider session lifecycle + cross-provider /model alias + RuntimeAdapter cancel seam + Fork-from-here messaging coord
2026-05-17 16:44:05 -07:00
nesquena-hermes
9543addd3d Stage 379 fix: remove stray CHANGELOG entry mis-attributing #2461 to v0.51.83 (Opus catch) 2026-05-17 23:41:15 +00:00
nesquena-hermes
0dc527517c Stamp CHANGELOG for v0.51.86 (Release BJ / stage-379 / 4-PR review-bypass batch) 2026-05-17 23:36:11 +00:00
nesquena-hermes
50d4f4cfb9 Stage 379: PR #2480
# Conflicts:
#	CHANGELOG.md
2026-05-17 23:35:19 +00:00
nesquena-hermes
6f9cead15e Stage 379: PR #2479 2026-05-17 23:35:18 +00:00
nesquena-hermes
b861422045 Stage 379: PR #2473 2026-05-17 23:35:18 +00:00
nesquena-hermes
935d9e6402 Stage 379: PR #2461
# Conflicts:
#	CHANGELOG.md
2026-05-17 23:35:18 +00:00
Frank Song
496b34fe4d Fix CSRF test isolation 2026-05-18 07:27:31 +08:00
Frank Song
8daf716307 Repair selected text reply review blockers 2026-05-18 07:26:19 +08:00
Frank Song
996942429c Add session-bound CSRF token checks 2026-05-18 07:14:26 +08:00
Frank Song
9646773487 Add selected text reply composer append 2026-05-18 07:13:14 +08:00
Michael Lam
310d69bed8 docs: inventory agent source boundary 2026-05-17 16:11:29 -07:00
Michael Lam
f986507809 fix: align fork-from-here with merged messaging history 2026-05-17 15:01:57 -07:00
Michael Lam
a5385e5859 feat(runtime): route cancel through RuntimeAdapter seam 2026-05-17 13:23:22 -07:00
nesquena-hermes
f1d399b437 Merge pull request #2478 from nesquena/stage-378
Release BI (stage-378): v0.51.85 — 3-PR review-bypass batch — workspace-prefix display leakage fix + release-tag update banner + Slice 3a cancel-control gate RFC
2026-05-17 13:05:33 -07:00
nesquena-hermes
c728de2a58 Stamp CHANGELOG for v0.51.85 (Release BI / stage-378 / 3-PR batch) 2026-05-17 19:55:48 +00:00
nesquena-hermes
77ccd2a29f Stage 378: PR #2469 2026-05-17 19:55:09 +00:00
nesquena-hermes
fa6e939c69 Stage 378: PR #2146 2026-05-17 19:55:09 +00:00
nesquena-hermes
dcf9b0f7f5 Stage 378: PR #2145 2026-05-17 19:55:09 +00:00
ts2111
64db8bd794 fix: support /model alias switch for cross-provider custom models
Backend (api/config.py):
- resolve_model_provider(): check custom_providers for prefix match
  BEFORE the config_base_url branch. Previously, providers with a
  base_url set (e.g. deepseek) would catch all slash-delimited model
  ids and return the config provider, preventing custom provider
  routing.
- get_available_models(): include model aliases in response so the
  frontend can resolve them on /model commands.

Frontend (static/commands.js):
- cmdModel(): resolve aliases by fetching /api/models before fuzzy
  matching the dropdown.
- Add bare-model fallback when the alias resolves to a slash-delimited
  provider/model id (e.g. "deepseek/deepseek-v4-flash").
- Add cross-provider fallback: when the model is from a custom provider
  not in the active provider dropdown, call /api/session/update directly
  with the provider/model id and provider override.
2026-05-17 21:22:06 +02:00
dobby-d-elf
ab3fd4ceca Update hover regression for gesture state machine 2026-05-17 12:54:11 -06:00
dobby-d-elf
a902bfb905 let session swipes continue past action reveal 2026-05-17 12:33:48 -06:00
Michael Lam
f2c5048741 docs(runtime): define cancel control gate 2026-05-17 11:30:50 -07:00
dobby-d-elf
5db7aa43aa smooth flip animation on delete/archive for session stack 2026-05-17 12:02:56 -06:00
dobby-d-elf
587101fb97 Animate session list reflow on removal 2026-05-17 11:55:56 -06:00
nesquena-hermes
02144aa863 Merge pull request #2471 from nesquena/stage-377
Stage 377: v0.51.84 (Release BH) — Docker hygiene (PR #2470)
2026-05-17 10:51:12 -07:00
nesquena-hermes
79b690b3d9 Stamp CHANGELOG for v0.51.84 (Release BH / stage-377) 2026-05-17 17:45:23 +00:00
nesquena-hermes
31ae565533 Stage 377: PR #2470 — docs(docker): document agent-image upgrade flow + read-only WebUI source mount (closes #1416 + addresses #2453 read-only half) 2026-05-17 17:44:42 +00:00
Nathan Esquenazi
2d66263a6c fix(docker): widen chown prune to the entire hermes-agent path
PR #2470 introduces a `:ro` mount for the `hermes-agent-src` named volume
on the WebUI side of `docker-compose.{two,three}-container.yml`. The
WebUI's docker_init.bash unconditionally runs `chown_home_hermeswebui`
which walks `/home/hermeswebui` with `find -exec chown -h {} +`,
pruning only `/home/hermeswebui/.hermes/hermes-agent/.git/objects` (the
narrow #2237 fix for macOS bind mounts).

With the new `:ro` mount, every other file inside the hermes-agent
subtree is also on a read-only filesystem.  `chown` returns `EROFS`,
`find -exec ... +` propagates the non-zero exit, and the wrapping
`chown_home_hermeswebui || error_exit "..."` under `set -e` kills the
container before the WebUI server can run.

Verified locally:

    $ /usr/bin/find /tmp/ftest -exec false {} +
    $ echo $?
    1

So `find` does propagate `-exec` command failures, which the existing
`|| error_exit` then catches.

The WebUI never writes to the agent source — `uv pip install
/home/hermeswebui/.hermes/hermes-agent` is a pure read.  So aligning
ownership inside the agent subtree was always a nicety, not a
requirement.  Widen the prune to skip the entire
`/home/hermeswebui/.hermes/hermes-agent` path.  This also subsumes the
original #2237 case (the `.git/objects` packs are inside the now-pruned
subtree) without needing a separate carve-out.

Test updates:

- Renamed `test_home_chown_skips_hermes_agent_git_objects` →
  `test_home_chown_skips_hermes_agent_subtree`, and pinned the broader
  prune target (`-path ".../hermes-agent" -prune`).
- Added `test_home_chown_helper_documents_readonly_mount_compat` so a
  future maintainer narrowing the prune back to `.git/objects` (and
  re-introducing the EROFS failure mode) trips a regression.

Verified:

- `tests/test_issue2237_docker_chown_git_objects.py` 4/4 pass.
- `tests/test_docker_docs_and_readonly.py` 9/9 pass.
- Full suite: 5738 passed, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 10:38:40 -07:00
nesquena-hermes
5cc8b6c654 docs(docker): document agent-image upgrade flow + read-only WebUI source mount
The hermes-agent-src named volume in the two- and three-container compose
files is initialised from the agent image's /opt/hermes on first `up` and
Docker reuses it verbatim on every subsequent `up` — even after a fresh
`docker pull` of the agent image. This was the root cause of #1416 (the
'missing entrypoint' symptom was a stale cached volume hiding the new
image's source tree).

Changes:

- Add an 'Upgrading the agent container' section to docs/docker.md with
  the canonical `down → docker volume rm → pull → up -d` recipe, plus the
  same pointer as a comment block in both multi-container compose files
  near the volume declarations.
- Switch the WebUI's hermes-agent-src mount to `:ro` in both multi-container
  compose files. The WebUI only reads this volume to install the agent's
  Python deps at startup; mounting it read-only enforces that at the kernel
  layer and brings the actual mount mode in line with the existing
  docs/docker.md architecture diagram (which already labelled this edge as
  read-only).
- Align the workspace bind default in both multi-container compose files
  with the single-container convention — `${HERMES_WORKSPACE:-${HOME}/workspace}`
  instead of `${HERMES_WORKSPACE:-~/workspace}` — so the default resolves
  the same way across Linux, macOS, WSL2, and Docker Desktop on Windows.
- Add a 'What the multi-container setup isolates (and what it doesn't)'
  section to docs/docker.md to frame the two/three-container setups as
  process/network/resource isolation, not filesystem isolation, so users
  don't reach for multi-container expecting a trust boundary it doesn't
  provide.
- Cross-link #1416 from the Related issues section.

Adds 9 regression tests in tests/test_docker_docs_and_readonly.py covering:
- :ro on the WebUI side of hermes-agent-src in both files
- agent side stays read-write (still needs to populate /opt/hermes on first run)
- ${HOME} (not ~) in workspace bind defaults in both files
- single-container file already uses ${HOME} (pin to prevent drift)
- docs/docker.md has the 'Upgrading the agent container' section + recipe
- compose files reference docs/docker.md + show the upgrade step inline
- docs/docker.md frames the isolation model honestly

Test suite: 42 passed (33 existing Docker tests + 9 new). No behaviour
change for users who set HERMES_WORKSPACE explicitly, and no migration is
required for existing deployments — Docker rebinds the existing volume
read-only on next `up`. Users upgrading the agent image should now follow
the documented `docker volume rm hermes-agent-src` recipe.

Closes #1416 (documented upgrade procedure) and addresses the read-only
half of the multi-container coupling concern raised on #2453.
2026-05-17 17:18:39 +00:00
dobby-d-elf
22aae48672 Polish session swipe affordances 2026-05-17 11:01:38 -06:00
nesquena-hermes
522efc2921 Merge pull request #2468 from nesquena/stage-376
Release BG (stage-376): v0.51.83 — 12-PR contributor batch — chat-start adapter parity + populated-core journal recovery + thinking card dedup + context metadata refresh + model cache fingerprint + stream fade cap + manual cron delivery + active-session spinner + email gateway label + thinking copy button + /theme i18n + compact activity semantics
2026-05-17 09:53:15 -07:00
nesquena-hermes
80a09c8f4e Stamp CHANGELOG for v0.51.83 (Release BG / stage-376 / 12-PR contributor batch) 2026-05-17 16:43:45 +00:00
nesquena-hermes
a2920c99bc Stage 376: PR #2466
# Conflicts:
#	CHANGELOG.md
2026-05-17 16:42:11 +00:00
nesquena-hermes
d2bb47f338 Stage 376: PR #2463
# Conflicts:
#	CHANGELOG.md
2026-05-17 16:42:10 +00:00
nesquena-hermes
45e11e794c Stage 376: PR #2460
# Conflicts:
#	CHANGELOG.md
2026-05-17 16:42:10 +00:00
nesquena-hermes
46810bd4b2 Stage 376: PR #2457
# Conflicts:
#	CHANGELOG.md
2026-05-17 16:42:10 +00:00
nesquena-hermes
ccea8ed66f Stage 376: PR #2455
# Conflicts:
#	CHANGELOG.md
2026-05-17 16:42:10 +00:00
nesquena-hermes
9f09f7e657 Stage 376: PR #2452
# Conflicts:
#	CHANGELOG.md
2026-05-17 16:42:10 +00:00
nesquena-hermes
54297bdda7 Stage 376: PR #2450
# Conflicts:
#	CHANGELOG.md
2026-05-17 16:42:10 +00:00
nesquena-hermes
dd2f855b9f Stage 376: PR #2445
# Conflicts:
#	CHANGELOG.md
2026-05-17 16:42:09 +00:00
nesquena-hermes
c1f0f069da Stage 376: PR #2444
# Conflicts:
#	CHANGELOG.md
2026-05-17 16:42:09 +00:00
nesquena-hermes
d15dfdbef4 Stage 376: PR #2441
# Conflicts:
#	CHANGELOG.md
2026-05-17 16:42:09 +00:00
nesquena-hermes
8f8005eab9 Stage 376: PR #2439
# Conflicts:
#	CHANGELOG.md
2026-05-17 16:42:09 +00:00
nesquena-hermes
9453046f65 Stage 376: PR #2438 2026-05-17 16:41:44 +00:00
swftwolfzyq
b2ee7e365f Merge latest origin/master into codex/workspace-prefix-display-fix 2026-05-17 23:44:16 +08:00
swftwolfzyq
3553e63a51 Merge origin/master into codex/workspace-prefix-display-fix 2026-05-17 23:39:12 +08:00
Frank Song
7a53fd4542 Clarify compact activity timeline semantics 2026-05-17 23:03:56 +08:00
dobby-d-elf
a5254509a3 refactor implementation and clean up 2026-05-17 08:03:51 -06:00
dobby-d-elf
4d7fa305fa Polish mobile session swipe feedback 2026-05-17 07:50:54 -06:00
Michael Lam
fdff99c9f8 fix: refresh theme command i18n help 2026-05-17 06:09:53 -07:00
starship-s
cdb979d2ca fix: remove importlib.reload(cfg) from eviction tests 2026-05-17 06:37:32 -06:00
starship-s
a48cacec73 fix: evict cleared sessions outside session lock 2026-05-17 05:22:39 -06:00
starship-s
625d8d02fd fix: preserve memory lifecycle mark ordering 2026-05-17 05:16:46 -06:00
starship-s
eb70a6dc5d fix: align WebUI memory commits with CLI boundaries 2026-05-17 05:04:57 -06:00
Michael Lam
2785065a09 feat: add thinking card copy button 2026-05-17 03:57:54 -07:00
Michael Lam
f56ec1ec35 fix: label email gateway sessions 2026-05-17 02:49:25 -07:00
Frank Song
bd9a4924bf fix: clear stale active session spinner 2026-05-17 17:34:43 +08:00
starship-s
aecad0f427 [verified] Fix WebUI memory session lifecycle commits 2026-05-17 03:30:06 -06:00
Michael Lam
00295a916a fix: deliver manual cron run results 2026-05-17 01:41:06 -07:00
Michael Lam
87e3b4e88e fix: cap stream fade done drain 2026-05-17 00:27:54 -07:00
Frank Song
fe55cf5b9e Refresh session context metadata on model changes 2026-05-17 13:27:40 +08:00
Michael Lam
cdbb785037 fix: invalidate model cache on catalog changes 2026-05-16 22:24:12 -07:00
Michael Lam
8ba16ab294 fix: reuse compact live thinking card 2026-05-16 21:44:18 -07:00
Frank Song
4661a5e94e Recover journal output after core transcript sync 2026-05-17 12:28:05 +08:00
Frank Song
1731232a12 Preserve chat start response shape for adapter path 2026-05-17 12:02:54 +08:00
nesquena-hermes
603183a301 Merge pull request #2437 from nesquena/stage-375
Release BF: stage-375 (v0.51.82) — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin
2026-05-16 20:56:34 -07:00
nesquena-hermes
403e9582af Stamp CHANGELOG for v0.51.82 (Release BF / stage-375) 2026-05-17 03:53:50 +00:00
nesquena-hermes
573b1e12ad Stage 375: PR #2428 stage-fix #2 — drop angle brackets from pipe-protect set (Opus catch)
Opus advisor review of stage-375 flagged that the protected-bracket set including `<` and `>` caused tables containing comparison operators across adjacent columns to mis-collapse: `| x < 5 | y > 10 |` matched `< ... >` as a bracket pair and stashed the inner pipe, producing one cell instead of two.

Real LLM table output uses angle brackets as comparison operators far more often than as content-grouping pairs, so the safer default is to NOT treat them as a matched pair. Dropped `<` from the opener class and `>` from both closer classes.

Three regression tests added (`TestComparisonOperatorsAcrossColumns` class): `< … >` across columns, `<` alone, `>` alone.
2026-05-17 03:53:29 +00:00
nesquena-hermes
d45d4c08b5 Stage 375: PR #2428 stage-fix — hex-escape brace literals in pipe-protect regex + drop apostrophe-stop + add 9 regression tests
PR #2428's iterative _protectPipes regex introduced two issues we caught during stage assembly:

1. The negated character classes [^)\]}'>] added `'` as a stop character. That breaks cells containing string-literal pipes like `('a'|'b')` (Python type-union examples) — they would still mis-split. Dropped the apostrophe-stop.

2. The literal `}` inside the regex character classes confused the brace-counting extractFunc driver in tests/test_renderer_js_behaviour.py, breaking all 45 existing node-driven renderer tests. Rewrote both brace literals as hex escapes (\\x7b and \\x7d) — semantically identical at the regex-engine level but the JS source carries no bare brace glyph.

Also added tests/test_issue2428_table_pipe_protection.py with 9 regression tests covering single-pipe, multi-pipe-in-brackets, apostrophes-with-pipes, and the KaTeX \$...\$ guard.
2026-05-17 03:41:43 +00:00
nesquena-hermes
e9c6b7f06c Stage 375: PR #2432 — feat(theme): add Catppuccin appearance skin (Latte + Mocha palettes) by @Michaelyklam (closes #2426)
Co-authored-by: Michael Lam <michael@example.local>
2026-05-17 03:35:19 +00:00
nesquena-hermes
1793aa8c85 Stage 375: PR #2428 — fix(renderer): protect pipes inside brackets from table cell split + KaTeX dollar in tables by @bengdan
Co-authored-by: manji <manji@local>
2026-05-17 03:35:19 +00:00
nesquena-hermes
c45895d6d7 Merge pull request #2433 from nesquena/stage-374
Release BE: stage-374 (v0.51.81) — 6-PR batch — cost-history POSIX lock + prompt-cache tokens + Plugins panel i18n + pending placeholder + journal partial recovery + RuntimeAdapter Slice 2
2026-05-16 20:12:54 -07:00
nesquena-hermes
c7365548b7 Stamp CHANGELOG for v0.51.81 (Release BE / stage-374) 2026-05-17 03:10:09 +00:00
nesquena-hermes
b78c739662 Stage 374: assemble unified CHANGELOG for v0.51.81 2026-05-17 02:50:19 +00:00
nesquena-hermes
9fec727cf7 Stage 374: PR #2424 — feat(runtime): add default-off RuntimeAdapter seam (HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal) by @Michaelyklam (refs #1925)
Co-authored-by: Michael Lam <michael@example.local>
2026-05-17 02:49:35 +00:00
nesquena-hermes
8f98465024 Stage 374: PR #2427 — fix(streaming): recover journaled partial assistant output after WebUI restart by @franksong2702 (fixes #2423)
Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-05-17 02:49:35 +00:00
nesquena-hermes
a33cd4195b Stage 374: PR #2431 — fix(chat): render pending assistant placeholder before /api/chat/start returns by @Michaelyklam (fixes #2429)
Co-authored-by: Michael Lam <michael@example.local>
2026-05-17 02:49:35 +00:00
nesquena-hermes
13265b7085 Stage 374: PR #2425 — i18n: add internationalization for Settings Plugins panel by @mccxj
Co-authored-by: BonyFish <mc02cxj@gmail.com>
2026-05-17 02:49:34 +00:00
nesquena-hermes
47c210899e Stage 374: PR #2421 — fix(cache-tokens): surface provider prompt-cache read/write tokens in WebUI usage by @Michaelyklam (fixes #2419)
Co-authored-by: Michael Lam <michael@example.local>
2026-05-17 02:49:34 +00:00
nesquena-hermes
b3bf2347e4 Stage 374: PR #2418 — fix(cost-history): provider-specific POSIX file lock for snapshot updates by @Michaelyklam (fixes #2402)
Co-authored-by: Michael Lam <michael@example.local>
2026-05-17 02:49:15 +00:00
nesquena-hermes
a03cd1312a Merge pull request #2416 from Michaelyklam/docs/issue-1925-runtime-adapter-seam
docs: define runtime adapter seam contract
2026-05-16 17:43:40 -07:00
Michael Lam
b985ca54dc docs: define runtime adapter seam contract 2026-05-17 00:41:20 +00:00
nesquena-hermes
d624d7f6b3 Merge pull request #2420 from nesquena/stage-373
Release BD / v0.51.80 — stage-373 (2-PR review-bypass batch: provider config flag filter + stale compaction greeting heuristic)
2026-05-16 17:36:42 -07:00
nesquena-hermes
ae83b61185 Stamp CHANGELOG for v0.51.80 (Release BD / stage-373) 2026-05-17 00:23:04 +00:00
nesquena-hermes
8a950cfbdd Stage 373: PR #2417 — fix(streaming): stale compaction task resume on fresh greetings (closes #2308, supersedes #2309)
Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-05-17 00:22:22 +00:00
nesquena-hermes
54f1a2acae Stage 373: PR #2415 — fix: ignore provider config flags in model picker by @Michaelyklam (fixes #2399) 2026-05-17 00:21:50 +00:00
nesquena-hermes
96021e7fc5 Merge pull request #2414 from nesquena/stage-372
Release BC / v0.51.79 — stage-372 (5-PR review-bypass batch: text-mode image fix + Activity-group compression + custom provider routing + quota chip toggle + RFC docs)
2026-05-16 16:12:43 -07:00
nesquena-hermes
0baefcd1e5 Stamp CHANGELOG for v0.51.79 (Release BC / stage-372) 2026-05-16 23:05:53 +00:00
nesquena-hermes
3480e75e13 Stage 372: PR #2413 — feat(quota-chip): add Settings toggle, flip default to off 2026-05-16 23:05:09 +00:00
nesquena-hermes
780bddca04 Stage 372: PR #2411 — fix: preserve named custom provider model selections by @Michaelyklam (fixes #2405) 2026-05-16 23:04:53 +00:00
nesquena-hermes
862d32ce27 Stage 372: PR #2408 — fix: split live Activity groups at auto-compression by @Michaelyklam (fixes #2404) 2026-05-16 23:04:18 +00:00
nesquena-hermes
9abf17b4f4 Stage 372: PR #2406 — fix: sanitize sync chat history with config by @Michaelyklam (fixes #2398) 2026-05-16 23:03:38 +00:00
nesquena-hermes
55f2d6792b Stage 372: PR #2407 — docs: advance runtime adapter gate by @Michaelyklam 2026-05-16 23:02:42 +00:00
nesquena-hermes
cef6b1b5f0 Merge pull request #2412 from nesquena/stage-371
Release BB / v0.51.78 — stage-371 (stuck-PR sweep salvage: RTL chat + ambient quota chip)
2026-05-16 15:27:07 -07:00
nesquena-hermes
cf23d47e2d fix(stage-371): apply Opus SHOULD-FIX — KaTeX/CSV/diff stay LTR + chip 1px boundary
Opus advisor on stage-371 caught three issues during pre-release review:

1. RTL salvage missed KaTeX math (display equations + inline LaTeX), diff
   blocks, CSV tables (column order must read left-to-right regardless of
   chat direction), and .skill-file-path. The first salvage commit only
   covered pre/code/kbd/samp/tt and tool-call bodies. Added a second
   force-LTR block covering: .katex, .katex-block, .katex-display,
   .katex-html, .katex-inline, .diff-block (+children), .csv-table-wrap,
   .csv-table (+children), .skill-file-path. Severity: KaTeX is the most
   user-visible gap — any user rendering math under RTL would see flipped
   equations.

2. Quota chip @media (max-width:1400px) hide rule conflicted at exactly
   1400px with the existing @media (min-width:1400px) .messages-inner
   rule — chip was hidden AT the wide-desktop boundary where it should
   first appear. Changed to (max-width:1399.98px). Visually verified at
   1400px: chip now correctly visible there.

3. Dead .icon-btn.provider-quota-chip selector — chip never has icon-btn
   class. Removed.

Test added: test_rtl_math_and_tables_stay_ltr (pins the 4 new LTR
surfaces). Also removed dead code in test_rtl_code_blocks_stay_ltr
(unused code_block variable).

Per stage-fix protocol: SHIP-with-followup applied on the stage rather
than the source PR, since #2409 is already merged-into-stage and
nesquena-approved. Stage-371 review-bypass batch path still holds.
2026-05-16 22:22:38 +00:00
nesquena-hermes
e3eda7e3df Stamp CHANGELOG for v0.51.78 (Release BB / stage-371) 2026-05-16 22:07:43 +00:00
nesquena-hermes
a4ab7d4d27 Stage 371: PR #2409 — Stuck-PR sweep: salvage RTL chat from #1721 + override quota chip from #2082 by @malulian and @ai-ag2026
Co-authored-by: malulian <malulian@users.noreply.github.com>
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-16 22:04:56 +00:00
nesquena-hermes
125da1e65a Merge pull request #2401 from nesquena/stage-370
v0.51.77 — Release BA (stage-370: PR #2390 live Activity grouping fix)
2026-05-16 13:34:52 -07:00
Hermes Agent
a5f429c28f Stamp CHANGELOG for v0.51.77 (Release BA / stage-370) 2026-05-16 20:22:33 +00:00
Hermes Agent
b937cf3583 Stage 370: PR #2390 — Fix live progress Activity grouping by @franksong2702
# Conflicts:
#	CHANGELOG.md
2026-05-16 20:21:58 +00:00
nesquena-hermes
12b0b6dab3 Merge pull request #2400 from nesquena/stage-369
v0.51.76 — Release AZ (stage-369: 4-PR safe-lane batch)
2026-05-16 13:16:43 -07:00
Hermes Agent
069503f0bf fix(stage-369): replace 'PR TBD' placeholder with #2347 in CHANGELOG
Opus advisor caught this on stage-369 review — PR #2347 left a 'PR TBD'
placeholder in CHANGELOG that should reference its own number. One-line
attribution fix, no behavior change.
2026-05-16 20:11:43 +00:00
Hermes Agent
9441e32adb test(stage-369): widen brittle setCompressionUi({ assertion
PR #2347 hoisted the inline state object to a `state` variable so the
auto-compression handler could share it with appendLiveCompressionCard.
Behavior is identical — same setCompressionUi() dispatch, same calm
compression-card path — but tests/test_run_journal_frontend_static.py
pinned the literal substring `setCompressionUi({` to verify the call
site. Relax the assertion to accept either inline (`{...}`) or hoisted
(`state`) argument form. Both forms route through the same compression
card path; the over-specific substring was the bug.
2026-05-16 20:11:06 +00:00
Hermes Agent
ade7401ae1 Stage 369: PR #2396 — fix(streaming): preserve session agents for credential pools by @starship-s 2026-05-16 20:03:44 +00:00
Hermes Agent
41f8b77a0b Stage 369: PR #2393 — fix: cap live chat stream transports by @Michaelyklam
# Conflicts:
#	CHANGELOG.md
2026-05-16 20:03:44 +00:00
Hermes Agent
757f63ee27 Stage 369: PR #2195 — Add OpenRouter cost history backend by @Michaelyklam 2026-05-16 20:03:43 +00:00
Hermes Agent
8b4f8b0600 Stage 369: PR #2347 — Preserve live agent timeline across session switches by @franksong2702 2026-05-16 20:03:43 +00:00
nesquena-hermes
3de4338610 Merge pull request #2397 from nesquena/stage-368
v0.51.75 — Release AY (stage-368: 11-PR safe-lane batch + pytest-loop fix)
2026-05-16 12:39:38 -07:00
Hermes Agent
20bd845416 fix(tests): permanent os.execv guard to stop pytest self-restart loop
api.updates._schedule_restart() spawns a daemon thread that calls
os.execv() after a short sleep. Tests in test_update_banner_fixes.py
monkeypatch os.execv to a no-op, but monkeypatch teardown can win the
race against the daemon thread — when the thread wakes up after
teardown, the real os.execv is back, and it re-execs pytest with the
original argv. From the outside this looked like pytest hanging at 99%
and then restarting the entire suite from 0% in a loop.

The fix shadows os.execv with a permanent no-op wrapper at conftest
module-import time, so late-firing daemon threads can't escape. Tests
that need to verify execv was called still patch it themselves; their
patches sit on top of the wrapper for their lifetime.

Also adds tests/test_pytest_execv_guard.py to pin the guard against
future conftest refactors.
2026-05-16 19:32:49 +00:00
dobby-d-elf
b13ed26f5b Merge branch 'master' into fix/session-list-touch-actions 2026-05-16 13:08:37 -06:00
starship-s
727e3c9c8f fix(streaming): preserve session agents for credential pools 2026-05-16 13:05:25 -06:00
Michael Lam
0b64e21264 fix: cap live chat stream transports 2026-05-16 10:27:28 -07:00
Hermes Agent
8f4d6c1431 Stage 368: PR #2391 — fix: reduce browser storage pressure by @Michaelyklam 2026-05-16 17:19:08 +00:00
Hermes Agent
89be2ef458 Stage 368: PR #2387 — fix: guard localStorage quota writes by @Michaelyklam 2026-05-16 17:19:08 +00:00
Hermes Agent
bcf44e586a Stage 368: PR #2377 — Add run-journal replay timeline parity checks by @franksong2702 2026-05-16 17:19:07 +00:00
Hermes Agent
a1da489770 Stage 368: PR #2368 — fix: hybridize background profile env routing by @Michaelyklam 2026-05-16 17:19:07 +00:00
Hermes Agent
57199f16ec Stage 368: PR #2382 — fix: serve raw chat attachments from inbox by @Michaelyklam 2026-05-16 17:19:06 +00:00
Hermes Agent
026a9957f4 Stage 368: PR #2385 — Keep fuller compression snapshots reachable in sidebar by @franksong2702 2026-05-16 17:19:05 +00:00
Hermes Agent
e994c8820e Stage 368: PR #2371 — Clarify interrupted turn recovery after WebUI restart by @franksong2702 2026-05-16 17:19:05 +00:00
Hermes Agent
8a6457c60f Stage 368: PR #2378 — fix: strip historical images in text mode by @Michaelyklam 2026-05-16 17:19:05 +00:00
Hermes Agent
a1d048af29 Stage 368: PR #2375 — fix: keep markdown tables block-level by @Michaelyklam 2026-05-16 17:19:04 +00:00
Hermes Agent
1775e5c054 Stage 368: PR #2372 — fix: add i18n support to settings conversation page action buttons by @mccxj 2026-05-16 17:18:27 +00:00
Hermes Agent
5a7ea025df Stage 368: PR #2381 — fix: interpolate German session time labels by @Michaelyklam 2026-05-16 17:18:26 +00:00
Michael Lam
48b82d57db fix: reduce browser storage pressure 2026-05-16 08:44:04 -07:00
Frank Song
2dfe3ffb42 Fix live progress activity grouping 2026-05-16 23:37:44 +08:00
Michael Lam
3bb8c7b276 fix: guard localStorage quota writes 2026-05-16 07:31:44 -07:00
Frank Song
4899ae17b9 Keep fuller compression snapshots reachable 2026-05-16 20:58:44 +08:00
Michael Lam
e4dad1c25d fix: serve raw chat attachments from inbox 2026-05-16 05:13:58 -07:00
Frank Song
c415c843df Update interrupted recovery comment wording 2026-05-16 20:05:47 +08:00
Frank Song
11796fe7a8 Avoid magic cursor-loop test window 2026-05-16 20:05:19 +08:00
Michael Lam
2284d42695 fix: interpolate German session time labels 2026-05-16 04:31:48 -07:00
Michael Lam
962b3840e6 fix: strip historical images in text mode 2026-05-16 03:55:12 -07:00
Frank Song
574ee36460 Add run journal replay timeline parity checks 2026-05-16 17:52:35 +08:00
Michael Lam
3cbe206832 fix: keep markdown tables block-level 2026-05-16 02:12:52 -07:00
Michael Lam
5bd1f1441d fix: route background worker credentials through profile env 2026-05-16 01:02:19 -07:00
BonyFish
f87e32d0c7 fix: add i18n support to settings conversation page action buttons
- Add data-i18n attributes to JSON, Import tooltip, and Clear button
- Reuse existing 'clear' and 'import' i18n keys for button labels
- Add 'export_session_json', 'export_session_json_tooltip',
  'import_session_json_tooltip', 'clear_conversation_btn_tooltip'
  keys to all 11 locales (en, it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr)
2026-05-16 14:57:01 +08:00
Frank Song
49bea3ad01 Clarify interrupted turn recovery marker 2026-05-16 14:29:58 +08:00
Michael Lam
9894157120 fix: hybridize background profile env routing 2026-05-15 23:19:37 -07:00
Frank Song
e7e45fe98b Stamp live assistant turns at creation 2026-05-16 14:12:08 +08:00
Frank Song
240fc42ad8 Align pending-user reattach test with live snapshot restore 2026-05-16 14:12:08 +08:00
Frank Song
cdef039613 Update inflight restore static test windows 2026-05-16 14:12:08 +08:00
Frank Song
10db8b3bb6 Preserve base Thinking card markup for animation tests 2026-05-16 14:12:08 +08:00
Frank Song
faedcab739 Preserve live agent timeline across session switches 2026-05-16 14:12:08 +08:00
nesquena-hermes
e3035b3e40 Merge pull request #2369 from nesquena/stage-367
Release v0.51.74 (stage-367) — 4-PR safe-lane batch with first-timer contributions
2026-05-15 22:58:20 -07:00
Hermes Agent
8303d59acd stage-367: stamp CHANGELOG v0.51.74 (4-PR safe-lane batch)
v0.51.74 — Release AX:
- PR #2362 (fixes #2360) — Markdown table cell paragraph spacing
- PR #2363 (refs #2361 + #1925) — Run state consistency RFC docs
- PR #2365 (fixes #1106) — custom_providers.models YAML list format
- PR #2367 — Settings sidebar i18n (with maintainer locale-parity fix)

None of the PRs touched CHANGELOG.md, so adding the canonical entries
manually with proper PR # attribution and issue refs.

Stage-367 maintainer fix on #2367: CI failed on 5 locale-parity tests
because the PR added settings_tab_plugins to English only. Added
translations to all 10 non-English locales (commit ddf8eb5d on the
PR's integration branch).
2026-05-16 05:45:01 +00:00
Hermes Agent
b8d2fca52f Stage 367: PR #2367 — fix: add i18n support to settings sidebar menu items by @mccxj 2026-05-16 05:43:27 +00:00
Hermes Agent
fcc9329ae5 Stage 367: PR #2365 — Fix: support YAML list format for custom_providers.models in model dropdown by @mccxj 2026-05-16 05:43:27 +00:00
Hermes Agent
bf78cebb55 Stage 367: PR #2363 — Document WebUI run state consistency contract by @franksong2702 2026-05-16 05:43:26 +00:00
Hermes Agent
031331662f Stage 367: PR #2362 — Fix Markdown table cell paragraph spacing by @franksong2702 2026-05-16 05:43:25 +00:00
BonyFish
b48e44a24a Add tests for list-format custom_providers.models
Covers 9 scenarios: plain string list, dict list, unnamed provider,
mixed formats, dedup, empty list, fallback keys, @-prefix routing,
and mixed list/dict items from multiple providers.
2026-05-16 05:43:09 +00:00
BonyFish
f82a763dfb fix: support list format for custom_providers.models in model dropdown
The get_available_models() function only handled dict-format models
(`{model_id: {}}`) for custom_providers entries, silently dropping
models specified as YAML lists (`[model1, model2]`) or list of dicts
(`[{id: ..., label: ...}]`).

This caused users who define their custom providers with list-format
model declarations to see zero or incomplete model entries in both
Settings → Preferences → Default Model dropdown and the chat
interface model picker.

The fix adds an `elif isinstance(_cp_models_dict, list)` branch with
support for three list sub-formats:
  - Plain string list: `models: [m1, m2]`
  - Dict list: `models: [{id: m1, label: ...}]`
  - Mixed: `models: [m1, {id: m2}]`

Refs: hermes-agent issue where YAML list models were invisible
2026-05-16 05:43:09 +00:00
Frank Song
58a43d7abd Document WebUI run state consistency contract 2026-05-16 05:43:06 +00:00
Frank Song
80be1d08dc Fix Markdown table cell paragraph spacing 2026-05-16 05:43:04 +00:00
Hermes Agent
ddf8eb5d54 stage-367: locale-parity fix for settings_tab_plugins (10 locales)
PR #2367 added settings_tab_plugins to English only. The locale-parity
tests (test_chinese_locale.py, test_japanese_locale.py, etc.) require
every English key to exist in all 10 other locales. CI failed on 5 of them.

Adds the key to all 10 non-English locales with translations:
- it: Plugin, ja: プラグイン, ru: Плагины, es/de/pt/fr: Plugins (loanword),
  zh: 插件, zh-TW: 外掛, ko: 플러그인

Co-authored-by: mccxj <mccxj@users.noreply.github.com>
2026-05-16 05:42:53 +00:00
BonyFish
9593380899 fix: add i18n support to settings sidebar menu items
Adds data-i18n attributes to all settings sidebar menu items
(Conversation, Appearance, Preferences, Plugins, System) so they
respect the user's selected locale.

Also adds missing settings_tab_plugins key to English locale.
2026-05-16 13:09:09 +08:00
nesquena-hermes
59ffd573ae Merge pull request #2366 from nesquena/stage-366
Release v0.51.73 (stage-366) — 1-PR compression card anchoring fix
2026-05-15 21:46:18 -07:00
Michael Lam
15513b81f4 fix: harden OpenRouter cost snapshots 2026-05-15 21:28:54 -07:00
Michael Lam
690b666887 Add OpenRouter cost history backend 2026-05-15 21:28:54 -07:00
Hermes Agent
54f680a8ef stage-366: stamp CHANGELOG v0.51.73 (1-PR safe-lane batch)
v0.51.73 — Release AW:
- PR #2357 (fixes #2355) — Compression reference card anchoring fix

The contributor's CHANGELOG entry was wiped during the --ours rebase
conflict resolution (because v0.51.72 stamping moved Unreleased entries
elsewhere). Added the canonical PR-numbered entry manually.

Stage-366 carries only #2357. Two other recent PRs were deferred:
- #2347 (live timeline restore, 380 LOC) — real semantic conflicts with
  v0.51.72's #2348 (_stripLiveVisibleAssistantEchoFromThinking +
  visibleInterimSnippets logic). Needs contributor rebase against current
  master.
- #2356 (mobile panel touch targets, 98 LOC) — incompatible architectural
  conflicts in static/style.css (.sidebar-nav vs .rail layout choice).
  Needs contributor redesign against current sidebar-nav layout.
2026-05-16 04:27:05 +00:00
Hermes Agent
c24eb4e648 Stage 366: PR #2357 — Fix compression reference card anchoring by @franksong2702 2026-05-16 04:26:08 +00:00
Frank Song
433ad29939 Fix compression reference card anchoring 2026-05-16 04:25:22 +00:00
nesquena-hermes
a08dfffd58 Merge pull request #2358 from nesquena/stage-365
Release v0.51.72 (stage-365) — 2-PR safe-lane batch
2026-05-15 21:23:09 -07:00
Hermes Agent
7516c9591f stage-365: stamp CHANGELOG v0.51.72 (2-PR safe-lane batch)
v0.51.72 — Release AV:
- PR #2354 (fixes #2353) — Recovered pending turn context fix
- PR #2348 (fixes #2346) — Thinking card interim-text echo suppression

Hit Pitfall 6 again: contributor branches predated v0.51.70+71 so their
CHANGELOG entries landed in pre-existing v0.51.68/69 sections after rebase.
Manually moved entries to a new v0.51.72 section above v0.51.71, with
proper PR # attribution (#2348 was 'PR TBD' on the contributor branch).
2026-05-16 04:17:51 +00:00
Hermes Agent
4dac7940e7 Stage 365: PR #2348 — Suppress interim text echoes in Thinking cards by @franksong2702 2026-05-16 04:08:42 +00:00
Hermes Agent
2811ba8fd0 Stage 365: PR #2354 — Keep recovered pending turns in model context by @franksong2702 2026-05-16 04:08:42 +00:00
Frank Song
d94320b4bf Avoid duplicate Thinking echo helper names 2026-05-16 04:07:03 +00:00
Frank Song
dd5f3ff9b5 Suppress interim text echoes in Thinking cards 2026-05-16 04:07:03 +00:00
Frank Song
40f69a2b75 Keep recovered pending turns in context 2026-05-16 04:07:02 +00:00
nesquena-hermes
761cf550de Merge pull request #2352 from nesquena/stage-364
Release v0.51.71 (stage-364) — 3-PR batch with Opus-caught replay fix
2026-05-15 21:01:28 -07:00
Hermes Agent
b293bf8bc5 stage-364: Opus-caught live SSE event_id fix (side-channel approach)
Replace the earlier frontend-reset approach with a backend side-channel
approach that preserves the queue (event, data) tuple shape.

Problem (Opus catch):
- Live SSE frames emitted by _sse() in api/streaming.py:2296 carried no
  'id:' field. Only journal-replay frames (via _sse_with_id) emitted IDs.
- Frontend's _lastRunJournalSeq cursor stayed at 0 during live streaming.
- Mid-stream error → reconnect-to-replay arrived with after_seq=0.
- Server replayed every journaled event from seq 1.
- assistantText (closure-scoped) had accumulated all live tokens already
  → double-rendered output.

Fix:
- api/config.py: STREAM_LAST_EVENT_ID: dict = {} module-level dict.
- api/streaming.py put(): capture journal event_id, write to
  STREAM_LAST_EVENT_ID[stream_id]. Keep queue tuple as (event, data).
- api/routes.py _handle_sse_stream: read STREAM_LAST_EVENT_ID[stream_id]
  at emit time, use _sse_with_id when set.
- api/streaming.py finally block: pop STREAM_LAST_EVENT_ID for cleanup.

Why side-channel instead of 3-tuple:
- Earlier attempt (queue tuple → (event, data, event_id)) broke 4 existing
  tests: test_cancel_interrupt, test_sprint42, test_sprint51,
  test_issue1857_usage_overwrite. These all unpack 'event, data = q.get()'.
- Frontend-reset approach (reset assistantText before replay) broke 3
  other tests: test_smooth_text_fade, test_streaming_markdown,
  test_streaming_race_fix. _wireSSE must NOT reset accumulators because
  legacy reconnect doesn't replay events; only journal-replay does.

Side-channel preserves both invariants:
- Queue contract stays (event, data) — legacy consumers unbroken.
- Frontend accumulators stay alive on _wireSSE — legacy reconnect unbroken.
- Live SSE emits 'id:' so the journal cursor advances correctly.

6 regression tests added in test_stage364_opus_live_sse_event_id.py.
1 existing test (test_run_journal_streaming_static.test_streaming_journals_sse_events_before_queue_delivery) updated to be tuple-shape-agnostic.

Test results:
- Full pytest: 5713 passed, 10 skipped, 1 xfailed, 2 xpassed, 0 failed
- Previously-failing 5 tests: ALL PASS
- 6 new regression tests: ALL PASS
2026-05-16 03:58:54 +00:00
Hermes Agent
f3b0c2cb5f stage-364: Opus-caught replay double-render fix + regression test
Opus advisor caught that the new run-journal replay path could double-render
when the live stream errors mid-stream:

- Live SSE frames emitted by _sse() in api/streaming.py:2296 carry no 'id:'
  field. Only _sse_with_id() (used in _replay_run_journal at routes.py:5853)
  emits IDs.
- During live streaming, EventSource.lastEventId stays empty, so the frontend's
  _lastRunJournalSeq stays at 0.
- If the server dies mid-stream, the error reconnect handler opens replay with
  after_seq=0 — server replays every journaled event from seq 1.
- assistantText accumulator (closure scope in messages.js) carries over from
  the live phase. The token handler unconditionally appends d.text. Double-
  rendered text.

Fix: reset assistantText, reasoningText, liveReasoningText, segmentStart, and
set _smdReconnect=true before opening the replay EventSource. Next live token
clears assistantBody.innerHTML to match the reset accumulator.

4 regression tests added in test_stage364_opus_replay_doublerender_fix.py.
Revert-fix verification confirms 3/4 tests fail against reverted code.

This is the TWO-LAYER catch in action: agent self-verified the producer→
consumer chain works end-to-end (Step 3 in agent-side-empirical-verification.md
PASSED for #2283), and Opus independently caught a separate frontend coupling
issue. Both checks required and both fire.
2026-05-16 03:28:07 +00:00
Hermes Agent
a204c75b54 stage-364: stamp CHANGELOG v0.51.71 (3-PR batch)
v0.51.71 — Release AU:
- PR #2349 (fixes #2345) — Stale-stream cleanup non-touching of updated_at
- PR #2343 (refs #2147) — Profiles vs workspaces help card
- PR #2283 (refs #1925) — WebUI run event journal replay (RFC slice 1)

Also relabeled #2283's CHANGELOG entry to add proper PR #2283 attribution
(it had been added without the PR number prefix during the contributor PR),
and #2349's 'PR TBD' placeholder filled in.
2026-05-16 03:19:58 +00:00
Hermes Agent
c0662a4624 Stage 364: PR #2283 — Add WebUI run event journal replay by @franksong2702 2026-05-16 03:08:35 +00:00
Hermes Agent
e38a4260eb Stage 364: PR #2343 — feat: clarify profiles and workspaces by @Michaelyklam 2026-05-16 03:08:34 +00:00
Hermes Agent
21d9b90bdc Stage 364: PR #2349 — Avoid touching updated_at during stale stream cleanup by @franksong2702 2026-05-16 03:08:33 +00:00
Frank Song
a3eace4380 Preserve SSE handler test shape 2026-05-16 02:58:34 +00:00
Frank Song
80d8d4b42c Address run journal replay review 2026-05-16 02:58:34 +00:00
Frank Song
3b96035af0 Add WebUI run event journal replay 2026-05-16 02:58:34 +00:00
Michael Lam
b373f090bd feat: clarify profiles and workspaces 2026-05-16 02:58:33 +00:00
Frank Song
685b075dd0 Avoid touching updated_at during stale stream cleanup 2026-05-16 02:58:33 +00:00
nesquena-hermes
dbd2c79891 Merge pull request #2350 from nesquena/stage-363
Release v0.51.70 (stage-363) — 4-PR snapshot+journal+UI batch
2026-05-15 19:56:41 -07:00
Hermes Agent
15100344f6 stage-363: stamp CHANGELOG v0.51.70 + retroactive v0.51.69 section
v0.51.70 — Release AS (this batch):
- PR #2337 (compression snapshot runtime-clear branch 2)
- PR #2334 (turn-journal fcntl lock)
- PR #2342 (INFLIGHT reattach pending user row)
- PR #2339 (workspace panel edge reopen toggle)

v0.51.69 — Release AT (retroactive — these PRs shipped at v0.51.69
tag yesterday but were never moved out of Unreleased at release time;
restoring proper attribution):
- PR #2332, #2333, #2322, #2326, #2327, #2328, #2330, #2331

CHANGELOG drift detected via Pitfall 6 in test-augmentation pitfalls
doc — Unreleased section contained 8 orphan PRs that shipped at the
v0.51.69 tag but were never sectioned correctly. Retroactively splicing
the v0.51.69 header to attribute them properly so future release notes
don't mis-attribute work to v0.51.70.
2026-05-16 02:49:34 +00:00
Hermes Agent
8399a1ade4 Stage 363: PR #2339 — feat: add workspace panel edge reopen toggle by @Michaelyklam 2026-05-16 02:39:23 +00:00
Hermes Agent
2f633ae32e Stage 363: PR #2342 — Fix pending user row on active reattach by @franksong2702 2026-05-16 02:39:23 +00:00
Hermes Agent
0d70ed9e26 Stage 363: PR #2334 — fix: lock turn journal appends by @Michaelyklam 2026-05-16 02:38:53 +00:00
Hermes Agent
9c6ebea393 Stage 363: PR #2337 — fix: clear runtime fields on loaded compression snapshots by @Michaelyklam 2026-05-16 02:38:52 +00:00
Frank Song
2162dab4a5 Fix pending user row on active reattach 2026-05-16 10:34:55 +08:00
Michael Lam
4d613e723f feat: add workspace panel edge reopen toggle 2026-05-15 18:33:27 -07:00
Michael Lam
0e91f89ce3 fix: clear runtime fields on loaded compression snapshots 2026-05-15 17:55:35 -07:00
Michael Lam
4dbf6712dc fix: lock turn journal appends 2026-05-15 16:39:45 -07:00
nesquena-hermes
352064eb93 Merge pull request #2335 from nesquena/stage-362
stage-362: 8-PR follow-up batch — Ollama routing + legacy toolset + cancel copy + cleanup + custom provider mismatch + cron metadata + dead-code removal (with #2323 revert after Opus-caught silent regression)
2026-05-15 16:27:05 -07:00
Hermes Agent
b7859cf98d stage-362: revert #2323 — Opus caught silent regression in profile routing 2026-05-15 23:23:18 +00:00
Hermes Agent
2161b81da2 Revert "Merge pull request #2323 into stage-362"
This reverts commit 0651fe13b0, reversing
changes made to 1e6ce3d2e7.
2026-05-15 23:22:44 +00:00
Hermes Agent
013a158fd1 docs: CHANGELOG stage-362 — close v0.51.68 (stage-361), open Unreleased for 9-PR stage-362 batch with proper attribution 2026-05-15 22:57:56 +00:00
Hermes Agent
0651fe13b0 Merge pull request #2323 into stage-362
fix: isolate background worker profile env (Michaelyklam, closes #2321)

# Conflicts:
#	CHANGELOG.md
2026-05-15 22:55:39 +00:00
Hermes Agent
1e6ce3d2e7 Merge pull request #2328 into stage-362
fix: clean session attachment and stream recovery leftovers (Michaelyklam, closes #2325)
2026-05-15 22:55:37 +00:00
Hermes Agent
59f73ba45a Merge pull request #2332 into stage-362
feat: show cron output usage metadata (Michaelyklam)
2026-05-15 22:55:36 +00:00
Hermes Agent
62e4d9b2f5 Merge pull request #2327 into stage-362
fix: use assistant name in cancel copy (dotBeeps)
2026-05-15 22:55:35 +00:00
Hermes Agent
300571936c Merge pull request #2331 into stage-362
fix: show readable live activity progress (Michaelyklam)

# Conflicts:
#	CHANGELOG.md
2026-05-15 22:55:33 +00:00
Hermes Agent
94be64b62c Merge pull request #2330 into stage-362
fix: skip provider mismatch for named custom providers (Michaelyklam)
2026-05-15 22:55:32 +00:00
Hermes Agent
5ab2ebed2e Merge pull request #2322 into stage-362
fix: route endpoint-discovered Ollama models correctly (Michaelyklam)
2026-05-15 22:55:30 +00:00
Hermes Agent
8b6673a3d1 Merge pull request #2326 into stage-362
fix: expand legacy Hermes CLI toolset alias (Michaelyklam, closes #2232)
2026-05-15 22:55:29 +00:00
Hermes Agent
bc72a7b29f Merge pull request #2333 into stage-362
test: retarget compression snapshot runtime regression (Michaelyklam, closes #2312 item #1)
2026-05-15 22:55:28 +00:00
Michael Lam
6799ec56cf test: retarget compression snapshot runtime regression 2026-05-15 15:29:28 -07:00
Michael Lam
8186577c7b feat: show cron output usage metadata 2026-05-15 14:54:20 -07:00
Michael Lam
2e039ab7f7 fix: show readable live activity progress 2026-05-15 14:11:58 -07:00
Michael Lam
5a2137bedb fix: skip provider mismatch for named custom providers 2026-05-15 13:50:18 -07:00
Michael Lam
c991f36021 fix: clean session attachment and stream recovery leftovers 2026-05-15 13:30:46 -07:00
Michael Lam
2fdc1d99e2 fix: expand legacy Hermes CLI toolset alias 2026-05-15 13:08:22 -07:00
dot 🐶
3add6f450f fix: use assistant name in cancel copy
Replace the hardcoded Skyly cancellation wording with the configured bot_name from settings, falling back to Hermes when unset.

Keep the client-side fallback in sync by using window._botName if the session refresh after cancellation fails.

Co-authored-by: Obryn 🐉 <obryn-ai@dotbeeps.dev>
2026-05-15 16:00:30 -04:00
nesquena-hermes
7ac4bf4f4a Merge pull request #2324 from nesquena/stage-361
stage-361: 4-PR follow-up batch — profile skill seeding + theme fallback + mobile stream defer + chat upload relocation (with vision-model regression fix)
2026-05-15 12:58:51 -07:00
Hermes Agent
29d13953d6 stage-361: apply Opus SHOULD-FIX — allow _attachment_root() in _build_native_multimodal_message 2026-05-15 19:55:34 +00:00
Michael Lam
ff9e20f132 fix: isolate background worker profile env 2026-05-15 12:47:24 -07:00
Hermes Agent
1b6870086d docs: CHANGELOG stage-361 — close v0.51.67 (stage-360), open Unreleased for 4-PR stage-361 batch with proper attribution 2026-05-15 19:18:12 +00:00
Hermes Agent
0a4d9679fc Merge pull request #2319 into stage-361
feat: store chat uploads outside workspace root (Michaelyklam)
2026-05-15 19:17:08 +00:00
Hermes Agent
ab749575df Merge pull request #2318 into stage-361
fix: defer mobile stream errors while tab is hidden (Michaelyklam, closes #2307)

# Conflicts:
#	CHANGELOG.md
2026-05-15 19:17:06 +00:00
Hermes Agent
74ee110b2f Merge pull request #2315 into stage-361
Seed bundled skills for WebUI profile creation (Michaelyklam, closes #2305)

# Conflicts:
#	CHANGELOG.md
2026-05-15 19:17:04 +00:00
Hermes Agent
62413067e6 Merge pull request #2317 into stage-361
fix: preserve explicit light/dark theme fallback (Michaelyklam, refs #2312)
2026-05-15 19:17:03 +00:00
Michael Lam
512c401e8a fix: route endpoint-discovered Ollama models correctly 2026-05-15 12:16:23 -07:00
nesquena-hermes
2170846031 Merge pull request #2320 from nesquena/stage-360
stage-360: 3-PR streaming-lane batch — stream completion recovery + profile-scoped aux routing + workspace panel polish (with _ENV_LOCK architectural fix)
2026-05-15 12:11:59 -07:00
Hermes Agent
a8a27eeb7d stage-360: Opus follow-up — update _ENV_LOCK docstring to reflect narrow-lock semantics
Opus stage-360 review caught that the docstring at api/streaming.py:40-43
said 'around the entire agent run' which is no longer accurate after the
narrow-lock refactor. The lock is now held only briefly for the env-mutation
critical section; the agent runs outside the lock and the finally block
re-acquires to atomically restore env vars.

Docstring now points to both narrow-lock implementations as references:
- _run_agent_streaming at line ~2719 (the original pattern)
- profile_env_for_background_worker at api/profiles.py:715 (added stage-360)
2026-05-15 19:05:37 +00:00
Michael Lam
3ead446f14 feat: store chat uploads outside workspace root 2026-05-15 11:52:23 -07:00
Michael Lam
aeda75271c fix: defer stream errors while mobile tabs are hidden 2026-05-15 11:25:53 -07:00
Hermes Agent
803ab170d8 stage-360: align test_sprint29::TestENVLock with non-reentrant invariant from QA
#2299 added test_env_lock_importable_from_streaming asserting reentrance,
which contradicts the architectural invariant enforced by QA
test_env_lock_is_non_reentrant. The QA test wins because the non-reentrant
property is what makes _ENV_LOCK catch deadlock bugs early.

Updated the new test to assert NON-reentrance to match the actual lock
type (threading.Lock) and the QA invariant.
2026-05-15 17:12:29 +00:00
Hermes Agent
fb0e664a10 stage-360 maintainer fix: narrow _ENV_LOCK to env mutation only in profile_env_for_background_worker
#2299 introduced profile_env_for_background_worker() in api/profiles.py and
changed _ENV_LOCK from threading.Lock() to threading.RLock(). Both changes
were incorrect:

1. RLock masked rather than fixed the underlying deadlock. The QA
   test_env_lock_is_non_reentrant test exists precisely to enforce
   non-reentrance — RLock would let a single thread hold _ENV_LOCK across
   nested critical sections, which hides bugs while still allowing
   different-thread races.

2. The original context manager held _ENV_LOCK for the ENTIRE 'yield'
   duration, meaning the lock was held for the full background worker's
   runtime (title generation, compression, update summary — possibly
   many seconds). That blocked ALL other sessions on _ENV_LOCK, which
   the QA test_third_message_completes runtime test caught as a timeout
   on the third sequential message.

Fix: mirror the narrow-lock pattern from _run_agent_streaming:
  - Acquire _ENV_LOCK only for env mutation (set runtime_env + patch
    skill modules)
  - Release immediately, yield to worker (no lock held)
  - Reacquire in finally to restore env + skill modules

Restored _ENV_LOCK back to threading.Lock(). All 20 QA tests now pass,
including test_third_message_completes (was timing out, now 35s).
2026-05-15 17:11:45 +00:00
Michael Lam
957bffc49e fix: preserve explicit light dark theme fallback 2026-05-15 10:10:34 -07:00
Michael Lam
b2cbf7bb2f Seed bundled skills for WebUI profile creation 2026-05-15 09:29:46 -07:00
Hermes Agent
f1bb73a7f2 docs: CHANGELOG stage-360 — close v0.51.66 (stage-359), open Unreleased for 3-PR stage-360 batch 2026-05-15 16:16:50 +00:00
Hermes Agent
3b05929f1a Merge pull request #2299 into stage-360
Fix profile-scoped auxiliary routing for background workers (starship-s)
2026-05-15 16:15:39 +00:00
Hermes Agent
b2ebbebf01 Merge pull request #2279 into stage-360
Fix WebUI stream completion recovery gaps (franksong2702, closes #2262 + #2168)
2026-05-15 16:15:38 +00:00
Hermes Agent
36d82f1aa1 Merge pull request #2306 into stage-360
Fix iPhone PWA mobile shell and workspace header layout — workspace panel polish + test cleanup (dobby-d-elf, follow-up to stage-359 #2306)
2026-05-15 16:15:36 +00:00
dobby-d-elf
fe1689caef style: remove opacity from .close-preview so X button matches other panel icons 2026-05-15 09:43:18 -06:00
dobby-d-elf
0f86030f5f fix: single close button on workspace panel, tooltip → 'Close'
- Remove duplicate mobile-close-btn from HTML
- Remove dead .mobile-close-btn CSS rules; unhide .close-preview at all viewports
- Change btnClearPreview tooltip from 'Hide workspace panel' to 'Close'
- Update tests across test_sprint41.py, test_sprint44.py, test_issue781.py,
  and test_mobile_layout.py to match new single-button model
2026-05-15 09:43:18 -06:00
dobby-d-elf
acce80a50a fix workspace panel header test syntax 2026-05-15 09:43:18 -06:00
dobby-d-elf
0e9017a665 refine workspace panel header layout 2026-05-15 09:43:18 -06:00
nesquena-hermes
6f22fe567c Merge pull request #2311 from nesquena/stage-359
stage-359: 17-PR safe-lane batch — Docker fixes + UI polish + compression snapshot improvements + i18n parity + profile validation
2026-05-15 08:31:38 -07:00
Hermes Agent
75a2464821 stage-359: apply Opus SHOULD-FIX — symmetric runtime-field clearing on snapshot load-and-mark path 2026-05-15 15:27:24 +00:00
Hermes Agent
4c102eaad5 docs: CHANGELOG stage-359 — close v0.51.65 (stage-358), open Unreleased for 17-PR stage-359 with proper attribution 2026-05-15 15:10:39 +00:00
Hermes Agent
fb8b91019e Merge pull request #2295 into stage-359
fix: clear runtime fields on compression snapshots (ai-ag2026)

# Conflicts:
#	CHANGELOG.md
#	api/streaming.py
2026-05-15 15:06:35 +00:00
Hermes Agent
4826a31fbc Merge pull request #2285 into stage-359
fix: hide pre-compression snapshots from sidebar (dso2ng, refs #2230)

# Conflicts:
#	CHANGELOG.md
2026-05-15 14:55:19 +00:00
Hermes Agent
90fd16e273 Merge pull request #2306 into stage-359
Fix iPhone PWA mobile shell and workspace header layout (dobby-d-elf, regression fix for #2238)
2026-05-15 14:55:17 +00:00
Hermes Agent
d510b8d2d5 Merge pull request #2303 into stage-359
Add assistant question jump buttons (franksong2702, fixes #2246)

# Conflicts:
#	CHANGELOG.md
2026-05-15 14:55:15 +00:00
Hermes Agent
f7d98cdf3f Merge pull request #2301 into stage-359
Add cron detail expansion controls (franksong2702, fixes #2289)
2026-05-15 14:55:13 +00:00
Hermes Agent
b28a132f91 Merge pull request #2296 into stage-359
fix: align offline banner with active theme palette (Jordan-SkyLF)

# Conflicts:
#	CHANGELOG.md
2026-05-15 14:55:12 +00:00
Hermes Agent
ad76db8651 Merge pull request #2291 into stage-359
feat: add Nous Research skin (linuxid10t)
2026-05-15 14:55:10 +00:00
Hermes Agent
8e610ce790 Merge pull request #2288 into stage-359
fix: prevent theme reset on refresh when autosave failed (linuxid10t)
2026-05-15 14:55:09 +00:00
Hermes Agent
49597df554 Merge pull request #2302 into stage-359
Validate profile model selections (franksong2702, refs #749 follow-up)

# Conflicts:
#	CHANGELOG.md
2026-05-15 14:55:08 +00:00
Hermes Agent
4365758a78 Merge pull request #2287 into stage-359
feat: make upload size limit runtime-configurable (mslovy, refs #2284)
2026-05-15 14:55:06 +00:00
Hermes Agent
7d10539852 Merge pull request #2282 into stage-359
Keep unknown update summary bullets (franksong2702, refs #2264)

# Conflicts:
#	CHANGELOG.md
2026-05-15 14:55:05 +00:00
Hermes Agent
e960285d55 Merge pull request #2281 into stage-359
Classify onboarding DNS probe failures consistently (franksong2702, refs #2260)

# Conflicts:
#	CHANGELOG.md
2026-05-15 14:55:03 +00:00
Hermes Agent
32c1e8319c Merge pull request #2280 into stage-359
Fix settings i18n parity coverage (franksong2702, closes #2276)
2026-05-15 14:55:02 +00:00
Hermes Agent
b01fb0e7ff Merge pull request #2275 into stage-359
fix: load CLI continuation session transcripts (ai-ag2026)
2026-05-15 14:55:01 +00:00
Hermes Agent
29db680f0a Merge pull request #2293 into stage-359
Skip agent git objects during Docker chown (franksong2702, refs #2237)
2026-05-15 14:54:59 +00:00
Hermes Agent
4dcf523f0c Merge pull request #2300 into stage-359
Tighten silent failure shrink detection (franksong2702, refs #2240)
2026-05-15 14:54:58 +00:00
Hermes Agent
adbbf0e507 Merge pull request #2277 into stage-359
fix: support rootless container runtimes (eleboucher)
2026-05-15 14:54:56 +00:00
dobby-d-elf
5b2c8c11f1 fix: avoid sticky touch hover in session list 2026-05-15 08:42:37 -06:00
dobby-d-elf
256b9d6294 fix: restore iPhone PWA mobile workspace layout 2026-05-15 08:14:53 -06:00
Frank Song
05a5ed5af1 Add assistant question jump buttons 2026-05-15 19:39:27 +08:00
Frank Song
1fb047b51a Validate profile model selections 2026-05-15 18:36:38 +08:00
starship-s
abb6057304 test(profiles): keep profile module reloads isolated 2026-05-15 04:14:09 -06:00
Frank Song
079d6b4e86 Add cron detail expansion controls 2026-05-15 18:04:53 +08:00
Frank Song
cadcf983d5 Tighten silent failure shrink detection 2026-05-15 18:04:53 +08:00
starship-s
4ffecdd7c9 refactor(profiles): consolidate background profile env 2026-05-15 03:58:40 -06:00
Dennis Soong
eb31b4ed1e test: tighten compression snapshot preservation coverage 2026-05-15 17:31:37 +08:00
starship-s
f38c70415f test(profiles): allow reentrant env lock 2026-05-15 03:09:16 -06:00
starship-s
aa1c7c24f4 fix(profiles): route background aux workers via session profile 2026-05-15 03:02:42 -06:00
Jordan SkyLF
fb869bbeb3 Add offline banner comparison screenshots 2026-05-15 01:51:35 -07:00
Jordan SkyLF
2601d41149 Add offline banner theme screenshots 2026-05-15 01:03:48 -07:00
Jordan SkyLF
b7f8b97e76 Fix offline banner warning theme colors 2026-05-15 00:40:21 -07:00
ai-ag2026
3a4259476d fix: clear runtime fields on compression snapshots 2026-05-15 09:20:19 +02:00
Frank Song
a90c0f6782 Skip agent git objects during Docker chown 2026-05-15 13:50:36 +08:00
linuxid10t
b2d4f13c5b feat: add Nous Research skin
Adds a cold steel-blue/monospace skin inspired by nousresearch.com:
- Steel-blue accent (#4682B4) replacing warm gold
- Monospace typography (SF Mono, Roboto Mono, Courier New)
- Sharp corners, technical dashed borders
- Dark navy palette (#0A0E14) for dark mode

Files changed:
- static/style.css — Nous skin CSS variables and component overrides
- static/boot.js — Nous skin entry in _SKINS array
- static/index.html — nous in inline skin validation list
- api/config.py — nous + sienna in server-side _SETTINGS_SKIN_VALUES
2026-05-15 00:28:34 -05:00
linuxid10t
45fe6294f9 fix: prevent theme reset on refresh when autosave failed
The boot IIFE unconditionally overwrote localStorage with whatever
settings.json had on the server.  If the appearance autosave POST
ever failed (network glitch, transient error) the next page load
would revert the user's chosen theme/skin to the server's stale
defaults.

Fix: reconcile localStorage against the server on boot.  When
localStorage carries a non-default skin or system theme (the user
explicitly chose something), localStorage wins and the fix pushes
those values back to the server.  When localStorage is at defaults
(new browser / first visit), the server still wins.

Tested scenarios:
- User chose non-default skin, autosave failed → preserved + reconciled
- New browser, server has non-default skin → server value applied
- Normal use (autosave works) → unchanged behavior
2026-05-14 23:52:57 -05:00
Yao Ning
b1bf800fa4 feat: make upload size limit runtime-configurable
Signed-off-by: Yao Ning <zay11022@gmail.com>
2026-05-15 11:39:23 +08:00
Dennis Soong
bfccdc5c94 fix: hide pre-compression snapshots from sidebar 2026-05-15 11:20:17 +08:00
Frank Song
86b2acb5e2 Keep unknown update summary bullets 2026-05-15 09:16:39 +08:00
dobby-d-elf
4ff6e25ef4 Merge branch 'master' into fix/session-list-touch-actions 2026-05-14 18:54:40 -06:00
Frank Song
f3fefbc2ab Classify onboarding DNS probe failures consistently 2026-05-15 08:51:30 +08:00
Frank Song
5dddaf0f3d Fix settings i18n parity coverage 2026-05-15 08:51:30 +08:00
Frank Song
5f9b9c02b2 Fix WebUI stream completion recovery gaps 2026-05-15 08:36:48 +08:00
Erwan Leboucher
fd45ed58f7 fix(docker_init): seed /app from /apptoo when started rootless 2026-05-15 00:09:43 +02:00
Erwan Leboucher
9243d0c9d0 fix(Dockerfile): loosen image-baked dir modes for rootless runtimes 2026-05-15 00:09:43 +02:00
ai-ag2026
5110005324 fix: load CLI continuation session transcripts 2026-05-14 23:48:49 +02:00
nesquena-hermes
5e518b1c10 Merge pull request #2274 from nesquena/stage-358
stage-358: 2-PR held-PR clearance batch — #2099 opt-in streaming text fade + #2165 pooled Codex quota status
2026-05-14 14:48:31 -07:00
Hermes Agent
d5d5118cfb docs: CHANGELOG stage-358 — close v0.51.64 (stage-357), open Unreleased for 2-PR stage-358 (#2099 opt-in streaming text fade + #2165 pooled Codex quota status) 2026-05-14 21:31:50 +00:00
Hermes Agent
ec689e32be Merge pull request #2099 into stage-358
feat: add opt-in streaming text fade (dobby-d-elf, off-by-default)
2026-05-14 21:27:52 +00:00
Hermes Agent
612480ce56 Merge pull request #2165 into stage-358
feat(providers): show pooled Codex quota status (starship-s, post-review follow-up)
2026-05-14 21:27:51 +00:00
nesquena-hermes
53825eb4e0 Merge pull request #2273 from nesquena/stage-357
stage-357: small 3-PR batch — #2268 docker_init k8s whoami fallback + #2270 PWA manifest session routes (closes #2226) + #2272 aux title test coverage
2026-05-14 13:17:36 -07:00
Hermes Agent
ef0063e203 docs: CHANGELOG stage-357 — close v0.51.63 (stage-356), open Unreleased for 3-PR stage-357 (#2268 docker_init k8s whoami fallback + #2270 PWA manifest session routes (closes #2226) + #2272 aux title test coverage) 2026-05-14 19:57:03 +00:00
Hermes Agent
618ab379be Merge pull request #2270 into stage-357
Fix PWA manifest resolution from session routes (Michaelyklam, closes #2226)
2026-05-14 19:56:03 +00:00
Hermes Agent
84ecdbf795 Merge pull request #2272 into stage-357
test: cover initial aux title routing (Michaelyklam, refs #2235)
2026-05-14 19:56:02 +00:00
Hermes Agent
95d4893f9e Merge pull request #2268 into stage-357
fix(docker_init): fall back to synthetic name when whoami fails (eleboucher)
2026-05-14 19:56:00 +00:00
Michael Lam
82343228a3 test: cover initial aux title routing 2026-05-14 12:34:49 -07:00
Michael Lam
b15b4eda31 fix: serve PWA manifest from session routes 2026-05-14 11:52:12 -07:00
Erwan Leboucher
d9c7dc6fe2 fix(docker_init): fall back to synthetic name when whoami fails 2026-05-14 18:53:57 +02:00
nesquena-hermes
e5ca59bc8f Merge pull request #2267 from nesquena/stage-356
stage-356: small 2-PR follow-up batch — #2234 aux-model routing + #2265 mixed-case provider canonicalization (closes #2245)
2026-05-14 09:24:23 -07:00
Hermes Agent
d5accbf92d docs: CHANGELOG stage-356 — close v0.51.62 (stage-355), open Unreleased for 2-PR stage-356 (#2234 post-rebase aux-model + #2265 mixed-case provider key fix) 2026-05-14 16:10:43 +00:00
Hermes Agent
8a13ebd2e2 Merge pull request #2265 into stage-356
Fix configured provider models after key canonicalization (Michaelyklam, closes #2245)
2026-05-14 16:09:28 +00:00
Hermes Agent
8eb310d975 Merge pull request #2234 into stage-356
fix: use documented aux model for update summaries (Jordan-SkyLF, post-rebase)
2026-05-14 16:09:26 +00:00
Michael Lam
d246bf2654 fix: canonicalize configured provider model lookup 2026-05-14 09:05:13 -07:00
Jordan SkyLF
339e62528f fix: use documented aux model for update summaries 2026-05-14 08:50:13 -07:00
nesquena-hermes
18297f3aff Merge pull request #2263 from nesquena/stage-355
stage-355: 11-PR full sweep batch — metadata-only cache hit fixes + skill detail fix + phone UX + escaping + display-title projection + RFC update + test fixture hardening
2026-05-14 08:43:28 -07:00
Hermes Agent
afba7169bb docs: CHANGELOG stage-355 — close v0.51.61 (stage-354), open Unreleased for 11-PR stage-355; clean up v0.51.60 (drop misattributed Phone bullet, which actually shipped in stage-355 as #2238) 2026-05-14 15:16:53 +00:00
Hermes Agent
29c166d813 Merge pull request #2234 into stage-355
fix: refine update summary category handling (Jordan-SkyLF, post-v0.51.61 rebase)

# Conflicts:
#	CHANGELOG.md
2026-05-14 15:15:09 +00:00
Hermes Agent
9370c483c3 Merge pull request #2241 into stage-355
fix: reconcile stale sidebar display titles (dso2ng)

# Conflicts:
#	CHANGELOG.md
2026-05-14 15:15:07 +00:00
Hermes Agent
673a927975 Merge pull request #2238 into stage-355
Improve phone sidebar panel navigation (franksong2702, fixes #2231)
2026-05-14 15:15:06 +00:00
Hermes Agent
da1ea52143 Merge pull request #2249 into stage-355
Fix metadata-only cache hits in session mutation routes (franksong2702, fixes #2248)

# Conflicts:
#	CHANGELOG.md
#	tests/test_metadata_save_wipe_1558.py
2026-05-14 15:14:50 +00:00
Hermes Agent
8f2bb77cc5 Merge pull request #2244 into stage-355
Fix Archive Session for metadata-only cache hits (franksong2702, fixes #2243)

# Conflicts:
#	CHANGELOG.md
2026-05-14 15:13:47 +00:00
Hermes Agent
116167ce00 Merge pull request #2250 into stage-355
[codex] Fix blank skill detail views (franksong2702, refs #1880)

# Conflicts:
#	CHANGELOG.md
2026-05-14 15:13:46 +00:00
Hermes Agent
6f5f5edb56 Merge pull request #2253 into stage-355
[codex] Show skill detail API errors (franksong2702)

# Conflicts:
#	CHANGELOG.md
2026-05-14 15:13:44 +00:00
Hermes Agent
46455e2d6f Merge pull request #2251 into stage-355
docs(runtime): codify #1925 adapter contract and migration gates (franksong2702)
2026-05-14 15:13:42 +00:00
Hermes Agent
1fbb2d6ce9 Merge pull request #2255 into stage-355
[codex] Escape model picker display text (franksong2702, closes #2254)

# Conflicts:
#	CHANGELOG.md
2026-05-14 15:13:41 +00:00
Hermes Agent
e1198e9e18 Merge pull request #2257 into stage-355
[codex] Fix start.sh dotenv filtering load (franksong2702)

# Conflicts:
#	CHANGELOG.md
2026-05-14 15:13:39 +00:00
Hermes Agent
90a7fedfb2 Merge pull request #2259 into stage-355
[codex] Harden update-link git fixture (franksong2702, closes #2258)
2026-05-14 15:13:37 +00:00
Frank Song
6cac6624f9 Harden update link git fixture 2026-05-14 23:07:14 +08:00
Frank Song
d93903eddb Fix start.sh dotenv filtering load 2026-05-14 23:05:35 +08:00
Frank Song
0461b97d15 Escape model picker display text 2026-05-14 23:03:48 +08:00
Frank Song
a56ec43d29 Show skill detail API errors 2026-05-14 22:57:15 +08:00
dobby-d-elf
892a946851 fix tests 2026-05-14 08:54:43 -06:00
Frank Song
5ba5551d05 Clarify runtime adapter replay gates 2026-05-14 22:42:15 +08:00
dobby-d-elf
ac66c9093f Improve mobile session list actions 2026-05-14 08:35:30 -06:00
Frank Song
93de0d8c31 Add skill detail screenshot evidence 2026-05-14 22:35:02 +08:00
Frank Song
b2b38354db Update runtime adapter RFC gates 2026-05-14 22:34:51 +08:00
Frank Song
ae8658affa Fix blank skill detail views 2026-05-14 22:28:20 +08:00
Frank Song
0042126473 Fix metadata-only session mutation routes 2026-05-14 22:16:53 +08:00
Dennis Soong
a145bf4364 fix: apply display titles in content search 2026-05-14 21:10:50 +08:00
Frank Song
2b537ffa1b Fix archive metadata-only session reload 2026-05-14 19:31:25 +08:00
Frank Song
35309760ae test: couple mobile safe-area contract to viewport fit 2026-05-14 18:09:21 +08:00
Dennis Soong
143d9d8ef7 fix: reconcile stale sidebar display titles 2026-05-14 16:18:53 +08:00
Jordan SkyLF
a291ffdde6 fix: refine update summary category handling
Keep distinct generated summary categories, route update-summary generation through the configured auxiliary model first, disclose capped large-range summary input, and constrain long summary panels.
2026-05-14 01:07:47 -07:00
nesquena-hermes
5677b12a88 Merge pull request #2239 from nesquena/stage-354
stage-354: 3-PR contributor batch — #2228 profile model picker + #2234 update-banner cleanup + #2236 silent-failure detection scope fix
2026-05-14 00:39:30 -07:00
Hermes Agent
be32c8311f docs: CHANGELOG stage-354 — close v0.51.60 (stage-353), open Unreleased for 3-PR stage-354 2026-05-14 07:17:03 +00:00
Frank Song
6beb59d61f Improve mobile sidebar panel navigation 2026-05-14 15:16:33 +08:00
Hermes Agent
b8e9951492 Merge pull request #2236 into stage-354
fix: silent failure detection scans only new messages (jasonjcwu)
2026-05-14 07:15:16 +00:00
Hermes Agent
efad585b86 Merge pull request #2228 into stage-354
Add model picker to profile creation (franksong2702, refs #749)
2026-05-14 07:15:14 +00:00
Hermes Agent
b7cc53108a Merge pull request #2234 into stage-354
fix: clean up update summaries and banner wrapping (Jordan-SkyLF)
2026-05-14 07:15:13 +00:00
Jordan SkyLF
afbcc9a6d5 fix: wrap update banner on mobile 2026-05-13 23:51:48 -07:00
fxd-jason
1e80b51560 fix: align usage-overwrite test FakeAgent with real agent message format
The FakeAgent in test_issue1857_usage_overwrite returned only 2 messages
(user + assistant) without the conversation history. The real agent always
returns the full history plus new messages. This mismatch caused the new
_has_new_assistant_reply helper (which checks only messages beyond the
pre-turn offset) to see len(result)==len(prev) and incorrectly flag the
turn as a silent failure.

Fix: prepend conversation_history to the FakeAgent's response so the
message list mirrors production behavior.
2026-05-14 14:48:08 +08:00
fxd-jason
120ec5eba2 fix: silent failure detection scans only new messages, not full history
When a provider error (401/429/rate-limit) causes the agent to return
without producing a new assistant reply, the WebUI should emit an
apperror event so the user sees an inline error. However, the detection
logic scanned ALL messages in result['messages'] — which includes the
full conversation history. If any prior turn had an assistant response,
_assistant_added would be True and the apperror would be silently
skipped, leaving the user staring at a blank response.

Extract a helper _has_new_assistant_reply(all_messages, prev_count)
that only inspects messages beyond the pre-turn history offset. Apply
it to both the main detection path and the self-heal/retry path.

Tests: 15 new cases covering history masking, empty content, whitespace,
edge-case shrinks, and multi-assistant scenarios.
2026-05-14 14:34:19 +08:00
Jordan SkyLF
62eb703dcf fix: avoid duplicate update summary bullets 2026-05-13 22:54:45 -07:00
Frank Song
8b30ade923 Add profile creation model picker 2026-05-14 12:13:49 +08:00
nesquena-hermes
a17d75431c Merge pull request #2229 from nesquena/stage-353
stage-353: overlapping Appearance PRs + critical compression-rotation data-loss fix — #2227 (closes #2223) + #2222 (markup) + #2225 (Extra Large font)
2026-05-13 21:12:01 -07:00
Hermes Agent
6bf2012244 docs: CHANGELOG note for stage-353 Opus SHOULD-FIX on #2227 parent_session_id 2026-05-14 04:08:46 +00:00
Hermes Agent
3d34a72ee8 stage-353: apply Opus SHOULD-FIX — unconditional parent_session_id stamp on compression rotation
Opus identified that PR #2227's preservation block had two related bugs in
the parent_session_id handling:

1. During preservation save: code did
     _old_parent = s.parent_session_id
     s.parent_session_id = None
     s.save(touch_updated_at=False, skip_index=True)
     s.parent_session_id = _old_parent
   The save persisted parent=None to disk. The in-memory restoration didn't
   reach the disk copy. Result: a /branch fork session that subsequently
   compressed lost its 'Forked from X' badge on the preserved old snapshot.

2. Stamping the continuation: code did
     if not s.parent_session_id:
         s.parent_session_id = old_sid
   The 'if not' guard skipped the stamp when the session already had a
   parent_session_id from a prior fork. Result: fork-of-fork compression
   broke lineage — the continuation jumped back to the original fork parent
   instead of the just-preserved immediate predecessor snapshot.

Fix (matches Opus's recommendation):
  - Remove the parent clearing during preservation save (preserve as-is)
  - Drop the 'if not' guard; always stamp continuation to old_sid

This makes the lineage chain consistent: new → old → old.parent → ... root.
Traversal from the continuation always walks through the just-preserved
snapshot to get to its parent's parent, never jumping over the snapshot.

Two new regression tests pin both invariants:
  - test_parent_session_id_stamped_unconditionally (no 'if not' guard)
  - test_old_session_parent_preserved_during_archive_save (no parent=None)

Both pass against the fix. All 8 tests in the file pass.
2026-05-14 03:59:02 +00:00
Hermes Agent
ccfa3b2081 docs: CHANGELOG stage-353 — close v0.51.59 (stage-352), open Unreleased for 3-PR stage-353; also drop duplicate #2219 entry from v0.51.58 section (it shipped in stage-352/v0.51.59, not stage-351) 2026-05-14 03:45:31 +00:00
Hermes Agent
bfb62abe35 Merge pull request #2225 into stage-353
Add extra-large Appearance font size option (franksong2702)
2026-05-14 03:43:52 +00:00
Hermes Agent
5a447604b6 Merge pull request #2227 into stage-353
fix: preserve session history during compression rotation (theh4v0c, closes #2223 — critical data-loss)
+ duplicate Appearance label-markup fix overlapping with #2222

Conflict resolution:

(1) static/index.html line ~896 — PR #2222 and PR #2227 both fix the same
    endless-scroll checkbox label issue with different structures.
    - #2222 (HEAD): wraps the input+span in an in-place <label> inside the
      shared session-jump settings-field div.
    - #2227 (incoming): closes the existing settings-field, opens a NEW
      settings-field for endless-scroll, then wraps in <label>. Strictly
      better — each preference owns its own settings-field, matching the
      rest of the Settings → Appearance layout.

    Adopted #2227's structural variant. Regression test
    test_session_endless_scroll.py was updated by #2222 to match the new
    structure and passes against the resolved markup.

(2) CHANGELOG.md — both PRs added a Fixed entry. Merged both attributions,
    leading with the critical #2223 data-loss fix from #2227 (immutable
    pre-compression archive + parent_session_id lineage stamping) and
    noting that the maintainer chose #2227's structural variant for the
    label-markup overlap.

11/11 tests pass across test_session_endless_scroll.py +
test_issue2223_compression_no_rename.py after resolution.
2026-05-14 03:43:45 +00:00
Hermes Agent
0635623f97 Merge pull request #2222 into stage-353
Fix Appearance endless-scroll label markup (franksong2702)
2026-05-14 03:42:10 +00:00
Frank Song
e2f319d730 Add extra large font size option 2026-05-14 11:09:21 +08:00
RØG3R L!M4
16d307061e fix: wrap endless-scroll checkbox in its own label (refs #2222)
The settingsSessionEndlessScroll checkbox and label were nested inside
the session-jump description div with a stray </label> closing tag,
causing browser markup recovery to shift the control layout.

Fix: properly close the session-jump settings-field div, create a new
settings-field div with margin-top:8px, and wrap the endless-scroll
checkbox in its own <label> element consistent with other Appearance
checkboxes.
2026-05-14 03:03:48 +00:00
RØG3R L!M4
5bbf18324c fix: preserve session history during compression rotation (#2223)
The previous implementation renamed old_sid.json → new_sid.json during
context compression, destroying the only persistent copy of the full
conversation history. If the summarisation LLM call also failed, the
user was left with zero recoverable messages.

Fix:
- Remove the destructive old_path.rename(new_path) call
- Preserve old_sid.json as an immutable pre-compression archive
- Create new_sid.json as a fresh file via s.save()
- Set parent_session_id on the continuation session for lineage
- Save in-memory messages to old_sid.json if they're newer than disk

Test: test_issue2223_compression_no_rename.py (6 tests, all passing)
2026-05-14 03:02:44 +00:00
Frank Song
3d2f9cf699 Fix Appearance endless scroll label markup 2026-05-14 10:44:16 +08:00
nesquena-hermes
3f4e82b4d1 Merge pull request #2221 from nesquena/stage-352
stage-352: 4-PR clean batch — _summary_cache LRU cap #2216 + re.MULTILINE strip fix #2217 + Compact sidebar lineage hide #2219 + CONTRIBUTORS/README refresh #2220
2026-05-13 19:38:36 -07:00
Hermes Agent
079697ac12 docs: CHANGELOG stage-352 — close v0.51.58 (stage-351), open Unreleased for 4-PR stage-352 2026-05-14 02:23:15 +00:00
Hermes Agent
6b2e6a2d98 Merge pull request #2220 into stage-352
docs: refresh CONTRIBUTORS.md and README to v0.51.58 (nesquena-hermes)
2026-05-14 02:22:11 +00:00
Hermes Agent
426cf7115e Merge pull request #2219 into stage-352
Hide prior-turn lineage metadata in compact sidebar (franksong2702, closes #2218)
2026-05-14 02:22:10 +00:00
Hermes Agent
549140df31 Merge pull request #2216 into stage-352
fix: cap _summary_cache with LRU (max 16 entries) (franksong2702, closes #2215 Fix A — closes #2215)
2026-05-14 02:22:08 +00:00
Hermes Agent
f92630445b Merge pull request #2217 into stage-352
fix: drop re.MULTILINE from 'the user is asking' strip pattern (franksong2702, refs #2215 Fix B)
2026-05-14 02:22:07 +00:00
nesquena-hermes
7192d856af docs: refresh CONTRIBUTORS.md and README to v0.51.58
Last refresh was at v0.51.44 (130 contributors / 568 PR credits). Since then,
14 releases shipped (v0.51.45 through v0.51.58) with 78 attributed PRs from
20 distinct contributors, adding 7 first-time contributors:

- @lucasrc — auth-hardening trilogy (#2191/#2192/#2193) → sustained 3-PR bucket
- @LumenYoung — stale-stream guard + 3 streaming-correctness fixes → sustained 4-PR bucket
- @MrFant — 2 PRs (reasoning_content whitelist, message preservation)
- @xz-dev — 2 PRs (thinking-card state, session-scoped metering)
- @legeantbleu — French (fr) locale (#2142)
- @ayushere — ctl.sh bash 3.2 macOS compat (#2117)
- @plerohellec — single PR

Bucket promotions (existing contributors who moved up):

- @dobby-d-elf: 2 → 6 PRs (jumps from two-PR list into top contributors)
- @samuelgudi: 1 → 2
- @michael-dg: 1 → 2
- @vcavichini: 1 → 2
- @hualong1009: 1 → 2

Top contributor PR-count updates (top 7 changed materially):

| Contributor       | Before | After | Delta | Latest release |
|-------------------|-------:|------:|------:|---------------|
| @franksong2702    |     92 |   117 |   +25 | Unreleased    |
| @Michaelyklam     |     81 |    92 |   +11 | v0.51.57      |
| @bergeouss        |     61 |    62 |    +1 | v0.51.46      |
| @ai-ag2026        |     49 |    55 |    +6 | v0.51.47      |
| @dso2ng           |     21 |    23 |    +2 | v0.51.51      |
| @jasonjcwu        |     13 |    16 |    +3 | v0.51.55      |
| @Jordan-SkyLF     |      6 |    12 |    +6 | Unreleased    |

Header refreshed: 130 → 137 contributors, 568 → 646 PR credits, refresh
tag bumped to v0.51.58. Notable contributions paragraphs in both files
updated with the new work (manual /compress async pair, worktree status +
guarded remove, fork-from-here, opencode-go resolver, the LumenYoung +
lucasrc + dobby-d-elf entries).

Source of truth: CHANGELOG.md attribution lines `**PR #N** by @user` between
the v0.51.44 header and the Unreleased section, plus baseline buckets in
the previous CONTRIBUTORS.md. Cross-bucket sanity check passes (no handle
appears in multiple buckets; 21 + 13 + 19 + 84 = 137 unique handles).
2026-05-14 02:18:57 +00:00
Frank Song
996d4f9a8c Hide session lineage metadata in compact sidebar 2026-05-14 10:14:15 +08:00
Frank Song
9681761cdf fix: cap _summary_cache with OrderedDict LRU
Refs #2215 Fix A: replace plain dict _summary_cache with OrderedDict-based LRU capped at 16 entries to prevent unbounded memory growth from long-running update summary generations.

Add regression coverage for the bounded LRU behavior: cache hits refresh recency, a new entry at capacity evicts the least-recently used key, and cache size never exceeds the cap.
2026-05-14 09:14:28 +08:00
Frank Song
28ec3af697 fix: strip only leading user-asking wrapper line
Refs #2215 Fix B: remove the mid-response stripping hazard without losing leading multi-line wrapper cleanup.

The pattern now strips only a leading 'the user is asking' wrapper line and preserves the visible answer that follows. Add regression coverage for both the leading-wrapper and mid-response prose cases.
2026-05-14 09:14:28 +08:00
starship-s
ab663cdfc8 fix(providers): avoid caching transient quota probe failures 2026-05-13 19:01:47 -06:00
nesquena-hermes
d8798ab355 Merge pull request #2214 from nesquena/stage-351
stage-351: net-positive ready batch — perf CLI scan cache #2149 + thinking-tag leading-only #2213 + MCP tools pagination #2210 + per-target update summaries #2207 + sweep animation tune #2212 + agent-mode cron badge #2206
2026-05-13 17:16:30 -07:00
Hermes Agent
6af22791ce docs: CHANGELOG stage-351 — close v0.51.57 (stage-350 batch), open Unreleased for 6-PR stage-351 2026-05-13 23:55:35 +00:00
Hermes Agent
2accf6335c Merge pull request #2149 into stage-351
perf(sessions): cache CLI session scans (starship-s)

Conflict resolution on api/routes.py:
  (1) Master grew a new helper '_messages_include_tool_metadata()' that
      pr-2149 doesn't have. Kept it (unrelated function — detects whether
      returned messages contain tool metadata, used elsewhere).
  (2) pr-2149 renames the CLI-metadata gate from '_needs_cli_session_metadata'
      to '_session_requires_cli_metadata_lookup' AND broadens it to cover
      legacy-imported sidecars with 'read_only=False' but persisted 'is_cli_session'
      or session_source markers. The new gate is strictly more inclusive than
      the master version — covers (a) is_cli_session, (b) read_only=True,
      (c) session_source in {messaging, external_agent}, AND (d) source_tag,
      raw_source, source, source_label, platform markers. All sessions that
      previously took the slow path still do, plus a few more legacy shapes
      that needed CLI metadata for correct display.
  (3) Removed the obsolete '_needs_cli_session_metadata()' definition from
      master (only consumer migrated to the new name).

29/29 tests pass across test_session_cli_scan_fast_path (new), claude_code
session import, session_index, and session_lineage_full_transcript.
2026-05-13 23:54:15 +00:00
Hermes Agent
89bf659b01 Merge pull request #2206 into stage-351
feat: show 🤖 badge and provider/model for agent-mode cron jobs (vcavichini)
2026-05-13 23:51:29 +00:00
Hermes Agent
70f09aaeb6 Merge pull request #2207 into stage-351
feat: add per-target update summaries with separate WebUI/Agent What's-new links (Jordan-SkyLF, fixes #1579)
2026-05-13 23:51:28 +00:00
Hermes Agent
f038c43d85 Merge pull request #2212 into stage-351
Tune Activity sweep animation (dobby-d-elf, follow-up to #2203)
2026-05-13 23:51:26 +00:00
Hermes Agent
f1fee3aea8 Merge pull request #2210 into stage-351
Fix MCP tools list overflow with pagination/search (Jordan-SkyLF)
2026-05-13 23:51:25 +00:00
Hermes Agent
78c08ee1b7 Merge pull request #2213 into stage-351
Preserve literal thinking tags in assistant messages (franksong2702, fixes #2152)
2026-05-13 23:51:23 +00:00
ubuntu
97564c2b01 feat: show 🤖 badge for cron jobs in agent mode
Identify cron jobs with mode=agent (no_agent=false) in the cron list
by displaying a robot emoji badge next to the job name.
2026-05-13 20:48:09 -03:00
Jordan SkyLF
25ce490e24 docs: add update banner summary mode screenshots 2026-05-13 16:27:06 -07:00
Frank Song
dc213d47b8 fix: preserve literal thinking tags 2026-05-14 07:13:34 +08:00
Jordan SkyLF
7d0a2c3621 docs: refresh update banner screenshots 2026-05-13 16:12:29 -07:00
Jordan SkyLF
1ad5fe9c14 fix: keep boot settings regression close 2026-05-13 16:00:46 -07:00
Jordan SkyLF
f0d2ebccd9 Persist generated summary button state 2026-05-13 15:53:01 -07:00
Jordan SkyLF
7925a15211 Clarify generated summary button states 2026-05-13 15:53:01 -07:00
Jordan SkyLF
90c2ee7e04 Split What's New summaries by target 2026-05-13 15:53:01 -07:00
Jordan SkyLF
cae007b069 Refine What's New summary sections 2026-05-13 15:53:01 -07:00
Jordan SkyLF
623dfef499 Stabilize What's New summaries 2026-05-13 15:53:01 -07:00
Jordan SkyLF
bec21eafa0 Add What's New summary toggle 2026-05-13 15:53:01 -07:00
Jordan SkyLF
cfc0f68d23 fix: show update whats-new links for webui and agent 2026-05-13 15:53:01 -07:00
Jordan SkyLF
9b2c402253 Add MCP tools overflow PR screenshots 2026-05-13 15:50:48 -07:00
dobby-d-elf
23cc22d048 Tune Activity sweep duration 2026-05-13 16:48:10 -06:00
dobby-d-elf
eead48fc56 Tune version B Activity highlight sweep: widen band to ~20%, slow to 3.5s 2026-05-13 16:34:02 -06:00
Jordan SkyLF
4b455822cb Fix MCP tools list overflow 2026-05-13 15:33:54 -07:00
nesquena-hermes
6aedb7e0cd Merge pull request #2209 from nesquena/stage-350
stage-350: medium-risk batch — auth trilogy (#2191/2/3) + cancel-status #2151 with conflict resolution + #2178 ollama guard + #2204 provider precedence + #2203 activity animation
2026-05-13 14:14:14 -07:00
Hermes Agent
7209e89ef4 stage-350: apply Opus SHOULD-FIX — tighten _partial_already_present dedup scope
Opus flagged that PR #2151's cancel-handler partial-dedup loop used a
substring check that was too broad: any short prior assistant reply
('OK', 'Here is the answer:') would dedup a longer new partial containing
it, silently dropping the partial and resurrecting the #893 data-loss bug.

Tightened to only dedup against actual prior _partial=True markers with
exact (whitespace-stripped) content match. Three new regression tests
added (short-non-partial-prefix-does-not-dedup, exact-partial-match-still-
dedups, same-content-non-partial-does-not-dedup).

10/10 partial-cancel tests pass after the fix. Also updated CHANGELOG with
the conflict-resolution notes for #2151 vs #2136 and the #2178 test-fix.
2026-05-13 21:11:01 +00:00
Hermes Agent
66ffc7d44b docs: CHANGELOG stage-350 — close v0.51.56, open Unreleased for 7-PR medium-risk batch 2026-05-13 20:46:45 +00:00
Hermes Agent
43f86d038e stage-350: fix #2178 CI — update Ollama test assertion to match new allowOllamaFormat guard
PR #2178 added an 'allowOllamaFormat' guard (resolves to false for non-ollama
@-provider prefixes like '@custom:ai_gateway') to stop the ollama label
formatter from reformatting custom-provider model IDs with dashes. The
existing test asserted on the pre-PR code shape and didn't pick up the new
guard.

Updated the assertion to match the actual post-PR code at static/ui.js:2202,
with an extended docstring explaining the bug class the guard fixes (bare
custom-provider model IDs like 'Qwen3.6-35B-A3B' had hyphens stripped to
spaces + last letter lowercased by the formatter).
2026-05-13 20:45:44 +00:00
Hermes Agent
1f9520d3f9 Merge pull request #2178 into stage-350
fix(ui): custom models not displayed in model configuration list (hualong1009)
2026-05-13 20:44:55 +00:00
Hermes Agent
3f851051cf Merge pull request #2151 into stage-350
fix: clarify cancelled chat turn status (Jordan-SkyLF)

Conflict resolution on api/streaming.py:4549-4567 (the cancel-handler
ownership guard). Both this PR and the already-shipped PR #2136 add a
guard at the same site against stale stream writebacks, from different
angles:

  - PR #2136 (HEAD): _stream_writeback_is_current(_cs, stream_id) — strictly
    dominates by checking the active_stream_id token equality.
  - PR #2151: 'worker won the race' check via (active_stream_id != stream_id
    and not pending_user_message), with _emit_cancel_event = False to suppress
    the terminal cancel event.

Resolution merges both: keep #2136's strictly-stronger condition for skip
detection, and adopt #2151's _emit_cancel_event = False semantic so the
cancel event isn't emitted in addition to skipping the writeback (when
client may have already received the successful done payload).

55/55 tests pass across cancelled-turn-status + stale-stream-writeback +
the four cancel/data-loss sibling test files.
2026-05-13 20:44:44 +00:00
Hermes Agent
df3352eae3 Merge pull request #2192 into stage-350
fix(auth) 2/3: invalidate password hash cache when password changes via Settings panel (lucasrc, depends on #2191)
2026-05-13 20:42:52 +00:00
Hermes Agent
5f8b834833 Merge pull request #2193 into stage-350
fix(auth) 3/3: full HMAC digest with upgrade migration bridge + restore Secure cookie heuristic (lucasrc)
2026-05-13 20:41:38 +00:00
Hermes Agent
ca82f60144 Merge pull request #2191 into stage-350
fix(auth) 1/3: thread-safe login rate limiter + PBKDF2 key separation + transparent migration (lucasrc)
2026-05-13 20:41:36 +00:00
Hermes Agent
73b47ec4b5 Merge pull request #2203 into stage-350
Activity highlight animation (dobby-d-elf)
2026-05-13 20:41:35 +00:00
Hermes Agent
f94314e164 Merge pull request #2204 into stage-350
Fix opencode-go custom provider overlap routing (Michaelyklam, closes #1894)
2026-05-13 20:41:33 +00:00
nesquena-hermes
1a9234af7f Merge pull request #2205 from nesquena/stage-349
stage-349: Tier 1 safe slice — #2201 reasoning_content whitelist + #2198 fork-from-here + #2200 Firefox sidebar scroll + #2202 provisional titles
2026-05-13 12:22:17 -07:00
Hermes Agent
67faed08dd stage-349: fix CHANGELOG inaccuracy — #2198 uses inline _oldestIdx+msgIdx, not a helper function
Opus advisor flagged that the original CHANGELOG entry referenced a
_fork_keep_count_for_anchor() helper that doesn't exist in the diff.
The actual fix is the inline (_oldestIdx + msgIdx) computation in
static/commands.js, captured BEFORE _ensureAllMessagesLoaded() resets
_oldestIdx. Updated the entry to match the code.
2026-05-13 19:19:11 +00:00
Lucas Coutinho
fe4689e280 test(auth): merge invalidation tests into hash cache test file, remove duplicate 2026-05-13 16:17:44 -03:00
Michael Lam
1e17760a04 Fix opencode-go provider overlap routing
Closes #1894
2026-05-13 12:13:37 -07:00
dobby-d-elf
efce9ebdd6 Merge remote-tracking branch 'origin/master' into tools-animation-version-b
# Conflicts:
#	static/ui.js
2026-05-13 13:11:35 -06:00
Hermes Agent
da4b6122cd docs: CHANGELOG stage-349 — close v0.51.55, open Unreleased for Tier 1 (#2198 #2200 #2201 #2202) 2026-05-13 19:03:51 +00:00
Hermes Agent
7150e9fe70 Merge pull request #2202 into stage-349
feat: show early session titles on chat start (Jordan-SkyLF)
2026-05-13 19:03:03 +00:00
Hermes Agent
26e726c17e Merge pull request #2200 into stage-349
fix: stabilize Firefox session sidebar scrolling (Jordan-SkyLF)
2026-05-13 19:03:01 +00:00
Hermes Agent
97b60c68b8 Merge pull request #2198 into stage-349
Fix fork-from-here keep count for truncated sessions (Michaelyklam)
2026-05-13 19:02:59 +00:00
Hermes Agent
e990adf54e Merge pull request #2201 into stage-349
fix: preserve reasoning_content in API message whitelist (MrFant — fixes MiMo/DeepSeek/Kimi multi-turn 400)
2026-05-13 19:02:58 +00:00
dobby-d-elf
11d9687483 Polish version B Activity highlight sweep 2026-05-13 13:02:07 -06:00
Jordan SkyLF
0381294f1c feat: add early session provisional titles 2026-05-13 11:37:11 -07:00
Jordan SkyLF
2dfe765b60 fix: stabilize Firefox session sidebar scrolling 2026-05-13 11:30:06 -07:00
MrFant
520795fdd2 fix: preserve reasoning_content in API message whitelist
Providers like Xiaomi MiMo, DeepSeek, and Kimi require reasoning_content
to be echoed back on every assistant message in multi-turn conversations
with tool calls. Omitting it causes HTTP 400: 'The reasoning_content in
the thinking mode must be passed back to the API.'

The WebUI's _sanitize_messages_for_api() strips all fields not in
_API_SAFE_MSG_KEYS before sending conversation history to the LLM API.
reasoning_content was not in this whitelist, so it was silently dropped.

The CLI path (run_agent.py) is unaffected because it has its own
_copy_reasoning_content_for_api() logic that operates on raw message
dicts without going through this filter. This is why the same session
works from CLI but fails from WebUI with HTTP 400.

The fix adds 'reasoning_content' to _API_SAFE_MSG_KEYS so the field
passes through sanitization intact.
2026-05-14 02:29:17 +08:00
Lucas Coutinho
2a96fb4a5b fix(auth): update HMAC sig length assertion to 64 chars and rebase on PR1 2026-05-13 14:19:09 -03:00
Lucas Coutinho
b734d95bc0 test(auth): add regression tests for HMAC migration bridge (32→64 char) 2026-05-13 14:18:47 -03:00
Lucas Coutinho
7e6f7372d5 fix(auth): add type hint to verify_session() 2026-05-13 14:18:47 -03:00
Lucas Coutinho
9921bbb412 docs(auth): add X-Forwarded-Proto trust warning to _is_secure_context() 2026-05-13 14:18:47 -03:00
Lucas Coutinho
07a5fe0838 fix(auth): HMAC length migration bridge and restore Secure cookie heuristic
HMAC length: create_session() now emits a full 64-char HMAC-SHA256 hex
digest instead of the truncated 32-char form. verify_session() accepts
both lengths during a transition window so existing sessions survive the
upgrade without a forced global logout. The legacy 32-char branch can be
removed once the default 30-day session TTL has elapsed.

Secure flag: introduce _is_secure_context(handler) to encapsulate the
env-var override and heuristic. Restores the getpeercert / X-Forwarded-Proto
heuristic that was present before this refactor, keeping the env-var
override (HERMES_WEBUI_SECURE) on top for proxy deployments that need
explicit control. The bare `return False` stub that the previous commit
left in place silently broke Secure-cookie delivery for all reverse-proxy
users who never set the env var.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:18:47 -03:00
nesquena-hermes
f5be6e3a69 Merge pull request #2199 from nesquena/stage-348
stage-348: 9-PR contributor batch — docs/onboarding + compress fixes + steer badge + perf + thinking-card state + #2171 prefilter URL-marker patch
2026-05-13 10:11:12 -07:00
Lucas Coutinho
3daa12ceb0 test(auth): add cache invalidation regression tests for save_settings() 2026-05-13 14:09:39 -03:00
Hermes Agent
32ba73c816 stage-348: fix CI-vs-local divergence on Opus prefilter test
The original tests asserted on the final output of _redact_text(), which
exercises agent.redact.redact_sensitive_text() from the hermes-agent venv.
That function's URL-userinfo / query-param redaction is available locally
but not in the CI test environment (different agent install version).

Rewrite the tests to assert on the prefilter routing decision instead:
_might_contain_sensitive_text() must return True for URL-shaped strings.
That's the actual contract #2171 establishes and the regression Opus
flagged. The downstream agent redactor behavior is its own contract.

Sanity-checked: 5 of 6 URL cases fail when '://' marker reverted, all
pass when restored. 62 redaction tests total pass.
2026-05-13 17:08:53 +00:00
Lucas Coutinho
2bcf411519 fix(auth): invalidate password hash cache in save_settings() on password change 2026-05-13 14:08:37 -03:00
Michael Lam
c522384c6d Fix fork-from-here keep count for truncated sessions 2026-05-13 09:57:40 -07:00
Hermes Agent
7c2b2785e7 stage-348: apply Opus SHOULD-FIX-pre-merge — add '://' to _SENSITIVE_LOWER_MARKERS
Opus advisor flagged that PR #2171's credential prefilter only listed
specific DB scheme prefixes and form keys, letting OAuth callback URLs,
URL userinfo, signed-URL query params bypass the hard agent redactor.

Adding the generic '://' marker restores the WebUI-as-hard-safety-boundary
contract. Plain URLs without sensitive substrings still pass through
unchanged because the redactor itself only mutates sensitive substrings.

Regression-pinned with 5 new parametric cases in test_security_redaction.py
plus 1 negative-case companion. Verified test FAILS without the fix and
PASSES with it.
2026-05-13 16:54:36 +00:00
Hermes Agent
3bfa0fc885 docs: CHANGELOG stage-348 — close v0.51.54, open Unreleased for 9-PR contributor batch 2026-05-13 16:35:35 +00:00
Hermes Agent
39df1a1ef3 Merge pull request #2171 into stage-348
Trim session tail response overhead (franksong2702)
2026-05-13 16:34:43 +00:00
Hermes Agent
ef042ad8c2 Merge pull request #2188 into stage-348
fix: refresh context ring after compression (LumenYoung)
2026-05-13 16:34:42 +00:00
Hermes Agent
fbd1e27181 Merge pull request #2185 into stage-348
fix: prevent 404 on /api/session/compress/status during session switch (jasonjcwu)
2026-05-13 16:34:40 +00:00
Hermes Agent
479e388be8 Merge pull request #2182 into stage-348
fix: keep compression banner attached to the compaction marker (LumenYoung)
2026-05-13 16:34:39 +00:00
Hermes Agent
63a1855b95 Merge pull request #2187 into stage-348
feat: show steer messages in chat with visual badge (jasonjcwu, split from #2164)
2026-05-13 16:34:38 +00:00
Hermes Agent
785b1fe7e9 Merge pull request #2186 into stage-348
fix: prevent concurrent send() from losing messages or swallowing stream output (jasonjcwu, split from #2164)
2026-05-13 16:34:36 +00:00
Hermes Agent
5ba097c40b Merge pull request #2162 into stage-348
docs: refresh project snapshot and agent onboarding entrypoint (franksong2702)
2026-05-13 16:34:35 +00:00
Hermes Agent
acefaa180e Merge pull request #2190 into stage-348
fix: preserve thinking card state during reasoning updates (xz-dev)
2026-05-13 16:34:34 +00:00
Hermes Agent
3c20e102df Merge pull request #2189 into stage-348
fix: keep background metering from overwriting visible session usage (xz-dev)
2026-05-13 16:34:32 +00:00
Lucas Coutinho
978dbc15d8 fix(auth): correct misleading cache invalidation comment in verify_password() 2026-05-13 12:48:35 -03:00
Lucas Coutinho
8ca29618fe fix(auth): tighten except to OSError, add type hints, fix test imports 2026-05-13 12:27:27 -03:00
dobby-d-elf
f6a5fc2885 Widen version B Activity highlight sweep 2026-05-13 09:21:59 -06:00
dobby-d-elf
a183378a05 Refine version B Activity highlight sweep 2026-05-13 08:17:12 -06:00
Lucas Coutinho
720e69cb83 fix(auth): cache signing and PBKDF2 keys in memory, remove migration side-effect call 2026-05-13 11:13:23 -03:00
dobby-d-elf
3640cd8edf Version B: use gold Activity highlight sweep 2026-05-13 08:08:49 -06:00
dobby-d-elf
a60c222e76 Version A: tune Activity sweep animation 2026-05-13 08:07:52 -06:00
Lucas Coutinho
e6e91e4973 fix(auth): thread-safe login rate limiter, PBKDF2 key separation, and migration path
Concurrent failed logins raced on _login_attempts because no lock guarded
the dict. Add _LOGIN_ATTEMPTS_LOCK and wrap both _check_login_rate() and
_record_login_attempt() with it.

Extract _load_key() to de-duplicate key file I/O. Add _pbkdf2_key() that
loads .pbkdf2_key (separate from .signing_key) so PBKDF2 and HMAC signing
no longer share a key — key reuse across cryptographic primitives is unsafe.

Update _hash_password() to use _pbkdf2_key() as its default salt, with an
optional *salt* kwarg so verify_password() can try the legacy .signing_key
salt during transparent migration. When the old hash matches, save_settings()
re-hashes with _pbkdf2_key() and _invalidate_password_hash_cache() ensures
the next request sees the upgraded hash without a restart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 10:40:23 -03:00
dobby-d-elf
7b263cea03 save 2026-05-13 07:40:21 -06:00
Xiangzhe
0b97f6d7ee fix: preserve thinking card state during reasoning updates 2026-05-13 20:58:50 +08:00
Xiangzhe
1a4e0522a0 fix: keep background metering from overwriting visible session usage 2026-05-13 20:58:50 +08:00
dobby-d-elf
de3dba3c62 feat: soften sweep edges and widen band for Activity animation 2026-05-13 06:40:38 -06:00
fxd-jason
2567242e2f test: widen _trySteer capture windows for steer indicator code
_showSteerIndicator function added before _trySteer extends the total
capture region. Widen helper_body 1500→2000 and try_body 1200→1600 so
assertions on cmd_steer_fallback and S.pendingFiles=[] still land within
the window.
2026-05-13 20:30:53 +08:00
fxd-jason
a8816fe22f feat: show steer indicator as transient DOM element
When busy_input_mode is 'steer' and the steer is accepted by the server,
show a transient indicator in the chat area (not in S.messages).

This mirrors the CLI/Gateway approach: steer text is never stored in the
message array. The done event's S.messages=d.session.messages replacement
therefore doesn't cause a flash where all SSE content vanishes and re-appears.

The indicator is an independent DOM element (.steer-indicator) appended to
msgInner. It naturally disappears when renderMessages rebuilds msgInner on
turn completion (done/cancel/error).
2026-05-13 20:30:44 +08:00
Lumen Yang
3289c44fb6 fix: refresh context ring after compression 2026-05-13 14:02:28 +02:00
Lumen Yang
7f01abf931 fix: ignore stale compaction markers when placing banner 2026-05-13 13:27:51 +02:00
fxd-jason
676d1f965e fix: prevent concurrent send() from losing messages or swallowing stream output
Problem: When two messages are sent in rapid succession, the second
send() can pass the S.busy check because setBusy(true) only runs after
the first await inside send(). This creates a window where two async
send() calls run concurrently, leading to:
- Streaming output from the first response getting swallowed when the
  second response's done event overwrites S.messages
- User messages disappearing when server returns 409 for the duplicate
  chat/start request

Root cause: send() is async and has awaits (uploadPendingFiles,
api('/api/chat/start')) before setBusy(true) at line 198. During those
await yields, S.busy is still false, allowing a second send() to enter.

Fix: Add a synchronous _sendInProgress guard at the very top of send()
(before any await). Concurrent calls re-queue the message instead of
silently dropping it. try/finally ensures the flag resets on all exit
paths.

Also widens the text-extraction window in
test_1062_busy_input_modes.py from 3000 to 5000 chars to accommodate
the new guard block at the top of send().
2026-05-13 19:21:50 +08:00
Frank Song
da73c00f06 Harden session tail redaction prefilter 2026-05-13 18:58:49 +08:00
fxd-jason
9e45de463d fix: prevent 404 on /api/session/compress/status during session switch
Two-part fix:
- Backend: handle_get returns True (not None from j()) for compress/status
  route, preventing edge-case 404 fallback in do_GET
- Frontend: resumeManualCompressionForSession silently returns on 404
  instead of showing "Compression failed: not found" toast

Includes 6 regression tests covering backend return value, idle/empty
session responses, and frontend 404 guard presence.
2026-05-13 18:56:55 +08:00
Lumen Yang
bc6a949272 fix: place compression banner at persisted marker 2026-05-13 10:59:36 +02:00
Frank Song
155a727ec1 docs: refresh current snapshot for v0.51.54 2026-05-13 16:56:21 +08:00
Frank Song
65fa18c7d9 docs: add agent onboarding entrypoint 2026-05-13 16:47:14 +08:00
Frank Song
be32b90cea docs: refresh current project snapshot 2026-05-13 16:47:14 +08:00
Frank Song
b7ac5a8b88 Trim session tail response overhead 2026-05-13 15:57:29 +08:00
nesquena-hermes
86740c425e Merge pull request #2181 from nesquena/stage-347
Some checks failed
Release & Docker / release (push) Has been cancelled
stage-347: singleton self-built — preserve nvidia/ prefix on NVIDIA NIM (closes #2177)
2026-05-13 00:44:50 -07:00
Hermes Agent
f03239daf2 docs: CHANGELOG stage-347 — close v0.51.53, open Unreleased for #2179 singleton 2026-05-13 07:34:34 +00:00
Hermes Agent
8060b2ba3a Merge pull request #2179 into stage-347
fix(config): preserve nvidia/ prefix on NVIDIA NIM (closes #2177)

Self-built. nesquena APPROVED with extensive end-to-end trace including
cross-tool agent CLI verification and 12-shape behavioural harness.
2026-05-13 07:33:45 +00:00
starship-s
6b76a1de9b fix(i18n): localize provider quota strings 2026-05-13 01:26:49 -06:00
nesquena-hermes
fdbbb0d92e Merge pull request #2180 from nesquena/stage-346
Some checks failed
Release & Docker / release (push) Has been cancelled
stage-346: 10-PR contributor batch — stale-stream guard extension + guarded worktree remove + CSP report collector + perf + i18n + ctl fix
2026-05-13 00:18:54 -07:00
Hermes Agent
fe3f810b56 stage-346: apply Opus SHOULD-FIX (defense-in-depth) — scope /api/csp-report auth bypass to POST only
Opus advisor flagged that PR #2160's CSP-report auth carve-out covered all
write methods on the path, not just POST. Currently harmless (PATCH/DELETE
fall through to CSRF 403 or routing 404), but defense-in-depth — scope the
bypass to its actual use case.

CSP report regression suite (6 tests) still passes.
2026-05-13 07:15:53 +00:00
nesquena-hermes
9b1d786459 fix(config): preserve nvidia/ prefix on NVIDIA NIM (closes #2177)
Move the `_PORTAL_PROVIDERS` guard in `resolve_model_provider()` to run
BEFORE the `prefix == config_provider` strip branch. The guard was added
for NVIDIA (along with the Nous portal cases in #854 / #894) but was
placed after the strip, so it never fired when `config_provider == "nvidia"`
and the model id started with `nvidia/`.

For `model_id="nvidia/nemotron-3-super-120b-a12b"`,
`config_provider="nvidia"`:
  - prefix = "nvidia", bare = "nemotron-3-super-120b-a12b"
  - prefix == config_provider → True → strip branch returned bare name
  - `_PORTAL_PROVIDERS` guard never reached
  - bare "nemotron-3-super-120b-a12b" sent to NVIDIA NIM → HTTP 404

NIM requires the full namespaced path. The fix moves the portal guard
to run first, so all portal providers (Nous, OpenCode-Zen, OpenCode-Go,
NVIDIA NIM) always preserve the full `provider/model` id regardless of
whether the prefix happens to equal the provider name.

This also closes a latent symmetric bug for the Nous case if a
`nous/<model>` id ever existed in the catalog.

Test plan:
- New `tests/test_issue2177_nvidia_prefix_preservation.py` covers:
  - nvidia/nemotron-... under nvidia (the reported case)
  - cross-namespace qwen/ and meta/ under nvidia (regression pin)
  - every static nvidia model in `_PROVIDER_MODELS` resolves to itself
  - latent nous/<model> under nous (structural ordering pin)
  - non-portal providers (anthropic) still strip — fix doesn't over-correct
- Existing portal-routing suites (test_nous_portal_routing.py,
  test_issue895_894_nous_prefix.py) continue to pass.
- Full test suite: 5320 passed, 4 skipped, 3 xpassed.

Reported on Discord by @vishnu (Nathan forwarded as #2177).
2026-05-13 07:05:57 +00:00
Hermes Agent
55047e19e7 docs: CHANGELOG stage-346 — close v0.51.52, open Unreleased for 10-PR contributor batch 2026-05-13 06:57:22 +00:00
Hermes Agent
afe42b96c1 Merge pull request #2156 into stage-346
Issue #2057 Slice 2: Add guarded worktree remove action
2026-05-13 06:56:25 +00:00
Hermes Agent
cc1df0c6c7 Merge pull request #2166 into stage-346
Consolidate session post-render processing

# Conflicts:
#	CHANGELOG.md
2026-05-13 06:56:24 +00:00
Hermes Agent
2a9d011022 Merge pull request #2160 into stage-346
Add CSP report collector endpoint (closes #2095)
2026-05-13 06:56:22 +00:00
Hermes Agent
4109394cdf Merge pull request #2159 into stage-346
Fix stale stream state in session list (closes #2157)

# Conflicts:
#	CHANGELOG.md
2026-05-13 06:56:21 +00:00
Hermes Agent
7b866df79a Merge pull request #2170 into stage-346
Skip CLI metadata lookup for native session loads

# Conflicts:
#	CHANGELOG.md
2026-05-13 06:56:20 +00:00
Hermes Agent
e665d36847 Merge pull request #2161 into stage-346
Localize logs severity filters (closes #2098)

# Conflicts:
#	CHANGELOG.md
2026-05-13 06:56:18 +00:00
Hermes Agent
129e42873c Merge pull request #2158 into stage-346
Fix stale stream exception writeback guards (closes #2154)

# Conflicts:
#	CHANGELOG.md
2026-05-13 06:56:17 +00:00
Hermes Agent
dcbced9357 Merge pull request #2173 into stage-346
Fix ctl Python wrapper ownership (closes #2172)
2026-05-13 06:56:16 +00:00
Hermes Agent
7e88ba391a Merge pull request #2175 into stage-346
Soften session lineage count badge (refs #2155)
2026-05-13 06:56:14 +00:00
Hermes Agent
77cebdfdd2 Merge pull request #2176 into stage-346
fix: handle dict model entries in provider models list
2026-05-13 06:56:13 +00:00
Lumen Yang
d13a174fe1 fix: keep compression anchor stable in windowed transcript 2026-05-13 08:49:25 +02:00
王浩生
a49c0fbf8b fix(ui): Fix the issue where custom models are not displayed in the model configuration list
- Fix the issue where custom models are not shown
- Fix the issue where custom models are not ollama but go through the ollama model processing function, causing the hyphen '-' in the model name to be replaced with a space " " and the last letter to be lowercase
2026-05-13 14:42:03 +08:00
MrFant
a4417d11f9 fix: handle dict model entries in provider models list
When a provider's 'models' config contains dicts (e.g. {"id": "x", "label": "y"})
instead of plain strings, _apply_provider_prefix() crashes with:
  AttributeError: 'dict' object has no attribute 'startswith'

This happens because the list comprehension at line 3505 passes the raw dict
as the model ID. The fix extracts 'id' and 'label' from dict entries while
keeping string entries as-is.

Fixes the /api/models and /api/onboarding/status 500 errors.
2026-05-13 13:49:40 +08:00
Michael Lam
29f5dea835 Soften session lineage count badge 2026-05-12 22:41:41 -07:00
Frank Song
d5dda03ec2 Fix ctl Python wrapper ownership 2026-05-13 13:09:42 +08:00
starship-s
1b9235c40c fix(providers): sentence-case retry cooldown text 2026-05-12 23:08:36 -06:00
Frank Song
e78945e7ca Skip CLI metadata lookup for native sessions 2026-05-13 12:35:12 +08:00
starship-s
cc3080d5ac fix(providers): localize pooled quota retry text 2026-05-12 22:27:51 -06:00
Lucas Coutinho
7acbb3d99d Cache PBKDF2 password hash to eliminate ~1s overhead on every HTTP request
get_password_hash() computes PBKDF2-SHA256 with 600k iterations to
hash the HERMES_WEBUI_PASSWORD env var.  This is called on nearly every
HTTP request via check_auth -> is_auth_enabled -> get_password_hash.

Before: ~1s of PBKDF2 per request, regardless of how many times the
same env-var value has already been hashed.  A page load hitting 5+
API endpoints would burn 5+ seconds purely on password hashing.

After: compute once on first call, cache the hex result in a module-
level variable.  Subsequent calls are a single global-variable read
(~50ns).  The env var is immutable for the process lifetime, so there
is nothing to invalidate.

Thread-safe: double-checked locking ensures that under a burst of
concurrent requests only one thread computes PBKDF2, while the fast
path (after initialisation) requires zero locks.

10 unit tests covering all branches, cache-lifetime semantics, and
concurrent burst safety (8 threads, exactly 1 PBKDF2 call).
Test isolation: reloads only api.auth via importlib.reload, leaving
api.config untouched so test_pytest_state_isolation.py is unaffected.

Security analysis: zero regression.  The hash is derived from a static
env var and a static signing key — both already readable from process
memory.  Caching does not introduce any new disclosure or replay
vector.  PBKDF2 is still used for the initial computation and for
verify_password() on login.

AI: deepseek/deepseek-v4-flash
2026-05-13 00:54:50 -03:00
Frank Song
02ca306ffc Consolidate session post-render processing 2026-05-13 11:50:31 +08:00
Lucas Coutinho
bc3f4e54a6 Cache PBKDF2 password hash to eliminate ~1s overhead on every HTTP request
get_password_hash() computes PBKDF2-SHA256 with 600k iterations to
hash the HERMES_WEBUI_PASSWORD env var.  This is called on nearly every
HTTP request via check_auth -> is_auth_enabled -> get_password_hash.

Before: ~1s of PBKDF2 per request, regardless of how many times the
same env-var value has already been hashed.  A page load hitting 5+
API endpoints would burn 5+ seconds purely on password hashing.

After: compute once on first call, cache the hex result in a module-
level variable.  Subsequent calls are a single global-variable read
(~50ns).  The env var is immutable for the process lifetime, so there
is nothing to invalidate.

Thread-safe: double-checked locking ensures that under a burst of
concurrent requests only one thread computes PBKDF2, while the fast
path (after initialisation) requires zero locks.

Security analysis: zero regression.  The hash is derived from a static
env var and a static signing key — both already readable from process
memory.  Caching does not introduce any new disclosure or replay
vector.  PBKDF2 is still used for the initial computation and for
verify_password() on login.

AI: deepseek/deepseek-v4-flash
2026-05-13 00:25:41 -03:00
starship-s
c562ce2e8c fix(providers): preserve quota cache on refresh failure 2026-05-12 21:16:34 -06:00
starship-s
a166625e02 fix(providers): refresh pooled Codex quota state 2026-05-12 21:00:24 -06:00
Frank Song
f1ca07c186 Localize logs severity filters 2026-05-13 10:55:59 +08:00
Frank Song
57ee0ce069 Add CSP report collector endpoint 2026-05-13 10:52:59 +08:00
dobby-d-elf
f23ee79938 Scale fade duration with playback speed 2026-05-12 20:50:38 -06:00
Frank Song
5ae63ddd13 Fix stale stream state in session list 2026-05-13 10:28:12 +08:00
Frank Song
9ea4f1145d Fix stale stream exception writeback guards 2026-05-13 10:23:03 +08:00
starship-s
1904eaed6b fix(providers): show fallback pool cooldown times 2026-05-12 20:05:20 -06:00
Frank Song
f4efe0790b Complete worktree remove i18n coverage 2026-05-13 09:57:24 +08:00
Frank Song
e177f64e78 Add worktree remove PR screenshots 2026-05-13 09:53:56 +08:00
Frank Song
46c62851ad Harden worktree removal safeguards 2026-05-13 09:49:15 +08:00
Frank Song
93b7d35bfa Issue #2057 Slice 2: Add worktree remove action
Backend:
- POST /api/session/worktree/remove — removes a session's git worktree
- Guards: stream/terminal lock, dirty/untracked without force
- remove_worktree_for_session() in api/worktrees.py

Frontend:
- 'Remove Worktree' context menu item + confirm modal
- i18n keys for all 11 locales

Tests:
- 5 tests: clean remove, missing worktree, no-path, route success, 404
2026-05-13 09:11:55 +08:00
starship-s
0eb9dbc3e5 fix(providers): skip exhausted Codex quota probes 2026-05-12 19:09:05 -06:00
starship-s
b87548b68e fix(providers): show pooled Codex quota status 2026-05-12 19:09:05 -06:00
starship-s
cd16ca756f docs(sessions): document CLI metadata lookup markers 2026-05-12 18:39:19 -06:00
dobby-d-elf
c7ac4ba2d3 rem 2026-05-12 18:24:52 -06:00
dobby-d-elf
0b51245bd3 Optimize smooth fade word append path 2026-05-12 18:20:08 -06:00
dobby-d-elf
5e2350e128 Harden smooth text fade before merge 2026-05-12 18:18:07 -06:00
dobby-d-elf
afc089c93b Tune smooth text fade playout 2026-05-12 18:06:11 -06:00
nesquena-hermes
9268f411d8 Merge pull request #2153 from nesquena/stage-345
stage-345: 2-PR low-risk batch — stream-ownership guard against stale writebacks + Refresh-usage button on provider quota card
2026-05-12 16:56:15 -07:00
Hermes Agent
9336161fbd docs: CHANGELOG stage-345 — close v0.51.51, open Unreleased for #2136 + #2150 2026-05-12 23:12:22 +00:00
Hermes Agent
5f33901b6a Merge pull request #2150 into stage-345
feat: add manual provider usage refresh (Jordan-SkyLF)

Adds a 'Refresh usage' button on the Provider quota card in Settings → Providers,
with cache: 'no-store' fetch + browser cache-bust query string. Pure browser-side
cache-busting; the server-side /api/provider/quota endpoint has no cache layer
yet (refresh=1 query param is currently a no-op server-side; the win is bypassing
browser/proxy/SW caches).
2026-05-12 23:11:49 +00:00
Hermes Agent
20717a0d0a Merge pull request #2136 into stage-345
fix: guard stale stream writebacks (LumenYoung)

Prevents stale WebUI stream workers from writing old results into a session
after that session has already moved on to another stream. Adds new helper
_stream_writeback_is_current() (a token equality check against the session's
active_stream_id) and short-circuits the two finalize/cancel paths when the
worker no longer owns the session writeback.
2026-05-12 23:11:48 +00:00
Jordan SkyLF
112eadc209 fix: address cancelled turn review feedback
- classify string-only CancelledError payloads as cancelled
- centralize cancel marker substring matching
- add targeted regression coverage
2026-05-12 15:43:36 -07:00
Jordan SkyLF
062ef74ec0 fix: guard provider quota refresh fallback button state 2026-05-12 15:41:19 -07:00
Lumen Yang
4b57b202a0 fix: guard stale stream writebacks 2026-05-13 00:05:09 +02:00
dobby-d-elf
00517b431e Merge branch 'master' into smooth-text-fade 2026-05-12 14:46:57 -06:00
dobby-d-elf
d039270c23 WIP: work in progress on smooth-text-fade 2026-05-12 14:46:17 -06:00
Jordan SkyLF
e4d16e93c7 fix: clarify cancelled chat turn status 2026-05-12 13:26:49 -07:00
Jordan SkyLF
b1f752ad3f feat: add provider quota refresh control 2026-05-12 13:17:27 -07:00
starship-s
4084c3cf56 perf(sessions): cache CLI session scans 2026-05-12 11:24:29 -06:00
nesquena-hermes
62974438f7 Merge pull request #2148 from nesquena/stage-344
Some checks failed
Release & Docker / release (push) Has been cancelled
stage-344: 16-PR contributor batch — i18n + insights + manual-compress async + workspace recovery + iOS PWA + Cloudflare login health + bash 3.2 (fr locale)
2026-05-12 09:41:30 -07:00
Hermes Agent
2def05f385 stage-344: apply Opus SHOULD-FIX #1+#2 — #2128 multi-tab race + stale-done re-emit
(1) compress/status no longer pops the job entry on first read of `done` payload.
    Second open tab no longer sees `idle` and a stale-job toast.
(2) compress/start no longer short-circuits to a stale `done` payload when
    re-invoked within the 10-minute TTL. Re-running /compress always starts
    fresh, so closing-and-reopening a tab mid-compress works correctly.

Third SHOULD-FIX (#2135 cfg["model"] fallback tightening when no custom_providers
entry matches) deferred to follow-up — strictly no-worse-than-master behavior.

tests/test_sprint46.py 10/10 still passes.
2026-05-12 16:37:37 +00:00
Hermes Agent
4ab6cd68ad docs: CHANGELOG stage-344 — close v0.51.50, open Unreleased for 16-PR contributor batch 2026-05-12 16:21:50 +00:00
Hermes Agent
7116c680df stage-344: maintainer fix for #2142 fr locale — add LOCALES tuple entries + _LOGIN_LOCALE block
#2142 (legeantbleu) added the fr locale to static/i18n.js but didn't update:
1. tests/test_issue1488_composer_voice_buttons.py: two TestComposerVoiceButtonI18n + TestVoiceModePreferenceGate LOCALES tuples needed 'fr'
2. api/routes.py: _LOGIN_LOCALE needed an 'fr' block so the login page localizes for French users (issue #1442 parity contract)
3. tests/test_login_locale_parity.py: the test asserting 'fr' falls-back-to-'en' is inverted — fr now resolves to fr, with sibling assertions for fr-FR and fr-CA

Mirrors the stage-340 fix for the it locale (PR #2067 → maintainer adds tuple entries). 46/46 i18n tests pass after fix.
2026-05-12 16:14:47 +00:00
Hermes Agent
9c7eb42658 Merge pull request #2142 into stage-344
i18n: add French (fr) locale (938 keys, alphabetical en→fr→it position)
2026-05-12 16:13:48 +00:00
Hermes Agent
c677c19a8f Merge pull request #2128 into stage-344
Fix manual compression proxy timeouts (closes #2087)

# Conflicts:
#	CHANGELOG.md
2026-05-12 16:13:01 +00:00
Hermes Agent
1ee8627acb Merge pull request #2135 into stage-344
Fix custom live model scoping (closes #2126, refs #2131)

# Conflicts:
#	CHANGELOG.md
2026-05-12 16:13:00 +00:00
Hermes Agent
aa85bd2e7c Merge pull request #2138 into stage-344
fix: recover from stale deleted workspaces
2026-05-12 16:12:58 +00:00
Hermes Agent
23425b23c8 Merge pull request #2129 into stage-344
fix: purge missing inflight sessions (closes #2092)
2026-05-12 16:12:57 +00:00
Hermes Agent
8dd0b4ec31 Merge pull request #2139 into stage-344
fix: audit turn journal terminal collisions
2026-05-12 16:12:56 +00:00
Hermes Agent
a06952ab00 Merge pull request #2140 into stage-344
Preserve fallback provider credential hints (closes #2133)

# Conflicts:
#	CHANGELOG.md
2026-05-12 16:12:54 +00:00
Hermes Agent
4c5a246647 Merge pull request #2125 into stage-344
docs: clarify compression anchor helpers (closes #2093)
2026-05-12 16:12:53 +00:00
Hermes Agent
8520755bd2 Merge pull request #2130 into stage-344
feat: load full lineage segments on demand
2026-05-12 16:12:52 +00:00
Hermes Agent
13c3646c55 Merge pull request #2121 into stage-344
fix: stack analytics usage cards on mobile (refs #2104) — TEST CONFLICT EXPECTED

# Conflicts:
#	tests/test_insights.py
2026-05-12 16:12:40 +00:00
Hermes Agent
2cccb8abcc Merge pull request #2120 into stage-344
fix: bucket long-range daily token charts (closes #2103)
2026-05-12 16:12:11 +00:00
Hermes Agent
56032151f7 Merge pull request #2143 into stage-344
Fix iPhone PWA chat bottom scroll stutter
2026-05-12 16:12:09 +00:00
Hermes Agent
45ac810a37 Merge pull request #2141 into stage-344
Fix Settings System mobile version wrapping (closes #2102)

# Conflicts:
#	CHANGELOG.md
2026-05-12 16:11:55 +00:00
Hermes Agent
76cf06a1a1 Merge pull request #2137 into stage-344
Fix login health probe credentials (closes #2122)
2026-05-12 16:11:42 +00:00
Hermes Agent
effd3321f9 Merge pull request #2123 into stage-344
fix: add Portuguese session management i18n (closes #2112)
2026-05-12 16:11:41 +00:00
Hermes Agent
890d65f3e5 Merge pull request #2132 into stage-344
docs: document turn journal fsync tradeoff (refs #2096)
2026-05-12 16:11:39 +00:00
dobby-d-elf
c9c70fc1c5 improve smoothness with punctiotion rules & performance optimizations 2026-05-12 09:06:08 -06:00
swftwolfzyq
f2e5e49442 Track updates by release tags 2026-05-12 22:52:12 +08:00
swftwolfzyq
5953c04cc4 Fix workspace-prefixed user turn leakage 2026-05-12 22:38:29 +08:00
dobby-d-elf
099fdaf012 fix(ui): stabilize chat bottom scrolling on iPhone PWA 2026-05-12 07:47:21 -06:00
JB
c5bad3e1c5 i18n: add French (fr) locale
Translation of all 938 string keys from English to French.
Generated programmatically with Google Translate.
2026-05-12 14:56:30 +02:00
Frank Song
76e611d49f Preserve fallback provider credential hints 2026-05-12 20:42:55 +08:00
Frank Song
b3f8bee96f Fix settings system mobile version wrapping 2026-05-12 20:42:55 +08:00
dobby-d-elf
516d942d6a refactor: reduce stale workspace recovery fix 2026-05-12 06:28:35 -06:00
Michael Lam
f5f59a5813 fix: audit turn journal terminal collisions 2026-05-12 05:20:06 -07:00
Frank Song
b718220077 Fix login health probe credentials 2026-05-12 20:09:54 +08:00
Frank Song
b7c5ba640c Fix custom live model scoping 2026-05-12 20:05:28 +08:00
dobby-d-elf
e03c197cdf fix: recover from stale deleted workspaces 2026-05-12 05:52:16 -06:00
Michael Lam
442f01bbca docs: document turn journal fsync tradeoff 2026-05-12 04:11:14 -07:00
Dennis Soong
f1b2a21bd4 feat: lazy-load full lineage segments 2026-05-12 18:02:49 +08:00
Michael Lam
dd543e4175 fix: purge missing inflight sessions 2026-05-12 02:57:37 -07:00
Frank Song
8fa92c680f Fix manual compression proxy timeouts 2026-05-12 17:33:59 +08:00
Michael Lam
265496782a docs: clarify compression anchor helpers 2026-05-12 01:43:16 -07:00
Michael Lam
7a16d09f10 fix: add Portuguese session management i18n 2026-05-11 23:23:41 -07:00
Michael Lam
a41b4d5afc fix: stack analytics usage cards on mobile 2026-05-11 23:07:35 -07:00
Michael Lam
245288c00d fix: bucket long-range daily token charts 2026-05-11 23:01:13 -07:00
nesquena-hermes
15d620392f Merge pull request #2119 from nesquena/stage-343
Some checks failed
Release & Docker / release (push) Has been cancelled
stage-343: ctl.sh bash 3.2 macOS compat fix (#2117) + regression test suite
2026-05-11 22:43:45 -07:00
Hermes Agent
8b8fa0b885 stage-343: add bash 3.2 compat regression tests + CHANGELOG
- New tests/test_ctl_bash32_compat.py (5 static-pattern assertions):
  * strict-mode is enabled (set -euo pipefail)
  * preserved[@] iteration is length-guarded (PR #2117)
  * CTL_BOOTSTRAP_ARGS[@] uses +alt expansion (commit 025f137f)
  * defense-in-depth: catch any future raw "${arr[@]}" w/o whitelist
  * denylist of bash 4+ features (declare -A, mapfile, [[ -v ]], etc.)
- Verified test fails when fix reverted, passes when restored.
- CHANGELOG: close v0.51.49, open Unreleased for #2117.
2026-05-12 05:36:31 +00:00
Hermes Agent
418848f8d2 Merge pull request #2117 from ayushere/fix/ctlsh-bash32-empty-array
fix(ctl): guard empty preserved array iteration for bash 3.2 compat (macOS default)
2026-05-12 05:34:48 +00:00
nesquena-hermes
f3d4d64fae Merge pull request #2118 from nesquena/stage-342
Some checks failed
Release & Docker / release (push) Has been cancelled
stage-342: 3-PR contributor batch — worktree status endpoint + worktree-retained response + Codex quota credential-pool fallback
2026-05-11 22:24:35 -07:00
Hermes Agent
10cfcee30e stage-342: apply Opus SHOULD-FIX — tighten worktree status _run_git timeout 5s → 2s
Worst case 4×5s=20s per polling request on ThreadingHTTPServer pool is risky
given today's _cron_env_lock near-miss on production 8787. Status probes
should fail fast; client can retry. All four call sites use default timeout.
2026-05-12 05:22:01 +00:00
Hermes Agent
08b6dc4f41 docs: CHANGELOG stage-342 — close v0.51.48, open Unreleased for #2109/#2113/#2116 2026-05-12 05:13:31 +00:00
Hermes Agent
4d64f6eee9 Merge pull request #2116 from starship-s/fix/codex-quota-pool-usage
fix(providers): load Codex quota from credential pool
2026-05-12 05:10:23 +00:00
Hermes Agent
1c659f374f Merge pull request #2109 from franksong2702/issue-2057-worktree-status
Add read-only worktree status endpoint (refs #2057)
2026-05-12 05:10:18 +00:00
Hermes Agent
6e62355c4b Merge pull request #2113 from franksong2702/issue-2111-worktree-retained-response
Prefer worktree retention responses in session UI (closes #2111)
2026-05-12 05:10:10 +00:00
Ayush Sahay Chaudhary
74926090e3 fix: guard empty array iteration for bash 3.2 compatibility
The _load_repo_dotenv_preserving_env() function iterates over
${preserved[@]} with set -euo pipefail. On bash 3.2 (macOS default),
an empty array triggers 'unbound variable' under set -u, crashing
ctl.sh start. Bash 4+ handles this fine, but macOS ships 3.2.

Wraps the for loop in a length check: [[ ${#preserved[@]} -gt 0 ]]
2026-05-12 10:34:02 +05:30
dobby-d-elf
ff0830de4d fix(ui): smooth iPhone PWA bottom-edge bounce in chat 2026-05-11 22:08:32 -06:00
starship-s
573fc25f96 fix(providers): load Codex quota from credential pool 2026-05-11 21:46:24 -06:00
dobby-d-elf
9e9ff30c78 adjust stream params 2026-05-11 21:13:31 -06:00
dobby-d-elf
8727d145fa Merge branch 'master' into smooth-text-fade 2026-05-11 20:35:40 -06:00
Frank Song
4e8899592d Prefer worktree retention responses in session UI 2026-05-12 10:17:12 +08:00
Frank Song
6e1e9fafbe Add worktree status endpoint 2026-05-12 10:08:01 +08:00
nesquena-hermes
306dd2bf09 Merge pull request #2110 from nesquena/stage-341
Some checks failed
Release & Docker / release (push) Has been cancelled
Release V0.51.48 — stage-341 (3-PR batch: title-retry fix + run-adapter RFC + worktree archive copy + 3 Opus SHOULD-FIX)
2026-05-11 17:19:17 -07:00
nesquena-hermes
d75b59135a stage-341: apply Opus SHOULD-FIX (it i18n + short-circuit logger.debug + docstring)
Opus advisor pass on stage-341 found three surgical items:

1. static/i18n.js:it — PR #2064 branched before stage-340 landed the 'it'
   locale (#2067), missing 9 session_*worktree* keys. Mechanical mirror of
   en/ja position. Italian falls back to English silently without this fix.
2. api/streaming.py — PR #2107's new break short-circuit was silent in both
   the aux and agent title-generation paths. Added logger.debug calls before
   each break so production logs surface the exit shape.
3. api/streaming.py — Expanded _title_should_skip_remaining_attempts docstring
   to document the membership criterion explicitly (vs the implicit
   reasoning-only-burn case it ships with today). Future additions
   (llm_safety_blocked, llm_oauth_quota) have a clear inclusion test.

CHANGELOG updated under the Stage-341 maintainer fixes section to mirror
the stage-340 pattern. All targeted tests pass (57/57 in the affected
modules).
2026-05-12 00:16:33 +00:00
nesquena-hermes
6947970e8d docs: CHANGELOG stage-341 — close v0.51.47, open stage-341 Unreleased
Renames the [Unreleased] section to [v0.51.47] (Release W, shipped today
via stage-340) and folds in the stage-341 batch — PR #2105 RFC, PR #2107
title-retry fix, PR #2064 worktree archive copy, plus the stage-341
maintainer fix (RFC conventions guidance).

Also removes the duplicate v0.51.46 heading line that landed in v0.51.47's
stage-340 merge (the duplicate was a no-op — empty body line under the
extra heading — but tidying it up here.
2026-05-12 00:07:25 +00:00
nesquena-hermes
43677b046d docs(rfcs): add anti-speculative-implementation conventions guidance
When merging PR #2105 (Hermes Run Adapter RFC) the standing concern was
that landing the RFC unconfirmed would invite the speculative-fragment
implementation pattern we just had to put on hold with PR #2071 — well-
written 651-LOC standalone scripts with no callers.

Add a single bullet to the conventions block so the contract is explicit:
an RFC is a design direction, not an invitation to PR fragments against
it. Implementation slices need maintainer confirmation first.

Applied during stage-341 build, not requested from @Michaelyklam — the
guardrail belongs in the conventions doc itself rather than as a one-off
ask on this PR.
2026-05-12 00:05:28 +00:00
Frank Song
2da4f108c5 Clarify worktree session archive/delete semantics
(cherry picked from commit f5c8fb58d1892f2c964389295530e8be5d84323f)
2026-05-12 00:05:05 +00:00
Michael Lam
de982d40a0 docs: add Hermes run adapter RFC
(cherry picked from commit 95cdaa6a1ff99ac1828faedb4ea68cc025a9f2e1)
2026-05-12 00:04:15 +00:00
nesquena-hermes
e20eb2c784 fix: skip budget-doubling title retry for reasoning-only responses (#2083)
Reasoning models (Qwen3-thinking via LM Studio, DeepSeek-R1, Kimi-K2,
etc.) can burn their entire output budget on hidden reasoning tokens and
emit no visible content. The previous title-generation retry path
classified that as llm_length and doubled the budget — but the second
call produces the same shape, so the retry only doubled the GPU/credit
burn. Repeated across the two prompts in _title_prompts() this came to
~3000 reasoning tokens of GPU work per new chat. On local LM Studio
servers behind a custom: provider (where is_lmstudio=False means
reasoning_effort: none never reaches the model) it manifested as the GPU
never going idle after a prompt.

Fix:
  - _extract_title_response: classify reasoning-bearing empty responses
    as llm_empty_reasoning regardless of finish_reason. The presence of
    reasoning_content is the diagnostic signal, not finish_reason.
  - _title_retry_status: drop llm_empty_reasoning from the retry set.
    Length-truncated responses WITHOUT reasoning still retry (those are
    legitimately recoverable by a larger budget).
  - Add _title_should_skip_remaining_attempts() and break out of the
    prompt-iteration loop on empty-reasoning. A second prompt against
    the same model would produce the same shape.
  - Falls through to _fallback_title_from_exchange for a local-summary
    title.

Tests updated to invert the previous reasoning-retry assertions:
  - test_aux_short_circuits_on_empty_reasoning_without_retrying
  - test_aux_still_retries_finish_length_without_reasoning
  - test_agent_route_short_circuits_on_empty_reasoning_without_retrying
  - test_agent_route_still_retries_finish_length_without_reasoning

Companion agent-side work (LM Studio classifier for custom: providers)
is tracked separately on the hermes-agent side; this WebUI fix is the
belt-and-braces guard so the loop stops regardless of agent classifier
state.

Reported by @darkopetrovic. Closes #2083.

Co-authored-by: darkopetrovic <darkopetrovic@users.noreply.github.com>
(cherry picked from commit efeae4a86e377069c0f09d140429ecb111a8dd1a)
2026-05-12 00:04:11 +00:00
dobby-d-elf
7fa2f70314 optimize performance & tune readability 2026-05-11 18:02:42 -06:00
nesquena-hermes
27fff66e4c Merge pull request #2108 from nesquena/stage-340
Some checks failed
Release & Docker / release (push) Has been cancelled
Release V0.51.47 — stage-340 (4-PR contributor batch: Italian locale + cron toast toggle + stale-gateway fix + CI hygiene)
2026-05-11 16:44:07 -07:00
dobby-d-elf
8ff368fd80 clean up implementation 2026-05-11 17:38:12 -06:00
nesquena-hermes
84e110db89 i18n(it): complete cron_toast_notifications_* keys
Opus SHOULD-FIX from stage-340 review. PR #2067 added the it locale
between en and ja; PR #2100 added 4 toast keys to 8 other locales but
missed it. Falls back to English via t() defaults so no user-visible
break, but it's an i18n parity hole.

4 LOC, mechanical add inside the it: block at the canonical position
(immediately after cron_profile_server_default_hint, mirroring en/ja).

Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Co-authored-by: Samuel Gudi <samuel.gudi.official@gmail.com>
2026-05-11 23:24:42 +00:00
nesquena-hermes
4c2c1190db docs: CHANGELOG Unreleased — stage-340 (4-PR contributor batch)
Italian locale + per-cron toast toggle + stale-gateway agent-health
fix + CI/console hygiene. One stage-340 test patch noted.

PRs: #2100 #2075 #2070 #2067.
2026-05-11 23:14:57 +00:00
nesquena-hermes
02ecc5aeea fix(tests): provide LOCALES on TestVoiceModePreferenceGate
PR #2067 made TestVoiceModePreferenceGate.test_settings_pane_has_voice_mode_i18n_keys
adaptive via self.LOCALES but only defined LOCALES on the sibling class
TestComposerVoiceButtonI18n. AttributeError on CI.

Mirror the tuple to TestVoiceModePreferenceGate so the count assert resolves
to 10 with Italian present.

Co-authored-by: Samuel Gudi <samuel.gudi.official@gmail.com>
2026-05-11 23:14:20 +00:00
Samuel Gudi
23a2ad818f fix(tests): update hardcoded locale counts for Italian (it)
6 test files had hardcoded locale counts/lists that broke when
the Italian locale block was added:

- test_issue1488_composer_voice_buttons.py: added 'it' to LOCALES,
  replaced assert count == 9 with len(self.LOCALES)
- test_issue1560_password_env_var_lock.py: added 'it' to LOCALES
- test_1560_password_env_var_no_op.py: added 'it' to EXPECTED_LOCALES
- test_login_locale_parity.py: bumped floor from 9 to 10, added 'it'
- test_stage268_opus_followups.py: bumped floor from 9 to 10

(cherry picked from commit f5e42cec9bc77354c594321b20ba83055d2e3cf7)
2026-05-11 23:13:55 +00:00
Samuel Gudi
ba3cc2c541 feat(i18n): add Italian (it) locale
Adds complete Italian translation for all ~280 UI strings in static/i18n.js
and the login page strings in api/routes.py (_LOGIN_LOCALE).

Ordered alphabetically: en → it → ja in both files.
Preserves all JS function templates, template literals, and plural forms.

(cherry picked from commit c66e04b190e960de2a2902157261a5e407501054)
2026-05-11 23:13:55 +00:00
ai-ag2026
98c9a3de72 test: tighten CI and console hygiene
(cherry picked from commit bd9e6df71c2e8a6f0902b9b7a348dc21c854141a)
2026-05-11 23:13:16 +00:00
Lumen Yang
e37c69cf57 fix(agent-health): treat stale running gateway as unknown
(cherry picked from commit 4be346fece529118b652485d9045080f03e326cf)
2026-05-11 23:13:09 +00:00
nesquena-hermes
0ee2a19cd8 Merge PR #2100 into stage-340 2026-05-11 23:12:59 +00:00
dobby-d-elf
e9c985a487 fix: make stream rAF cancellation explicit 2026-05-11 15:15:58 -06:00
ai-ag2026
52fedbc783 feat: add per-cron toast notification toggle 2026-05-11 21:58:35 +02:00
dobby-d-elf
67e29fa991 feat: add opt-in streaming text fade 2026-05-11 13:13:26 -06:00
nesquena-hermes
6b682a61f7 Merge pull request #2094 from nesquena/stage-339
Some checks failed
Release & Docker / release (push) Has been cancelled
Release V — v0.51.46 (5-PR contributor batch — CSP report-only + logs panel polish + plugin slash commands + turn-journal crash-safe writer + lifecycle events)
2026-05-11 10:56:05 -07:00
nesquena-hermes
4443e5845e docs: CHANGELOG v0.51.46 Release V (5-PR batch + 3 Opus SHOULD-FIX) 2026-05-11 17:53:38 +00:00
nesquena-hermes
96ca83bf53 fix(security): drop unsafe-eval + add jsdelivr to CSP, sanitize plugin error
Opus stage-339 review SHOULD-FIX items:

1. server.py: drop 'unsafe-eval' from CSP report-only policy.
   Verified by grepping all production JS — zero matches for eval(),
   new Function(), or string-form setTimeout/setInterval. Keeping it
   was a gratuitous privilege.

2. server.py: add https://cdn.jsdelivr.net to script-src + style-src.
   index.html loads Prism/xterm/katex from this CDN with SRI hashes —
   without the allowance every page load fires known-good CSP violations
   that drown out real signal once a collector is wired.

3. api/commands.py: sanitize plugin command error. Previously returned
   f'Plugin command error: {exc}' which would leak paths/env from
   FileNotFoundError('/etc/something/secret.key') etc. Now returns only
   the exception type name; full traceback goes to server log.

Test asserts updated to match the new policy shape.

Co-authored-by: Opus advisor <opus-advisor@hermes.local>
2026-05-11 17:53:02 +00:00
nesquena-hermes
55fbe66c79 docs: CHANGELOG Unreleased — stage-339 (5-PR batch + turn-journal stack) 2026-05-11 17:44:34 +00:00
nesquena-hermes
fd069155af Merge PR #2062 into stage-339
feat: record turn journal lifecycle events
by @ai-ag2026
2026-05-11 17:43:58 +00:00
nesquena-hermes
f6ce79185c Merge PR #2059 into stage-339
feat: add crash-safe turn journal writer
by @ai-ag2026
2026-05-11 17:43:58 +00:00
nesquena-hermes
2a1244f342 Merge PR #2089 into stage-339
support slash commands implemented in hermes plugin
by @plerohellec
2026-05-11 17:43:57 +00:00
nesquena-hermes
0456fb5619 Merge PR #2085 into stage-339
fix(logs): clipboard fallback + severity filter for Logs panel (#2081)
by @bergeouss
2026-05-11 17:43:56 +00:00
nesquena-hermes
9db1da76bd Merge PR #2084 into stage-339
fix: add report-only CSP header
by @ai-ag2026
2026-05-11 17:43:55 +00:00
nesquena-hermes
6b170513d4 Merge pull request #2091 from nesquena/stage-338
Some checks failed
Release & Docker / release (push) Has been cancelled
Release U — v0.51.45 (9-PR contributor batch — themes docs + skill cache + lineage forks + spinner + slug + recovery polish + compression anchor)
2026-05-11 10:33:46 -07:00
nesquena-hermes
85ae0acbdc docs: CHANGELOG v0.51.45 Release U (9-PR batch + Opus SHOULD-FIX) 2026-05-11 17:31:18 +00:00
nesquena-hermes
83de9d0cf0 fix(providers): log warning when custom provider entry yields empty slug
Opus stage-338 review SHOULD-FIX: silent drop at api/providers.py:1049
was diagnostically opaque. logger.warning() now surfaces the bad
config entry so operators can spot misconfigurations.

Co-authored-by: Opus advisor <opus-advisor@hermes.local>
2026-05-11 17:30:56 +00:00
nesquena-hermes
87bd9ea372 docs: CHANGELOG Unreleased — stage-338 (9 PRs) 2026-05-11 17:18:16 +00:00
nesquena-hermes
6a016dae6c Merge PR #2077 into stage-338
Refactor compression anchor visibility helpers
by @franksong2702
2026-05-11 17:17:25 +00:00
nesquena-hermes
98b6925333 Merge PR #2065 into stage-338
Fix session recovery polish
by @franksong2702

# Conflicts:
#	CHANGELOG.md
2026-05-11 17:17:24 +00:00
nesquena-hermes
0662f0986f Merge PR #2056 into stage-338
Fix custom provider name slugs with ports
by @franksong2702

# Conflicts:
#	CHANGELOG.md
2026-05-11 17:17:19 +00:00
nesquena-hermes
4388cb1a10 Merge PR #2068 into stage-338
fix(ui): prevent stuck sidebar spinner on completed sessions (closes #2066)
by @franksong2702
2026-05-11 17:17:05 +00:00
nesquena-hermes
2bfd538714 Merge PR #2063 into stage-338
fix: keep explicit forks out of lineage report
by @dso2ng
2026-05-11 17:17:05 +00:00
nesquena-hermes
ee6c67f30c Merge PR #2074 into stage-338
Fix HERMES_HOME skill cache patching
by @franksong2702
2026-05-11 17:17:04 +00:00
nesquena-hermes
da6b897e54 Merge PR #2076 into stage-338
test: add kanban locale parity check (refs #1973)
by @bergeouss
2026-05-11 17:17:03 +00:00
nesquena-hermes
d87b23e76f Merge PR #2073 into stage-338
test: allow top-level markdown docs
by @ai-ag2026
2026-05-11 17:17:01 +00:00
nesquena-hermes
7037b084de Merge PR #2088 into stage-338
docs(themes): align THEMES.md with Theme × Skin architecture
by @michael-dg
2026-05-11 17:17:00 +00:00
Philippe Le Rohellec
281a57b60a support slash commands implemented in hermes plugin 2026-05-11 09:42:40 -07:00
Michael De Gols
0f8ba4d8d3 docs(themes): align THEMES.md with Theme × Skin architecture
THEMES.md still described the pre-#627 model where each theme was a
monolithic palette name (Dark, Light, Slate, Solarized Dark, Monokai,
Nord, OLED). The current architecture splits appearance into two
orthogonal pickers:

- Theme (System / Dark / Light) — applied as `.dark` class on <html>
- Skin (8 named accent palettes) — applied as `data-skin` attribute

Rewrite the doc to:
- Open with the Theme × Skin separation and how they combine
- List the 3 themes and 8 actual skins shipped in static/style.css
  (default, ares, mono, slate, poseidon, sisyphus, charizard, sienna),
  with the same descriptive tone as the original
- Replace "Creating a Custom Theme" with "Creating a Custom Skin" as
  the primary extension point, with paired light + dark CSS variants
- Note the WebUI extensions surface (docs/EXTENSIONS.md) as a
  no-fork path for self-hosted custom skins
- Update internals to reflect classList.toggle('dark') + dataset.skin
  + dataset.fontSize instead of the old data-theme-only model
- Add a brief Font Size section since it sits in the same picker
- Keep a smaller Custom Theme section for the rare case someone wants
  to override the core palette, redirecting most users to skins

Docs-only change; no code touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:35:12 +02:00
bergeouss
85547612fe fix(logs): clipboard fallback + severity filter for Logs panel (#2081)
- replace navigator.clipboard.writeText with _copyText (has textarea fallback)
- add severity filter dropdown (All / Errors / Warnings+)
- add _severityForLine and _filteredLogsLines helpers
- add logsSeverityFilter HTML element + CSS class hooks
- add 5 new i18n keys across all 8 locales
- update test_logs_ui_static.py to match new implementation

Closes #2081
2026-05-11 15:40:49 +00:00
ai-ag2026
80c12123d2 Merge branch 'master' into fix/csp-report-only 2026-05-11 17:26:45 +02:00
ai-ag2026
c3fea4db3e fix: add report-only CSP header 2026-05-11 17:26:20 +02:00
ai-ag2026
c864ad47af fix: address turn journal lifecycle review 2026-05-11 17:16:43 +02:00
ai-ag2026
d04d48f5a0 fix: harden turn journal submitted writes 2026-05-11 17:13:57 +02:00
ai-ag2026
c4b7a65356 test: keep local context docs ignored 2026-05-11 17:09:19 +02:00
Frank Song
6a52edf2ab Fix stale inflight purge runtime lookup 2026-05-11 21:53:43 +08:00
Frank Song
18124ced62 Refactor compression anchor visibility helpers 2026-05-11 20:56:30 +08:00
bergeouss
c0ccefd322 test: add kanban locale parity check (refs #1973)
Add test_kanban_locale_parity to test_kanban_ui_static.py that asserts
every kanban_* i18n key in the English locale exists in all non-English
locale blocks. Pattern follows test_lineage_segment_locale_keys_are_defined_for_sidebar_locales.
2026-05-11 12:38:48 +00:00
Frank Song
c8d110a7f0 test: align sidebar spinner state assertions 2026-05-11 20:31:00 +08:00
Frank Song
a0e9c06102 Fix HERMES_HOME skill cache patching 2026-05-11 19:12:02 +08:00
ai-ag2026
d30263bcf1 test: allow top-level markdown docs 2026-05-11 12:36:35 +02:00
Frank Song
c60078b356 fix(ui): prevent stuck sidebar spinner on completed sessions (closes #2066)
The spinner (.session-state-indicator.is-streaming) can remain spinning
indefinitely on completed sessions when the INFLIGHT in-memory cache is
not cleaned up due to abnormal stream termination (page refresh, network
disconnect, gateway restart).

Add a staleness guard in _isSessionLocallyStreaming: if the server
reports is_streaming=false and last_message_at is older than 5 minutes,
force the streaming state to false regardless of stale INFLIGHT entries.
2026-05-11 17:54:14 +08:00
Frank Song
f6115b78c6 Fix custom provider name slugs with ports 2026-05-11 17:24:53 +08:00
Dennis Soong
5efd287264 fix: align fork lineage projection paths 2026-05-11 17:15:22 +08:00
Frank Song
2cd10868aa Fix session recovery polish 2026-05-11 16:30:25 +08:00
Dennis Soong
1e8d65ea01 fix: keep explicit forks out of lineage report 2026-05-11 15:23:52 +08:00
ai-ag2026
4b486f2860 feat: record turn journal lifecycle events 2026-05-11 09:13:25 +02:00
Nathan Esquenazi
b766b7f759 Merge pull request #2060 from nesquena/contributors-refresh-v0.51.44
docs(contributors): refresh contributor stats to v0.51.44
2026-05-11 00:03:19 -07:00
nesquena-hermes
b34643b92c docs(contributors): refresh contributor stats to v0.51.44
Update CONTRIBUTORS.md and the README contributors section to reflect
130 contributors and 568 PR credits as of v0.51.44 (was 66/142 at
v0.50.245). The numbers grew because:

- The previous refresh was 1 release-cycle ago (50+ tags + 8 batch
  releases of contributor PRs ago).
- The new counting rule explicitly includes closed-but-absorbed PRs:
  PRs whose original branch shows "closed" on GitHub but whose content
  shipped via batch-release squash with a Co-authored-by trailer, or
  via salvage rewrite with CHANGELOG attribution. This better reflects
  what users actually contributed.

The compilation pipeline:

1. Pull every closed PR from gh api (state=closed, both merged and
   unmerged on GitHub) — 1421 PRs.
2. Walk CHANGELOG.md release-by-release and extract:
   - `PR #N by @user` (canonical bullet form)
   - `(#N by @user`, `(PR #N by @user`, `(#N, @user;`
   - `PRs #A, #B by @user` (plural)
   - `@user — PR #N`, `@user — N PR (#A, #B)`
   - `(credit: @user)` and `(credit: @userA and @userB)`
3. For every PR# mentioned in CHANGELOG, union the explicit @-attributed
   users with the gh PR author (when external). Maintainer accounts
   (@nesquena, @nesquena-hermes) are excluded.
4. For PRs merged on GitHub but not mentioned in CHANGELOG (very early
   PRs, non-noteworthy direct merges), credit the gh author.
5. Three salvaged-design contributors not directly in CHANGELOG are
   credited in the special-thanks roll: @indigokarasu (#213 →
   v0.50.0 design language), @andrewy-wizard (#177 → initial Chinese
   locale absorbed into v0.42.0), @zenc-cp (#133 → anti-hallucination
   guard absorbed into streaming.py).

Pre-cleaning step strips HTML entities (`&#10;` etc.) before PR# scan
to avoid false matches. PR# regex requires a whitespace/paren/bracket
preceder so identifiers like `--key=123` and `(##10`-style headings
don't pollute the count.

Per-user first/last release computed from:
- For merged-on-GH PRs: the smallest tag whose creator-date is >= the
  PR's merged_at timestamp.
- For absorbed PRs: the release section in CHANGELOG that explicitly
  attributes to the user (or the earliest release that mentions the
  PR# if no explicit attribution exists for that user).

CONTRIBUTORS.md sections:
- Top contributors (5+ PRs) — 20 people, ranked
- Sustained contributors (3–4 PRs) — 11 people
- Two-PR contributors — 14 people, flat list
- Single-PR contributors — 85 people, flat list
- How credit is tracked — four paths described
- Special thanks — 11 highlight blurbs

README contributors section trimmed to top-10 table + notable-
contribution blurbs (29 distinct contributors mentioned with concrete
PR numbers). Same data, condensed for the README.

No code changes. Docs only.
2026-05-11 06:59:42 +00:00
ai-ag2026
5cd001d545 feat: add crash-safe turn journal writer 2026-05-11 08:49:53 +02:00
nesquena-hermes
f00cb74f77 Merge pull request #2058 from nesquena/stage-337
Some checks failed
Release & Docker / release (push) Has been cancelled
Release T (v0.51.44): 5-PR batch (#2048 + #2052 + #2053 + #2055 + #1970) + test-suite network isolation
2026-05-10 23:20:29 -07:00
nesquena-hermes
cd7107cefb test(infra): identity check by qname (CI re-imports conftest under multiple roots)
CI's pytest invocation imports conftest twice (once via the standard
tests/ discovery, once via repo-root rootdir discovery), producing two
distinct function objects with the same __qualname__ but different `is`
identity. The strict identity assertion failed because each import
created a fresh closure. Switch to __qualname__ substring check — same
guarantee (default-on state has the wrapper installed; fixture restores
the real one) without the multi-import sensitivity.
2026-05-11 06:18:13 +00:00
nesquena-hermes
d9bc8360a4 test(infra): fixture swaps real functions via monkeypatch (CI-robust)
CI on Python 3.11 still failed test_allow_outbound_network_fixture_*
because the previous module-global toggle (_ALLOW_OUTBOUND=True/False)
was unreliable on the runner — the wrapper's global lookup at call time
sometimes saw False even after the fixture's True assignment.

Switch to monkeypatch-based fixture: instead of toggling a global that
the wrapper checks, restore socket.create_connection and
socket.socket.connect to their REAL captured implementations for the
duration of the test. Pytest's monkeypatch fixture handles teardown so
the wrappers are reinstalled automatically.

Rewrote the two paired tests to check function identity
(socket.create_connection is _hermes_blocked_create_connection vs. is
_REAL_CREATE_CONNECTION) instead of attempting a live outbound to
8.8.8.8:53 — direct identity check is hermetic and doesn't depend on
whether the CI runner has any outbound network access at all.
2026-05-11 06:15:46 +00:00
nesquena-hermes
6d83d16016 test(infra): tighten IPv6 unique-local check + replace self-passing fixture test
Two low-severity follow-ups from Opus regrounding review:

1. The IPv6 unique-local fc00::/7 check was `h.startswith('fc') or
   h.startswith('fd')` — too loose. It would also classify hostnames
   like 'food.example.com' or 'fdsa.test' as 'local' and silently let
   them through the block. Tightened to a regex match for canonical
   IPv6 syntax (`f[cd][0-9a-f]{0,2}:`) so only actual IPv6 addresses
   match. Same fix in both tests/conftest.py and server.py.

2. test_allow_outbound_network_fixture_unblocks was technically
   self-passing: it tried to connect to a *.invalid hostname, which is
   in the allow-list, so the real socket.create_connection would run
   regardless of whether the fixture toggled the block. Replaced with
   a public-IP-based test that actually proves the toggle works, plus
   a paired test_block_is_active_outside_the_fixture sanity test that
   proves the block is on without the fixture.

Both follow-ups noted by Opus advisor as 'defer-OK' but trivial fixes
so landing them in this batch.
2026-05-11 06:12:07 +00:00
nesquena-hermes
23cfc99738 fix(config): split hermes_cli and urlopen fallback in lmstudio branch (CI fix)
CI on Python 3.13 (clean editable install, no hermes_cli package) was still
failing the 3 lmstudio tests after the first fix attempt. Root cause: the
outer try/except in the lmstudio branch was catching ImportError from
`from hermes_cli.models import provider_model_ids`, hijacking the whole
branch and silently skipping the urlopen fallback.

Restructured into two independent tiers:
  1. hermes_cli lookup in its own try/except — ImportError logs at DEBUG
     and continues with lm_ids=[].
  2. urlopen fallback runs unconditionally when lm_ids is empty, including
     after hermes_cli import failure.

New regression test `test_lmstudio_fallback_works_when_hermes_cli_unavailable`
explicitly blocks hermes_cli via sys.meta_path and verifies the lmstudio
group still populates from the urlopen fallback. Without this test, the
CI-vs-local divergence (local env had hermes_cli installed, CI didn't)
would keep slipping through.

All 12 lmstudio-related tests pass, including the 3 #1527 tests that
broke on stage-337.
2026-05-11 06:06:58 +00:00
nesquena-hermes
1819ead93d docs: CHANGELOG v0.51.44 Release T (5-PR batch + test network isolation) 2026-05-11 06:03:12 +00:00
nesquena-hermes
12cef733e3 fix(recovery): preserve worktree metadata + workspace + message_count on state.db sidecar rebuild
PR #2053 added worktree-backed session creation. PR #2041 (shipped in
v0.51.42) added state.db sidecar reconciliation that rebuilds a missing
<sid>.json sidecar from the canonical state.db row when the JSON file is
gone (failed save, manual rm, restore-from-backup with mismatched dirs).

The two interact silently. `_state_db_row_to_sidecar()` was hard-coding
`'workspace': ''` and never propagating the four worktree_* fields from
the row to the rebuilt sidecar dict. So a worktree-backed session that
loses its sidecar and gets rebuilt from state.db:

- loses `worktree_path` → matches the empty-session sidebar filter at
  `api/models.py:1067/1107` (which spares worktree-backed empty sessions
  via `not s.get('worktree_path')`) → session disappears from the
  sidebar even though the worktree directory still exists on disk.

- loses `workspace` → downstream tools (terminal panels, file pickers
  that use `s.workspace`) operate on empty string instead of the original
  worktree path.

- always reports `message_count == 0` → contributes to the empty-session
  filter even for sessions that have messages in `state.db.messages`.

Fix:

1. `_read_state_db_missing_sidecar_rows()` SELECT now includes
   `workspace, worktree_path, worktree_branch, worktree_repo_root,
   worktree_created_at, message_count` (each gated by
   `_sql_optional_col()` so older state.db schemas without those columns
   continue to work — recovery degrades gracefully rather than 500ing).

2. `_state_db_row_to_sidecar()` propagates each field. workspace comes
   from the row if it's a string, otherwise '' (matching pre-fix behavior
   for non-worktree sessions). message_count comes from the row if
   it's an int, otherwise falls back to `len(messages)` so the rebuilt
   sidecar always has a coherent count.

3 new regression tests in tests/test_state_db_worktree_recovery.py
exercise:
- worktree session with messages → all four worktree_* fields preserved.
- non-worktree session → worktree_* fields all None (no spurious
  propagation), workspace=''.
- empty worktree session (the worst case) → confirms the rebuilt sidecar
  does NOT match the empty-session-exempt filter, so it stays visible
  in the sidebar.

Caught by Opus advisor during stage-337 review (the cross-PR interaction
between #2053 and the previously-shipped #2041 wasn't exercised by either
PR's individual test suite).
2026-05-11 06:00:13 +00:00
nesquena-hermes
2ca220eec0 fix(config): PR #1970 lmstudio branch must honor cfg.model.base_url fallback
PR #1970 added a dedicated `elif pid == "lmstudio":` branch in
`get_available_models()` that fetches the live /v1/models list when the
hermes_cli helper doesn't have ids cached. The fallback path inside that
branch only looked at `cfg["providers"]["lmstudio"]["base_url"]`, missing
the historical config shape where the URL lives under `cfg["model"]`:

  model:
    provider: lmstudio
    base_url: http://192.168.1.22:1234/v1   ← here, not under providers.lmstudio
  providers:
    lmstudio:
      api_key: local-key

3 pre-existing tests in tests/test_issue1527_lmstudio_base_url_classification
broke on stage-337 because of this — they passed on master, failed after
the PR #1970 merge.

The simpler fix is to enhance the already-introduced `_get_provider_base_url()`
helper so it falls back to `cfg["model"]["base_url"]` when
`cfg["model"]["provider"] == provider_id`, then use the helper inside the
lmstudio branch instead of a direct lookup. This keeps the previous
behaviour (where the generic configured-provider branch handled lmstudio
via the model block) while preserving PR #1970's live-discovery additions.

Belt-and-suspenders: `_get_provider_base_url()` explicitly does NOT inherit
model.base_url for providers other than the active one — if a user's config
says `model.provider: anthropic` and they have `providers.openai` configured
without a base_url, openai must still resolve to None (use SDK default),
not to the anthropic proxy URL.

6 new regression tests in tests/test_pr1970_lmstudio_base_url_fallback.py
lock the two-location lookup, the precedence rule (explicit providers entry
wins over model fallback), trailing-slash stripping, and the negative case
(model.base_url MUST NOT leak to non-active providers).

All 51 tests in the existing model-resolver + custom-provider banks still
pass.

Caught by maintainer review on stage-337 (full pytest with the new network
isolation in place surfaced the regression that the fork-CI mock-server path
would have hidden).
2026-05-11 05:59:59 +00:00
nesquena-hermes
a6174d08db test(infra): hermetic network isolation — block all outbound from tests
Tests should not reach the public internet. Before this commit, an
accidentally-leaking outbound socket from the test_server fixture (real
TLS handshakes to Anthropic / Amazon / OpenRouter, sometimes triggered
by SDK-init paths that found a credential the credential-strip allowlist
missed) was adding 60+s of wall-time to a 100s test run and creating a
class of flaky failures.

This installs a default-deny socket-block at two layers:

1. Pytest process, via tests/conftest.py module-level monkey-patch on
   socket.create_connection + socket.socket.connect. Loopback / RFC1918
   private / link-local / RFC2606 reserved-TLD destinations pass through;
   anything else raises OSError("hermes test network isolation: outbound
   to ... blocked"). Tests that legitimately need real outbound opt back
   in via the new `allow_outbound_network` fixture (no current callers).

2. Test_server subprocess (server.py), via a HERMES_WEBUI_TEST_NETWORK_BLOCK=1
   environment-variable-gated guard at the top of server.py. tests/conftest.py
   sets the env var on every test_server spawn. Without this, the subprocess
   could make outbound that the pytest-side block can't see (which is exactly
   what was happening — verified via `ss -tnp` showing the server.py child
   with established ESTAB sockets to [2607:6bc0::10]:443).

In production the env var is unset, so the guard is a no-op.

Companion changes:

- test_dns_resolution_failure refactored to mock socket.getaddrinfo
  raising gaierror, instead of relying on a real DNS lookup of a
  *.invalid hostname. The test was the one outlier that genuinely
  exercised real DNS; mocking matches what every other probe-error test
  in the same file already does.

- New tests/test_conftest_network_isolation.py with 9 adversarial
  tests proving the block fires for public IPs (including the exact
  Anthropic IPv6 and Amazon IPv4 destinations we observed leaking),
  the allow-list passes loopback / RFC1918 / link-local / reserved-TLDs,
  and the opt-in fixture re-enables real outbound when needed.

Test suite: 5,120 → 5,192 (+72 net new from this commit + the regression
tests in the companion commits). Wall time: 161s → 95s on the same
hardware. No remaining outbound from any test path.
2026-05-11 05:59:42 +00:00
nesquena-hermes
d86dcc12c6 Merge PR #2055: fix: duplicate assistant transcript merge 2026-05-11 05:12:05 +00:00
nesquena-hermes
e0ecf2a035 Merge PR #1970: feat: LM Studio provider with live model discovery 2026-05-11 05:12:04 +00:00
nesquena-hermes
44e7378be8 Merge PR #2053: feat: worktree-backed session creation
# Conflicts:
#	CHANGELOG.md
2026-05-11 05:12:00 +00:00
nesquena-hermes
48cccbcd2e Merge PR #2052: docs: add first-run onboarding guide 2026-05-11 05:11:23 +00:00
nesquena-hermes
e3001d16fc Merge PR #2048: [security] validate workspace on import 2026-05-11 05:11:21 +00:00
Frank Song
5a445e7562 Fix duplicate assistant transcript merge 2026-05-11 13:09:16 +08:00
nesquena-hermes
640cf6e6a9 Merge pull request #2054 from nesquena/feat/sidebar-collapse-fused
Some checks failed
Release & Docker / release (push) Has been cancelled
feat(ux): collapse sidebar by clicking the active rail icon (fuses #1884 + #1924)
2026-05-10 22:04:29 -07:00
nesquena-hermes
b13bc9619c docs: CHANGELOG v0.51.43 Release S 2026-05-11 05:02:13 +00:00
Nathan Esquenazi
ba66872f70 fix(sidebar): align collapse CSS breakpoint with JS _isDesktopWidth (641px)
`_isDesktopWidth()` in boot.js gates every collapse path on
`matchMedia('(min-width:641px)')` — matching where the rail itself becomes
visible. The CSS rules driving the actual visual collapse were nested inside
the workspace-panel block at `@media(min-width:901px)` — a threshold copied
from the right-panel collapse but with no functional reason to apply here.

Behavioural consequence in the 641–900 px band (tablet portrait + small
laptop windows):

  - Rail is visible, user clicks the active icon
  - JS adds `.layout.sidebar-collapsed` and writes localStorage='1'
  - JS sets aria-expanded='false' on the active rail button
  - CSS at min-width:901px does NOT apply → sidebar stays at 300 px width
  - User sees no visual change; screen reader announces collapsed state for
    a sidebar that is still visible; localStorage silently persists
  - Resize to ≥901 px later → sidebar suddenly collapses (surprise state)

Fix: hoist the three `.sidebar-collapsed` / flash-prevention rules out of
the workspace-panel @media block and into their own `@media(min-width:641px)`
block. The rail visibility breakpoint, the JS gate, and the CSS gate now
all agree.

`:not(.mobile-open)` is preserved on both selectors so the mobile slide-in
overlay (handled in the `max-width:640px` block) is never targeted — the
new @641 boundary doesn't change that contract.

Verified breakpoint matrix end-to-end (Node harness over real boot.js +
style.css):

  Width | JS desktop | CSS applies | Effect
  ------|------------|-------------|------------
   640  | no         | no          | no-op (mobile overlay)
   641  | yes        | yes         | collapses ✓
   700  | yes        | yes         | collapses ✓
   768  | yes        | yes         | collapses ✓
   900  | yes        | yes         | collapses ✓
   1024 | yes        | yes         | collapses ✓

Regression test added: `test_css_breakpoint_matches_js_isdesktopwidth`
parses boot.js for the `_isDesktopWidth` matchMedia query, walks CSS to
find the @media block enclosing `.layout.sidebar-collapsed`, and asserts
the thresholds match. Locks the invariant so a future refactor can't
re-introduce the asymmetric-band silent-state-leak.

Test counts:
  - tests/test_sidebar_collapse_toggle.py: 35/35 pass (was 34, +1 regression)
  - Full suite (Python 3.14, local): 5040 passed, 0 failed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:57:47 -07:00
Frank Song
db6857ba86 Address worktree session review notes 2026-05-11 12:51:57 +08:00
nesquena-hermes
1a2cf2812c test(conftest): block AWS IMDS probing + expand credential-strip allowlist
Two test-infrastructure fixes surfaced while running the full suite on
this branch. Both prevent accidental outbound network calls from the
pytest process — a class of bug that doesn't show up as test failures
but corrupts timing, leaks credentials, and was responsible for a recent
10× slowdown observation.

## 1. AWS_EC2_METADATA_DISABLED for the whole pytest session

When hermes-agent's bedrock_adapter / botocore credential chain is
imported during tests (e.g. via api/config.py provider-catalog imports),
botocore probes the EC2 Instance Metadata Service at 169.254.169.254
looking for an instance role. On VPS hosts where IMDS is reachable but
rate-limited (HTTP 429) or non-responsive, those probes dominate wall
time — a 161s test run was observed extending to 600+s.

Set `AWS_EC2_METADATA_DISABLED=true` at module load (before any test-file
imports trigger botocore initialisation). This is the documented AWS-
supported way to silence the probe and matches the guard the agent's own
`hermes_cli/doctor.py` already uses inside its parallel-probe block.

Also explicitly re-set the var on the spawned test-server env so it
can't be accidentally cleared by a later `env.update(...)`.

## 2. Expanded credential-strip allowlist

The original strip list covered 6 providers (OpenRouter, OpenAI,
Anthropic, Google, DeepSeek, Xiaomi). Several others leaked through
into the test server subprocess:

- `MEM0_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, `OLLAMA_API_KEY`,
  `GROQ_API_KEY`, `TOGETHER_API_KEY`, …
- AWS credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`,
  `AWS_SESSION_TOKEN`, `AWS_PROFILE`, `AWS_BEARER_TOKEN_BEDROCK`)
- Messaging bot tokens (`TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`,
  `SLACK_BOT_TOKEN`, `SIGNAL_API_TOKEN`, `WHATSAPP_API_TOKEN`)
- Memory providers (`HONCHO_API_KEY`, `SUPERMEMORY_API_KEY`)
- Search / browser / image-gen (`FIRECRAWL_API_KEY`, `FAL_KEY`,
  `TAVILY_API_KEY`, `SERPER_API_KEY`, `BRAVE_API_KEY`)
- GitHub tokens (`GH_TOKEN`, `GITHUB_TOKEN`)
- Azure OpenAI (`AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`)

A real outbound TLS connection to a provider's IPv6 endpoint was
observed during a test run on this host before the strip was expanded.
The test server uses a mock config and has no business making real API
calls.

## Test status

5,151 passed / 11 skipped / 1 xfailed / 2 xpassed / 0 regressions in
139s on Python 3.11. Down from 147s before the fixes (and from
intermittent 10×-slowdowns on IMDS-rate-limited hosts). All API/feature
contracts unchanged.

## Security audit of remaining test-suite host references

Every IP / URL / hostname referenced in `tests/**.py` was classified:
- Loopback (127.0.0.1, localhost, ::1, 0.0.0.0)
- RFC1918 private (10.*, 172.16-31.*, 192.168.*)
- RFC 5737 TEST-NET-3 documentation (203.0.113.*)
- RFC 2606 reserved docs domains (*.example.com, *.example.local,
  *.example.test)
- Security-attack input strings used only as parser/validator input
  (evil.com, attacker, evil.example.com — never resolved or contacted)
- Real provider/CDN endpoints used only as `base_url` config strings
  or CSP-allowlist assertions — never actually fetched
- 8.8.8.8 used only as a "non-loopback example" in `_is_local_from_handler()`
  unit tests

No suspicious egress destinations.
2026-05-11 04:49:46 +00:00
nesquena-hermes
2dbee503c2 feat(ux): collapse sidebar by clicking the active rail icon (fuses #1884 + #1924)
Lets desktop users collapse the session-list sidebar to maximise the chat
area, without adding any visible UI affordance. Default appearance is
identical to master — only users who actively try to toggle (or know the
keyboard shortcut) ever see a difference.

## Behaviour (desktop only, ≥641px)

| State                              | Action                | Result                                  |
|------------------------------------|-----------------------|-----------------------------------------|
| Sidebar open, click active rail    | Toggle                | Sidebar collapses to width:0            |
| Sidebar open, click different rail | Normal switch         | **Sidebar stays open** (no surprise)    |
| Sidebar collapsed, click any rail  | Expand + switch       | Sidebar expands, then panel switches    |
| Anywhere, Cmd/Ctrl+B               | Toggle                | Same as same-active-rail click          |
| Mobile (<641px), any of the above  | No-op                 | Mobile overlay behaviour unchanged       |

Two discoverability paths, both opt-in. **No new visible buttons.** Users
who never click the active rail icon see zero UI change vs. master.

## Surface-minimal design

The behaviour is contained behind one extra arg on the rail/sidebar-nav
onclick: `switchPanel('chat',{fromRailClick:true})`. Without that flag the
function preserves master's behaviour exactly — every programmatic
`switchPanel(name)` callsite (commands, deeplinks, internal state changes)
is unaffected. The guard chain inside `switchPanel`:

  opts.fromRailClick && _isDesktopWidth() && (
      _isSidebarCollapsed() ? expandSidebar() :
      prevPanel === nextPanel ? (toggleSidebar(true); return false))

is the ONLY new code path that can cause a collapse. Cross-panel clicks
fall through to the existing switch logic untouched.

## Polish from both source PRs

- **Click-active gesture** as the primary toggle (#1884 @jasonjcwu — the
  genuine UX innovation; no extra button needed)
- **Cmd/Ctrl+B keyboard shortcut** (#1924 @spektro33; VS Code convention).
  Guarded against firing when typing in INPUT / TEXTAREA / contenteditable
  so the shortcut never steals from in-progress text editing.
- **Inline flash-prevention `<script>`** in `<head>` (#1924) sets
  `data-sidebar-collapsed='1'` on `<html>` BEFORE the stylesheet loads,
  so cold loads with a persisted-collapsed state paint correctly from
  frame 0 with no flicker. Cleared by JS once the class system takes over.
- **Smooth slide animation** via `.24s cubic-bezier(.22,1,.36,1)`
  (#1924, mirrors the existing workspace-panel collapse on the right)
- **`aria-expanded` mirrored** on the active rail button (#1884) so
  screen readers announce open/collapsed transitions.
- **`body.resizing` transition-suppression** (#1884) keeps the drag-resize
  cursor instant — no animation during a width-resize gesture.
- **bfcache `pageshow` re-sync** (#1884) — if another tab toggled the
  sidebar while this page was frozen, bring it in line on restore.

## Drops vs. #1924

- No persistent rail "toggle sidebar" button (Nathan: keep the UI stealth)
- No close-X button in chat panel head (same reason)
- No i18n keys for the dropped buttons

## What did NOT change

- 22 rail/sidebar-nav `onclick` handlers gained the `{fromRailClick:true}`
  arg — function-call shape, invisible to users
- 1 inline `<script>` in `<head>` (flash prevention) — invisible
- 5 lines of CSS — invisible unless someone collapses

That's the entire visible-UI delta. **23 ins / 22 del on `index.html`,
all string-replace.**

## Verification

- 5,151 pytest passing including a new 34-test structural suite covering
  every contract (CSS rules, JS functions, fromRailClick guard, legacy
  proxy forwarding, flash-prevention `<script>` ordering, mobile
  exclusion via :not(.mobile-open) selector, aria-expanded sync).

- Live browser walkthrough at 1280px verified:
  - Default boot state identical to master (sidebar open, width 300px)
  - Click active rail → collapse (width 1, opacity 0, translateX -14px,
    localStorage='1', aria-expanded=false). Panel unchanged.
  - Click active rail again → expand back to width 300, aria=true
  - Click DIFFERENT rail → normal switch, sidebar stays open (legacy-
    preserving case, verified explicitly)
  - Click rail while collapsed → expand + switch in one gesture
  - Cmd+B toggles correctly
  - Cmd+B inside `<textarea>` → suppressed (defaultPrevented=false)
  - Reload with collapsed state persisted → restores without flash
  - Mobile simulation (matchMedia returns false for min-width:641px):
    same-active-rail click is no-op, Cmd+B is no-op, sidebar stays at 300px

Co-authored-by: jasonjcwu <jasonjcwu@users.noreply.github.com>
Co-authored-by: spektro33 <spektro33@users.noreply.github.com>
Closes #1884
Closes #1924
2026-05-11 04:49:18 +00:00
Frank Song
186453ea0e Add worktree-backed session creation 2026-05-11 12:12:40 +08:00
Frank Song
7aa1a5f42c docs: add first-run onboarding guide 2026-05-11 11:47:26 +08:00
nesquena-hermes
9c9d65a41a Merge pull request #2049 from nesquena/stage-336
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.42 (Release R): 5-PR contributor batch — session recovery state.db reconciliation + RFC convention + MEDIA_ALLOWED_ROOTS + Slack cron delivery
2026-05-10 20:04:41 -07:00
nesquena-hermes
0c26ab3425 test(conftest): strip HERMES_WEBUI_SKIP_ONBOARDING env globally; rfcs: note discussion-first for contributor RFCs
Two follow-ups from Opus pre-release review of stage-336:

1. tests/conftest.py — autouse session fixture that removes
   HERMES_WEBUI_SKIP_ONBOARDING from os.environ for the whole pytest run, and
   restores it after. Hosting providers and isolated harnesses set this var
   to short-circuit the onboarding wizard, but it leaked into pytest and
   caused tests that exercise apply_onboarding_setup() to fail with cryptic
   FileNotFoundError. Tests that specifically validate the short-circuit
   behavior can opt back in with monkeypatch.setenv. Surgical per-test
   delenv calls remain as defense-in-depth but are now redundant.

2. docs/rfcs/README.md — one-line note that first-time contributor RFCs
   should be discussed in an issue before opening a PR. Gates drive-by
   design-doc PRs without us having to decline them on contribution.

Verified: 96 onboarding-related tests pass with HERMES_WEBUI_SKIP_ONBOARDING=1
exported in the test runner env (would have failed before this fixture).
2026-05-11 03:02:01 +00:00
nesquena-hermes
8c803c0a07 fix(tests): clear two test failures (one pre-existing, one bumped by #2044)
1. test_issue1362_codex_oauth_onboarding.py::test_anthropic_onboarding_setup_allows_linked_oauth_without_api_key
   Pre-existing env-collision bug, surfaced when HERMES_WEBUI_SKIP_ONBOARDING=1
   is in the test runner env (set by hosting providers and by isolated test
   harnesses). `apply_onboarding_setup()` short-circuits without writing the
   config file when SKIP_ONBOARDING is set, but the test asserts the file was
   written, so it fails with FileNotFoundError on read_text().
   Fix: `monkeypatch.delenv("HERMES_WEBUI_SKIP_ONBOARDING", raising=False)` —
   matches the convention already used in test_issue1499_keyless_onboarding.py
   and test_issue1500_lmstudio_env_var_alignment.py.

2. test_issue1800_file_html_interactions.py::test_media_html_inline_keeps_csp_sandbox
   Slicing-based source-string assertion (4000-char window after `def _handle_media`)
   broke because PR #2044's MEDIA_ALLOWED_ROOTS parsing was inserted earlier in
   the function and pushed the CSP block to offset 4211. Widened window to 5000.
   Assertion content is structural (CSP sandbox string present), not positional.
2026-05-11 02:55:50 +00:00
nesquena-hermes
7e25c6f55d docs: CHANGELOG v0.51.42 Release R 2026-05-11 02:47:01 +00:00
hinotoi-agent
3fd20599e8 fix: validate workspaces on session import 2026-05-11 10:46:17 +08:00
George Davis
8178c5e57b feat: add slack to cron delivery options 2026-05-11 02:45:46 +00:00
Chris Watson
8566462b72 feat: add MEDIA_ALLOWED_ROOTS env var for configurable /api/media whitelist
The /api/media endpoint only serves files from ~/.hermes, /tmp, and the
active workspace. Power users with media in custom directories (models,
Downloads, Pictures, ComfyUI outputs) have no way to serve those files
inline without copying or symlinking.

Add MEDIA_ALLOWED_ROOTS env var — a colon-separated list of absolute
paths — that extends the allowed roots at runtime. Each entry is resolved
and validated as an existing directory before being appended. Non-existent
or invalid paths are silently skipped.

This is purely additive: the built-in security whitelist is unchanged,
and if MEDIA_ALLOWED_ROOTS is unset, behavior is identical to before.
2026-05-11 02:45:46 +00:00
nesquena-hermes
7690e08e70 docs(rfcs): establish docs/rfcs/ convention and polish turn-journal RFC
Moves docs/turn-journal-rfc.md → docs/rfcs/turn-journal.md, establishing
the convention for future design documents on hermes-webui's data-at-rest
and recovery surfaces. Adds docs/rfcs/README.md describing when an RFC
applies (large changes, durability/recovery semantics, new infrastructure
primitives) and the simple status header convention.

Polish on turn-journal.md:
- Added 3-line status header (Status / Author / Created) at top.
- Light tone edits on two flourishes that read fine in a PR description
  but felt off in permanent repo documentation. Author's voice preserved
  throughout the rest of the document.

Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
2026-05-11 02:45:38 +00:00
nesquena-hermes
9f3f8ea902 fix(recovery): close concurrency hazards in state.db sidecar reconciliation
Two concrete data-corruption vectors flagged in Opus review of PR #2041,
both fixed atomically so the new repair-safe endpoint is safe for production:

1. Shared tmp filename under concurrent calls
   `tmp = target.with_suffix('.json.reconcile.tmp')` produced a fixed path
   per session ID. Two simultaneous repair-safe POSTs would interleave bytes
   in the same tmp file, then both rename → corrupted JSON. Now matches the
   `Session.save()` convention at api/models.py:484 with a pid+tid suffix.

2. TOCTOU between target.exists() check and tmp.replace(target)
   `os.replace()` overwrites unconditionally. If a concurrent Session.save()
   for the same SID materialized the live sidecar in the microsecond window
   between the existence check and the rename, the reconciliation would
   silently overwrite a live sidecar with a (lossier) state.db reconstruction.
   Switched to `os.link()` + `unlink(tmp)` which is atomic create-or-fail —
   on FileExistsError we record `skipped: sidecar_appeared_during_reconcile`
   and keep the live sidecar untouched.

Plus a round-trip schema-parity test: materialize a sidecar from state.db,
then load it back through `Session.load()` and assert the messages survive.
Catches future schema drift between `_state_db_row_to_sidecar()` and
`Session.__init__()`. Also adds a guard test confirming the .reconcile.tmp
suffix includes pid+tid (regression guard for hazard #1).

Tests: 23 passing across the recovery suite (was 21; +2 new in this commit).

Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
2026-05-11 02:44:38 +00:00
ai-ag2026
c710efb463 docs: propose crash-safe turn journal 2026-05-11 02:43:00 +00:00
ai-ag2026
a34ded8e99 feat: reconcile missing WebUI sidecars from state db 2026-05-11 02:43:00 +00:00
ai-ag2026
90c3611732 feat: expose session recovery audit and safe repair endpoints 2026-05-11 02:43:00 +00:00
nesquena-hermes
14839248ea Merge pull request #2043 from nesquena/stage-335
Some checks failed
Release & Docker / release (push) Has been cancelled
Release Q — v0.51.41 — 3-PR contributor batch (session recovery audit + run-lifecycle health + transcript dedup)
2026-05-10 17:52:39 -07:00
nesquena-hermes
4bbed44b21 docs: CHANGELOG v0.51.41 Release Q 2026-05-11 00:43:59 +00:00
nesquena-hermes
db32b70771 Merge PR #2038 into stage-335
# Conflicts:
#	CHANGELOG.md
2026-05-11 00:25:35 +00:00
nesquena-hermes
97b283c5a4 Merge PR #2039 into stage-335 2026-05-11 00:25:07 +00:00
nesquena-hermes
91f1e3df94 Merge PR #2036 into stage-335 2026-05-11 00:25:06 +00:00
nesquena-hermes
50acda3919 Merge pull request #2037 from nesquena/stage-334
Some checks failed
Release & Docker / release (push) Has been cancelled
Release P — v0.51.40 — 4-PR contributor batch (quota subprocess hardening + env-lock prewarm + cron one-shot warning + Xiaomi env key)
2026-05-10 17:21:46 -07:00
ai-ag2026
2ead7daa2f fix: expose active run lifecycle in health 2026-05-11 02:15:00 +02:00
Frank Song
642249747f Fix session message identity dedup 2026-05-11 08:14:50 +08:00
nesquena-hermes
e5dc58b700 docs: CHANGELOG v0.51.40 Release P 2026-05-11 00:09:50 +00:00
ai-ag2026
7b6d91d490 feat: add read-only session recovery audit 2026-05-11 02:06:43 +02:00
ai-ag2026
663817570c fix: recover orphaned session backups on startup 2026-05-11 02:03:37 +02:00
nesquena-hermes
9c471be4a3 Merge PR #2034 into stage-334
# Conflicts:
#	CHANGELOG.md
2026-05-10 23:38:05 +00:00
nesquena-hermes
c3d40ad51f Merge PR #2033 into stage-334 2026-05-10 23:37:39 +00:00
nesquena-hermes
02506eadb5 Merge PR #2032 into stage-334 2026-05-10 23:37:39 +00:00
nesquena-hermes
5dbf9627ca Merge PR #2030 into stage-334 2026-05-10 23:37:39 +00:00
Frank Song
128e734df4 Fix Xiaomi API key env detection 2026-05-11 07:33:52 +08:00
Frank Song
a27f1bf7db Clarify one-shot cron schedules 2026-05-11 07:03:17 +08:00
Michael Lam
d620f4394a fix: prewarm skill imports outside env lock 2026-05-10 15:51:49 -07:00
Michael Lam
cb3284b73f fix: harden quota probe subprocess handling 2026-05-10 12:18:02 -07:00
nesquena-hermes
b997067ae8 Merge pull request #2029 from nesquena/stage-333
Some checks failed
Release & Docker / release (push) Has been cancelled
Release O — v0.51.39 — 4-PR contributor batch (Railway docker + Stop-button race + model resolver + live context)
2026-05-10 11:43:34 -07:00
nesquena-hermes
567dc4d355 chore: CHANGELOG for v0.51.39 — Release O (4-PR contributor batch) 2026-05-10 18:17:57 +00:00
nesquena-hermes
2377216860 Stage 333: PR #2009 — feat(context): live status tracking during streaming by @dobby-d-elf 2026-05-10 18:16:59 +00:00
nesquena-hermes
8824f3c88d Stage 333: PR #2022 — fix(resolver): prefer active provider for default model overlap by @Michaelyklam 2026-05-10 18:16:59 +00:00
nesquena-hermes
83bce07d29 Stage 333: PR #2018 — fix(stop): refresh button after chat/start stream id by @rhelmer 2026-05-10 18:16:59 +00:00
nesquena-hermes
96c1c988f3 Stage 333: PR #2017 — fix(docker_init): fall back when /tmp not root-writable on Railway by @michael-dg 2026-05-10 18:16:59 +00:00
nesquena-hermes
6fbb6e452e Merge pull request #2021 from nesquena/stage-332
Some checks failed
Release & Docker / release (push) Has been cancelled
Release N — v0.51.38 — UI polish (4 PRs)
2026-05-10 11:11:01 -07:00
nesquena-hermes
fe922d83b0 Merge remote-tracking branch 'origin/master' into stage-332
# Conflicts:
#	CHANGELOG.md
2026-05-10 18:07:50 +00:00
nesquena-hermes
a42adbeb3c Merge pull request #2020 from nesquena/stage-331
Some checks failed
Release & Docker / release (push) Has been cancelled
Release M — v0.51.37 — Compression / lineage backend (6 PRs)
2026-05-10 11:07:11 -07:00
nesquena-hermes
22991fa820 Merge remote-tracking branch 'origin/master' into stage-331
# Conflicts:
#	CHANGELOG.md
2026-05-10 18:03:55 +00:00
nesquena-hermes
952754acf7 Merge pull request #2019 from nesquena/stage-330
Some checks failed
Release & Docker / release (push) Has been cancelled
Release L — v0.51.36 — Locale + provider + cross-cutting (6 PRs)
2026-05-10 11:03:01 -07:00
Michael Lam
ed183784d4 fix: prefer active provider for default model overlap 2026-05-10 10:49:12 -07:00
nesquena-hermes
c9d4100218 Merge remote-tracking branch 'origin/master' into stage-332
# Conflicts:
#	CHANGELOG.md
2026-05-10 17:46:34 +00:00
nesquena-hermes
16535e1f66 Merge remote-tracking branch 'origin/master' into stage-331
# Conflicts:
#	CHANGELOG.md
2026-05-10 17:46:10 +00:00
nesquena-hermes
4f900d0763 Merge remote-tracking branch 'origin/master' into stage-330
# Conflicts:
#	CHANGELOG.md
#	static/i18n.js
2026-05-10 17:45:29 +00:00
nesquena-hermes
ad290cc703 Merge pull request #2016 from nesquena/stage-329
Some checks failed
Release & Docker / release (push) Has been cancelled
Release K — v0.51.35 — Kanban polish + i18n DE pluralization (6 PRs from @franksong2702)
2026-05-10 10:43:23 -07:00
nesquena-hermes
024cd87580 chore: CHANGELOG for v0.51.38 (stage-332) 2026-05-10 17:31:37 +00:00
nesquena-hermes
dc522ad0c0 chore: CHANGELOG for v0.51.37 (stage-331) 2026-05-10 17:31:34 +00:00
nesquena-hermes
d922845bbd chore: CHANGELOG for v0.51.36 (stage-330) 2026-05-10 17:31:32 +00:00
Robert Helmer
ce27499762 Fix Stop button not refreshing after chat/start stream id
Call updateSendBtn after S.activeStreamId is cleared for a new turn and
again after the server returns streamId, since setBusy(true) already
refreshed the button while activeStreamId was still null.

Add regression tests in test_1062_busy_input_modes (TestBusySendButton).
2026-05-10 10:15:16 -07:00
Michael De Gols
4ba31f9462 fix(docker_init): fall back when /tmp not root-writable (Railway)
On user-namespaced rootless runtimes (Railway), in-container UID 0 maps
to a host UID outside the writable subuid range, so /tmp writes fail
despite id -u returning 0. The existing read-only-rootfs guard only
covers /etc/{group,passwd} and doesn't catch this.

Probe /tmp writability before save_env and fall back through
$itdir → /app, exporting _HW_ROOT_ENV_PATH so the post-su phase reads
from the same path.

Closes #2010

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:14:49 +02:00
nesquena-hermes
71aaad6e7f fix(stage-330): broaden chinese-locale test to accept both \uXXXX and literal CJK forms (PR #2002 source-form refresh) 2026-05-10 17:13:52 +00:00
nesquena-hermes
cb27ab0142 Stage 332: PR #2013 — fix(sessions): avoid sidebar jumps when active session is visible by @ai-ag2026 2026-05-10 17:09:44 +00:00
nesquena-hermes
2fb29e508c Stage 332: PR #2008 — fix(diff): CLI session patch diff rendering by @franksong2702 2026-05-10 17:09:44 +00:00
nesquena-hermes
f5a8a6f9ef Stage 332: PR #2007 — fix(mobile): wrap markdown code blocks on mobile by @insecurejezza 2026-05-10 17:09:44 +00:00
nesquena-hermes
c7ab7cfcda Stage 332: PR #1988 — fix(toast): lengthen auto-compression toast by @Michaelyklam 2026-05-10 17:09:44 +00:00
nesquena-hermes
c624770c63 Stage 331: PR #2015 — fix(sessions): stitch continued session transcripts by @Jellypowered 2026-05-10 17:09:21 +00:00
nesquena-hermes
44dc7d05e8 Stage 331: PR #2014 — fix(sessions): keep explicit fork sessions out of compression lineage by @ai-ag2026 2026-05-10 17:09:21 +00:00
nesquena-hermes
b68d7c62e7 Stage 331: PR #2012 — feat(sessions): read-only session lineage report endpoint by @dso2ng 2026-05-10 17:09:21 +00:00
nesquena-hermes
f4d3e9eed4 Stage 331: PR #2011 — fix(sessions): prefer latest compressed segment by @ai-ag2026 2026-05-10 17:09:21 +00:00
nesquena-hermes
c156e5a256 Stage 331: PR #2006 — fix(compression): stamp profile on continuation session by @qxxaa 2026-05-10 17:09:21 +00:00
nesquena-hermes
9b7aba6a50 Stage 331: PR #2004 — fix(compression): persist compression boundary summary for reload UI by @franksong2702 2026-05-10 17:09:21 +00:00
nesquena-hermes
a897ccfd9c Stage 330: PR #2005 — feat(provider): add Xiaomi MiMo provider support by @vikarag 2026-05-10 17:08:46 +00:00
nesquena-hermes
38b6df01c3 Stage 330: PR #2002 — i18n(zh): update Chinese language translation by @eov128 2026-05-10 17:08:42 +00:00
nesquena-hermes
9060bdb344 Stage 330: PR #2001 — fix(clarify): honor clarify.timeout config by @franksong2702 2026-05-10 17:07:37 +00:00
nesquena-hermes
7eced19463 Stage 330: PR #2000 — fix(skills): patch module-level caches on per-request profile switch by @qxxaa 2026-05-10 17:07:37 +00:00
nesquena-hermes
26b700648e Stage 330: PR #1998 — fix(goal): localize /goal runtime status strings by @franksong2702 2026-05-10 17:07:37 +00:00
nesquena-hermes
7c26783888 Stage 330: PR #1992 — fix(ctl): bash 3.2 compatible variable check by @29n 2026-05-10 17:07:37 +00:00
nesquena-hermes
941c8051a9 chore: CHANGELOG note for stage augmentation 9242305a 2026-05-10 17:06:27 +00:00
nesquena-hermes
9242305a81 fix(stage-329): zh-Hant locale parity for kanban_status_original_hint + extend locale parity test (Opus advisor SHIP-WITH-CAVEATS follow-up) 2026-05-10 17:06:10 +00:00
nesquena-hermes
52c1053baa chore: CHANGELOG for v0.51.35 — Release K (kanban polish + i18n DE) 2026-05-10 17:00:40 +00:00
nesquena-hermes
f44ead1404 Stage 329: PR #1996 — test(kanban): modal locale parity regression by @franksong2702 2026-05-10 16:48:44 +00:00
nesquena-hermes
b01df72727 Stage 329: PR #1995 — feat(kanban): trap focus in kanban modals + status hint by @franksong2702 2026-05-10 16:48:41 +00:00
nesquena-hermes
7ce48de817 Stage 329: PR #1993 — fix(kanban): invalidate profile cache for assignee select by @franksong2702 2026-05-10 16:48:15 +00:00
nesquena-hermes
bf98ffec9b Stage 329: PR #1991 — fix(i18n): correct German profile_skill_count pluralization by @franksong2702 2026-05-10 16:48:15 +00:00
nesquena-hermes
c6e4306d7b Stage 329: PR #1990 — fix(kanban): guard dispatcher actions while in-flight by @franksong2702 2026-05-10 16:48:15 +00:00
dobby-d-elf
fecfc5f6db fix: reanchor live context usage updates 2026-05-10 10:31:14 -06:00
Jellypowered
8aed650b4c Stitch continued session transcripts in WebUI 2026-05-10 11:10:54 -05:00
ai-ag2026
017a631b6c fix: keep explicit fork sessions out of compression lineage 2026-05-10 18:03:21 +02:00
ai-ag2026
8226328cba fix: avoid sidebar jumps when active session is visible 2026-05-10 18:00:10 +02:00
Dennis Soong
c3cf8b10e9 feat: add read-only session lineage report 2026-05-10 23:28:14 +08:00
ai-ag2026
2a34a1256e fix: prefer latest compressed session segment 2026-05-10 17:04:33 +02:00
dobby-d-elf
56d68b7511 fix: keep live context metering session-scoped 2026-05-10 08:20:37 -06:00
dobby-d-elf
a300d9a323 Drop configured provider model badges 2026-05-10 08:07:59 -06:00
dobby-d-elf
1cf0ff01b5 feat: live context window status tracking during streaming 2026-05-10 06:51:46 -06:00
Frank Song
e64e02479f Fix CLI session patch diff rendering 2026-05-10 20:44:34 +08:00
insecurejezza
f7938372ba fix: wrap markdown code blocks on mobile 2026-05-10 19:12:20 +10:00
qxxaa
f665e50738 fix: stamp profile on continuation session after context compression
When context compression fires, the agent rotates to a new session_id.
The compression migration block correctly migrates the session lock,
SESSION_AGENT_CACHE, SESSIONS dict, and the session file rename, but
does not ensure s.profile is set on the continuation session.

On the next request, _run_agent_streaming resolves the profile via:

    get_hermes_home_for_profile(getattr(s, 'profile', None))

With s.profile == None this falls back to the default profile's
HERMES_HOME. Memory tool calls then read and write the wrong profile's
MEMORY.md — confirmed by investigation: session 0dfefb (continuation
after compression from a troubleshooting profile session) read memory
at 16% / 1,184 chars with 4 entries, while the troubleshooting profile's
actual state was 72-77% / 5,000+ chars. That reading could only come
from the default profile's bank. Subsequent replace operations failed
because the target entries existed only in the troubleshooting profile.

There are two failure paths:

1. In-memory: if s.profile was None from the start (legacy session or
   one created before this fix), the continuation session object carries
   null through the current request.

2. Persistence: s.save() persists "profile": null to the continuation
   session's JSON file (profile is in METADATA_FIELDS, models.py ~408).
   On the next request, Session.load(new_sid) reads it back as null and
   get_hermes_home_for_profile(None) falls back to the default profile.

Fix: capture _resolved_profile_name at request entry (~line 2019),
immediately after profile home resolution. This is the only point where
profile context is reliable: s.profile if already set, otherwise
get_active_profile_name() — which at that point reads thread-local
storage (_tls.profile) correctly set by the HTTP handler thread via
set_request_profile(). Calling get_active_profile_name() at compression
time instead would be unsafe: the streaming thread is a separate
threading.Thread, does not inherit TLS, and the call would fall back to
the process-global _active_profile which may belong to a different
concurrent tab.

Stamp s.profile in the compression migration block immediately after
s.session_id = new_sid. Guarded by `if not s.profile` so sessions that
already have a profile set are unaffected. A logger.info line records
when the stamp fires, making future investigation straightforward.

Fixes: memory writes bleeding into default profile after compression
Reproduces: reliably on any long non-default profile session that hits
the compression threshold (default: 0.80 context fill)
2026-05-10 09:57:45 +01:00
vikarag
84a172b572 feat: add Xiaomi MiMo provider support
Add xiaomi to _PROVIDER_DISPLAY, _PROVIDER_MODELS, and _PROVIDER_ALIASES
so the WebUI recognizes Xiaomi as a first-class provider.

Models included:
- mimo-v2.5-pro (MiMo V2.5 Pro)
- mimo-v2.5 (MiMo V2.5)
- mimo-v2-pro (MiMo V2 Pro)
- mimo-v2-omni (MiMo V2 Omni)
- mimo-v2-flash (MiMo V2 Flash)

Aliases: mimo, xiaomi-mimo -> xiaomi

The hermes-agent CLI already registers xiaomi as a provider
(hermes_cli/models.py, hermes_cli/auth.py) but the WebUI was missing
the corresponding entries, causing the model dropdown to fall back to
OpenRouter and the provider list to show 'Unsupported'.
2026-05-10 17:48:37 +09:00
Frank Song
1bec8070f2 fix(1833): persist compression anchor summary for reload UI 2026-05-10 16:45:16 +08:00
eov128
9c37104c94 Add files via upload
Update Chinese language translation
2026-05-10 16:08:14 +08:00
Frank Song
2e6b3601bd fix(clarify): honor clarify.timeout config in webui prompts 2026-05-10 16:05:50 +08:00
qxxaa
7ee41c9b12 fix: patch skills module-level caches on per-request profile switch
Per-request profile switches (process_wide=False, introduced in #1700)
update os.environ['HERMES_HOME'] but skip _set_hermes_home(), which is
responsible for monkeypatching module-level caches.

Both tools/skills_tool.py and tools/skill_manager_tool.py set
HERMES_HOME and SKILLS_DIR once at import time. When a non-default
profile is active in the WebUI, os.environ['HERMES_HOME'] is correctly
updated per-turn in the _ENV_LOCK block, but the module-level
constants still point at the root profile. All agent-side skill
operations — skills_list(), skill_view(), skill_manage() — read and
write to the wrong directory.

Add the same monkeypatching that _set_hermes_home() already performs
(profiles.py line ~620) to the per-turn env setup block in
streaming.py, covering both skills_tool and skill_manager_tool.

The WebUI display half was already fixed in #1917 via
_active_skills_dir() in routes.py. This patch fixes the agent-side
half so the running agent resolves skills from the correct profile.
2026-05-10 09:02:49 +01:00
Frank Song
ba51efec26 test(kanban): assert profile-cache invalidation on profile delete 2026-05-10 15:49:14 +08:00
Frank Song
2427f1e598 test(kanban): harden locale-block parsing for quoted locales 2026-05-10 15:48:18 +08:00
Frank Song
1e1a9481b4 fix(i18n): localize /goal runtime status strings 2026-05-10 15:21:24 +08:00
Frank Song
4c95d9274e test: add kanban modal locale parity regression 2026-05-10 15:03:21 +08:00
Frank Song
42a23818b3 Fix 1974: trap focus in kanban modals 2026-05-10 14:57:51 +08:00
Frank Song
45a5253292 fix(i18n): add kanban status hint key to all locales for #1994 2026-05-10 14:49:30 +08:00
Frank Song
b67d2676e4 fix(kanban): show original status hint in edit modal 2026-05-10 14:40:46 +08:00
Frank Song
b06eb99d91 fix(kanban): invalidate profile cache for assignee select 2026-05-10 14:37:37 +08:00
Alan
025f137f28 fix: guard empty CTL_BOOTSTRAP_ARGS expansion for bash 3.2
With set -u, bash 3.2 treats empty array expansion
"\${CTL_BOOTSTRAP_ARGS[@]}" as an unbound variable.

Use ${CTL_BOOTSTRAP_ARGS[@]+"..."} pattern to skip
expansion when the array is empty.
2026-05-10 14:34:39 +08:00
Alan
630981a068 fix: use bash 3.2 compatible variable check in ctl.sh
Replace [[ -v ${key} ]] (requires bash 4.2+) with
[[ -n "${!key+x}" ]] which works on bash 3.2+ and all
POSIX-compatible shells.

macOS ships with bash 3.2, causing:
  ./ctl.sh: line 42: conditional binary operator expected
2026-05-10 14:29:07 +08:00
Frank Song
8f077d37f7 Fix German profile_skill_count interpolation 2026-05-10 14:25:08 +08:00
Frank Song
a0b757a9d4 Fix Kanban dispatch double-click race guard 2026-05-10 14:18:50 +08:00
Michael Lam
c7afae0e07 fix: lengthen auto-compression toast 2026-05-09 19:35:05 -07:00
nesquena-hermes
e4a9c5b7f5 Merge pull request #1983 from nesquena/stage-328
Some checks failed
Release & Docker / release (push) Has been cancelled
release: v0.51.34 — Release J (#1979 zh-Hant kanban i18n + #1981 kanban edit/dispatch/assignee)
2026-05-09 14:16:47 -07:00
nesquena-hermes
189c9bf556 release: v0.51.34 — Release J (kanban edit/dispatch + zh-Hant kanban i18n) 2026-05-09 21:13:43 +00:00
nesquena-hermes
3fbecc489c fix(stage-328): backfill #1981's 17 new kanban keys into zh-Hant locale
PR #1979 (@Michaelyklam) backfilled the existing kanban keys into zh-Hant
which was the missing locale block.  PR #1981 then added 17 NEW kanban
keys (edit_task, run_dispatcher_confirm, assignee_profiles_label,
dispatch_* result fields, etc.) but only to the 8 existing kanban-supporting
locales — zh-Hant was again left without those new keys.

This commit closes the gap fully: the 17 new keys from #1981 now exist in
zh-Hant too, with Traditional Chinese translations adapted from the
Simplified Chinese (zh) versions in the same file.

Without this commit, zh-Hant users would have:
  - The full create-task modal localized (from #1979 + #1965)
  - But the new edit-task / run-dispatcher / assignee-dropdown / dispatch
    result strings falling back to English

Adapted translations preserve the same shape and tone as the zh block.
The gap is mechanical (translation drift, not architectural) and worth
closing inline rather than leaving as another follow-up issue.

JS syntax: clean (`node -c` on i18n.js + panels.js).
Kanban tests: 34/34 pass on this stage.
2026-05-09 21:03:48 +00:00
nesquena-hermes
c67336e4e3 Stage 328: PR #1981 — feat(kanban): edit task button, real Run dispatcher, assignee dropdown by @nesquena-hermes
# Conflicts:
#	CHANGELOG.md
2026-05-09 21:02:27 +00:00
nesquena-hermes
fb128ef288 Stage 328: PR #1979 — fix(i18n): backfill zh-Hant Kanban keys by @Michaelyklam 2026-05-09 21:02:14 +00:00
Nathan Esquenazi
8e0eedd163 fix(kanban-edit): preserve real status when editing non-{triage,todo,ready} tasks
PR #1981's edit-task modal silently demotes tasks whose real status is
running/blocked/done/archived. The dropdown only offers triage/todo/ready,
so `_kanbanEditableStatusFor()` maps any other status to 'triage' for
display. If the user just edits the title and saves, the dropdown's
displayed 'triage' lands in the PATCH payload — and `_patch_task` calls
`_set_status_direct` which:
  - ends any active run with outcome='reclaimed' (worker yanked back)
  - nulls claim_lock / claim_expires / worker_pid
  - moves the task to triage

So editing a 'running' task's title would reclaim the running worker.
Editing a 'done' task would un-done it. Editing an 'archived' task would
un-archive it. All silent, no warning.

Reproducer (Node):
  Original: {status: 'running'}
  Modal display: 'triage' (mapped)
  User leaves dropdown alone → submit
  Payload: {title: 'X', status: 'triage'}  ← destructive

Fix: track the modal's initial displayed status in
_kanbanTaskModalInitialDisplayedStatus on edit-mode open. In submit's
edit branch, only include `status` in the PATCH payload when the user
actually picked a different value than what the dropdown opened with.
Create-mode resets the tracker to null so create payloads always include
status.

Verified end-to-end via Node harness:
  - edit running, untouched → no status sent ✓ (server keeps running)
  - edit running, picked ready → status:ready sent ✓ (worker reclaimed
    intentionally)
  - edit triage, untouched → no status sent ✓ (idempotent)
  - edit triage, picked ready → status:ready sent ✓
  - create new → status always sent ✓
  - edit done, untouched → no status sent ✓ (no un-done)

Adds test_kanban_edit_mode_preserves_status_when_dropdown_untouched
pinning the tracker variable, openKanbanEdit captures, submit-skip
condition, and create/close reset paths. Verified to fail pre-fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:57:31 -07:00
nesquena-hermes
c71312b2e8 feat(kanban): edit task button, real Run dispatcher, assignee dropdown
Three connected gaps in the Kanban UX, fixed together because they're
load-bearing for the actual work-queue lifecycle:

1. Edit task — the detail view had only status-transition buttons (Triage/
   Todo/Ready/Blocked/Done/Archived) plus Block/Unblock and Add comment.
   No way to edit title, body, assignee, tenant, or priority once the task
   was created. Backend already supported it via PATCH /api/kanban/tasks/<id>
   (api/kanban_bridge.py::_patch_task) — purely a UI gap.

   Now: an Edit button on the task-detail header opens the existing modal
   pre-filled with current values, switches the modal title to 'Edit task'
   and the submit button to 'Save', PATCHes instead of POSTing on submit.

2. Run dispatcher — the existing 'Preview dispatcher' button always passed
   ?dry_run=1 (nudgeKanbanDispatcher), so it was preview-only. There was
   literally no UI button anywhere in the WebUI that actually ran the
   dispatcher to claim Ready tasks and spawn workers. Users had to drop
   to the CLI.

   Now: new runKanbanDispatcher() entry point hits /api/kanban/dispatch
   without dry_run=1, after a showConfirmDialog confirmation because it
   spawns subprocess workers. Two UI surfaces: a lightning-bolt button in
   the board header (visually distinct from the dry-run preview ▶), and
   a primary 'Run dispatcher' button in the sidebar bulk bar next to a
   relabeled 'Preview' button. Toast result shows concrete numbers from
   dispatch_once(): 'Dispatched: 1 spawned, 2 skipped (no assignee)' —
   not just a generic 'OK'.

3. Assignee dropdown — the previous create modal accepted free-text
   assignee with no validation. The dispatcher (kanban_db.py:3567) only
   spawns workers when row['assignee'] is a real Hermes profile name; any
   typo or blank value made the task sit in Ready forever.

   Now: <select> populated from /api/profiles (Hermes profile names) with
   historical board assignees grouped under 'Other (CLI lanes / removed
   profiles)', plus an explicit '— Unassigned (won't auto-run) —' option.
   Default selection is the first profile, not Unassigned. Custom SVG
   chevron so the field reads visually as a dropdown. Helper text under
   the field explains the dispatcher claim contract. Soft warning if user
   explicitly picks Unassigned + Ready ('You picked Unassigned + Ready.
   The dispatcher will skip this task. Submit again to confirm, or pick
   a profile.'); proceeds on second submit.

Side effect: default new-task status changed from triage to ready, since
'ready' is what users want for tasks they intend to actually run. Triage
is still in the dropdown for tasks that need staging review.

i18n: 19 new keys translated across all 8 supported locales.

Tests: 3 new regression tests in tests/test_kanban_ui_static.py:
- test_kanban_task_detail_has_edit_button_and_modal_supports_edit_mode
- test_kanban_assignee_dropdown_uses_select_not_freetext
- test_kanban_run_dispatcher_button_exists_and_is_distinct_from_preview

Verified end-to-end in browser: created board → opened modal with profile
dropdown → created task with assignee=archivist → clicked Edit → changed
all 5 fields → saved → verified persistence → clicked Run dispatcher →
confirm dialog → confirmed → toast 'Dispatched: 1 spawned' → task moved
Ready → Running.

Test suite: 5042 passed, 11 skipped, 3 xpassed, 0 regressions in 151s.
2026-05-09 20:48:28 +00:00
Michael Lam
2aa8b1adc0 fix(i18n): backfill zh-Hant kanban keys 2026-05-09 13:40:19 -07:00
nesquena-hermes
ed776ee1a1 Merge pull request #1976 from nesquena/fix/mcp-profile-discovery
Some checks failed
Release & Docker / release (push) Has been cancelled
fix(profile/mcp): discover MCP tools after per-session HERMES_HOME mutation (#1968)
2026-05-09 13:29:06 -07:00
nesquena-hermes
a3af4a3c8f fix(profile/mcp): discover MCP tools after per-session HERMES_HOME mutation
Issue #1968: switching to a non-default profile in the WebUI dropdown
had no effect on which MCP servers were available. Every chat session,
regardless of profile, only saw the default profile's mcp_servers from
~/.hermes/config.yaml. Non-default profile MCP servers (postgres, custom
stdio servers, anything in <profile>/config.yaml) never registered.

Root cause: api/streaming.py:1922 called discover_mcp_tools() at the
TOP of _run_agent_streaming(), about 100 lines BEFORE the per-session
'os.environ["HERMES_HOME"] = _profile_home' mutation at line 2053.
discover_mcp_tools() reads ~/.hermes/config.yaml via get_hermes_home(),
which uses os.environ['HERMES_HOME']. So at the call site, HERMES_HOME
was still whatever the WebUI server process had at startup — the default
profile, every time.

Fix: relocate the discover_mcp_tools() call past the _ENV_LOCK block so
get_hermes_home() resolves to the session's actual profile home. Same
try/except wrapping is preserved; same idempotency semantics on
already-connected servers; same lazy-import pattern.

Caveat (out of scope, agent-side): _servers in tools/mcp_tool.py is a
process-global Dict[str, MCPServerTask] keyed only by server name. So
once profile A registers a server named e.g. 'postgres', profile B's
discovery sees 'postgres' as already connected and skips it — even if
B's config points at a different binary or DB. Concurrent multi-profile
WebUI processes will still hit 'first profile wins per server name'.
Fully fixing that requires keying _servers by (profile_home, name)
upstream in hermes-agent. This PR ships layer 1 only — fixes the
single-non-default-profile case (the headline symptom).

Tests: tests/test_issue1968_mcp_profile_discovery.py — 4 static tests
pinning the lexical ordering invariants. Verified mutation-safety: a
proof-of-concept revert (re-adding a discover call before the
HERMES_HOME mutation) makes the 'only called once' test fail.

Test suite: 5047 passed, 4 skipped, 3 xpassed, 0 regressions.

Closes #1968
2026-05-09 20:08:16 +00:00
nesquena-hermes
ba535e0c69 Merge pull request #1971 from nesquena/stage-327
Some checks failed
Release & Docker / release (push) Has been cancelled
release: v0.51.32 — Release I (2-PR batch: #1943 lineage segment expand + #1965 kanban modal)
2026-05-09 13:03:21 -07:00
nesquena-hermes
4ce113f324 Stage 327: PR #1965 — fix(kanban): header + button opens create-task modal (#1964) by @nesquena-hermes
# Conflicts:
#	CHANGELOG.md
2026-05-09 19:51:30 +00:00
nesquena-hermes
55623ef249 Stage 327: PR #1943 — feat: expand collapsed session lineage segments by @dso2ng 2026-05-09 19:50:50 +00:00
nesquena-hermes
10ea2a014f fix(kanban): header '+' button opens create-task modal
The Kanban sidebar panel's header '+' button (#kanbanNewTaskBtn) was
wired straight to createKanbanTask(), which reads the inline
#kanbanNewTaskTitle input and silently returns when empty. The inline
input lives below five rows of filters (search, assignee, tenant,
archived/mine toggles, stats, bulk-action bar) and is typically off-screen
on first panel open, so the header button looked dead — clicking it with
no title typed did nothing visible (no modal, no scroll, no focus shift,
no toast).

Now the header '+' opens #kanbanTaskModal — a centered overlay with the
same .kanban-modal-overlay shell the existing create-board modal uses,
so the two flows look and behave identically (centered card, dim
backdrop, ESC closes, click-on-backdrop closes). The modal exposes the
fields the backend already accepts at /api/kanban/tasks: Title, Description,
Status (Triage/Todo/Ready), Priority, Assignee (datalist suggestions from
the active board), Tenant (datalist).

UX details:
- Title is required; submit-with-empty shows a properly styled red error
- Title field auto-focuses on open
- ESC closes the modal; backdrop click closes; Enter on simple inputs
  submits, Enter in the description textarea inserts a newline
- Submit POSTs only the fields the user filled in (no forced empty strings)
  and auto-opens the new task's detail view
- Submit button disables while posting to prevent double-submit
- Inline quick-add (Enter on #kanbanNewTaskTitle) is preserved as a
  power-user shortcut

Side effect: .kanban-modal-error styling improved (proper red alert with
border + tinted background) so the existing create-board modal benefits
from the same polish for free.

i18n: 11 new keys added across all 8 supported locales (en, ja, ru, es,
de, zh, pt, ko).

Tests: tests/test_kanban_ui_static.py::test_kanban_new_task_header_button_opens_modal
covers the modal markup, button wiring, ESC/Enter handling, datalist
population, submit behavior, and inline-quick-add fallthrough.

Verified end-to-end in the browser on an isolated test env (port 8789):
created a board from scratch, opened the modal via header '+',
submitted with title/description/status/priority/assignee/tenant filled in,
moved the task through statuses (Triage → Todo → Ready → Blocked → Archived),
added a comment, verified Cancel + ESC + backdrop-click all close cleanly,
verified validation error rendering, verified inline quick-add still works.

Closes #1964
2026-05-09 19:33:07 +00:00
nesquena-hermes
9a1b68a955 Merge pull request #1969 from nesquena/fix/docker-env-readonly-vars
fix(docker): salvage operational hardening from #1686 — .env readonly-var parser + xz-utils/git apt deps + root re-exec
2026-05-09 12:25:57 -07:00
nesquena-hermes
1681ce567e fix(start.sh): NOPASSWD precheck on root re-exec — silent fall-through
Per Opus advisor on PR #1969: the original three-guard root re-exec
(EUID==0, hermeswebui exists, sudo on PATH) would exit non-zero with
`sudo: a password is required` on host machines where the developer's
hermeswebui user doesn't have NOPASSWD configured.

Better failure mode: silent fall-through to running as root (back to
pre-PR behavior). Adds a fourth guard `sudo -n -u hermeswebui true 2>/dev/null`
that pre-flights the sudo capability without producing visible output.

Also expands the comment to clarify which guard is load-bearing on the
canonical container path (the production image doesn't ship sudo at all,
so `command -v sudo` is the silent-no-op gate there; the entrypoint
docker_init.bash never invokes start.sh in any case).

No new tests needed — existing behavioral tests already cover the
non-root + non-sudo paths, which is what runs in CI and on host.
2026-05-09 19:23:54 +00:00
dobby-d-elf
35cf332c9a feat: add LM Studio provider support with live model discovery
- api/config.py: resolve merge conflict, keep both _custom_slug_rest_looks_like_host_port
  and new _get_provider_base_url helper. Custom providers now return their configured
  base_url in resolve_model_provider(). Add 'Configured' badge for explicitly configured
  providers in the models dropdown. Detect LM Studio via LM_API_KEY+LM_BASE_URL env vars.
  Fetch live loaded models from LM Studio with fallback to direct HTTP requests.

- api/providers.py: fetch live LM Studio model list via hermes_cli for the providers card.

- static/style.css: add purple 'Configured' badge style.
2026-05-09 13:20:01 -06:00
nesquena-hermes
57c71e89f3 fix(docker): salvage operational hardening from #1686 (env readonly + apt deps)
Three independent operational hardening fixes salvaged from PR #1686
(@binhpt310) after the parent PR was deferred over a separate sibling-repo
build-context concern unrelated to these fixes:

1. start.sh's .env loader now filters readonly bash vars (UID, GID, EUID,
   EGID, PPID) before `source`-ing.  docker-compose.yml's macOS instructions
   document `echo "UID=$(id -u)" >> .env` to set host UID/GID for bind-mount
   permission fixing — that .env was crashing start.sh with
   `UID: readonly variable` when `set -a; source ...; set +a` tried to
   assign to those names.  Replaced with
   `source <(grep -vE '^[[:space:]]*(export[[:space:]]+)?(UID|GID|EUID|EGID|PPID)=' "${REPO_ROOT}/.env")`.
   The bootstrap regression guard at tests/test_bootstrap_dotenv.py:181
   still passes — both `source` and `.env` are still on the modified line.

2. start.sh now defensively re-execs as the unprivileged hermeswebui user
   when invoked as root.  Fires only when EUID==0 AND a hermeswebui user
   actually exists AND sudo is on PATH — so it's a no-op on host machines
   without the container user setup.  The production image's entrypoint
   (docker_init.bash) already drops to hermeswebui before invoking start.sh,
   so this is a no-op on the canonical container path; it only matters for
   `sudo ./start.sh` or accidental root shells inside the container during
   interactive debugging.

3. Dockerfile installs xz-utils + git apt packages.  xz-utils is required
   to decompress .tar.xz archives (e.g. Node.js distribution tarballs);
   git is needed for `git describe` (powers WEBUI_VERSION resolution at
   api/updates.py:_detect_webui_version) and any clone-based agent install
   path.  Both are tiny apt packages on top of python:3.12-slim with no
   measurable image-size impact.

What's NOT in this commit (deferred from #1686):

- Pre-baking hermes-agent source into the image via
  `COPY hermes-agent-desktop/hermes-agent /opt/hermes/` plus a build-context
  flip to `..`.  Requires a sibling-repo layout that breaks the canonical
  `git clone hermes-webui && cd hermes-webui && docker compose build` flow.
  The right shape is a build arg gating the COPY behind
  --build-arg WITH_AGENT_SOURCE=1; left to a separate PR.
- Pre-installing Node.js 22 LTS system-wide.  Real motivation but worth
  evaluating the fix shape (full Node bake vs. opt-in vs. layer cache)
  separately from these three operational fixes.

Tests: tests/test_docker_env_readonly_vars.py — 11 tests (4 source-grep
on the start.sh filter pattern + 5 behavioral that actually run bash
against synthetic .env files containing readonly vars + 2 Dockerfile
package-presence tests).  All 11 pass.  Behavioral tests skip if bash
is not on PATH.

Full suite: 5028 → 5036 passing (+8 net new after pytest collection
counted some behavioral tests under skip), 0 regressions, 147.84s.

Closes the operational-hardening portion of #1686.

Co-authored-by: binhpt310 <binhpt310@users.noreply.github.com>
2026-05-09 19:17:34 +00:00
nesquena-hermes
8a653bac20 Merge pull request #1967 from nesquena/stage-326
Some checks failed
Release & Docker / release (push) Has been cancelled
release: v0.51.31 — Release H (12-PR contributor batch: image-mode + race fixes + composer drafts + locale parity)
2026-05-09 11:55:08 -07:00
nesquena-hermes
1d7344c602 release: v0.51.31 — Release H (12-PR contributor batch)
CHANGELOG, ROADMAP, TESTING refresh for v0.51.31 stage release covering
12 contributor PRs:

Added (2 PRs):
- #1956 JKJameson — persistent composer draft (server-side, cross-client)
- #1957 hermes-gimmethebeans — configurable session TTL via env + settings

Fixed (10 PRs):
- #1939 ai-ag2026 — theme-color + sw cache regression coverage
- #1941 ai-ag2026 — preserve chat scroll across final render
- #1945 franksong2702 — localize session jump controls (#1938)
- #1947 happy5318 — show same model from different custom providers
  (Co-authored-by hacker1e7 for #1874 close)
- #1949 Sanjays2402 — close #1937 endless-scroll vs Start-jump race
  with generation-token + mutex
  (Co-authored-by franksong2702 + Michaelyklam)
- #1950 franksong2702 — mute stale stopped gateway heartbeat (#1944)
- #1951 amlyczz — gate goal hook on goal-related turns (#1932)
  (Co-authored-by franksong2702 for #1946 close)
- #1953 lucky-yonug — skip provider peel for custom host:port slugs
- #1960 Michaelyklam — translate hidden-files workspace label (#1841)
- #1961 sbe27 — respect image_input_mode (#1959)

Closed in favor of canonical: #1942, #1962, #1946, #1874, #1311.

Stage-326 hotfixes (per Opus advisor):
- CRITICAL #1951 PENDING_GOAL_CONTINUATION race fix (removed finally
  discard that race-erased the marker before consumer could read it)
- #1956 composer-draft input validation (50 KB text / 50 file clamp +
  type coercion to prevent unbounded session-JSON bloat)
- #1957 SESSION_TTL constant preserved as named fallback (existing
  regression tests pin it; #1957 originally deleted it)

Tests: 5006 → 5028 (+51 net new) — 0 regressions, 142.61s runtime.
2026-05-09 18:46:25 +00:00
nesquena-hermes
8782fd2675 fix(stage-326): apply Opus advisor critical + recommended fixes
CRITICAL: #1951 PENDING_GOAL_CONTINUATION race
  Removes `PENDING_GOAL_CONTINUATION.discard(session_id)` from the
  streaming worker's `finally` cleanup block. The marker is set inside
  the SAME function call (line ~3328 on `goal_continue`) and the discard
  in the `finally` (line ~3553) almost always raced ahead of the
  frontend's SSE-receive → POST /api/chat/start round-trip, erasing
  the marker before the consumer in routes.py could read it. The
  consumer (`_start_chat_stream_for_session` in routes.py:6522) already
  discards atomically when consuming, so removing the streaming-side
  discard preserves single-use semantics and unblocks the
  goal-continuation chain.

  Adds tests/test_stage326_pending_goal_continuation_race.py with 5
  regression guards:
  1. streaming.py's finally must NOT discard PENDING_GOAL_CONTINUATION
  2. routes.py consumer must check + set + discard atomically
  3. PENDING_GOAL_CONTINUATION must be a set (GIL-safe single-op)
  4. STREAM_GOAL_RELATED.pop must be keyed by stream_id, not session_id
  5. PENDING_GOAL_CONTINUATION.add must precede the goal_continue SSE
     emission in source ordering

HARDENING: #1956 composer-draft input validation
  Per Opus, the POST /api/session/draft handler accepted unbounded /
  arbitrary-typed text and files inputs. With the 400ms debounced
  auto-save firing on every keystroke, a misbehaving client could
  persist multi-MB strings into the session JSON. Adds:
  - text: coerced to str if not already; clamped to 50_000 chars
  - files: coerced to list if not already; clamped to 50 entries
  Validation runs BEFORE the session lock acquire / save.

  Adds tests/test_stage326_composer_draft_validation.py with 5 guards.

Verdict from Opus advisor on stage-326: SHIP-WITH-FIXES.
This commit applies the required + recommended fixes; #1957 hardening
fixed in a prior stage commit.
2026-05-09 18:36:01 +00:00
nesquena-hermes
404e24ac9d fix(stage-326): preserve SESSION_TTL constant + reconcile #1957 tests
PR #1957 deleted the SESSION_TTL = 86400 * 30 module-level constant in
favor of the new _resolve_session_ttl() helper. Two existing regression
tests pin the constant: test_auth_sessions.TestSessionPruning.test_session_ttl_is_24_hours
imports SESSION_TTL directly, and test_v050258_opus_followups.test_redirect_session_ttl_30_days
asserts the literal "SESSION_TTL = 86400 * 30" line is present in source
(guarding against the daily-kick-out regression from #1419).

Restore SESSION_TTL as the named fallback for _resolve_session_ttl(); the
new env-var/settings.json path is unchanged. Backwards-compatible.

Also fix the new TestSessionTtlResolution suite:
- Switch from pytest's `monkeypatch` fixture (incompatible with
  unittest.TestCase subclasses) to setUp/tearDown env snapshotting
- Reconcile clamp tests with actual implementation: out-of-range env
  values fall through to settings/default, not snap to bounds
- test_session_uses_dynamic_ttl now sets the env var so the dynamic
  resolved value (3600s) is exercised rather than expecting the default

Verified: tests/test_auth_sessions.py + tests/test_v050258_opus_followups.py
21/21 pass.
2026-05-09 18:33:28 +00:00
nesquena-hermes
7cf8dcff4c Stage 326: PR #1956 — feat: persistent composer draft — server-side, cross-client, survives refresh by @JKJameson 2026-05-09 18:17:51 +00:00
nesquena-hermes
07d39612ce Stage 326: PR #1949 — fix(#1937): close endless-scroll prefetch vs Start-jump race with generation-token + mutex by @Sanjays2402
# Conflicts:
#	CHANGELOG.md
2026-05-09 18:17:51 +00:00
nesquena-hermes
4751b5ace5 Stage 326: PR #1951 — fix: only evaluate goal hook on goal-related turns (#1932) by @amlyczz 2026-05-09 18:17:20 +00:00
nesquena-hermes
a0a65ba0bc Stage 326: PR #1941 — fix: preserve chat scroll across final render by @ai-ag2026 2026-05-09 18:17:20 +00:00
nesquena-hermes
f0ecd94e04 Stage 326: PR #1945 — Localize session jump controls by @franksong2702
# Conflicts:
#	CHANGELOG.md
2026-05-09 18:17:03 +00:00
nesquena-hermes
22ea145d49 Stage 326: PR #1950 — Mute stale stopped gateway heartbeat by @franksong2702 2026-05-09 18:16:16 +00:00
nesquena-hermes
979f30e46a Stage 326: PR #1960 — fix: translate hidden-files workspace label by @Michaelyklam 2026-05-09 18:16:16 +00:00
nesquena-hermes
c2f0c6ccc0 Stage 326: PR #1961 — fix: WebUI respects image_input_mode — stop unconditionally embedding native images by @sbe27 2026-05-09 18:16:16 +00:00
nesquena-hermes
072ec41e0a Stage 326: PR #1947 — fix: show same model from different custom providers instead of deduplicating by @happy5318 2026-05-09 18:16:16 +00:00
nesquena-hermes
1c84da07fc Stage 326: PR #1953 — fix(config): skip #1776 provider peel for custom host:port slugs by @lucky-yonug 2026-05-09 18:16:16 +00:00
nesquena-hermes
9732795e9c Stage 326: PR #1957 — feat(auth): make session TTL configurable via env var and settings.json by @hermes-gimmethebeans 2026-05-09 18:16:16 +00:00
nesquena-hermes
7a0e4f1ee7 Stage 326: PR #1939 — test: cover theme-color media fallback by @ai-ag2026 2026-05-09 18:16:16 +00:00
nesquena-hermes
6f7479944c test(#1947): regression coverage for same-model-multiple-named-custom-providers
Adds tests/test_pr1947_same_model_multiple_custom_providers.py covering:

1. Two named custom providers exposing the same model id — both must
   surface in the rendered groups (one bare, one @custom:slug:model)
2. Three named providers all exposing the same model — none dropped
3. Distinct-model-per-provider sanity check (still grouped correctly)

Verified the regression-detecting tests (1 + 2) FAIL against master's
api/config.py (where _seen_custom_ids was seeded from auto_detected_models
and used as a global bare-id bucket — the second provider's entry was
silently dropped) and PASS against the contributor fix on this branch.

Test 3 (distinct-models sanity) passes either way as expected.

Co-authored-by: happy5318 <happy5318@users.noreply.github.com>
Co-authored-by: hacker1e7 <hacker1e7@users.noreply.github.com>
2026-05-09 18:15:50 +00:00
hermes-agent
b443e8ea5a fix: WebUI respects image_input_mode — stop unconditionally embedding native images
_build_native_multimodal_message() unconditionally embedded images as
native image_url parts, bypassing the agent's image_input_mode config.

Add _resolve_image_input_mode(cfg) helper mirroring the agent's
decide_image_input_mode logic, and wire it into
_build_native_multimodal_message with a new cfg parameter.

When mode resolves to 'text' (explicit aux vision config, or
image_input_mode: text), returns plain string so the agent's
existing text-mode pipeline (vision_analyze) handles images.

Closes #1959
2026-05-09 19:39:50 +02:00
Michael Lam
ce6685a27c fix: translate hidden-files workspace label 2026-05-09 10:36:30 -07:00
hermes-gimmethebeans
9d7c213971 feat(auth): make session TTL configurable via env var and settings.json
Add _resolve_session_ttl() with three-layer precedence:
  1. HERMES_WEBUI_SESSION_TTL env var (highest priority)
  2. session_ttl_seconds in settings.json
  3. Default: 86400 * 30 (30 days)

Clamped to [60s, 1 year] for safety. Settings changes take effect
immediately since the function is called dynamically at each login/cookie-write.

Closes #1954
2026-05-09 17:11:53 +00:00
Minimax
08c4ef8d88 feat: persistent composer draft — server-side, cross-client, survives refresh
- Session.composer_draft field: {text, files} stored in session JSON
- POST+GET /api/session/draft endpoint for save/load
- loadSession: save draft before switch, restore from S.session.composer_draft
- textarea input: debounced 400ms auto-save to server
- send(): clear draft after message is sent
- lockComposerForClarify(): save draft before card locks composer
- _restoreComposerDraft: clears textarea when target has no draft, guards
  against stale responses racing new session loads, exact text comparison
- Session.compact(): includes composer_draft in response
- Fix: use handler.command instead of parsed.method (ParseResult has no .method)

Co-authored-by: Minimax <noreply@minimax.io>
2026-05-09 13:47:57 +01:00
happy5318
a6599cd68e fix: show same model from different custom providers instead of deduplicating
When multiple custom providers expose the same model ID (e.g. baidu,
huoshan, and liantong all offering glm-5.1), only the first provider's
entry was shown in the model dropdown.

Root cause (backend):  used the bare model ID as the
dedup key, so the second and subsequent providers with the same model
were silently skipped.

Root cause (frontend):  stripped the @provider: prefix before
comparing, so @custom:baidu:glm-5.1 and @custom:huoshan:glm-5.1 were
treated as duplicates.

Fix:
- Backend: change _seen_custom_ids key to '{slug}:{model_id}' so each
  provider's models are tracked independently.
- Frontend: add _providerOf() helper and deduplicate on the composite
  (normId, provider) key instead of normId alone. Bare model IDs
  (without @provider: prefix) still deduplicate on normId for backward
  compatibility.
2026-05-09 16:17:23 +08:00
liyang1116
7532482393 fix: fix(config): skip #1776 provider peel for custom host:port slugs
model_with_provider_context can emit @custom:<host>:<port>:<model> when
model_provider is derived from an OpenAI base_url authority (e.g.
custom:10.8.0.1:8080). The colon-count heuristic meant for @custom:slug:model:free
mistook those extra colons for an over-split model ID and prepended the port
segment onto the bare model (8080:Qwen3-235B), breaking WebUI while CLI/curl
stayed correct.

Detect endpoint-style slugs (IPv4/localhost/hostname + numeric port) and skip
the peel in that case. Add regression tests for IPv4, dotted hostname,
localhost, and model_with_provider_context round-trip.
2026-05-09 16:16:32 +08:00
zqy
6fd07c2af4 fix: only evaluate goal hook on goal-related turns (#1932)
The goal evaluation hook was firing on every completed assistant turn
when a goal was active, even for unrelated messages like "what time is
it". This burned the goal budget, triggered continuation prompts that
interrupted unrelated conversations, and made /goal status numbers
misleading.

Add STREAM_GOAL_RELATED and PENDING_GOAL_CONTINUATION flags to gate
the evaluate_goal_after_turn() call in the streaming loop. Only streams
started from goal kickoff (/goal <text>) or goal continuation are
marked as goal-related. Normal user messages skip the hook entirely.
2026-05-09 15:08:13 +08:00
Frank Song
b38cc2f1ea Mute stale stopped gateway heartbeat 2026-05-09 14:53:42 +08:00
Sanjay Santhanam
fb822239ea fix(#1937): close endless-scroll prefetch vs Start-jump race with generation-token + mutex
The originally-proposed fix (gate _ensureAllMessagesLoaded on the existing
_loadingOlder flag) does not actually close the race. By the time the
prefetch reaches its post-await body, it has already cleared the entry-
gate that reads _loadingOlder, so a same-flag check inside the resolved
callback would be a no-op for an in-flight request.

The actual fix is two-pronged:

1. New module-scoped _messagesGeneration counter, bumped every time
   S.messages is wholesale-replaced. _loadOlderMessages snapshots it
   BEFORE its await and re-checks after — if it changed, the prepend
   is aborted. This is the canonical async-invalidation pattern.

2. _ensureAllMessagesLoaded now claims the _loadingOlder mutex around
   its body so a new prefetch cannot start mid-replace and concurrent
   ensure-all calls (rapid double-click on Start) serialize cleanly.
   It bumps the generation token before mutating S.messages, yields
   until any in-flight prefetch finishes, and resets _oldestIdx so a
   subsequent prefetch cannot request stale older messages.

Also adds the same-session / _loadingSessionId guards that the original
ensure-all body was missing post-await — if the user switched sessions
mid-flight, the old code would happily overwrite the new session's
messages with the previous session's full history.

12 new regression tests in tests/test_issue1937_endless_scroll_jumpstart_race.py
lock in: generation token declaration, bump-helper presence, snapshot-
before-await ordering, post-await-abort behaviour, mutex acquisition and
finally-release, yield-then-claim ordering when a prefetch is in flight,
generation bump during the wait phase, _oldestIdx reset, and the new
session-switch guard.

Closes #1937.
2026-05-08 21:14:22 -07:00
Dennis Soong
376727a6d1 fix: localize lineage segment row labels 2026-05-09 10:39:44 +08:00
Frank Song
3dfd692d75 Localize session jump controls 2026-05-09 10:03:27 +08:00
Dennis Soong
a3ab46e345 fix: keep project-dot regression resilient 2026-05-09 09:53:38 +08:00
Dennis Soong
5b36232cbf feat: expand collapsed session lineage segments 2026-05-09 09:49:10 +08:00
ai-ag2026
d84eaea594 ci: retrigger flaky ctl test 2026-05-09 02:19:32 +02:00
ai-ag2026
1559c70a41 fix: preserve chat scroll across final render 2026-05-09 02:15:35 +02:00
ai-ag2026
5dcb4e9ade test: cover theme-color media fallback 2026-05-08 23:51:24 +02:00
nesquena-hermes
0b7e1e60e8 Release v0.51.30 — Release G (offline recovery + PWA hardening + opt-in session jump buttons + opt-in endless-scroll)
Some checks failed
Release & Docker / release (push) Has been cancelled
Merge stage-325 to master.
2026-05-08 14:37:53 -07:00
nesquena-hermes
bc4421a1b6 release: v0.51.30 — Release G (3-PR batch: offline recovery + PWA hardening + opt-in session jump buttons + opt-in endless-scroll)
Three-PR contributor batch (all from @ai-ag2026):
- PR #1891: Browser offline recovery + PWA cache hardening
- PR #1928: Opt-in session Start/End jump buttons
- PR #1929: Opt-in session endless-scroll (builds on shipped #1927)

Tests: 4960 → 4977 (+17 net new). Browser API harness all-green.
Manual browser verification on port 8789 passed.
Opus advisor: SHIP-WITH-FIXES (both fast-follows are non-blocking).
2026-05-08 21:31:41 +00:00
nesquena-hermes
bec4433c2a Stage 325: PR #1929 — feat: add opt-in session endless scroll by @ai-ag2026
Conflict resolution: both #1928 (session jump buttons) and #1929 (endless
scroll) add their own settings/UI/i18n keys. Resolved by keeping both —
the features are independent opt-in toggles.
2026-05-08 21:23:34 +00:00
nesquena-hermes
fba860da48 Stage 325: PR #1928 — feat: add opt-in session jump buttons by @ai-ag2026 2026-05-08 21:16:33 +00:00
nesquena-hermes
503d549cd2 Stage 325: PR #1891 — feat: add browser offline recovery and PWA cache hardening by @ai-ag2026 2026-05-08 21:16:33 +00:00
ai-ag2026
ea8aca2818 feat: add opt-in session endless scroll 2026-05-08 21:16:21 +00:00
ai-ag2026
df1ba9fde8 feat: add opt-in session jump buttons 2026-05-08 21:16:19 +00:00
ai-ag2026
8f58a8c94e feat: add browser offline recovery and PWA cache hardening 2026-05-08 21:16:17 +00:00
nesquena-hermes
596c6b314d Release v0.51.29 — Release F (Docker hardening + login persistence + scroll/lineage fixes + i18n cleanup)
Some checks failed
Release & Docker / release (push) Has been cancelled
Merge stage-324 to master.
2026-05-08 14:01:17 -07:00
nesquena-hermes
351fbd3dd2 release: v0.51.29 — Release F (6-PR batch — Docker hardening + login persistence + scroll/lineage fixes + i18n cleanup)
Six-PR contributor batch:
- PR #1919 (franksong2702): Persist login rate limit attempts (closes #1910)
- PR #1920 (franksong2702): Remove dead Kanban start i18n key
- PR #1921 (Michaelyklam): Production Docker image hardening (closes #1908)
- PR #1926 (ai-ag2026): Prevent chat scroll resets after final render
- PR #1927 (ai-ag2026): Preserve viewport when loading older messages
- PR #1930 (ai-ag2026): Collapse stale compression sidebar segments

Tests: 4947 → 4960 (+13 net new). Browser API harness all-green.
Opus advisor: SHIP-READY. CHANGELOG conflict on #1919 auto-resolved
during stage rebase (CHANGELOG took ours strategy).
2026-05-08 20:58:56 +00:00
nesquena-hermes
383507f368 Stage 324: PR #1926 — fix: prevent chat scroll resets after final render by @ai-ag2026 2026-05-08 20:49:00 +00:00
nesquena-hermes
1f8e641e27 Stage 324: PR #1927 — fix: preserve viewport when loading older messages by @ai-ag2026 2026-05-08 20:49:00 +00:00
nesquena-hermes
89b8914704 Stage 324: PR #1930 — fix: collapse stale compression sidebar segments by @ai-ag2026 2026-05-08 20:49:00 +00:00
nesquena-hermes
55fdf48db4 Stage 324: PR #1921 — security: harden production Docker image by @Michaelyklam 2026-05-08 20:49:00 +00:00
nesquena-hermes
afb5edff1a Stage 324: PR #1919 — Persist login rate limit attempts by @franksong2702 2026-05-08 20:49:00 +00:00
nesquena-hermes
a44fa531ed Stage 324: PR #1920 — Remove dead Kanban start i18n key by @franksong2702 2026-05-08 20:49:00 +00:00
ai-ag2026
447b4e6c0f fix: collapse stale compression sidebar segments 2026-05-08 20:48:47 +00:00
ai-ag2026
018d491570 fix: preserve viewport when loading older messages 2026-05-08 20:48:44 +00:00
ai-ag2026
c65ae46983 fix: prevent chat scroll resets after final render
Keep explicit bottom pins stable across late layout growth and make clicking the already-active sidebar session a no-op before loadSession mutates state. Update scroll regression tests for the delayed settle path.
2026-05-08 20:48:43 +00:00
Frank Song
e8fd8dac5d Persist login rate limit attempts 2026-05-08 20:48:41 +00:00
Michael Lam
b1b0cedbe9 security: harden production Docker image 2026-05-08 20:48:39 +00:00
Frank Song
431705e498 Remove dead Kanban start i18n key 2026-05-08 20:48:37 +00:00
nesquena-hermes
dec2d25fcc Release v0.51.28 — Release E2 (MCP server Option A rewrite + WebUI /goal command)
Some checks failed
Release & Docker / release (push) Has been cancelled
Merge stage-323 to master.
2026-05-08 13:28:13 -07:00
nesquena-hermes
0590d597a3 ci: install mcp + pytest-asyncio in CI; importorskip in test_mcp_server.py
CI failed on stage-323 because:
1. mcp_server.py imports the 'mcp' package (optional runtime dep) — only
   users who actually run the MCP integration install it. CI runs with
   stdlib-only deps (pyyaml + pytest + pytest-timeout).
2. tests/test_mcp_server.py uses pytest.mark.asyncio which requires
   pytest-asyncio — not installed in CI.

Fix:
- Add pytest-asyncio to CI install line.
- Try-install mcp; if it fails (Python 3.13 wheel issues, etc.) the test
  module uses pytest.importorskip and skips cleanly without breaking the
  matrix.
- tests/test_mcp_server.py: add module-level importorskip for both 'mcp'
  and 'pytest_asyncio' as a safety net.

Local: 4947/4947 still pass after change.
2026-05-08 20:26:11 +00:00
nesquena-hermes
a1d72dc423 release: v0.51.28 — Release E2 (MCP server Option A rewrite + WebUI /goal command)
Two-PR contributor batch:
- PR #1895 (samuelgudi): MCP server Option A rewrite with canonical
  api.models/api.profiles imports, env-aware WEBUI_URL, data-loss
  safety in delete_project. 53-test coverage.
- PR #1866 (Michaelyklam): WebUI /goal command with goal-tracking,
  budget enforcement, continuation prompts. 489-LOC api/goals.py +
  full SSE wire-up.

Tests: 4898 → 4947 (+49 net new). Browser API harness all-green.
Opus advisor: SHIP-READY. Two follow-up items filed for next sweep
(goal-hook firing on unrelated turns; runtime i18n strings).
2026-05-08 20:20:24 +00:00
nesquena-hermes
9655504350 test(mcp_server): restore module identity + fix sys.modules.patch.dict pollution
Root cause: tests/test_mcp_server.py and tests/test_issue1857_usage_overwrite.py
both leaked module state into the full pytest suite, causing 20+ failures in
unrelated test files when they ran together.

Two distinct bugs:

1. test_issue1857_usage_overwrite.py used mock.patch.dict(sys.modules, {...}).
   patch.dict tracks original keys at __enter__ and DELETES any keys added
   during the patch on __exit__. That silently evicted lazily-imported
   pydantic submodules (e.g. pydantic.root_model), producing
   KeyError: 'pydantic.root_model' in test_mcp_server.py downstream.
   Fix: manual save/restore of only the three keys we explicitly inject.

2. test_mcp_server.py mutated module-level constants on api.config / api.models /
   mcp_server (STATE_DIR, SESSION_DIR, PROJECTS_FILE, …) without restoring,
   leaving downstream tests reading deleted tmpdirs. Fix: snapshot original
   values on first _reimport_mcp() call and restore in _cleanup_state_dir.

   Additionally, test_profiles_match_single_source_of_truth re-imported
   api.routes / api.profiles into sys.modules and only restored sys.modules,
   not the parent api package's attributes. `import api.routes as r` resolves
   via sys.modules['api'].routes (parent attribute), NOT directly via
   sys.modules['api.routes']. So fresh modules leaked through despite the
   sys.modules restore. Fix: also restore parent-package attributes.

Result: full pytest suite goes from 20 failures + 36 errors back to all green
(4947 passed, 8 skipped). Up from 4898 in v0.51.27, gain of 49 from
PR #1895 (MCP server tests) + #1866 (goal handler tests).
2026-05-08 19:58:21 +00:00
nesquena-hermes
b71a2d4cba Stage 323: PR #1866 — add WebUI /goal command support by @Michaelyklam 2026-05-08 17:40:31 +00:00
nesquena-hermes
92e868cb00 Stage 323: PR #1895 — MCP Option A rewrite — canonical api.models/api.profiles imports by @samuelgudi 2026-05-08 17:12:01 +00:00
Michael Lam
8e513b596b fix: surface goal evaluation status 2026-05-08 17:12:01 +00:00
Samuel Gudi
6fb1c24d60 test(mcp): wire-format coverage + --profile CLI ordering regression (#1895)
Maintainer review on #1895 asked for two test additions:

TestApiWireFormat — stands up a tiny http.server stub on a free port,
points WEBUI_URL at it, and captures (path, body, headers) of every
request the MCP issues:
  - test_rename_session_posts_to_canonical_path: locks /api/session/rename
    URL + body shape so a typo in the path or field names cannot slip
    through validation-only tests.
  - test_move_session_posts_to_canonical_path: same for /api/session/move
    including profile pre-flight against a real local project.
  - test_move_session_unassign_sends_null_project_id: explicit JSON null
    in the body, not an omitted key.
  - test_url_built_from_env_vars: HERMES_WEBUI_HOST/HERMES_WEBUI_PORT
    flow through to WEBUI_URL — would have caught the original 8788 bug.
  - test_url_default_when_env_unset: default 127.0.0.1:8787 matches the
    upstream contract from api/config.py:33.

TestProfileCliOrdering — locks the --profile CLI ordering invariant
(mcp_server.py:62-64): the override of _active_profile must bind before
any consumer reads it. Today this is safe because get_active_profile_name
reads the module global lazily, but a regression that latched the value
at import time would silently make --profile foo a no-op.

50/50 mcp tests pass.

Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
2026-05-08 17:12:01 +00:00
Michael Lam
0db5bc6b76 feat: add WebUI goal command support 2026-05-08 17:12:01 +00:00
Samuel Gudi
c613cfa9a7 refactor(profiles): relocate _profiles_match to api/profiles.py (#1895 review)
Maintainer review on PR #1895 flagged that mcp_server.py duplicated the
visibility model from api/routes.py:75. Move the canonical helper into
api/profiles.py (next to _is_root_profile, on which it depends) so both
api/routes.py and mcp_server.py import the same function instead of
carrying parallel definitions that could drift as the model evolves.

- api/profiles.py: + _profiles_match (verbatim from former routes.py:75-97)
- api/routes.py:   replace local definition with re-export to keep all
                   existing _profiles_match(...) call sites resolving
                   without per-call-site refactors
- mcp_server.py:   drop local copy, import _profiles_match alongside the
                   existing api.profiles imports (line 59)
- tests:           + test_profiles_match_single_source_of_truth asserts
                   identity (mcp.module._profiles_match is api.profiles._profiles_match
                   is api.routes._profiles_match) so any re-introduction of
                   a local copy trips the test
                   + test_profiles_match_input_matrix parametrize across
                   the (None|''|'default'|'foo') x (None|''|'default'|'foo'|'bar')
                   visibility matrix per maintainer suggestion

Behaviour unchanged. Zero call-site changes anywhere in api/routes.py.

Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
2026-05-08 17:12:01 +00:00
Samuel Gudi
453f2519f0 fix(mcp): env-aware WEBUI_URL + refuse delete_project unassign without auth
Blocker fixes from maintainer review of #1895.

WEBUI_URL: replace hardcoded 'http://127.0.0.1:8788' with HERMES_WEBUI_HOST/
HERMES_WEBUI_PORT env vars defaulting to 127.0.0.1:8787, mirroring the
contract in api/config.py:32-33. The 8788 default would have failed every
fresh upstream install — 8787 is canonical, 8788 is a local-deployment
quirk on hosts where 8787 is taken by another service.

delete_project no-auth path: remove the filesystem fallback that wrote
session_data['project_id']=None directly via os.replace(). That bypassed
_write_session_index() and left _index.json holding the stale project_id,
causing a running WebUI to keep grouping sessions under the deleted
project until something else triggered a re-compact. Even calling
Session.save() in-process would not have helped because the WebUI's
SESSIONS dict cache lives in a separate process and would overwrite our
update on its next save. The HTTP API is the only cache-safe path —
without auth we now refuse the unassign and surface a 'warning' field.

Tests: + test_delete_no_auth_refuses_unassign locks the new behaviour
(project deleted, sessions and index untouched, warning surfaced).

Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
2026-05-08 17:12:00 +00:00
Samuel Gudi
6b80cc781f feat(mcp): Option A rewrite — import api.models/api.profiles canonically (#1616)
Per maintainer review, replace duplicated I/O with canonical helpers
for locking, profile scoping, index consistency, and validation.
Profile scoping (#1614) enforced on all CRUD via _profiles_match
matching api/routes.py:75 semantics exactly. AI-authored, human-reviewed.

Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
2026-05-08 17:12:00 +00:00
nesquena-hermes
891c09c2bc Merge pull request #1923 from nesquena/stage-322
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.27 — Release E1: 4-PR batch (workspace-prefix sentinel hardening, custom named provider API key resolution, streaming chat scroll-pin, Kanban detail scrollable)
2026-05-08 10:09:32 -07:00
nesquena-hermes
81da27f45d chore(release): stamp v0.51.27 — 4-PR Release E1 batch (workspace-prefix sentinel + custom-provider keys + scroll-pin + kanban scroll) + Opus #1918 absorbed fixes 2026-05-08 17:07:16 +00:00
nesquena-hermes
8c4c253654 Stage 322: PR #1814 — custom named provider API key resolution by @hualong1009 2026-05-08 16:55:20 +00:00
nesquena-hermes
692b48cd12 Stage 322: PR #1918 — fix workspace prefix sentinel handling by @franksong2702 2026-05-08 16:40:17 +00:00
王浩生
cdbdc28f5c fix(config): custom named provider API key resolution in WebUI
- add robust custom provider credential/base_url resolver
- apply fallback in streaming and routes agent init/self-heal paths
- support slug normalization and config fallbacks for custom:* providers
2026-05-08 16:40:17 +00:00
Frank Song
ccdc055c36 Fix workspace prefix sentinel handling 2026-05-08 16:40:17 +00:00
nesquena-hermes
71115b0d3a Stage 322: PR #1914 — keep streaming chat pinned after final render by @ai-ag2026 2026-05-08 16:40:16 +00:00
nesquena-hermes
cefbd01e7e Stage 322: PR #1916 — make kanban detail view scrollable by @Michaelyklam 2026-05-08 16:40:16 +00:00
ai-ag2026
c4328c0a23 fix: keep streaming chat pinned after final render 2026-05-08 16:40:16 +00:00
Michael Lam
af98bad9de fix: make kanban detail view scrollable 2026-05-08 16:40:16 +00:00
nesquena-hermes
6253032b53 Merge pull request #1917 from nesquena/stage-321
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.26 — Release D: 5-PR follow-on batch (profile-isolation hardening, context-length config overrides, sidebar segment count polish)
2026-05-08 09:30:49 -07:00
nesquena-hermes
b58d796a32 chore(release): stamp v0.51.26 — 5-PR Release D follow-on batch (profile-isolation hardening + context-length config overrides + sidebar polish) 2026-05-08 16:28:42 +00:00
nesquena-hermes
b8426d047c Stage 321: PR #1900 — pass config overrides into context-length fallback (closes #1896) 2026-05-08 16:08:42 +00:00
Nathan Esquenazi
15b7b7ae12 fix(routes): pass config overrides into session-load context-length fallback
PR #1900 patches the two get_model_context_length() fallback callsites in
api/streaming.py to pass config_context_length, provider, and
custom_providers — but a third callsite of the same shape lives at
api/routes.py:2849, in the /api/session/get path that resolves
context_length for older sessions (pre-#1318) that have context_length=0
persisted.

Same bug shape: only `(model, base_url)` were forwarded, so the resolver
fell through to the 256K DEFAULT_FALLBACK_CONTEXT even when the user had
`model.context_length: 1048576` set in config.yaml. Visible symptom: the
very first paint of a reloaded old session shows the wrong window in the
chat-toolbar indicator until a turn fires (which would then trigger the
streaming.py fallbacks fixed in this PR and overwrite with the correct
value).

Fix mirrors streaming.py: pass `config_context_length=`,
`provider=effective_provider or ""`, and `custom_providers=` from the
per-profile config (`get_config()`), with a TypeError fallback that
retries the legacy 2-arg form for older hermes-agent builds whose
get_model_context_length signature pre-dates the new kwargs.

Adds `test_routes_session_load_fallback_passes_config_overrides` to lock
the call shape — verified to fail pre-fix with the same "missing
config_context_length=" error the streaming.py tests catch.

Defense-in-depth completion of #1896 — closes the third leg of the same
bug shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:08:42 +00:00
nesquena-hermes
0efa75827a fix(streaming): pass config overrides into context-length fallback (#1896)
The two get_model_context_length() fallback callsites in api/streaming.py
(session save + SSE usage payload) were calling the resolver with only
model + base_url. When the agent's compressor reports 0 (fresh/cached/
transitioning agent), resolution fell through to the 256K DEFAULT_FALLBACK
even when users had set model.context_length: 1048576 in config.yaml.

For LCM users on 1M-context models, the wrong window cascaded into a
session-killing failure: auto-compression triggered at ~25% of the wrong
value, floods of compress requests, 429s, credential pool exhaustion,
fallback 429s, then 'API call failed after 3 retries'.

Reported by @AvidFuturist on Discord with deepseek-v4-flash. Reproduced 5x.

Both callsites now pass config_context_length, provider, and
custom_providers. The resolver consults these BEFORE probing, so the
config override wins. Both are wrapped in except TypeError blocks that
retry with the legacy 2-arg form for older hermes-agent builds whose
get_model_context_length signature pre-dates these kwargs.

Tests: 7 source-string regressions guarding both call shapes, the safe
config parse, the legacy fallback, and the per-profile config source.
Also bumped the line-distance assertion in test_pr1341 (the test
explicitly invites bumping when a new pre-save mutation block is added).

Closes #1896

Co-authored-by: Hermes Agent <agent@hermes.local>
2026-05-08 16:08:42 +00:00
nesquena-hermes
03bb364917 Stage 321: PR #1898+#1904 — profile-home in agent cache signature + functional regression test (closes #1897) 2026-05-08 16:08:18 +00:00
nesquena-hermes
e0aa5d1731 test(#1897): replace source-string test with functional same-session profile-switch reproduction
Replaces the source-string-only test from #1898 with @Michaelyklam's functional
regression from #1904. The new test creates two synthetic profile homes with
distinct SOUL.md contents, runs _run_agent_streaming() three times on the same
session (profile A, profile A, profile B), and asserts that the profile switch
rebuilds the agent and uses profile B's cached SOUL prompt — proving the
user-visible failure mode directly rather than relying on cache-signature shape.

Kept source checks that _profile_home is resolved before the signature and
included as `_profile_home or ''` for stable empty-home behavior, since the
functional test alone wouldn't catch ordering regressions.

Co-authored-by: Michael Lam <Michaelyklam1@gmail.com>
2026-05-08 16:08:18 +00:00
nesquena-hermes
f456daa574 fix(streaming): include profile home in agent cache signature (#1897)
Same-session profile switches reused cached AIAgent from previous profile,
silently leaking the old persona's SOUL.md / system prompt into the new
profile's turns. session_id stays stable across profile switches, and the
signature didn't include the active profile home, so every signature input
matched and the stale agent was returned from SESSION_AGENT_CACHE.

Append _profile_home to the signature blob so profile switches force a
cache miss and a fresh agent build under the new HERMES_HOME (which
triggers a fresh load_soul_md() call).

Tests: 3 source-string regressions guarding the signature contract,
ordering, and empty-home fallback.

Closes #1897

Co-authored-by: Hermes Agent <agent@hermes.local>
2026-05-08 16:08:18 +00:00
nesquena-hermes
681456fc11 Stage 321: PR #1903 — scope skills endpoints to active profile by @Michaelyklam 2026-05-08 16:07:49 +00:00
nesquena-hermes
b1ea079c49 Stage 321: PR #1906 — show collapsed session segment count by @dso2ng 2026-05-08 16:07:49 +00:00
Michael Lam
2e2dca4eb8 test: skip profile skills regression without agent modules 2026-05-08 16:07:49 +00:00
Dennis Soong
4e71fb75d7 fix: show collapsed session segment count 2026-05-08 16:07:49 +00:00
Michael Lam
6c4b769324 fix: scope skills endpoints to active profile 2026-05-08 16:07:49 +00:00
nesquena-hermes
bbd41f2b61 Stage 321: PR #1901 — use root home for gateway health status by @Michaelyklam 2026-05-08 16:07:48 +00:00
Michael Lam
4366daba24 fix: use root home for gateway health status 2026-05-08 16:07:48 +00:00
nesquena-hermes
c7272dbfc9 Merge pull request #1911 from nesquena/stage-320
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.25 — Release C: 6-PR streaming/runtime batch (profile-isolated quotas, wedge diagnostics, max_turns, per-turn usage, interim_assistant SSE, workspace dedup)
2026-05-08 08:54:40 -07:00
nesquena-hermes
02b1b156bd chore(release): stamp v0.51.25 — 6-PR Release C streaming/runtime batch + Opus #1861 absorbed fix 2026-05-08 15:52:36 +00:00
nesquena-hermes
72b077ecce Stage 320: PR #1889 — deduplicate workspace-prefixed user turns by @ai-ag2026 2026-05-08 15:48:28 +00:00
ai-ag2026
f6d09e06ca fix: deduplicate workspace-prefixed user turns 2026-05-08 15:37:10 +00:00
nesquena-hermes
518453545c Stage 320: PR #1865 — interim_assistant streaming in runtime + live UI by @franksong2702 2026-05-08 15:37:09 +00:00
nesquena-hermes
035c537281 Stage 320: PR #1861 — overwrite session usage per turn by @franksong2702 2026-05-08 15:37:09 +00:00
Frank Song
8c02bfacd2 Restore explicit tool-segment reset calls for legacy assertions 2026-05-08 15:37:09 +00:00
Frank Song
c1a9d7ce79 fix: overwrite session usage per turn 2026-05-08 15:37:09 +00:00
Frank Song
82c7367cef Add interim_assistant streaming path to WebUI 2026-05-08 15:37:09 +00:00
nesquena-hermes
0039ae8c64 Stage 320: PR #1877 — honor configured max_turns in WebUI agents by @Michaelyklam 2026-05-08 15:37:08 +00:00
nesquena-hermes
f2194f13cd Stage 320: PR #1860 — request wedge diagnostics by @franksong2702 2026-05-08 15:37:08 +00:00
Michael Lam
01b9c82dc9 fix: honor configured max_turns in WebUI agents
Read agent.max_turns when constructing streaming WebUI AIAgent instances, pass it as max_iterations when supported, and include it in the per-session agent cache signature so budget changes take effect.

Add regression coverage for the config read, constructor kwarg, and cache key.
2026-05-08 15:37:08 +00:00
Frank Song
7e2709e281 fix: add request wedge diagnostics 2026-05-08 15:37:08 +00:00
nesquena-hermes
8324cb178f Stage 320: PR #1873 — profile-isolated account usage probes by @franksong2702 2026-05-08 15:37:07 +00:00
Frank Song
6808e06083 fix: isolate profile quota usage probes 2026-05-08 15:37:07 +00:00
nesquena-hermes
773857d159 Merge pull request #1902 from nesquena/stage-319
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.24 — Release B: 5-PR contributor batch (custom-provider preservation, upload preflight, ai-gateway dedup, Kanban lifecycle, cross-container liveness)
2026-05-08 08:35:04 -07:00
nesquena-hermes
4ccee8fb18 chore(release): stamp v0.51.24 — 5-PR Release B contributor batch 2026-05-08 15:32:55 +00:00
nesquena-hermes
a21d14ead3 Stage 319: PR #1886 — Kanban lifecycle controls by @franksong2702 2026-05-08 15:22:48 +00:00
Frank Song
6879390b8f Fix Kanban lifecycle controls
- Remove Kanban card Start and bulk Running controls (PATCH to running was unsafe)
- Rename "Nudge dispatcher" → "Preview dispatcher" (matches dry-run semantics)
- Add empty-board guidance kanban_work_queue_hint

Rebased onto master post-v0.51.23 by maintainer; preserves Japanese translations
from #1863 (kanban_nudge_dispatcher: ディスパッチャープレビュー).

Closes #1885

Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-05-08 15:19:04 +00:00
nesquena-hermes
0cf405cc16 Stage 319: PR #1868 — oversized upload preflight by @franksong2702 2026-05-08 15:16:19 +00:00
Frank Song
29829c3edf fix: preflight oversized browser uploads 2026-05-08 15:16:19 +00:00
nesquena-hermes
a11cbd3ee9 Stage 319: PR #1862 — preserve local custom provider model ids by @franksong2702 2026-05-08 15:16:18 +00:00
Frank Song
414c474d97 fix: preserve local custom provider model ids 2026-05-08 15:16:18 +00:00
nesquena-hermes
1105d496e9 Stage 319: PR #1887 — cross-container gateway liveness via state-file freshness fallback by @Sanjays2402 2026-05-08 15:15:50 +00:00
Sanjay Santhanam
efcfff3d7f fix(#1879): cross-container gateway liveness via state-file freshness
The dashboard banner 'Hermes agent is not responding' fires on every
multi-container deployment that doesn't set 'pid: "service:hermes-agent"'
in compose, because get_running_pid() relies on fcntl.flock and
os.kill(pid, 0) — both PID-namespace-scoped and invisible across container
boundaries.

Fix: when get_running_pid() returns None, fall back to a freshness check on
gateway_state.json. The gateway already writes that file on every tick with
gateway_state == 'running' and an aware ISO-8601 updated_at timestamp, so a
recent (<= 120s) timestamp is an equivalent live-process signal that needs
only a shared volume — no PID namespace, no compose workaround, no extra
HTTP probe URL.

Behavior preserved:
- In-namespace deployments still hit the PID-based path first; payload shape
  unchanged (no 'reason' key) so #716 contract holds.
- Cross-container alive path adds reason='cross_container_freshness' so
  support diagnostics can tell which signal succeeded.
- Stale updated_at, non-running gateway_state, malformed/naive/missing
  timestamps, and timestamps far in the future all still report 'down' — the
  fallback never produces a false positive.
- Same redaction rules: argv/command/executable/env/raw pid never leak.

Tests: 15 new cases in test_issue1879_cross_container_gateway_liveness.py
covering the cross-container alive path, every refusal case, clock-skew
tolerance, and backward compat with the #716 PID path. Existing #716
heartbeat tests (8) continue to pass.
2026-05-08 15:15:50 +00:00
nesquena-hermes
2c2e5142e3 Stage 319: PR #1883 — phantom duplicate Custom group when active provider is ai-gateway by @Sanjays2402 2026-05-08 15:15:49 +00:00
Sanjay Santhanam
a958c29373 fix(config): phantom Custom group when active provider is ai-gateway (#1881)
Two bugs in get_available_models() conspired to duplicate the active
provider's auto-detected models under a phantom 'Custom' group whenever
custom_providers was also declared in config.yaml:

1. custom:* PIDs not in _named_custom_groups (e.g. stale slugs left from
   prior configs) fell through to the auto_detected_models fallback, copying
   the active provider's whole catalog into a phantom Custom: <slug> group.
   Fix: continue unconditionally for ANY custom:* PID — the named-group
   branch is the only legitimate population path.

2. The bare 'custom' PID, with the active provider being concrete (e.g.
   ai-gateway), hit 'elif auto_detected_models: copy.deepcopy(...)' and
   built a duplicate Custom group of the active provider's models with
   mismatched provider prefixes. Fix: when pid == 'custom' and the active
   provider is non-custom, leave models_for_group empty.

The reporter also suggested a third fix gating resolve_model_provider() on
config_provider — that's intentionally NOT applied because it conflicts with
the long-standing model-specific-override semantics covered by
test_model_resolver.py::test_custom_provider_*_routes_to_named_custom_provider
(custom_providers entries explicitly override the active provider's routing
when the user opted-in). The reporter's symptom (duplicate UI group) lives
entirely in get_available_models()'s group construction and is fully fixed
by the two changes above.

Tests: 6 new regression tests (3 in #1881 file + reuse), 774 broader
tests still green (model/provider/custom/config domain).
2026-05-08 15:15:49 +00:00
nesquena-hermes
82aa628317 Merge pull request #1899 from nesquena/stage-318
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.23 — Release A: 7-PR contributor batch (stale-cleanup, title refresh, ja i18n, Kanban + cron + workspace polish)
2026-05-08 08:13:49 -07:00
nesquena-hermes
8e72dc771a chore(release): stamp v0.51.23 — 7-PR Release A contributor batch 2026-05-08 15:11:13 +00:00
nesquena-hermes
2c66d349ab Stage 318: PR #1872 — Fix workspace heading affordance without workspace by @franksong2702 2026-05-08 15:01:50 +00:00
nesquena-hermes
0ba6724e16 Stage 318: PR #1871 — Fix no-agent cron edit snapshot source by @franksong2702 2026-05-08 15:01:50 +00:00
nesquena-hermes
94d3cd5e95 Stage 318: PR #1870 — Fix Kanban stale-client false-positive by @franksong2702 2026-05-08 15:01:49 +00:00
nesquena-hermes
b5f8a48de5 Stage 318: PR #1869 — Test Kanban double-404 guard across methods by @franksong2702 2026-05-08 15:01:49 +00:00
nesquena-hermes
2730d775c2 Stage 318: PR #1863 — i18n: add Japanese (ja) locale bundle by @koshikai 2026-05-08 15:01:49 +00:00
nesquena-hermes
0dcce8e434 Stage 318: PR #1859 — fix: persist generated title refresh marker by @ai-ag2026 2026-05-08 15:01:48 +00:00
nesquena-hermes
c8e6207ca3 Stage 318: PR #1856 — fix: preserve pending turn during stale cleanup by @ai-ag2026 2026-05-08 15:01:48 +00:00
Frank Song
ee0828f53d fix: disable workspace heading affordance without workspace 2026-05-08 13:32:05 +08:00
Frank Song
b0876982c4 fix: use cron edit snapshot for no-agent saves 2026-05-08 13:18:29 +08:00
Frank Song
153c34cac0 fix: tighten Kanban stale-client heuristic 2026-05-08 13:12:16 +08:00
Frank Song
b684317554 test: parametrize kanban double-404 guard across HTTP methods 2026-05-08 12:48:23 +08:00
koshikai
9ddd1ae02c i18n: add Japanese (ja) locale bundle 2026-05-08 10:16:54 +09:00
ai-ag2026
755c18bdf9 fix: persist generated title refresh marker 2026-05-08 01:36:10 +02:00
ai-ag2026
f69a81c8c3 fix: preserve pending turn during stale cleanup 2026-05-07 23:57:01 +02:00
nesquena-hermes
5005f1c8ba Merge pull request #1853 from nesquena/fix/1793-workspace-prefs-kebab
fix(workspace): move 'Show hidden files' toggle into kebab + accent-dot state indicator (#1793)
2026-05-07 14:19:34 -07:00
nesquena-hermes
8804a5c5e9 Merge pull request #1854 from nesquena/stage-316
Some checks failed
Release & Docker / release (push) Has been cancelled
Stage 316: 3-PR batch — P0 markdown streaming hotfix + CSP source-map allowance + LaTeX delimiter rendering
2026-05-07 14:17:42 -07:00
nesquena-hermes
bbf707aa1c chore(release): document late absorbed commits — d703959 (code-fence-vs-math ordering) + 1448f42 (csp test pathlib)
Both stage-316 absorption commits documented in CHANGELOG. Test count
bumped 4815 → 4817 (+2 from d703959 regression coverage). Pre-release
pytest re-run confirmed 4790 passed, 0 failed.
2026-05-07 21:16:59 +00:00
ChaseFlorell
9a6e7483f6 test: align csp test with pathlib rooting pattern from existing suite
Use Path(__file__).resolve().parents[1] so the test survives being run
from a non-repo-root cwd, matching test_issue1112_csp_google_fonts.py.

Absorbed from PR #1852 follow-up commit 1448f42 by @ChaseFlorell.

Co-authored-by: Chase Florell <ChaseFlorell@users.noreply.github.com>
2026-05-07 21:14:16 +00:00
Nathan Esquenazi
d703959b74 fix(user-bubble): stash code fences before math to keep code-blocks literal
PR #1854 added a math stash to _renderUserFencedBlocks so backslash LaTeX
delimiters (\[..\], \(..\)) survive esc() and reach the KaTeX renderer in
user bubbles. The stash ran BEFORE the existing code-fence stash, so a
user-typed code block containing LaTeX-like syntax was extracted as
KaTeX and rendered as math inside <pre><code>:

    ```
    \[ a + b \] is wrong
    ```
  → <pre><code><div class="katex-block"> a + b </div> is wrong</code></pre>

renderMd() (assistant path) handles this correctly by running fence_stash
before math_stash. The user-bubble path got the order inverted. Fix:
stash code fences first, then run the math regexes on the
outside-of-fence text only. Both top-level math and code-fenced literals
now render correctly:

  - "math: \[ x + y \]"           → KaTeX block
  - "```\n\[ a + b \]\n```"       → literal <pre><code>\[ a + b \]</code></pre>

Adds two regression tests:
  - test_user_code_block_with_latex_syntax_renders_as_literal_code
    (fails pre-fix, asserts no KaTeX wrappers inside <pre><code>)
  - test_user_bubble_top_level_latex_still_renders_after_fence_reorder
    (sibling guard against over-correcting and disabling math entirely)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:03:04 -07:00
nesquena-hermes
945e7af751 fix: keep panel-header label at flex-shrink:2 (preserves shrink hierarchy)
Earlier in this branch I'd reduced .panel-header > span:first-child to
flex-shrink:1 thinking it would let heading + chip fit better at the
default 300px panel width. That broke
test_workspace_label_shrinks_with_ellipsis which pins the
git-badge:3 > label:2 > icons:0 shrink hierarchy as load-bearing
(git badge collapses first, label second, icons never).

The chip-on-narrow-panel concern is now addressed by the @container
query that hides the chip entirely below 420px container width — the
heading no longer competes with the chip for horizontal space, so
flex-shrink:2 is fine again.
2026-05-07 20:50:13 +00:00
nesquena-hermes
4c51521c89 chore(release): stamp v0.51.22 — 3-PR batch (P0 markdown streaming hotfix + CSP source-map allowance + LaTeX delimiter rendering) 2026-05-07 20:48:09 +00:00
Michaelyklam
d44513aabd fix: render backslash LaTeX delimiters in chat
Closes #1847

Co-authored-by: Michaelyklam <Michaelyklam@users.noreply.github.com>
2026-05-07 20:43:01 +00:00
ChaseFlorell
d8612ba323 fix: add cdn.jsdelivr.net to CSP connect-src to allow xterm source map fetches
Closes #1850

Co-authored-by: Chase Florell <ChaseFlorell@users.noreply.github.com>
2026-05-07 20:42:55 +00:00
nesquena-hermes
4ffa40282f test: tighten smd import shape — forbid bare AND root-absolute, require './' relative
The two tests that pin streaming-markdown's import shape were updated
to require the './' relative form and forbid BOTH the bare specifier
(broken by ES spec, #1849) AND the root-absolute form (broken under
subpath deployments like /hermes/). The original tests only forbade
root-absolute, which let the bare-specifier regression land
unnoticed.
2026-05-07 20:42:55 +00:00
ChaseFlorell
94aeb538f2 fix: use './' relative ES module specifier for smd.min.js (closes #1849)
The original specifier 'static/vendor/smd.min.js' was a bare module
specifier, which the [HTML spec](https://html.spec.whatwg.org/multipage/webappapis.html#resolve-a-module-specifier)
rejects: relative ES module references must start with '/', './', or
'../'. The block failed silently, window.smd was never set, and live
streaming markdown was broken for all users.

Fix: change to './static/vendor/smd.min.js' — the './'-relative form
satisfies both the ES module spec AND keeps the import resolution
mount-agnostic, so subpath deployments like /hermes/ continue to work.
Tests test_smd_vendor_import_is_mount_agnostic and
test_static_vendor_import_is_relative_to_current_mount updated to
require the './' form and forbid both the bare-specifier and
root-absolute forms.

Adapted from PR #1851 by @ChaseFlorell. Original PR fix used the
root-absolute form which fixed the bare-specifier bug but broke
subpath deployments; the './' form is the only shape that satisfies
both constraints.

Co-authored-by: Chase Florell <ChaseFlorell@users.noreply.github.com>
2026-05-07 20:42:19 +00:00
nesquena-hermes
1a533ec770 ux(workspace): hide hidden-files chip entirely on narrow panels
At the default 300px panel width, even the icon-only chip + 'Workspace'
heading + 5 action buttons overflowed and triggered ellipsis on the
heading ('WORKSP...'). Cleaner: hide the chip below 420px container
width and rely on the kebab's accent dot as the non-default-state
signal. The dot costs zero horizontal space (absolute-positioned over
the kebab icon) and the kebab's tooltip still labels what's happening.
On wider panels (user-resized, or future layouts), the full chip with
text appears.
2026-05-07 19:39:46 +00:00
nesquena-hermes
d8afba8001 ux(workspace): mute chip color + collapse to icon-only on narrow panels
Vision review of v1 flagged the chip's accent-yellow as 'loud and ugly'.
Switched to muted hover-bg + 1px border for a subtler badge look. Also
addressed heading truncation: at the default 300px panel width, heading
(95px) + 5 action buttons (154px) + chip text (110px) overflows, so the
heading was ellipsing to 'W...'. Added a container query on the existing
.rightpanel container that drops the chip text below 360px container
width, leaving just the eye icon (tooltip still labels it).
2026-05-07 19:36:27 +00:00
nesquena-hermes
9d971b7d3f ux(workspace): move 'Show hidden files' toggle to kebab menu (#1793)
Replaces the always-visible inline toggle row that ate ~32px below the
breadcrumb on every panel view (root, subdir, file preview). The toggle
is a set-once preference — most users flip it once or never — so the
control hides behind a kebab dropdown in the panel-actions row instead.

A small 'hidden visible' indicator next to the WORKSPACE heading flags
the non-default state so users don't forget the pref is on. Click the
indicator to reopen the menu and uncheck.

The localStorage key, filtering behavior, and the canonical
\`workspaceShowHiddenFiles\` checkbox id are unchanged — the checkbox
is rebuilt inside the dropdown each time it opens. All 11 existing
regression tests for #1793 stay green; 7 new tests pin the kebab
affordance shape.
2026-05-07 19:32:51 +00:00
nesquena-hermes
9f7f5a03e4 Merge pull request #1844 from nesquena/stage-315
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.21 — 3-PR batch (P0 hotfix for #1828 + auto-compression UI + shell HTML fallback)
2026-05-07 11:55:52 -07:00
hermes-agent
2b2dd23e03 chore(release): stamp v0.51.21 — 3-PR batch (P0 hotfix + auto-compression UI + shell HTML fallback)
3 PRs across kanban (#1843: P0 hotfix for v0.51.20 #1828's double-404
JSON corruption on the wire), streaming (#1838: SSE compressing event
bridge for auto-compression running state), and shell route (#1836:
HTML 503 fallback so / never returns JSON during restart races).

In-stage absorb:
- api/kanban_bridge.py: documented handle_kanban_* three-valued return
  contract with bool|None type annotations + docstring after PR #1843
  made False-vs-None load-bearing for the caller's 404 decision.

4805 → 4810 collected (+5). 4799 pass + 8 skip + 1 xfail + 2 xpass.
Browser API harness 11/11 green. JS syntax 1/1 clean.
Opus advisor SHIP verdict, 1 absorbed in-release, 1 deferred to follow-up.

Closes #1832, #1835. Hotfix for v0.51.20 #1828.
2026-05-07 18:53:37 +00:00
hermes-agent
5f6a55185c stage-315 absorb: document handle_kanban_* three-valued return contract
Per Opus pre-release verdict on PR #1843: the four handle_kanban_*
entry points declare '-> bool' but actually return True | None | False
(after PR #1843 made the False-vs-None distinction load-bearing for
the caller's '_kanban_unknown_endpoint' decision). Update the type
annotations to 'bool | None' and add a docstring on handle_kanban_get
(with cross-references on the three siblings) so a future contributor
adding a new return path doesn't accidentally produce a 0/'' value
that would silently revert the double-404 fix.

Test-only verification: kanban tests pass (49/49). Production behavior
unchanged. Cheap defensive cleanup per Nathan's standing absorb-in-release
default for ≤20-LOC documentation/type-annotation fixes.
2026-05-07 18:52:01 +00:00
nesquena-hermes
d750fab14a Stage 315: PR #1836 — keep shell route errors html by @Michaelyklam 2026-05-07 18:41:14 +00:00
nesquena-hermes
740e5412a5 Stage 315: PR #1838 — show auto-compression running state by @Michaelyklam 2026-05-07 18:41:13 +00:00
Michael Lam
78c09e1fd9 fix: keep shell route errors html 2026-05-07 18:41:13 +00:00
Michael Lam
e31b7e72d6 fix: show auto-compression running state 2026-05-07 18:41:13 +00:00
nesquena-hermes
a6301e426d Stage 315: PR #1843 — avoid double 404 response when bridge already sent error by @nesquena 2026-05-07 18:41:12 +00:00
Nathan Esquenazi
f3b56d8793 fix(kanban): avoid double 404 when bridge already sent error response
PR #1837's new `_kanban_unknown_endpoint` wrapper was triggered for any
falsy bridge return — but `handle_kanban_*` returns `None` (not `True`)
when an inner handler calls `bad(...)` to send an error response. The
wrapper then sent a SECOND 404 on top of the bridge's response, producing
concatenated JSON bodies on the wire.

Concrete reproducer (caught by behavioural harness, not the merged tests):

    GET /api/kanban/tasks/<missing-id>/log
    →  '{"error":"task not found"}{"error":"unknown Kanban endpoint: GET ..."}'

This affected every `bad(...)`-shaped error path in the bridge:
- task-not-found returns from `_task_log_payload` / `_task_detail_payload`
- exception handlers for ImportError (503), LookupError (404),
  ValueError (400), RuntimeError (409) across all four method handlers
- the `_handle_events_sse_stream` board-resolution failure path

The fix: distinguish an explicit `False` (truly unmatched path) from
`None` (handled, response already sent). Only `False` should trigger
the unknown-endpoint diagnostic.

Adds a regression test that exercises the task-not-found path through
`routes.handle_get` and asserts only one JSON body is on the wire.

Follow-on to #1837 (already merged into master at v0.51.20).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:35:57 -07:00
nesquena-hermes
ac8a41bc1f Merge pull request #1837 from nesquena/stage-314
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.20 — 5-PR contributor follow-on batch + 2 in-stage absorbs
2026-05-07 11:26:35 -07:00
hermes-agent
ab348219ff chore(release): stamp v0.51.20 — 5-PR follow-on batch + 2 in-stage absorbs
5 contributor PRs across Kanban (#1828: stale-client recovery + hard-refresh
button + board-pointer drift fix), providers (#1827: Codex card live+cache
merge enhancing v0.51.19 #1812), cron (#1826: no-agent edits without prompt),
and workspace UI (#1825: cruft filter; #1822: heading root actions).

In-stage absorbs:
- static/panels.js: removed duplicate loadKanbanBoards tail call to avoid
  doubling /api/kanban/boards traffic under SSE-driven refreshes.
- tests/test_issue1807_codex_provider_card_live_models.py: CODEX_HOME
  isolation for v0.51.19 tests now load-bearing under PR #1827's cache merge.

Parallel-discovery resolution: #1821 (ai-ag2026, leaner) closed as
superseded by #1826 (Michaelyklam, more thorough — Mode badge,
disabled-prompt, i18n hint, screenshot).

4790 → 4805 collected (+15). 4794 pass + 8 skip + 1 xfail + 2 xpass.
Browser API harness 11/11 green. JS syntax 3/3 clean.
Opus advisor SHIP verdict, 1 absorbed in-release, 4 deferred to follow-ups.

Closes #1786, #1793, #1820, #1823.
2026-05-07 18:23:59 +00:00
hermes-agent
a1eec6d191 stage-314 absorb: remove duplicate loadKanbanBoards tail call in loadKanban
PR #1828 added an await loadKanbanBoards() at the START of loadKanban() to
resolve the active board before board-scoped requests fire (so a stale saved
slug can fall back to default cleanly). The existing tail-of-function refresh
at line 1278 was harmless under one-time loads but doubles /api/kanban/boards
traffic under SSE-driven refreshes (debounced at 250ms via
_scheduleKanbanRefresh). The 30-second polling interval started by
_kanbanStartPolling() picks up any board state changes that arrive after
the render, so the tail call is redundant in PR #1828's new model.

Per Opus pre-release verdict: SHIP with this perf cleanup as in-release
absorb (5 LOC delta, clearly defensive, no behavior change for the
single-load case).
2026-05-07 18:21:56 +00:00
hermes-agent
d69d0eb35b stage-314 absorb: isolate CODEX_HOME in v0.51.19 codex provider card tests
PR #1827 introduced _read_visible_codex_cache_model_ids() merging
into the providers card live-fetch path. The two v0.51.19 tests in
tests/test_issue1807_codex_provider_card_live_models.py predate that
helper and didn't isolate CODEX_HOME, so the dev machine's real
~/.codex/models_cache.json (which contains entries like
gpt-5.3-codex-spark from #1680) was leaking into their assertions.

Add CODEX_HOME isolation in the existing _configure_codex helper —
matches the pattern PR #1827's own test already uses. Test-only fix;
production code unchanged. Caught by pre-release pytest gate.
2026-05-07 18:09:40 +00:00
nesquena-hermes
2bb9b0e4c2 Stage 314: PR #1822 — workspace heading root actions by @ai-ag2026 2026-05-07 18:00:40 +00:00
ai-ag2026
72982db94b fix: add workspace heading root actions 2026-05-07 18:00:35 +00:00
nesquena-hermes
ef3d34527a Stage 314: PR #1826 — allow no-agent cron edits without prompt by @Michaelyklam 2026-05-07 17:59:23 +00:00
Michael Lam
48773e8ff7 fix: allow no-agent cron edits without prompt 2026-05-07 17:59:23 +00:00
hermes-agent
0ed63968b6 Stage 314: PR #1827 — sync Codex provider card models with picker by @Michaelyklam
Note: PR #1827 was branched before v0.51.19 shipped #1812, which
introduced an initial (pure live-fetch) Codex provider card hook in
api/providers.py at the same line range. The contributor's PR was
filed AFTER #1812 shipped but their diff didn't yet account for it.
Stage 314 absorbs the contributor's intent (visible Codex cache
merge for gpt-5.3-codex-spark visibility) by replacing the v0.51.19
hook with the richer merged version directly in stage. Production
code change ≡ what the contributor's PR would have produced if
rebased onto current master. Test file + pr-media adopted verbatim.
Marker commit so the stage log makes the absorption visible.
2026-05-07 17:58:52 +00:00
nesquena-hermes
eb88d5390e Stage 314: PR #1825 — hide workspace file tree cruft by default by @ai-ag2026 2026-05-07 17:57:10 +00:00
ai-ag2026
36de8f1fc6 fix: hide workspace file tree cruft by default 2026-05-07 17:57:10 +00:00
nesquena-hermes
3c6c278c36 Stage 314: PR #1828 — surface stale Kanban client recovery by @Michaelyklam 2026-05-07 17:57:09 +00:00
Michael Lam
bb75707331 fix: surface stale Kanban client recovery 2026-05-07 17:57:09 +00:00
nesquena-hermes
bc732995c4 Merge pull request #1829 from nesquena/stage-313
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.19 — 15-PR contributor sweep + 1 in-stage absorb
2026-05-07 10:34:34 -07:00
hermes-agent
b0407f9373 chore(release): stamp v0.51.19 — 15-PR contributor sweep + 1 in-stage absorb
- 15 contributor PRs across backend (workspace, IPv6, bootstrap pair,
  named custom provider routing, quota cards, live Codex models),
  frontend (sessions trio: optimistic-row preservation, cross-surface
  continuation, session-owned approval prompts; ui trio: workspace
  metadata strip, error toast Copy + hover-pause, file picker + HTML
  preview interactions), streaming (workspace-prefix dedupe), and
  ops (workspace user-turn repair script).
- 1 in-stage absorb on api/config.py: gate _resolve_configured_provider_id
  alias resolution behind resolve_alias flag so resolve_model_provider
  preserves raw provider strings for #1625 _LOCAL_SERVER_PROVIDERS
  literal-match.
- 1 in-stage test absorb on test_bootstrap_discover_agent.py: pin
  Path.home() in isolation helper so PR #1817 tests don't pick up
  the dev machine's real ~/.hermes/hermes-agent.
- 4747 → 4790 collected (+43). 4776 pass + 11 skip + 1 xfail + 2 xpass.
- Browser API harness 11/11 green. JS syntax 5/5 clean.
- Opus advisor SHIP verdict, 0 MUST-FIX, 0 SHOULD-FIX in-release.

Closes #1792, #1795, #1796, #1800, #1806, #1807, #1694.
2026-05-07 17:31:42 +00:00
hermes-agent
1f702c7569 stage-313 absorb: gate _resolve_configured_provider_id alias resolution + harden bootstrap test isolation
Two in-stage fixes for v0.51.19 batch:

1) api/config.py — add resolve_alias=False param to
   _resolve_configured_provider_id() and pass it from
   resolve_model_provider(). The PR #1818 swap from
   _resolve_provider_alias() to _resolve_configured_provider_id()
   was correct for active-provider/badge surfaces but broke #1625's
   local-server-provider literal-preservation contract: 'ollama' →
   'custom' and 'lm-studio' → 'lmstudio' alias-collapse caused
   _LOCAL_SERVER_PROVIDERS membership check to miss, breaking the
   model-id full-path preservation for LM Studio/Ollama. The new
   flag preserves the raw provider value when called from
   resolve_model_provider, and named-custom-slug + base-url
   fallback both still run unchanged.

2) tests/test_bootstrap_discover_agent.py — pin Path.home() in
   _isolate_discover_agent_dir so the hard-coded
   'Path.home() / .hermes / hermes-agent' / 'Path.home() /
   hermes-agent' candidates in discover_agent_dir() can't pick up
   the dev machine's real install. The original PR #1817 isolation
   helper covered HERMES_HOME, HERMES_WEBUI_AGENT_DIR, and
   REPO_ROOT but missed the Path.home() leak.

Both surfaced on full pytest pre-release gate, fixed in stage,
ship in v0.51.19. Tests: full suite green.
2026-05-07 17:07:48 +00:00
nesquena-hermes
fc8cab4d1c Stage 313: PR #1803 — repair file picker and html preview interactions by @franksong2702 2026-05-07 16:59:00 +00:00
nesquena-hermes
0b736cb642 Stage 313: PR #1801 — make error toasts copy-friendly by @Michaelyklam 2026-05-07 16:59:00 +00:00
Frank Song
8bc2677691 fix: repair file picker and html preview interactions 2026-05-07 16:59:00 +00:00
Michael Lam
f704fb52e8 fix: make error toasts copy-friendly 2026-05-07 16:59:00 +00:00
nesquena-hermes
49501959b8 Stage 313: PR #1813 — hide workspace metadata in user bubbles by @ai-ag2026 2026-05-07 16:58:59 +00:00
ai-ag2026
1fd3198cc8 chore: rerun ci for workspace prefix fix 2026-05-07 16:58:59 +00:00
ai-ag2026
9633ed345b fix: preserve context card render ordering 2026-05-07 16:58:59 +00:00
ai-ag2026
ae22a80238 fix: hide workspace metadata in user bubbles 2026-05-07 16:58:59 +00:00
nesquena-hermes
a3072d05af Stage 313: PR #1819 — keep approval and clarify prompts session-owned by @dso2ng 2026-05-07 16:58:40 +00:00
Dennis Soong
fbc023bb17 fix: keep approval and clarify prompts session-owned 2026-05-07 16:58:40 +00:00
nesquena-hermes
e991d756e5 Stage 313: PR #1802 — keep cross-surface session continuations visible by @ai-ag2026 2026-05-07 16:58:39 +00:00
nesquena-hermes
f77b8aad5b Stage 313: PR #1797 — preserve first-turn sidebar row during refresh by @Michaelyklam 2026-05-07 16:58:39 +00:00
ai-ag2026
7d5704c3bc fix: keep cross-surface session continuations visible 2026-05-07 16:58:39 +00:00
Michael Lam
20861b6721 fix: preserve first-turn sidebar row during refresh 2026-05-07 16:58:39 +00:00
nesquena-hermes
5e01b00b8b Stage 313: PR #1809 — dedupe workspace-prefixed user turns after compaction by @ai-ag2026 2026-05-07 16:58:16 +00:00
nesquena-hermes
9cb51638ca Stage 313: PR #1812 — live Codex models in provider card by @franksong2702 2026-05-07 16:58:16 +00:00
ai-ag2026
256866ace6 fix: dedupe workspace-prefixed user turns after compaction 2026-05-07 16:58:16 +00:00
Frank Song
f7902776d4 fix: use live Codex models in providers card 2026-05-07 16:58:16 +00:00
nesquena-hermes
db7b72596e Stage 313: PR #1805 — provider account quota cards by @franksong2702 2026-05-07 16:58:15 +00:00
Frank Song
b763f22f36 fix: clarify Codex quota window labels 2026-05-07 16:58:15 +00:00
nesquena-hermes
06b858d062 Stage 313: PR #1817 — discover agent dir via hermes CLI shebang by @Saik0s 2026-05-07 16:57:13 +00:00
Igor Tarasenko
b7ed4dca3e fix(bootstrap): clarify shebang fallback precedence + tighten test setup
Addresses review feedback on PR #1817:

1. Extend the `_agent_dir_from_hermes_cli` docstring to spell out that
   the shebang fallback is a last-resort discovery step, not an override.
   Stale clones in known candidate paths still win — same precedence as
   today, but now documented so a future maintainer doesn't get the
   wrong idea.

2. Drop the misleading "install exists but no run_agent.py" comment in
   `test_returns_none_when_shebang_interpreter_does_not_walk_to_run_agent`.
   The test exercises a shebang pointing at /usr/bin/python3 whose
   parents never reach a run_agent.py — it doesn't actually need a fake
   install dir at all. Renamed for accuracy and removed the unused
   _make_agent_install call.
2026-05-07 16:57:13 +00:00
Igor Tarasenko
9f72472896 fix(bootstrap): discover agent dir via hermes CLI shebang
`discover_agent_dir()` only checked four hard-coded layouts:

  - HERMES_WEBUI_AGENT_DIR
  - $HERMES_HOME/hermes-agent
  - <webui-parent>/hermes-agent
  - ~/.hermes/hermes-agent / ~/hermes-agent

Users who clone hermes-agent somewhere else (e.g. ~/Projects/GitHub/hermes-agent)
hit:

    [bootstrap] ERROR: Python environment cannot import both WebUI dependencies
    and Hermes Agent. Set HERMES_WEBUI_PYTHON to the Hermes Agent venv Python
    or install the WebUI requirements into that environment.

…even though the `hermes` CLI is on PATH and works fine. The CLI is a
console-script with a venv-relative shebang:

    #!/path/to/hermes-agent/venv/bin/python3

After the explicit candidates miss, fall back to introspecting that shebang
and walking up parents until we find `run_agent.py`. That's a reliable
pointer to the install root regardless of where the user cloned the repo.

Tests cover happy path, no `hermes` on PATH, missing/invalid shebang,
shebang pointing outside any agent install (e.g. /usr/bin/python3), and
explicit candidates winning over the shebang fallback.

Verified end-to-end: with hermes-agent at a non-standard path,
`uv run bootstrap.py` now succeeds without any HERMES_WEBUI_AGENT_DIR
override.
2026-05-07 16:57:13 +00:00
nesquena-hermes
1706bbdcef Stage 313: PR #1815 — venv symlinks=True for shared-library Python by @Saik0s 2026-05-07 16:57:12 +00:00
nesquena-hermes
6ab384618a Stage 313: PR #1818 — named custom provider routing by @franksong2702 2026-05-07 16:56:49 +00:00
nesquena-hermes
63e85f2626 Stage 313: PR #1811 — workspace user turn repair script by @ai-ag2026 2026-05-07 16:56:49 +00:00
ai-ag2026
4c03fdfaa8 fix: add workspace user turn repair utility 2026-05-07 16:56:49 +00:00
nesquena-hermes
f020434109 Stage 313: PR #1816 — IPv6 support in HTTP server by @MacLeodMike 2026-05-07 16:56:48 +00:00
nesquena-hermes
58a2398392 Stage 313: PR #1798 — workspace path inaccessibility by @Michaelyklam 2026-05-07 16:56:48 +00:00
Michael Lam
1192a0a766 fix: preserve inaccessible workspace entries 2026-05-07 16:56:48 +00:00
Igor Tarasenko
4ae28a685a fix(bootstrap): note Windows fallback + add symlinks regression test
Addresses review feedback on PR #1815:

1. Extend the inline comment to note that CPython's venv falls back to
   copy mode when symlink creation fails (e.g. older Windows without
   SeCreateSymbolicLinkPrivilege), so symlinks=True is safe to set
   unconditionally — no platform branching needed.

2. Add a regression test that asserts EnvBuilder is called with
   symlinks=True. Cheap insurance against a future "simplify" pass
   removing the flag without realising it's load-bearing on macOS.
2026-05-07 18:35:00 +02:00
Frank Song
3ac89c2696 fix: route named custom provider model selections 2026-05-07 21:40:23 +08:00
Igor Tarasenko
3df6a8d29a fix(bootstrap): create local .venv with symlinks=True
Without symlinks=True, mise/asdf shared-library Python builds on macOS
default venv to copy mode. The copied python3 binary still references
@executable_path/../lib/libpython3.X.dylib in its load command, but the
dylib is never copied into .venv/lib — so any import in the new venv
(starting with ensurepip) aborts with SIGABRT.

Reproduces with mise's cpython 3.13.9 build:

    [bootstrap] Creating local virtualenv at .../.venv
    [bootstrap] ERROR: Command '[".../.venv/bin/python3.13", "-m",
      "ensurepip", "--upgrade", "--default-pip"]' died with
      <Signals.SIGABRT: 6>.

Symlinking the interpreter keeps @executable_path resolving back to the
original install where libpython lives. uv-managed Pythons already
symlink by default; mise's do not.
2026-05-07 15:01:57 +02:00
Michael MacLeod
dcc4076788 fix: support IPv6 bind address in QuietHTTPServer
Detect IPv6 addresses (containing ':') in QuietHTTPServer.__init__ and set address_family to AF_INET6 before socket creation, fixing EAFNOSUPPORT when binding to :: or ::1.

Also updates the loopback check to recognize ::1 and the container warning to mention :: as the IPv6 equivalent of 0.0.0.0. Documents IPv6 usage in HERMES_WEBUI_HOST env var description.
2026-05-07 08:55:16 -04:00
Frank Song
a6b88c8c1e feat: show account limits in provider quota 2026-05-07 17:36:04 +08:00
nesquena-hermes
a8de4e7c0a Merge pull request #1799 from nesquena/stage-312
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.18 — 5-PR batch (#1783, #1789, #1790, #1791, #1794)
2026-05-06 23:43:40 -07:00
nesquena-hermes
dcce07b2af chore(release): stamp v0.51.18 — 5-PR batch (#1783, #1789, #1790, #1791, #1794)
Constituent PRs:
- #1783 (@Sanjays2402) custom provider + :free/:beta/:thinking suffix fix. Closes #1776.
- #1789 (@Michaelyklam) preserve sidebar scrolling while streaming. Closes #1784.
- #1790 (@Michaelyklam) keep workspace open from preview breadcrumb. Closes #1785.
- #1791 (@Michaelyklam) keep assistant-only stream deltas on current turn. Closes #1787.
- #1794 (@nesquena-hermes, APPROVED by @nesquena) UX bundle: rail tooltip
  cascade fix, +new-conversation has-tooltip--bottom-right variant, context-menu
  hover-bg, rename pre-fill via setSelectionRange.

Tests: 4723 → 4747 collected (+24). 4733 passed, 0 failed in 149s.

Pre-release verification:
- All 5 PRs CI-green individually
- File overlaps (style.css + ui.js between #1789 + #1794) auto-merged cleanly
- node -c clean on all 4 changed JS files
- Browser API sanity 11/11 endpoints
- Pre-stamp re-fetch: all PR heads match local rebases
- Opus advisor: SHIP all 5, 0 MUST-FIX, 1 SHOULD-NOTE on test pattern divergence (acceptable)

Closes #1776, #1784, #1785, #1787.
2026-05-07 06:41:33 +00:00
nesquena-hermes
aad16801ff Stage 312: PR #1794 — fix(ux): rail tooltips + new-conversation clipping + context-menu hover + rename pre-fill by @nesquena-hermes 2026-05-07 06:25:18 +00:00
nesquena-hermes
b49c3cbd43 fix(ux): rail tooltips, +new-conversation clipping, context-menu hover, rename pre-fill
Four small UX bugs Nathan caught while dogfooding the v0.51.17 release on
desktop. All independently reproduced with browser_console + browser_vision
on a fresh worktree before fixing.

(1) **Left-rail icon tooltips never appeared.** The rail was migrated to the
    new `.has-tooltip` system in #1782, but the legacy suppression rule
    `.rail .nav-tab:hover::after { content: none }` survived the migration.
    Its specificity (0,3,1) outweighs `.has-tooltip:hover::after` (0,2,1),
    and `content: none` removes the pseudo-element entirely on hover — so the
    new tooltip system silently no-op'd on every rail icon. Fix: drop the
    suppression rule and scope the legacy `data-label` tooltip to
    `.sidebar-nav .nav-tab` (mobile) only, so it doesn't fire on rail buttons
    that carry no `data-label` (which would render an empty styled box).

(2) **`+ New conversation` tooltip clipped at panel right edge.** The button
    sits flush with the chat panel's right edge but used `--bottom` which
    centers the tooltip on `left:50%` — half the label overflowed past the
    panel edge ("New convers..."). New `.has-tooltip--bottom-right` variant
    anchors the tooltip's RIGHT edge to the trigger so the label extends
    inward. Reusable for any future right-edge panel-head button.

(3) **Workspace right-click menu items had no hover state.** The five sites
    in `_showFileContextMenu` (Rename / Reveal / Copy path / Delete) and two
    in `_showProjectContextMenu` set `style.background = 'var(--hover)'`. The
    custom property `--hover` is undefined anywhere in the codebase. An
    undefined `var()` falls back to the property's initial value
    (`transparent` for `background`) → no visible hover feedback. The defined
    variable is `--hover-bg` (`rgba(255,255,255,.06)`), already used by every
    other hover state in the app. One-letter typo, seven sites.

(4) **Rename dialog didn't pre-fill the current filename.** The caller
    (`_inlineRenameFileItem`) passed `defaultValue: item.name` to
    `showPromptDialog`, but the dialog's input setter reads `opts.value`
    only — the param name was silently dropped, leaving only the placeholder
    visible (Nathan called it the "ghost name"). Fixed two ways for
    defense-in-depth:
    - Caller switched to canonical `value: item.name`.
    - Dialog now also accepts `defaultValue` as an alias for `value`, so
      future typos using the standard `HTMLInputElement.defaultValue` param
      name don't repeat the bug.
    Plus: added `selectStem:true` opt that selects the stem before the last
    `.` on focus (Finder-style: `report.txt` → selects `report`, extension
    preserved). Edge cases verified live: directories full-select,
    `.gitignore` full-selects (dot at index 0), `noextension` full-selects,
    `a.b.c.d` selects `a.b.c`.

## Tests

+12 new regression tests, +5 net (existing test_css_tooltips suite gained 5
class-based tests; new tests/test_workspace_context_menu_and_rename.py file
adds 7 more). Total: 4728 passed (was 4723 in v0.51.17), 4 skipped, 3
xpassed, 0 failed in 141s.

- `RailTooltipCascadeTests` — pins the killer rule's absence (with comment
  stripping so the explanatory note doesn't false-positive), pins the
  scoped `.sidebar-nav .nav-tab` form, walks every rail button to confirm
  `has-tooltip` + non-empty `data-tooltip`.
- `BottomRightTooltipVariantTests` — pins variant existence, mechanics
  (`right:0`, `left:auto`, `transform:none`), and `#btnNewChat` adoption
  (with mutual-exclusion check that it doesn't carry both `--bottom` and
  `--bottom-right`).
- `ContextMenuHoverBackgroundTests` — `var(--hover)` may not appear in
  ui.js or sessions.js (the bug shape); affirmative pin that
  `_showFileContextMenu` sets ≥4 items to `var(--hover-bg)` and
  `_showProjectContextMenu` ≥2.
- `ShowPromptDialogPrefillTests` — pins both `opts.value` and
  `opts.defaultValue` references; pins the `selectStem` mechanic
  (`lastIndexOf('.')` + `setSelectionRange(0, dot)`); pins the caller's
  use of `value:item.name` and `selectStem`.

## Verification

Live in browser at port 8789 (worktree-served):
- Rail Tasks tooltip renders 8px right of the icon at the same vertical
  level (math: btn at y=87-123, tooltip at left=44px = 36px width + 8px gap).
- New-conversation tooltip renders below + button with right edge aligned
  to button's right edge, extending leftward, fully visible.
- Right-click → Reveal in File Manager shows `rgba(255, 255, 255, 0.035)`
  background on hover (the `--hover-bg` value); was `rgba(0, 0, 0, 0)`
  (transparent) before.
- Right-click → Rename on `report.txt`: input shows `report.txt`,
  selectionStart=0, selectionEnd=6, selected text = "report". Edge cases:
  directory `docs` → full-select; `.gitignore` → full-select;
  `noextension` → full-select; `a.b.c.d` → selects `a.b.c`.

`node -c` syntax check passes on both modified JS files.

Reported by: Nathan via screenshots (rail tooltips missing, + button
clipped tooltip, Workspace right-click no hover, rename dialog blank).
2026-05-07 06:25:18 +00:00
nesquena-hermes
d09466c62a Stage 312: PR #1789 — fix: preserve sidebar scrolling while streaming by @Michaelyklam 2026-05-07 06:25:17 +00:00
nesquena-hermes
b62f9dbbf8 Stage 312: PR #1790 — fix: keep workspace open from preview breadcrumb by @Michaelyklam 2026-05-07 06:25:17 +00:00
Michael Lam
eeedccec58 fix: preserve sidebar scrolling while streaming 2026-05-07 06:25:17 +00:00
Michael Lam
f90f283b73 docs: add workspace breadcrumb before screenshot 2026-05-07 06:25:17 +00:00
Michael Lam
ee5600e46c fix: keep workspace open from preview breadcrumb 2026-05-07 06:25:17 +00:00
nesquena-hermes
3d1d42cdf7 Stage 312: PR #1791 — fix: keep assistant-only stream deltas on current turn by @Michaelyklam 2026-05-07 06:25:16 +00:00
nesquena-hermes
34726c3356 Stage 312: PR #1783 — fix(config): custom provider + :free/:beta/:thinking suffix mis-resolution by @Sanjays2402 2026-05-07 06:25:16 +00:00
Michael Lam
048f1fa24e fix: keep assistant-only stream deltas on current turn 2026-05-07 06:25:16 +00:00
Sanjay Santhanam
064d14c85b fix(config): custom provider + :free/:beta/:thinking suffix mis-resolution (#1776)
PR #1762 fixed the rsplit grammar collision for plain @openrouter:model:free
qualifiers, but skipped the fallback whenever the provider hint started with
'custom:' on the assumption that custom providers route directly. That left
'@custom:my-key:some-model:free' broken: rsplit yields
provider='custom:my-key:some-model', bare='free' → custom guard skips the
split-fallback → returns provider='custom:my-key:some-model', model='free'.

Detect the over-split structurally instead of using a known-suffix allowlist:
custom hints carry exactly one segment after 'custom:' (constructed at
api/config.py:1363 as 'custom:' + entry_name). So any rsplit result of
'custom:<a>:<b>' with bare model '<c>' has eaten one model segment — peel
it back with a second rsplit and prepend it to the bare model.

This is robust for :free / :beta / :thinking / :preview / any future
OpenRouter suffix without an allowlist to maintain.

Adds 5 regression tests covering the matrix (free/beta/thinking/preview/
slashed-model). All 7 existing #1744 tests still pass; #1228 tests
unaffected.

Co-authored-by: Cake <51058514+Sanjays2402@users.noreply.github.com>
2026-05-07 06:25:16 +00:00
nesquena-hermes
9875967528 Merge pull request #1788 from nesquena/stage-311
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.17 — 2-PR batch (#1780, #1782)
2026-05-06 21:54:15 -07:00
nesquena-hermes
428e83750c chore(release): stamp v0.51.17 — 2-PR batch (#1780, #1782)
Constituent PRs:
- #1780 (@jasonjcwu) kanban-bridge: docstring + board_exists early-out
- #1782 (@jasonjcwu) replace native title tooltips with custom CSS tooltips
  + extensive maintainer-side polish: i18n.js title-clear, ui.js
    _applyDashboardStatus tooltip-aware, boot.js _setButtonTooltip helper
    + 6 callsites refactored, CSS rewrite (z-index 60→1500, gold-tinted
    border, stronger shadow, no arrow per VS Code/Slack/Linear pattern,
    150ms onset / 0ms dismissal), coverage +11 buttons, panel-header
    overflow:visible escape, has-tooltip--left for right-edge clipping,
    btnWorkspacePanelToggle reverted (chip already labels it),
    test tolerance updates + 3 new regression tests.

Tests: 4716 → 4723 collected (+7). 4716 passed, 0 failed.

Pre-release verification:
- pytest 4716 passed, 0 failed (~141s)
- Browser API sanity 11/11 endpoints
- Browser-verified each major tooltip surface — zero stuck title
  attributes at runtime, all coordinate-fits within 1280px viewport
- Opus advisor reviewed PR head + brief; flagged CI failures and
  i18n.js title leak — BOTH already fixed in stage-311's maintainer
  polish layer (Opus reviews contributor PR head, not the stage)

Closes #1775.
2026-05-07 04:51:45 +00:00
nesquena-hermes
c731803312 fix(ux): remove tooltip from workspace toggle (chip already labels it)
Browser verification showed the side-tooltip on btnWorkspacePanelToggle
was being clipped by its parent .composer-workspace-group's overflow:hidden
(necessary for the chip's border-radius:999px rounded-pill clipping).

Per user feedback: 'tooltips are only for things where there's really a
possibility you wouldn't know what it is — if there's already text on
the screen, no need.' The workspace toggle button is part of a chip
group whose adjacent .composer-workspace-chip label already shows the
current workspace path (e.g. /home/hermes/workspace, or 'Home') —
making the toggle icon's purpose self-evident.

Reverts btnWorkspacePanelToggle from data-tooltip='Show workspace panel'
+ class='has-tooltip' to title='Show workspace panel' (legacy native).
The native tooltip's slow display is acceptable here since (a) the chip
already contextualizes the button, and (b) the rounded-chip overflow:hidden
is non-negotiable for the visual design.

bot.js _setButtonTooltip helper is still in place — it correctly falls
back to el.title for elements without data-tooltip, so the runtime
title swap (open vs collapsed state) still works.
2026-05-07 04:35:55 +00:00
nesquena-hermes
56d88723cf fix(ux): add has-tooltip--left variant for right-edge buttons + fix tests
(1) Send-button tooltip clipping fix:
    The send button (btnSend) sits at the right edge of the composer area.
    Its side-positioned tooltip extended 'Send message' (~95px wide) past
    the viewport edge, leaving only 'Se' visible in some viewports —
    confirmed by maintainer screenshot review.

    Added a new `.has-tooltip--left` variant that flips the tooltip to
    the LEFT side of the trigger via `right: calc(100% + 8px)` instead
    of `left: calc(100% + 8px)`. Applied to btnSend in index.html.
    Browser-verified: full 'Send message' text now readable to the left
    of the gold Send button, no clipping.

(2) Test compatibility for the tooltip coverage expansion:
    5 pre-existing tests hardcoded specific class strings or 'title='
    attributes that no longer apply after we added has-tooltip + replaced
    title= with data-tooltip= on 11 high-traffic icon buttons.

    - tests/test_issue1488_composer_voice_buttons.py:
      - test_dictation_button_has_dictate_i18n_key: accept either
        title='Dictate' or data-tooltip='Dictate' as the static fallback.
      - test_buttons_have_distinct_static_titles: extracted helper
        _static_tooltip() that prefers data-tooltip over title.
    - tests/test_sprint20.py::test_mic_button_has_mic_btn_class:
      regex tolerant to additional utility classes between icon-btn and
      mic-btn (now 'icon-btn mic-btn has-tooltip').
    - tests/test_sprint20b.py::test_send_button_has_title_attribute:
      accept title= OR data-tooltip= per #1775.
    - tests/test_sprint20b.py::test_send_button_still_has_send_btn_class:
      regex tolerant to additional utility classes.
    - tests/test_workspace_panel_session_list.py::TestWorkspacePanelCollapsePriority::test_panel_header_no_longer_uses_space_between:
      panel-header was changed from overflow:hidden to overflow:visible
      so its tooltips can escape the header bar. The title-text ellipsis
      moved to the inner span (.panel-header > span:first-child) which
      already had its own overflow:hidden + text-overflow:ellipsis.
      Test now accepts either parent-level or inner-span overflow handling.

All 192 of the previously-failing or impacted tests now pass.
2026-05-07 04:30:02 +00:00
nesquena-hermes
53ad5eccba fix(ux): allow tooltips to escape panel-header overflow + polish shadow
Browser-verified two issues with stage-311 tooltip rendering:

(1) Workspace panel header tooltips (NewFile, NewFolder, Refresh, etc.)
    were being clipped because .panel-header had overflow:hidden. The
    title span at `.panel-header > span:first-child` already has its own
    overflow:hidden + text-overflow:ellipsis for the workspace name
    truncation, so the parent doesn't need it. Changed .panel-header to
    overflow:visible — verified tooltip now floats correctly below the
    icon row, ellipsis on the title still works because the inner span
    handles it locally.

(2) Strengthened tooltip body styling per browser screenshot review:
    - Border: var(--border) (#2A2A45 dark slate) → var(--accent-bg-strong)
      (gold-tinted at 15% alpha). Subtle brand-tied edge that's slightly
      more visible against the very dark page background.
    - Shadow: 6px/20px / 0.55 alpha + 1px ring at 0.25 → 8px/24px / 0.65
      alpha + 1px ring at 0.35 + 1px inset highlight at 0.04 alpha. Gives
      the tooltip more elevation against the dark theme so it reads as a
      floating element rather than painted onto the background.

All 19 tooltip pytest checks still pass. Browser-verified on rail
(Tasks, Settings), composer (Attach files, Send message), and workspace
panel header (New folder) — screenshots delivered to maintainer for
visual sign-off.
2026-05-07 04:24:31 +00:00
nesquena-hermes
6dd133b1f7 fix(ux): drop tooltip arrow/caret, use spatial proximity instead
Browser verification of the rail tooltip showed the 5px arrow ::before
pseudo-element was rendering as a tiny rectangle slice (not a triangle)
because the global `*, ::before, ::after { box-sizing: border-box }`
reset makes the colored border eat inward from a 10×10 box rather than
projecting outward from a 0×0 box. Adding `box-sizing: content-box`
inline to the pseudo fixes the geometry but at 11px text size and 5px
border-width the resulting triangle reads as visual noise rather than
a clear connector — multiple AI vision passes consistently couldn't
identify the arrow even when it was rendering correctly.

VS Code, Slack, and Linear's rail/icon-button tooltips all skip the
arrow for the same reason: spatial proximity at small sizes (an 8px gap
between trigger and tooltip body) is sufficient association without
the visual clutter of a tiny triangle.

Removes both ::before pseudo-rules. Tooltip body unchanged. Side
tooltip moved 12px → 8px gap (closer to trigger now that the arrow is
gone), bottom tooltip 10px → 8px for the same reason.

Browser-verified: rail Tasks tooltip rendering at 8/10 polish per
vision-AI assessment of the standalone tooltip body (solid surface bg,
solid border, warm-white text, 6px shadow + 1px ring, z-index 1500).

Co-authored-by: Jason Wu <jasonjcwu@users.noreply.github.com>
2026-05-07 04:11:40 +00:00
test
119a994341 Stage 311: PR #1782 — fix(ux): replace native title tooltips with custom CSS tooltips by @jasonjcwu (with maintainer-side polish + coverage expansion) 2026-05-07 04:00:46 +00:00
nesquena-hermes
d41555cec6 fix(ux): polish CSS tooltips + clear native title + extend coverage
Stage 311 maintainer-side enhancements on top of @jasonjcwu's PR #1782,
addressing browser-verified issues + extending coverage to high-traffic
icon buttons:

(1) Clear native title when custom data-tooltip is present (the core bug fix):
    - static/i18n.js: when data-i18n-title runs against an element that has
      data-tooltip, sync data-tooltip AND removeAttribute('title'). Without
      this, the slow ~1.5s native browser tooltip co-fires alongside the
      fast custom CSS tooltip — exactly the bug #1775 reports.
    - static/ui.js _applyDashboardStatus: same treatment for the dashboard
      rail/mobile buttons (was setting btn.title=warning unconditionally).
    - static/boot.js: added _setButtonTooltip() helper, replaced 6 direct
      .title assignments (workspace toggle/collapse/clear, voice dictate,
      voice mode active/inactive) with calls through the helper.

(2) Extend coverage to high-traffic icon buttons in static/index.html:
    - Composer area (side tooltip): btnAttach, btnMic, btnVoiceMode,
      btnWorkspacePanelToggle, btnSend.
    - Workspace panel header (bottom tooltip): btnCollapseWorkspacePanel,
      btnUpDir, btnNewFile, btnNewFolder, btnRefreshPanel, btnClearPreview.
    - All 11 buttons gain has-tooltip[--bottom] class and data-tooltip,
      lose their native title=. Total covered surfaces: rail (12), sidebar
      nav-tabs (12), panel-head (31), composer/workspace icons (11) = 66.

(3) CSS polish (browser-verified visible improvement):
    - z-index 60 → 1500/1501 so the tooltip clears all sidebar/panel
      stacking contexts. Earlier verification showed the tooltip overlapping
      the Filter conversations search input.
    - background: var(--bg-strong, ...) → var(--surface) (solid #1A1A2E
      instead of falling back via undefined cascade).
    - color: var(--text, var(--accent-text)) → var(--text) (solid warm white
      #FFF8DC instead of gold which clashed at body-text size).
    - border: var(--accent-bg-strong) → var(--border) (#2A2A45 solid
      instead of gold at 0.15 alpha — the old border was barely visible
      and the arrow ::before triangle was invisible).
    - shadow: 4px/0.45 alpha → 6px/0.55 alpha + 0 0 0 1px ring fallback.
    - Added 150ms hover-onset delay (matches Cygnus's spec in #1775); 0s
      dismissal-delay so quick mouse-aways don't leave the tooltip behind.
    - Fixed has-tooltip--bottom arrow direction: was pointing down (wrong),
      now points up at the trigger (border-color order corrected).
    - Bumped offsets: side tooltip 10px → 12px (clearance from icon edge),
      bottom tooltip 8px → 10px.

(4) Test fixes (the 2 CI failures):
    - tests/test_cron_refresh_button_835.py: assertion accepts either
      title= or data-tooltip= per #1775 (was hardcoded title=).
    - tests/test_mobile_layout.py::test_profiles_sidebar_tab_present:
      regex tolerant to additional utility classes (has-tooltip).

(5) Regression tests added to tests/test_css_tooltips.py:
    - test_native_title_cleared_when_custom_tooltip_present: pins the
      removeAttribute('title') call so we don't regress to dual tooltips.
    - test_native_title_path_preserved_for_non_tooltip_elements: pins the
      el.title fallback for elements without data-tooltip.

Browser-verified: all 72 has-tooltip elements have zero native title at
runtime (was 94 with native, 2 stuck via dashboard JS path).

Co-authored-by: Jason Wu <jasonjcwu@users.noreply.github.com>
2026-05-07 04:00:40 +00:00
test
57ccdcb965 Stage 311: PR #1780 — fix(kanban): docstring + board_exists early-out by @jasonjcwu 2026-05-07 03:58:16 +00:00
fxd-jason
b86bdf9dc8 fix(ux): replace native title tooltips with custom CSS tooltips (#1775)
- Add .has-tooltip CSS utility class with 300ms delay (vs ~1500ms native)
  - Position-aware: right side for rail buttons, bottom for nav/panel buttons
  - Arrow indicator pointing back at trigger element
  - :focus-visible support for keyboard accessibility
  - prefers-reduced-motion: no animation for users who opt out
- Replace native title="" with data-tooltip="" on all rail-btn, sidebar
  nav-tab, and panel-head-btn elements in index.html
- Sync data-tooltip via data-i18n-title handler for locale switching
- 17 tests covering HTML coverage, CSS class definitions, and i18n sync

Closes #1775
2026-05-07 03:58:16 +00:00
fxd-jason
a80b7695d8 fix(kanban): update stale read-only docstring + board_exists early-out in board counts
The bridge module docstring still described the API as 'deliberately
read-only' but it now exposes full CRUD (tasks, boards, comments,
links, SSE). Updated to list the supported operations.

For _board_counts_for_slug (the hot path for the board-switcher badge),
added a board_exists() early-out that mirrors the agent's own helper
in plugin_api.py (path.exists() before connect()). This avoids a
redundant init_db()+connect() schema pass per board per list refresh.
connect() already handles auto-init for fresh databases via its
needs_init check, so the extra init_db was unnecessary overhead on
the hot path that scales linearly with board count.

Tests:
- test_board_counts_returns_empty_for_nonexistent_board: verifies the
  early-out (no connect() call, returns {})
- test_board_counts_returns_real_counts_for_populated_board: verifies
  actual per-status counts are returned for existing boards
2026-05-07 03:58:16 +00:00
nesquena-hermes
697a7a10d1 Merge pull request #1781 from nesquena/stage-310
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.16 — 3-PR batch (#1768, #1778, #1779)
2026-05-06 20:12:44 -07:00
nesquena-hermes
c38ee6c339 chore(release): stamp v0.51.16 — 3-PR batch (#1768, #1778, #1779)
Constituent PRs:
- #1768 (@franksong2702) serialize Anthropic env fallback reads. Closes #1736.
- #1778 (@Michaelyklam) preserve CLI session tool metadata. Closes #1772.
- #1779 (@Michaelyklam) reset model picker on session switch. Closes #1771.
  AUTO-FIX: Opus stage-310 caught a regression in the new !hasSessionModel
  branch — it dropped the deferModelCorrection guard that the parallel
  else-branch keeps. Fired spurious /api/session/update POSTs against
  imported/read-only CLI sessions whose model field reads 'unknown' (the
  exact surface #1778 introduces in this same release). Wrapped the new
  branch's _persistSessionModelCorrection call + state mutation in
  if(!deferModelCorrection). Added test_sync_topbar_does_not_persist_correction_while_model_resolution_deferred
  regression test covering both empty and 'unknown' fast-path interaction.

Tests: 4694 → 4702 collected (+8). 4695 passed, 4 skipped, 3 xpassed,
0 failed in 141.29s.

Pre-release verification:
- All 3 PRs CI-green individually.
- node -c clean on static/ui.js.
- 11/11 browser API endpoints PASS.
- Pre-stamp re-fetch: all PR heads match local rebases.
- Opus advisor: SHIP #1768 + #1778, #1779 SHOULD-FIX before merge — auto-fix
  applied at stage with regression test, re-verified clean.

Closes #1736, #1771, #1772.
2026-05-07 03:10:43 +00:00
test
db132b97db Stage 310: PR #1779 — fix: reset model picker on session switch by @Michaelyklam 2026-05-07 02:52:01 +00:00
Michael Lam
24f76bcf37 fix: reset model picker on session switch 2026-05-07 02:52:01 +00:00
test
8ed7a7f61c Stage 310: PR #1778 — fix: preserve CLI session tool metadata by @Michaelyklam 2026-05-07 02:47:19 +00:00
test
3bc8bc8bdd Stage 310: PR #1768 — fix(oauth): serialize Anthropic env fallback reads by @franksong2702 2026-05-07 02:47:19 +00:00
Michael Lam
0bd65ef0bf fix: preserve CLI session tool metadata 2026-05-07 02:47:19 +00:00
Frank Song
91f99d8194 fix(oauth): serialize Anthropic env fallback reads 2026-05-07 02:47:19 +00:00
nesquena-hermes
9cc106272f Merge pull request #1777 from nesquena/stage-309
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.15 — 4-PR batch (#1762, #1767, #1769, #1770)
2026-05-06 19:06:58 -07:00
nesquena-hermes
516e5ad1f0 chore(release): stamp v0.51.15 — 4-PR batch (#1762, #1767, #1769, #1770)
Constituent PRs:
- #1762 (@bergeouss) openrouter/ prefix for tencent/hy3-preview:free. Closes #1744.
- #1767 (@Michaelyklam) use spawn for manual cron subprocesses. Closes #1754.
  AUTO-FIX applied: 2 tests skip on dev machines with editable hermes_agent
  install (the spawn child resolves the real cron.scheduler first instead of
  the fake one). Tightened detector to use importlib.util.find_spec origin
  check per Opus stage-309 SHOULD-FIX.
- #1769 (@nesquena-hermes, APPROVED by @nesquena) three context-menu
  essentials from #1764: Reveal-in-finder, Copy-path, Open-with-system.
- #1770 (@Michaelyklam) surface Codex usage exhaustion errors. Closes #1765.

Tests: 4662 → 4694 collected (+32). 4687 passed, 4 skipped (2 dev-only +
2 prong-2 noise), 3 xpassed, 0 failed in 135s.

Pre-release verification:
- All 4 PRs CI-green individually.
- node -c clean on all 4 changed JS files.
- 11/11 browser API endpoints PASS.
- Pre-stamp re-fetch: all PR heads match local rebases.
- Opus advisor: SHIP, all 5 verification questions clean, 0 MUST-FIX,
  2 SHOULD-FIX (one absorbed: detector tightening; one filed as #1776
  follow-up: custom provider + :free suffix edge case in #1762).

Closes #1744, #1754, #1764, #1765.
2026-05-07 02:04:36 +00:00
test
fc8c5d56f2 Stage 309: PR #1770 — fix: surface Codex usage exhaustion errors by @Michaelyklam 2026-05-07 01:39:52 +00:00
test
de10246a84 Stage 309: PR #1769 — feat(ux): three high-leverage context-menu essentials from #1764 by @nesquena-hermes 2026-05-07 01:39:52 +00:00
Michael Lam
2d20842450 fix: surface Codex usage exhaustion errors 2026-05-07 01:39:52 +00:00
nesquena-hermes
f77a44fce2 feat(ux): three high-leverage context-menu essentials from #1764
Issue #1764 asked for a much larger surface (Reveal + Copy-path on
every UI surface that references a file path, plus Rename in session
menus). Per Nathan's curation we ship only the three highest-leverage
pieces in this PR — they cover the three concrete user-visible
frictions Cygnus reported, and leave the broader sweep for follow-up.

## 1. Copy file path in workspace tree right-click menu

The tree's right-click already had Rename and Reveal in File Manager.
Reveal is slow when the user just wants the path string for a
terminal/editor — and there was no Copy-path action anywhere.

Added "Copy file path" between Reveal and Delete. It POSTs to a new
`/api/file/path` endpoint that resolves the relative tree-rooted path
into the absolute on-disk path (the frontend can't compute it because
only the server knows the workspace root) and writes the result to
the OS clipboard via `navigator.clipboard.writeText()`. Falls back to
the legacy execCommand pattern on browsers where the modern Clipboard
API is gated.

The new endpoint deliberately does NOT require the target to exist:
copy-path on a recently-deleted file is still useful (paste into a
terminal to investigate). `safe_resolve` continues to gate path
traversal — the test suite pins this with a `../../../../../etc/passwd`
attempt that 400s.

## 2. Rename in session three-dot menu

Cygnus's specific ask: double-click rename in the sidebar is timing-
sensitive — the first click frequently registers as "open the chat"
before the second click arrives, so users open the conversation when
they meant to rename it. Putting Rename in the menu eliminates the
timing entirely.

Added Rename as the FIRST item in `_openSessionActionMenu` (above
Pin). It reuses the existing `startRename` closure attached to each
session row — no duplicated state, no second API call out of band
with the double-click path. Mechanism: the row builder now stores
`el._startRename = startRename` and `el.dataset.sid = s.session_id`,
so the menu can find the row by data-sid and call its closure
directly. This keeps all the `_renamingSid`/`oldTitle`/`applyTitle`
bookkeeping single-sourced.

Read-only imported sessions skip the menu item via the same
`_isReadOnlySession` gate the closure already uses.

## 3. Reveal-failed toast includes the resolved server-side path

Cygnus posted a screenshot of a "Failed to reveal: not found" toast
that dropped the path entirely. Without it the user can't tell which
file the system expected — useful when a stale session row still
references a deleted file.

Server-side fix in `_handle_file_reveal`: instead of returning
`bad(handler, "File not found", 404)`, return
`bad(handler, f"File not found: {target}", 404)` where target is the
resolved absolute path. Frontend toast also defends against err with
no .message: `(err.message||err)` instead of `err.message` alone.

Verified live: a missing-file reveal now produces:

    Failed to reveal: File not found: /home/hermes/workspace/missing-xyz.txt

Cygnus's exact diagnostic-friction is gone.

## Tests

* tests/test_1764_context_menu_essentials.py (new)
  - 13 source-level pinning tests
  - 6 live HTTP behaviour tests against the conftest test server

* tests/test_1466_sidebar_cancel_clarify.py
  - Two assertion-window bumps (3200→4400, 3600→4800) to accommodate
    the new Rename action prepended to _openSessionActionMenu. The
    test relied on a fixed-byte-window function-body slice — comments
    added explaining why the bumps were needed.

* All 9 locales got translations for the 5 new keys
  (copy_file_path, path_copied, path_copy_failed, session_rename,
  session_rename_desc) — locale parity tests pass.

## Verification

Full pytest suite: 4671 passed, 2 skipped, 3 xpassed (matches
pre-change baseline).

Live browser verification on port 8789:
- Right-click .git folder in workspace tree → menu shows
  Rename / Reveal in File Manager / Copy file path / Delete (red).
- Click Copy file path → clipboard gets "/home/hermes/workspace/.git",
  toast confirms "File path copied to clipboard".
- Open session three-dot menu → Rename conversation appears first
  with pencil icon, followed by Pin / Move / Archive / Duplicate /
  Delete in the same order as before.
- Trigger reveal on a non-existent file → toast reads
  "Failed to reveal: File not found: /home/hermes/workspace/<filename>".
  The resolved server-side path is now visible in the failure.

Refs nesquena/hermes-webui#1764.
2026-05-07 01:39:52 +00:00
test
922c3e530d Stage 309: PR #1767 — fix: use spawn for manual cron subprocesses by @Michaelyklam 2026-05-07 01:39:51 +00:00
test
12bae4bce6 Stage 309: PR #1762 — fix: add missing openrouter/ prefix for tencent/hy3-preview:free by @bergeouss 2026-05-07 01:39:51 +00:00
Michael Lam
1fc8e83c90 fix: use spawn for manual cron subprocesses 2026-05-07 01:39:51 +00:00
bergeouss
9711070119 fix: resolve rsplit collision for OpenRouter models with :free/:beta/:thinking suffixes (#1744)
The previous approach of prepending 'openrouter/' to the model ID in the
catalog was incorrect — it only masked the symptom while regressing the
config_provider=openrouter codepath.

The root cause is in resolve_model_provider(): rsplit(':', 1) on
'@openrouter:tencent/hy3-preview:free' yields provider='openrouter:tencent/hy3-preview'
and model='free', because the ':free' suffix collides with the @provider:model
grammar.

Fix: after rsplit, validate that the extracted provider hint is a known
provider (in _PROVIDER_MODELS, _PROVIDER_DISPLAY, or starts with 'custom:').
If not, fall back to split(':', 1) so trailing suffixes stay attached to
the model ID.

This fixes all current and future OR models with colon-suffixed tags
(:free, :beta, :thinking, :nitro, etc.) without catalog changes.

Also adds regression tests for the affected models and edge cases.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-05-07 01:39:51 +00:00
bergeouss
ca1a268512 fix: add missing openrouter/ prefix for tencent/hy3-preview:free model (#1744) 2026-05-07 01:39:51 +00:00
nesquena-hermes
2106083e71 Merge pull request #1763 from nesquena/stage-308
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.51.14 — 4-PR contributor batch (#1756, #1757, #1760, #1761)
2026-05-06 15:22:13 -07:00
nesquena-hermes
e8659d1a40 chore(release): stamp v0.51.14 — 4-PR contributor batch (#1756, #1757, #1760, #1761)
Constituent PRs:
- #1760 (@ai-ag2026) preserve pending user turn on stream errors. Closes #1361.
- #1761 (@dso2ng) scope terminal stream cleanup to owner session. Refs #1694.
  AUTO-FIX applied: restored !INFLIGHT[S.session.session_id] disjunct in
  _setActivePaneIdleIfOwner (regression introduced by helper centralization).
- #1756 (@ng-technology-llc) isolate profile cookie per webui instance. Closes #803.
- #1757 (@skspade) tri-state gateway status (alive: True/False/None).

Tests: 4642 → 4662 collected (+20). 4649 passed, 9 skipped (test-isolation
prong-2 noise), 3 xpassed, 0 failed in 152s.

Pre-release verification:
- All 4 PRs CI-green or rebased clean (#1757 had stale base; CHANGELOG conflict
  auto-resolved by dropping the PR's redundant entry).
- node -c clean on static/messages.js + static/panels.js.
- 11/11 browser API endpoints PASS.
- Pre-stamp re-fetch: all PR heads match local rebases.
- Opus advisor: SHIP, all 5 verification questions clean, 0 MUST-FIX, 0 SHOULD-FIX.
- Two NICE-TO-HAVE coverage gaps absorbed in-release:
  (1) test_sprint36.py asserts !INFLIGHT[...] disjunct in helper body
  (2) test_issue1361_cancel_data_loss.py adds structural-grep test to pin
      _materialize_pending_user_turn_before_error call sites at error branches.

Closes #803, #1361, #1694.
2026-05-06 22:20:17 +00:00
test
74edc38aac Stage 308: PR #1757 — fix: gateway status card shows not running when no platforms connected by @skspade 2026-05-06 22:02:51 +00:00
test
54c9fb48dd Stage 308: PR #1756 — fix: isolate profile cookie per webui instance by @ng-technology-llc 2026-05-06 22:02:51 +00:00
test
5ecce3cbe5 Stage 308: PR #1761 — fix: scope terminal stream cleanup to owner session by @dso2ng 2026-05-06 22:02:51 +00:00
test
7c39ff608a Stage 308: PR #1760 — fix: preserve pending user turn on stream errors by @ai-ag2026 2026-05-06 22:02:51 +00:00
nesquena-hermes
fc5423f4aa auto-fix: preserve _setActivePaneIdleIfOwner permissive-fallback disjunct from PR #1753
PR #1753 (shipped v0.51.12) introduced the 3-way OR guard in done/error/cancel
handlers: 'isActiveSession || !S.session || !INFLIGHT[S.session.session_id]'.
The third disjunct ('no other inflight on the active pane') is the permissive
fallback Opus stage-306 verified — it allows the active pane to idle when no
other session is running, even when the completing stream is from a different
session. PR #1761's centralizing helper _setActivePaneIdleIfOwner inadvertently
dropped this disjunct, so a user viewing pane A (idle) while pane B completes
in the background would not get pane A's composer state cleared.

Restored: _setActivePaneIdleIfOwner now checks the same 3-way OR.

Verified via:
- node -c static/messages.js — clean
- pytest tests/test_session_runtime_ownership_invariants.py
       tests/test_1694_terminal_cleanup_ownership.py — 9 passed

Co-authored-by: dso2ng <dso2ng@users.noreply.github.com>
2026-05-06 22:02:37 +00:00
skspade
7193cee152 fix: tri-state gateway status — distinguish not-configured from not-running
- Backend: return `configured` field alongside `running`. When
  alive=None (no gateway metadata), configured=false with fallback to
  identity_map heuristic.
- Frontend: amber "Gateway not configured" when configured=false,
  red "Gateway not running" only when configured but process is down,
  green "Running" when both true.
- Replace dead try/except fallback with explicit tri-state check on
  health["alive"].
- Add regression test for last_active guard when alive=true and
  identity_map is empty.

All 87 gateway-related tests pass.
2026-05-06 22:01:36 +00:00
skspade
eab39f14db fix: gateway status card shows 'not running' when no platforms connected
Use agent_health.build_agent_health_payload() as the authoritative
running signal instead of bool(identity_map). An empty identity_map
means zero connected messaging platforms, not that the gateway is down.

Falls back to identity_map heuristic when agent_health module is unavailable
(e.g. WebUI-only deployments).
2026-05-06 22:01:35 +00:00
Nick
d5a31a0f4d fix: isolate profile cookie per webui instance 2026-05-06 22:01:20 +00:00
Dennis Soong
98a6f88ef7 fix: scope terminal stream cleanup to owner session 2026-05-07 05:56:17 +08:00
ai-ag2026
a7b04bbc1e fix: preserve pending user turn on stream errors 2026-05-06 22:47:58 +02:00
1006 changed files with 187950 additions and 8935 deletions

View File

@@ -15,12 +15,15 @@
# Port to listen on (default: 8787)
# HERMES_WEBUI_PORT=8787
# Where to store sessions, workspaces, and other state (default: ~/.hermes/webui-mvp)
# Where to store sessions, workspaces, and other state (default: ~/.hermes/webui)
# HERMES_WEBUI_STATE_DIR=~/.hermes/webui
# Default workspace directory shown on first launch
# HERMES_WEBUI_DEFAULT_WORKSPACE=~/workspace
# Optional model override. Leave unset to use the active Hermes provider default.
# HERMES_WEBUI_DEFAULT_MODEL=
# Base directory for all Hermes state (affects all paths above if set)
# HERMES_HOME=~/.hermes

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: nesquena

42
.github/workflows/browser-smoke.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: Browser smoke
# Headless page-load smoke: boots the real server.py (agent-free) and loads the
# key pages in Chromium, failing on any console error or uncaught JS exception.
# This catches the runtime-JS brick class (const-reassign, function/window
# collision) that `node --check`, ESLint, and the mocked pytest suite cannot
# see — they only manifest when a real browser executes the page.
#
# No secrets, no credentials: the server boots agent-free and the smoke script
# strips every *_API_KEY from the environment before launch.
on:
pull_request:
branches: [master]
push:
branches: [master]
jobs:
browser-smoke:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
cache-dependency-path: |
**/requirements*.txt
**/pyproject.toml
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install "pyyaml>=6.0" playwright
# Install only the Chromium browser + its system deps.
python -m playwright install --with-deps chromium
- name: Run browser smoke
run: python tests/browser_smoke.py

233
.github/workflows/docker-smoke.yml vendored Normal file
View File

@@ -0,0 +1,233 @@
name: Docker smoke
# Runtime smoke gate for Docker init logic.
#
# Background: v0.51.84 (PR #2470) shipped a startup-killing :ro mount + chown
# interaction (EROFS under `set -e`) that 9 source-level pytest invariants +
# 5800+ existing tests all passed. The independent reviewer caught it by eye.
# This workflow closes that class of gap by actually `docker compose up`-ing
# each variant against a real Docker daemon on the GHA runner.
#
# Scope (intentionally small for v1):
# - 3 compose variants (single, two-container, three-container)
# - For multi-container variants, rebuild the local Dockerfile and re-tag
# it as ghcr.io/nesquena/hermes-webui:latest BEFORE `up` so the PR's
# changes to docker_init.bash / Dockerfile actually execute. Without this
# the multi-container variants would pull the previous release from GHCR
# and silently miss every PR-level regression.
# - Pre-flight `docker compose config` job to catch schema/interpolation drift.
# - Reaper before each smoke run + trap on EXIT for orphan defence.
#
# Out of scope for v1 (per design review):
# - HERMES_WEBUI_SMOKE_TEST env flag in docker_init.bash (production-code footgun)
# - --user 60000:60000 (skips the chown branch we're protecting against)
# - Hadolint / yamllint (separate lint workflow, follow-up PR)
# - Local-runnable scripts/docker-smoke-test.sh (ship CI first, then iterate)
# - Podman runtime smoke (defer until a podman-specific bug ships)
on:
pull_request:
branches: [master]
paths:
- 'Dockerfile'
- 'docker_init.bash'
- 'docker-compose*.yml'
- '.dockerignore'
- '.env.docker.example'
- '.github/workflows/docker-smoke.yml'
push:
branches: [master]
paths:
- 'Dockerfile'
- 'docker_init.bash'
- 'docker-compose*.yml'
- '.dockerignore'
- '.env.docker.example'
- '.github/workflows/docker-smoke.yml'
workflow_dispatch:
# Fork PRs run with no secrets — that's the right model. Pin to least privilege.
permissions:
contents: read
jobs:
compose-config:
name: Compose config validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate every compose file parses
run: |
set -euo pipefail
for f in docker-compose.yml docker-compose.two-container.yml docker-compose.three-container.yml; do
echo "::group::compose config: $f"
docker compose -f "$f" config > /dev/null
echo "::endgroup::"
done
# Build the Docker image once and cache layers via GHA cache.
# The smoke matrix jobs then pull from this cache instead of rebuilding
# from scratch, saving ~1-3 minutes per variant.
build-image:
name: Build Docker image (cache layers)
runs-on: ubuntu-latest
needs: compose-config
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build and cache Docker image
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: ghcr.io/nesquena/hermes-webui:latest
cache-from: type=gha
cache-to: type=gha,mode=max
smoke:
name: Smoke ${{ matrix.variant }}
runs-on: ubuntu-latest
needs: [compose-config, build-image]
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
variant:
- single
- two-container
- three-container
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
# Restore the cached Docker image from the build-image job.
# Read-only: the build-image job already populated the GHA cache, so the
# smoke variants only need cache-from (no redundant cache-to re-export).
- name: Restore Docker image from cache
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: ghcr.io/nesquena/hermes-webui:latest
cache-from: type=gha
- name: Resolve compose file + project name
id: vars
run: |
set -euo pipefail
case "${{ matrix.variant }}" in
single)
echo "compose_file=docker-compose.yml" >> "$GITHUB_OUTPUT"
;;
two-container)
echo "compose_file=docker-compose.two-container.yml" >> "$GITHUB_OUTPUT"
;;
three-container)
echo "compose_file=docker-compose.three-container.yml" >> "$GITHUB_OUTPUT"
;;
esac
# Per-run project name so concurrent jobs / reruns can't clobber each other.
echo "project=hermes-smoke-${{ matrix.variant }}-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT"
- name: Reap any prior hermes-smoke resources on this runner
run: |
set -euo pipefail
# Hosted GHA runners are fresh, so this is mostly defence-in-depth for
# self-hosted runner re-use. We rely primarily on the unique per-run
# project name + `compose down -v --remove-orphans` in the EXIT trap
# to clean up the resources THIS run creates; this step only sweeps
# leftovers from prior runs that crashed before their trap fired.
# Match by project-name prefix instead of labels (the compose files
# don't carry hermes-smoke labels on their resources).
for c in $(docker ps -aq --filter "name=hermes-smoke-"); do
docker rm -f "$c" || true
done
for v in $(docker volume ls -q | grep "^hermes-smoke-" || true); do
docker volume rm -f "$v" || true
done
for n in $(docker network ls --format '{{.Name}}' | grep "^hermes-smoke-" || true); do
docker network rm "$n" || true
done
- name: Prepare ephemeral host paths
id: paths
run: |
set -euo pipefail
STATE_DIR="$(mktemp -d -t hermes-smoke-state-XXXXXX)"
WORK_DIR="$(mktemp -d -t hermes-smoke-work-XXXXXX)"
echo "state_dir=$STATE_DIR" >> "$GITHUB_OUTPUT"
echo "work_dir=$WORK_DIR" >> "$GITHUB_OUTPUT"
echo "Allocated:"
echo " HERMES_HOME = $STATE_DIR"
echo " HERMES_WORKSPACE = $WORK_DIR"
- name: Smoke (up + health + log scan + down)
env:
COMPOSE_FILE: ${{ steps.vars.outputs.compose_file }}
PROJECT: ${{ steps.vars.outputs.project }}
HERMES_HOME: ${{ steps.paths.outputs.state_dir }}
HERMES_WORKSPACE: ${{ steps.paths.outputs.work_dir }}
run: |
set -euo pipefail
# ----- Trap-guaranteed cleanup, regardless of exit reason -----
cleanup() {
local rc=$?
echo "::group::Cleanup (rc=$rc)"
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" logs --no-color --tail=200 || true
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans || true
rm -rf "$HERMES_HOME" "$HERMES_WORKSPACE" || true
echo "::endgroup::"
return $rc
}
trap cleanup EXIT
echo "::group::docker compose up"
# --wait blocks until all services report healthy OR --wait-timeout fires.
# Compose v2 returns nonzero on either failure mode.
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait --wait-timeout 120
echo "::endgroup::"
echo "::group::container roster"
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" ps
echo "::endgroup::"
# ----- WebUI /health probe -----
# Single-container: WebUI is on the host on 127.0.0.1:8787.
# Two/three-container: same — both compose files publish 127.0.0.1:8787.
echo "::group::Probe /health"
attempts=0
max_attempts=30
until curl --fail --silent --max-time 5 http://127.0.0.1:8787/health > /dev/null; do
attempts=$((attempts + 1))
if [ "$attempts" -ge "$max_attempts" ]; then
echo "❌ WebUI /health never returned 200 after $max_attempts attempts (~60s)"
exit 1
fi
sleep 2
done
echo "✅ /health = 200 after $attempts attempts"
echo "::endgroup::"
# ----- Startup log scan: must not contain any known-bad signatures -----
# These are the exact patterns that would have flagged #2470 in real time.
# The grep -i is anchored to actual error tokens; benign log lines that
# contain the substring 'error' in a stack-friendly context (e.g.
# "errorless", URL paths) are improbable for these specific tokens.
echo "::group::Startup log scan"
LOGS="$(docker compose -p "$PROJECT" -f "$COMPOSE_FILE" logs --no-color)"
# `!! ERROR` + `!! Exiting script` are the actual strings emitted by
# docker_init.bash's error_exit() helper — the function name itself
# never appears in output. The literal token `error_exit` is kept as
# a belt-and-suspenders catch for any stray debug/echo of the name.
BAD_PATTERNS='EROFS|Read-only file system|Traceback|PermissionError|!! ERROR|!! Exiting script|error_exit|groupmod: cannot|usermod: cannot|Failed to set (UID|GID|owner|permissions|ownership)'
if echo "$LOGS" | grep -E -i "$BAD_PATTERNS"; then
echo "❌ Startup logs contain known-bad pattern (see above)"
exit 1
fi
echo "✅ No known-bad patterns in startup logs"
echo "::endgroup::"

View File

@@ -0,0 +1,132 @@
name: Native Windows startup
# Runs on PRs that touch start.ps1 (or this workflow). Validates the
# native-Windows launch script catches the bug classes the recent
# Windows-only batch caught manually (#2805 WOW64 ProgramFiles redirect,
# #2806 venv-portability claim, #2807 port-parse + finally-cleanup).
#
# Scope (per nesquena-hermes comment on #2811 — option 1, mock-only):
# hermes-agent is not published to PyPI, so we cannot pip-install it on
# the runner. Instead we stub a hermes_cli/ directory next to a sibling
# hermes-agent/ folder — just enough for start.ps1's existence guard to
# pass. The workflow then runs start.ps1 for a few seconds and asserts
# that none of start.ps1's own Write-Error guards fired. Server-boot
# regressions remain covered by the Linux jobs and docker-smoke.yml.
on:
pull_request:
paths:
- 'start.ps1'
- '.github/workflows/native-windows-startup.yml'
workflow_dispatch:
jobs:
native-windows-startup:
name: start.ps1 path discovery (mock hermes-agent)
runs-on: windows-latest
timeout-minutes: 8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
# Create the WebUI venv. start.ps1 prefers $AgentDir\venv if it
# exists, then falls back to the python on PATH. We create a
# WebUI-local venv to mirror the README's documented native path
# and to give start.ps1 a real python.exe to invoke.
- name: Create venv (README path)
shell: pwsh
run: |
python -m venv venv
if (-not (Test-Path venv\Scripts\python.exe)) {
throw "venv\Scripts\python.exe missing after venv create"
}
# Mock-only hermes-agent provisioning. We can't pip-install
# hermes-agent (not on PyPI), so we stub the minimum that
# start.ps1's `Test-Path hermes_cli -PathType Container` guard
# needs to pass. server.py would crash on this stub at import
# time — we deliberately do NOT probe /health below.
- name: Stub hermes-agent (mock hermes_cli only)
shell: pwsh
run: |
$agentDir = Join-Path (Split-Path -Parent $PWD) 'hermes-agent'
$cliDir = Join-Path $agentDir 'hermes_cli'
New-Item -ItemType Directory -Force -Path $cliDir | Out-Null
Set-Content -Path (Join-Path $cliDir '__init__.py') -Value '# stub for CI path-discovery test only'
"HERMES_WEBUI_AGENT_DIR=$agentDir" >> $env:GITHUB_ENV
Write-Host "Stub hermes-agent provisioned at $agentDir"
# Run start.ps1 and verify it passes its own discovery guards
# without erroring out. server.py will exit non-zero on the stub
# (no real CLI code) — that's expected and not asserted against.
# We only fail if start.ps1's own Write-Error guards fire.
- name: Run start.ps1 + verify path discovery
shell: pwsh
run: |
$stdout = Join-Path $env:RUNNER_TEMP 'start-ps1.out'
$stderr = Join-Path $env:RUNNER_TEMP 'start-ps1.err'
$proc = Start-Process -FilePath 'pwsh' `
-ArgumentList '-NoLogo','-File','.\start.ps1' `
-WorkingDirectory $PWD `
-PassThru `
-RedirectStandardOutput $stdout `
-RedirectStandardError $stderr
"SERVER_PID=$($proc.Id)" >> $env:GITHUB_ENV
Write-Host "Spawned start.ps1 wrapper PID $($proc.Id)"
# Path discovery is sub-second; the 8s buffer lets the python
# launch land in the logs (and immediately exit on the stub).
Start-Sleep -Seconds 8
Write-Host "===== start.ps1 stdout ====="
$stdoutContent = if (Test-Path $stdout) { Get-Content $stdout -Raw } else { '<empty>' }
Write-Host $stdoutContent
Write-Host "===== start.ps1 stderr ====="
$stderrContent = if (Test-Path $stderr) { Get-Content $stderr -Raw } else { '<empty>' }
Write-Host $stderrContent
# Pattern set: every Write-Error message start.ps1 can emit on
# its own discovery path. If any of these appear in stderr,
# path discovery regressed and the job must fail.
$guardErrors = @(
'Python 3 is required',
'hermes-agent not found',
'HERMES_WEBUI_AGENT_DIR is set to',
'is not a valid integer port',
'is out of TCP-port range',
'server.py not found'
)
foreach ($msg in $guardErrors) {
if ($stderrContent -and $stderrContent -match [regex]::Escape($msg)) {
throw "REGRESSION: start.ps1 errored on guard '$msg' - path discovery failed."
}
}
Write-Host "OK: start.ps1 path discovery - all guards passed."
# taskkill /T walks the process tree, /F forces. taskkill returns
# 128 ("process not found") if the PID is already gone — that's
# the expected steady state for this mock-only workflow because
# server.py exits immediately on the stub hermes_cli. Reset
# $LASTEXITCODE so the step never fails on the cleanup itself.
- name: Stop background server (tree-kill)
if: always()
shell: pwsh
run: |
if ($env:SERVER_PID) {
& taskkill /PID $env:SERVER_PID /T /F 2>&1 | Out-Host
$global:LASTEXITCODE = 0
}
# Belt-and-suspenders: kill anything still bound to 8787.
$hanging = Get-NetTCPConnection -LocalPort 8787 -State Listen -ErrorAction SilentlyContinue
if ($hanging) {
foreach ($c in $hanging) {
try { Stop-Process -Id $c.OwningProcess -Force -ErrorAction Stop } catch {}
}
}
exit 0

View File

@@ -7,11 +7,73 @@ on:
branches: [master]
jobs:
# Forward-looking Python lint gate (ruff). The Python twin of the ESLint runtime
# guard. Runs the curated [tool.ruff] ruleset (E9 + F + B) but only on lines this
# PR adds/modifies vs the merge-base — so it keeps NEW code clean without demanding
# a reformat of the existing tree's cosmetic backlog (#3273). Fast, fails early.
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Need history so the gate can diff against the merge-base with master.
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install ruff
run: pip install ruff
- name: Ensure origin/master ref is available for the diff gate
run: git fetch --no-tags --depth=1 origin master || true
- name: Ruff forward gate (new/changed lines only)
run: python3 scripts/ruff_lint.py --diff origin/master
- name: Ruff whole-tree report (informational — never blocks)
if: always()
run: python3 scripts/ruff_lint.py --all
# Static-JS runtime-error guards. These catch brick-class bugs that throw only
# when the browser executes the code — node --check, source-presence tests, and
# the mocked pytest suite all miss them. Two complementary ESLint passes:
# * runtime-guard config: no-const-assign / no-import-assign (#3162 class)
# * scope_undef_gate.py: no-undef across the shared classic-<script> global
# scope, catching a function defined nested but called from a sibling
# scope (#3696 — ReferenceError: _sessionAttentionState is not defined).
- name: Set up Node for ESLint runtime guards
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install ESLint
run: npm install --no-save eslint@^10
- name: ESLint runtime-error gate (no-const-assign / no-import-assign, #3162)
run: npx eslint --no-config-lookup -c eslint.runtime-guard.config.mjs "static/**/*.js"
- name: Scope / undefined-reference gate (#3696)
run: python3 scripts/scope_undef_gate.py
test:
runs-on: ubuntu-latest
strategy:
# Don't cancel the other shards/versions when one fails — we want the full
# failure picture across the matrix in a single run.
fail-fast: false
matrix:
python-version: ['3.11', '3.12', '3.13']
# Split the suite across 3 parallel shards per Python version. pytest-shard
# partitions tests deterministically by test-id hash; the suite was made
# shard-safe (no cross-test state leakage) so every shard passes
# independently. See docs/agent-memory note on test-suite shard-safety.
# NOTE: pytest-shard is 0-indexed — shard ids must be 0..num_shards-1.
# Using 1-based ids would crash the out-of-range job AND silently skip
# shard 0's tests.
shard: [0, 1, 2]
steps:
- uses: actions/checkout@v4
@@ -20,11 +82,28 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: |
**/setup.cfg
**/requirements*.txt
**/pyproject.toml
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pyyaml>=6.0 pytest pytest-timeout
pip install "pyyaml>=6.0" pytest pytest-timeout pytest-asyncio pytest-shard
# ruff is installed so tests/test_ruff_forward_lint.py runs its E9/F821
# tree-clean assertions in-suite (mirrors how eslint is available for
# tests/test_static_js_runtime_lint.py). If install fails the test
# skips cleanly — it never blocks the matrix.
pip install ruff || echo "ruff install failed — test_ruff_forward_lint.py will skip"
# Install the `mcp` package so tests/test_mcp_server.py runs in CI.
# The package is an optional runtime dep of mcp_server.py — users
# who run the MCP integration install it themselves; CI installs
# it so test coverage exists. If mcp install fails (Python 3.13
# wheel not yet available, etc.), tests/test_mcp_server.py uses
# importorskip and the matrix stays green.
pip install mcp || echo "mcp install failed — test_mcp_server.py will importorskip"
- name: Run tests
run: pytest tests/ -v --timeout=60
- name: Run tests (shard ${{ matrix.shard }} of 3)
run: pytest tests/ -v --timeout=60 --shard-id=${{ matrix.shard }} --num-shards=3

30
.gitignore vendored
View File

@@ -19,7 +19,7 @@ archive/
!.env.docker.example
.claude/
CLAUDE.md
AGENTS.md
AGENTS.local.md
.cursorrules
.windsurfrules
.aider*
@@ -36,17 +36,37 @@ api/_version.py
.DS_Store
Thumbs.db
# Local reference clones — never committed (except tracked design/UI-UX reference pages)
# Local reference clones/artifacts — never committed by default.
# Markdown docs at docs/*.md are intentionally trackable for contributor docs.
docs/*
!docs/*.md
!docs/ui-ux/
!docs/ui-ux/**
!docs/docker.md
!docs/supervisor.md
!docs/troubleshooting.md
!docs/rfcs/
!docs/rfcs/**
!docs/architecture/
!docs/architecture/**
# Local-only AI assistant context — never committed even under docs/.
docs/AGENTS.md
docs/CLAUDE.md
docs/.cursorrules
docs/.windsurfrules
.specify/
AGENT_TRACE.yaml
EXECUTION_LOG.md
# Local-only PR review harness: rendering drivers, sample bank, fixtures.
# Used by Claude during deep reviews; never shared in the repo.
.local-review/
.review-crops/
graphify-out/
.graphify_cached.json
.graphify_uncached.txt
.venv/
# Dev-only lint tooling (ESLint runtime-error guard) — see TESTING.md
node_modules/
package-lock.json
docs/ui-ux/review-shots/

80
AGENTS.md Normal file
View File

@@ -0,0 +1,80 @@
# Agent instructions for Hermes WebUI
This file is the shared entry point for AI assistants working in this
repository. Keep it project-specific and safe to publish. Do not put personal
machine setup, private network details, credentials, tokens, or local-only
workflow notes here.
## Read first
Before making changes, read:
1. `README.md`
2. `CONTRIBUTING.md`
3. `docs/CONTRACTS.md`
4. `CHANGELOG.md`
For architecture, testing, or setup work, also read the matching reference:
- `ARCHITECTURE.md` for design constraints and current module layout
- `TESTING.md` for local verification commands and manual test guidance
- `docs/onboarding.md` for first-run onboarding behavior
- `docs/troubleshooting.md` for diagnostic flows
- `docs/rfcs/README.md` for larger RFCs and state/durability contracts
For UI or UX work, read `docs/UIUX-GUIDE.md` and `DESIGN.md` before
changing layout, interaction flow, themes, chat rendering, or composer chrome.
## Onboarding and reinstall support
If the task involves install, reinstall, bootstrap, first-run onboarding,
provider setup, local model server setup, Docker onboarding, WSL onboarding, or
support for a failed first run, read `docs/onboarding-agent-checklist.md`
before running commands or inspecting logs.
Follow that checklist's safety rules:
- use isolated `HERMES_HOME` and `HERMES_WEBUI_STATE_DIR` for trials unless the
human explicitly asks to use real state
- do not delete or overwrite a real `~/.hermes` directory without explicit
approval
- do not print API keys, OAuth tokens, cookies, full `.env` files, full
`auth.json` files, or password hashes
- collect non-secret status and log evidence before recommending a fix
## Contribution style
- Keep one logical change per PR; split unrelated refactors or cleanup.
- Read `docs/CONTRACTS.md` and the linked contract/RFC for the touched
subsystem before editing.
- Prefer the existing Python + vanilla JavaScript structure. Do not add
dependencies, build tools, frameworks, or long-lived processes without clear
justification and a rollback story.
- Update docs when changing setup, onboarding, runtime behavior, architecture,
testing guidance, or user-facing workflows.
- Update `CHANGELOG.md` for user-visible behavior, setup, workflow, or
documentation changes that should be release-note ready.
- For UI or UX changes, include before/after evidence and test relevant
desktop, narrow, and mobile states.
- For behavior changes, add or update automated tests where practical and list
the manual verification performed.
- For runtime, streaming, recovery, replay, compression, or sidebar metadata
changes, name the state layer being mutated and prove the relevant invariant.
## Local state and secrets
Hermes WebUI can read and write real agent state, sessions, workspaces,
credentials, and cron data. Treat local validation as potentially destructive
unless you have confirmed the active state directories.
Prefer isolated trial state for experiments:
```bash
HERMES_HOME=/tmp/hermes-webui-agent-home \
HERMES_WEBUI_STATE_DIR=/tmp/hermes-webui-agent-state \
HERMES_WEBUI_PORT=8789 \
python3 bootstrap.py
```
Do not include private machine instructions in this tracked file. Use a
git-ignored local note for personal workflow details.

View File

@@ -7,10 +7,12 @@
>
> Keep this document updated as architecture changes are made.
> Current shipped build: `v0.50.245` (April 30, 2026).
> Automated coverage: 3309 tests via `pytest tests/ --collect-only -q`. CI runs on Python 3.11, 3.12, and 3.13 against every PR.
> Current shipped build: `v0.51.192` (May 31, 2026).
> Automated coverage: ~7,150 tests via `pytest tests/ --collect-only -q`. CI runs on
> Python 3.11, 3.12, and 3.13 (3 parallel shards each) against every PR, plus a ruff
> lint gate, a headless browser smoke test, and a Docker smoke test.
>
> Notable architecture state as of v0.50.245: workspace panel closed/open state is preloaded via a `documentElement` dataset marker before `style.css` paints to avoid first-load flash; transcript disclosure cards animate via transitionable `max-height`/`opacity` states; thinking cards share rounded bordered card chrome with tool cards (gold palette); incremental streaming-markdown via vendored `streaming-markdown@0.2.15` (no CDN); HTTP byte-range streaming for large media; SSE-driven session sidebar with `pending_user_message` + `active_stream_id` lifecycle tracking; configurable model badges (`primary` / `fallback N`) computed in `_build_configured_model_badges()` and provider-aware in the dropdown picker.
> Notable architecture state: the bootstrap and first-run onboarding flow own setup discovery; the default WebUI state directory is `~/.hermes/webui`; `ctl.sh` provides a daemon wrapper for homelab installs; chat streaming is still WebUI-owned SSE with stream-ownership guards, cancellation, async manual compression, and turn-journal audit plumbing; provider/model discovery is profile-aware with live-model cache invalidation and custom-provider scoping. (Version/test-count numbers above are a periodic snapshot — the authoritative source is the latest git tag and `pytest --collect-only`.)
---
@@ -43,54 +45,66 @@ actions. The topbar remains focused on conversation context and the workspace/fi
## 2. File Inventory
<repo>/
server.py Thin routing shell + HTTP Handler + auth middleware. ~81 lines.
server.py Thin routing shell + HTTP Handler + auth middleware.
Delegates all route handling to api/routes.py.
bootstrap.py One-shot launcher: optional agent install, deps, health wait, browser open.
start.sh Thin wrapper around bootstrap.py for shell-based startup.
Dockerfile python:3.12-slim container image (~23 lines)
docker-compose.yml Compose config with named volume and optional auth (~22 lines)
ctl.sh Daemon lifecycle wrapper (start/stop/restart/status/logs) for homelab installs.
pyproject.toml Tooling config (ruff lint gate). NOT a packaged distribution.
Dockerfile python:3.12-slim container image
docker-compose.yml Compose config with named volume and optional auth
.dockerignore Excludes .git, tests/, .env* from Docker builds
api/
__init__.py Package marker
auth.py Optional password authentication, signed cookies (~149 lines)
config.py Discovery, globals, model detection, reloadable config (~701 lines)
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve(), security headers (~71 lines)
models.py Session model + CRUD, per-session profile tracking (~137 lines)
profiles.py Profile state management, hermes_cli wrapper (~246 lines)
onboarding.py First-run onboarding status, real provider config writes, and readiness detection.
routes.py All GET + POST route handlers (~1180 lines)
startup.py Startup helpers: auto_install_agent_deps() (~50 lines)
streaming.py SSE engine, run_agent, cancel, HERMES_HOME save/restore (~236 lines)
upload.py Multipart parser, file upload handler (~78 lines)
workspace.py File ops: list_dir, read_file_content, workspace helpers (~77 lines)
auth.py Optional password authentication, signed cookies, passkeys/WebAuthn
config.py Discovery, globals, model detection, reloadable config
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve(), security headers
models.py Session model + CRUD, per-session profile tracking, CLI/state.db bridge
profiles.py Profile state management, hermes_cli wrapper
onboarding.py First-run onboarding status, real provider config writes, OAuth linking, readiness detection
routes.py All GET + POST route handlers (if/elif dispatch, no decorators)
startup.py Startup helpers: auto_install_agent_deps()
state_sync.py /insights sync — message_count to the agent's state.db
streaming.py SSE engine, run_agent, cancel, compression, HERMES_HOME save/restore
updates.py Self-update check and release notes
upload.py Multipart parser, file upload handler
workspace.py File ops: list_dir, read_file_content, git detection, workspace helpers
static/
index.html HTML template (~364 lines)
style.css All CSS incl. mobile responsive (~670 lines)
ui.js DOM helpers, renderMd, tool cards, model dropdown, file tree (~977 lines)
workspace.js File preview, file ops, loadDir, clearPreview (~185 lines)
sessions.js Session CRUD, list rendering, search, SVG icons, dropdown actions (~533 lines)
messages.js send(), SSE event handlers, approval, transcript (~297 lines)
panels.js Cron, skills, memory, workspace, profiles, todo, settings (~974 lines)
commands.js Slash command registry, parser, autocomplete dropdown (~156 lines)
onboarding.js First-run wizard overlay, provider setup flow, and settings/workspace orchestration.
boot.js Event wiring, mobile sidebar/workspace nav, voice input, boot IIFE (~338 lines)
index.html HTML template
style.css All CSS incl. mobile responsive, themes + skins, KaTeX
ui.js DOM helpers, renderMd, tool cards, context indicator, file tree
workspace.js File preview, file ops, git badge, central api() fetch wrapper
sessions.js Session CRUD, list rendering, collapsible groups, search, SSE sync
messages.js send(), SSE event handlers, approval/clarify, transcript, recovery
panels.js Cron, skills, memory, profiles, todo, settings (Control Center)
commands.js Slash command registry, parser, autocomplete dropdown
boot.js Event wiring, mobile nav, voice input, theme/skin boot, bfcache handler
onboarding.js First-run wizard overlay, provider setup flow
i18n.js Localization catalog (en, es, de, zh, zh-Hant, ru, …)
login.js Login page + open-redirect guard
icons.js Lucide icon path registry
sw.js Service worker: offline shell cache, version-pinned assets
tests/
conftest.py Isolated test server (port 8788, separate HERMES_HOME) (~240 lines)
test_sprint{1-20b}.py Feature tests per sprint (21 files, 415 test functions)
test_regressions.py Permanent regression gate (23 tests)
AGENTS.md Instruction file for agents working in this directory.
conftest.py Isolated test server/state fixtures
~700 test files ~7,150 tests collected via pytest (run `pytest --collect-only -q` for exact)
test_regressions.py Permanent regression gate
CONTRIBUTING.md Contributor workflow and PR expectations.
ROADMAP.md Feature and product roadmap document.
SPRINTS.md Forward sprint plan with CLI + Claude parity targets.
ARCHITECTURE.md THIS FILE.
TESTING.md Manual browser test plan and automated coverage reference.
CHANGELOG.md Release notes per sprint.
BUGS.md Bug backlog and fixed items tracker.
CHANGELOG.md Release notes per version.
CONTRIBUTORS.md Community credit roll (regenerated via the maintainer workspace script).
requirements.txt Python dependencies.
.env.example Sample environment variable overrides.
> Per-file line counts intentionally omitted — they drift every release. Use
> `git ls-files | xargs wc -l` (or your editor) for current sizes; the role of
> each file above is the durable part.
State directory (runtime data, separate from source):
~/.hermes/webui-mvp/
~/.hermes/webui/
sessions/ One JSON file per session: {session_id}.json
workspaces.json Registered workspaces list
last_workspace.txt Last-used workspace path
@@ -99,7 +113,8 @@ State directory (runtime data, separate from source):
Log file:
/tmp/webui-mvp.log stdout/stderr from the background server process
~/.hermes/webui/bootstrap-8787.log start.sh/bootstrap background server log
~/.hermes/webui.log ctl.sh daemon log
---
@@ -118,15 +133,20 @@ Environment variables controlling behavior:
HERMES_WEBUI_DEFAULT_WORKSPACE Default workspace path for new sessions
HERMES_WEBUI_STATE_DIR Where sessions/ folder lives
HERMES_CONFIG_PATH Path to ~/.hermes/config.yaml
HERMES_WEBUI_DEFAULT_MODEL Default LLM model string
HERMES_WEBUI_DEFAULT_MODEL Optional model override; unset means provider default
HERMES_WEBUI_PASSWORD Optional: enable password auth (off by default)
HERMES_WEBUI_SKIP_ONBOARDING Optional: bypass the first-run onboarding wizard
HERMES_PREFILL_MESSAGES_FILE Optional JSON message list for browser-turn prefill context
HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT Optional command that prints JSON messages or plain-text user prefill context
HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT Optional script timeout in seconds (default 5, max 30)
HERMES_WEBUI_PREFILL_CONTEXT_MAX_CHARS Optional parsed prefill budget in characters (default 12000, 0 disables)
HERMES_HOME Base directory for Hermes state (~/.hermes by default)
Test isolation environment variables (set by conftest.py):
HERMES_WEBUI_PORT=8788 Isolated test port
HERMES_WEBUI_STATE_DIR=~/.hermes/webui-mvp-test Isolated test state
HERMES_WEBUI_DEFAULT_WORKSPACE=.../test-workspace Isolated test workspace
HERMES_WEBUI_TEST_PORT=... Optional pinned test port
HERMES_WEBUI_TEST_STATE_DIR=~/.hermes/webui-test-* Optional pinned test state
HERMES_WEBUI_DEFAULT_WORKSPACE=.../test-workspace Isolated test workspace
Tests NEVER talk to the production server (port 8787).
The test state dir is wiped before each test session and deleted after.
@@ -156,10 +176,11 @@ Python stdlib ThreadingHTTPServer (from http.server). Each HTTP request runs in
thread. The Handler class subclasses BaseHTTPRequestHandler with two methods:
do_GET Routes: /, /health, /api/session, /api/sessions, /api/list,
/api/chat/stream, /api/file, /api/approval/pending
/api/chat/stream, /api/file, /api/approval/pending,
/api/session/worktree/status
do_POST Routes: /api/upload, /api/session/new, /api/session/update,
/api/session/delete, /api/chat/start, /api/chat,
/api/approval/respond
/api/approval/respond, /api/session/worktree/remove
Routing is a flat if/elif chain inside each method. No routing framework.
@@ -319,7 +340,7 @@ POST /api/approval/respond:
### 4.6 File Upload Parser
parse_multipart(rfile, content_type, content_length):
- Reads all content_length bytes from rfile into memory (up to MAX_UPLOAD_BYTES = 20MB)
- Reads all content_length bytes from rfile into memory (up to MAX_UPLOAD_BYTES, default 20MB, env-overridable via HERMES_WEBUI_MAX_UPLOAD_MB)
- Extracts boundary from Content-Type header
- Splits raw bytes on b'--' + boundary
- For each part: parses MIME headers via email.parser.HeaderParser
@@ -362,16 +383,18 @@ read_file_content(workspace, rel):
### 5.1 Structure
The frontend is served from static/ as separate files: one HTML template, one CSS file,
and six JavaScript modules (~2,786 lines total). External dependencies: Prism.js (syntax
highlighting) and Mermaid.js (diagrams) from CDN, both loaded async/deferred with SRI hashes.
and multiple JavaScript modules. External dependencies include Prism.js (syntax
highlighting), Mermaid.js (diagrams), xterm.js, and KaTeX assets loaded with the
current static template's integrity/CSP assumptions.
Six JS modules loaded in order at end of <body>:
1. ui.js (~846 lines) DOM helpers, renderMd, tool card rendering, global state
2. workspace.js (~169 lines) File tree, preview, file operations
3. sessions.js (~532 lines) Session CRUD, list rendering, search, SVG icons, dropdown actions, project picker
4. messages.js (~293 lines) send(), SSE event handlers, approval, transcript
5. panels.js (~771 lines) Cron, skills, memory, workspace, todo, switchPanel
6. boot.js (~175 lines) Event wiring + boot IIFE
Core JS modules loaded by the app include:
1. ui.js (~7216 lines) DOM helpers, renderMd, tool card rendering, global state
2. workspace.js (~369 lines) File tree, preview, file operations
3. sessions.js (~3517 lines) Session CRUD, list rendering, search, SVG icons, dropdown actions, project picker
4. messages.js (~2301 lines) send(), SSE event handlers, approval, transcript
5. panels.js (~6480 lines) Cron, skills, memory, workspace, profiles, todo, settings
6. commands.js (~1302 lines) Slash command registry, parser, autocomplete dropdown
7. boot.js (~1607 lines) Event wiring + boot IIFE
sessions.js defines an `ICONS` constant at module level with hardcoded SVG strings for all
session action buttons (pin, unpin, folder, archive, unarchive, duplicate, trash). All icons
@@ -679,27 +702,28 @@ Split server.py into a proper package. Completed across Sprints 4-10.
Current structure:
<repo>/
server.py Entry point + HTTP Handler dispatch (~76 lines)
server.py Entry point + HTTP Handler dispatch (~446 lines)
api/
__init__.py
routes.py All GET + POST route handlers (~1016 lines)
config.py Configuration, constants, global state, model discovery (~640 lines)
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve() (~57 lines)
models.py Session model + CRUD (~132 lines)
workspace.py File ops, workspace management (~77 lines)
upload.py Multipart parser, file upload handler (~77 lines)
streaming.py SSE engine, run_agent, cancel support (~222 lines)
routes.py All GET + POST route handlers (~9772 lines)
config.py Configuration, constants, global state, model discovery (~4139 lines)
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve() (~302 lines)
models.py Session model + CRUD (~1927 lines)
workspace.py File ops, workspace management (~810 lines)
upload.py Multipart parser, file upload handler (~284 lines)
streaming.py SSE engine, run_agent, cancel support (~4420 lines)
static/
index.html HTML document (served from disk)
style.css All CSS (~560 lines)
ui.js, workspace.js, sessions.js, messages.js, panels.js, boot.js
style.css All CSS (~3767 lines)
ui.js, workspace.js, sessions.js, messages.js, panels.js, commands.js, boot.js
tests/
conftest.py Isolated test server on port 8788
test_sprint1-16.py Feature tests per sprint (14 files)
conftest.py Isolated test server/state fixtures
488 test files 5303 tests collected
test_regressions.py Permanent regression gate
Route extraction to api/routes.py completed in Sprint 11. server.py is now a ~76-line
thin shell: Handler class with structured logging, dispatch to routes, and main().
Route extraction to api/routes.py completed in Sprint 11. server.py remains a
thin shell relative to the rest of the app: Handler class with headers,
structured logging, dispatch to routes, TLS wrapping, and main().
### Phase B: Thread-Safe Request Context (Priority: Critical, Effort: Medium)
@@ -778,7 +802,7 @@ Replacing with marked.js + DOMPurify is a future improvement (not blocking).
### Phase G: Observability -- MOSTLY COMPLETE
1. Structured JSON logging: COMPLETE (Sprint 1). Per-request JSON to /tmp/webui-mvp.log.
1. Structured JSON logging: COMPLETE (Sprint 1). Per-request JSON is printed to the active launcher log (`~/.hermes/webui/bootstrap-8787.log` for `start.sh`, `~/.hermes/webui.log` for `ctl.sh`).
2. Enhanced /health: COMPLETE (Sprint 7). Returns `active_streams`, `uptime_seconds`.
3. GET /api/debug/stats: NOT YET IMPLEMENTED. Low priority.
@@ -794,13 +818,13 @@ Optional password gate for non-SSH-tunnel deployments.
### Phase I: Test Infrastructure -- COMPLETE
289 tests across 14 test files + regression gate. Isolated test server on port 8788
with separate HERMES_HOME, wiped per run. Production data never touched.
5303 tests across 488 test files + regression gates. The pytest fixture derives
an isolated port and state directory from the repo path unless
`HERMES_WEBUI_TEST_PORT` / `HERMES_WEBUI_TEST_STATE_DIR` pin them explicitly.
Production data never touched.
Test files: `test_sprint1.py` through `test_sprint11.py`, `test_sprint16.py`, `test_regressions.py`.
Fixtures in `conftest.py`: auto-cleanup, cron isolation, workspace reset.
Remaining: no CI (GitHub Actions), no frontend tests (browser-based).
Fixtures in `conftest.py`: auto-cleanup, profile/config isolation, cron
isolation, workspace reset, and test-server lifecycle.
### Phase J: Performance (Priority: Low, Effort: High)
@@ -888,7 +912,8 @@ The api() helper:
curl -s http://127.0.0.1:8787/health | python3 -m json.tool
# Tail the server log live
tail -f /tmp/webui-mvp.log
tail -f ~/.hermes/webui/bootstrap-8787.log
tail -f ~/.hermes/webui.log # when launched through ctl.sh
# List all sessions (metadata only)
curl -s http://127.0.0.1:8787/api/sessions | python3 -m json.tool
@@ -898,15 +923,15 @@ The api() helper:
curl -s "http://127.0.0.1:8787/api/session?session_id=$SID" | python3 -m json.tool
# Kill and restart server cleanly
pkill -f "python.*webui-mvp/server.py"
<agent-dir>/webui-mvp/start.sh
pkill -f "python.*server.py"
<repo>/start.sh
# Check if server process is running
ps aux | grep "webui-mvp/server.py"
ps aux | grep "server.py"
# Inspect session files on disk
ls -lt ~/.hermes/webui-mvp/sessions/
cat ~/.hermes/webui-mvp/sessions/SESSION_ID.json | python3 -m json.tool
ls -lt ~/.hermes/webui/sessions/
cat ~/.hermes/webui/sessions/SESSION_ID.json | python3 -m json.tool
# Count messages in a session
python3 -c "import json; d=json.load(open('sessions/SID.json')); print(len(d['messages']))"
@@ -919,9 +944,9 @@ The api() helper:
curl -s http://127.0.0.1:8787/health # streams not exposed yet, add in Phase G
# Find all sessions with messages (not Untitled empty)
ls ~/.hermes/webui-mvp/sessions/ | xargs -I{} python3 -c "
ls ~/.hermes/webui/sessions/ | xargs -I{} python3 -c "
import json, sys
d = json.load(open('~/.hermes/webui-mvp/sessions/{}'))
d = json.load(open('~/.hermes/webui/sessions/{}'))
if d['messages']: print('{}', d['title'][:50])
" 2>/dev/null
@@ -1194,31 +1219,22 @@ will be working on this codebase. Read this before touching any file.
### Before Making Any Change
1. Read this document (ARCHITECTURE.md) fully. Especially sections 4, 5, and the ADRs.
2. Read the relevant section of server.py by searching for the SECTION header.
2. Inspect the relevant module under `api/` or `static/`; `server.py` is only the routing shell.
3. Check the Sprint Log (Section 15) to understand what was recently changed.
4. Run the test suite first to confirm baseline: cd <agent-dir> &&
venv/bin/python -m pytest webui-mvp/tests/test_sprint1.py -v
4. Run the relevant test slice first to confirm baseline, for example:
venv/bin/python -m pytest tests/test_regressions.py -q
5. Check server health: curl -s http://127.0.0.1:8787/health
### Making Changes
Always back up server.py before a non-trivial change:
cp server.py server.py.$(date +%Y%m%d_%H%M).bak
Use exact string matching when patching. The pitfalls are documented in the
hermes-webui-mvp skill. Key ones:
- Never use sed on this file from the shell. Use execute_code with Python string replace.
- Always assert the old string is found before replacing (prevents silent no-op patches).
- Unicode escape sequences in JS (\u2026) exist as literal backslash-u in the file.
Match the file's raw content, not interpreted Python strings.
- The HTML block is a Python raw string (r"""..."""). Standard triple-quote escaping
rules do not apply inside it, but Python escape sequences \n etc. work in JS strings
inside it as literal two-character sequences.
Keep edits scoped to the module that owns the behavior. Use exact string
matching when making mechanical patches and verify that the intended old string
was found before replacing it.
After any change:
venv/bin/python -m py_compile webui-mvp/server.py # syntax check
venv/bin/python -m py_compile server.py # syntax check
curl -s http://127.0.0.1:8787/health # server still alive
venv/bin/python -m pytest webui-mvp/tests/ -v # tests still pass
venv/bin/python -m pytest tests/ -v # tests still pass
### Critical Rules (do NOT regress these)
@@ -1320,7 +1336,7 @@ Complete list of all HTTP endpoints as of Sprint 1 (v0.3).
/api/crons/output ?job_id=X&limit=N -> {outputs: [{filename, content}]}
/api/skills All skills. Returns {skills: [{name, description, category}]}
/api/skills/content ?name=X -> full skill data including SKILL.md content
/api/memory MEMORY.md + USER.md. Returns {memory, user, *_path, *_mtime}
/api/memory MEMORY.md + USER.md + SOUL.md. Returns {memory, user, soul, *_path, *_mtime}
### POST Endpoints Added in Sprint 3
@@ -1455,7 +1471,7 @@ fetches GET /api/skills/content and renders in the right panel using `showPrevie
#### Memory Panel
`loadMemory()` fetches GET /api/memory (reads MEMORY.md + USER.md from
~/.hermes/memories/), renders both as markdown via renderMd() with timestamps.
~/.hermes/memories/, and SOUL.md from ~/.hermes/), renders both as markdown via renderMd() with timestamps.
#### New API Endpoints (Section 18 update)
@@ -1466,7 +1482,7 @@ fetches GET /api/skills/content and renders in the right panel using `showPrevie
POST /api/crons/resume {job_id} -> resume_job(job_id)
GET /api/skills All skills via tools.skills_tool.skills_list()
GET /api/skills/content ?name=X -> full skill data via skill_view(name)
GET /api/memory MEMORY.md + USER.md content and mtimes
GET /api/memory MEMORY.md + USER.md + SOUL.md content and mtimes
#### Phase D Input Validation Applied

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,40 @@ Thanks for contributing.
Hermes WebUI is intentionally simple to work on: Python on the server, vanilla JS in the browser, no build step, no bundler, no frontend framework. The best pull requests preserve that simplicity while solving a real problem cleanly.
## Before You Start
- Read [`AGENTS.md`](AGENTS.md) if an AI assistant is doing or helping with the
change.
- Read [`docs/CONTRACTS.md`](docs/CONTRACTS.md) and any linked contract/RFC for
the subsystem you will touch.
- For UI or UX work, read [`docs/UIUX-GUIDE.md`](docs/UIUX-GUIDE.md)
and [`DESIGN.md`](DESIGN.md).
- For runtime, streaming, recovery, replay, compression, context reconstruction,
or session metadata work, start with [`docs/rfcs/README.md`](docs/rfcs/README.md)
and the relevant RFC listed there.
Use those documents as review guardrails: keep the change scoped, preserve the
no-build-step architecture, update docs/changelog when behavior changes, include
UI evidence for UI changes, and add tests for behavior changes where practical.
### Contract-affecting PRs
A contract-affecting PR is any change that updates a public contract document,
an RFC, a contributor guide, a product-semantics test, or behavior that those
documents already describe. These PRs need an explicit `Contract Routing` section
in the PR body that names the touched contract family and the evidence used.
See [`docs/CONTRACTS.md#contract-routing`](docs/CONTRACTS.md#contract-routing)
for the short routing shape and [`docs/CONTRACTS.md#contract-changes`](docs/CONTRACTS.md#contract-changes)
for intentional contract changes.
If the PR intentionally changes an existing contract, add a `Contract Change`
section that states the old rule, the new rule, and why the change is justified.
Do not silently redefine product behavior by changing tests alone; update the
corresponding docs in the same PR.
A release batch should call out included contract-affecting PRs separately
from ordinary fixes, even when the code diff is small and CI is green.
## Two Paths to a Strong Pull Request
### Path 1: Small, Focused Changes
@@ -60,7 +94,7 @@ There is currently no PR template in this repo, so include the important section
If the change is user-visible, include screenshots or a short video.
For UI or UX changes, before/after images are required. PRs that change the interface or interaction flow without before/after images will likely be ignored, or closed in a regular maintainer sweep without review.
For UI or UX changes, before/after images are required. PRs that change the interface or interaction flow without before/after images may not receive meaningful review until that evidence is added.
### 4. AI Usage Disclosure

File diff suppressed because one or more lines are too long

View File

@@ -150,6 +150,14 @@ A tool card is a debug event row, not a chat message. Show icon, name, short tar
Same visual family as tool-call metadata. They should be quieter than assistant prose and should not use bright tinted full cards unless the user expands them.
Automatic compression follows a quiet live-only divider treatment rather than a
tool-card row. Use `Compressing context` for the active barrier and
`Context auto-compressed` after continuation/completion; render both as centered
non-interactive text with horizontal rules. Do not give it a caret, click
target, distinct accent color, special leading dot, or separate card identity.
Once the final answer is settled, omit the live-only compression row unless it is
needed to explain a visible recovery or error state.
### Composer
The composer is the command surface. Keep it legible and focused: modest radius, subtle border, transparent inactive chips, no theatrical hover scaling.

View File

@@ -21,14 +21,35 @@ RUN apt-get update -y --fix-missing --no-install-recommends \
apt-utils \
locales \
ca-certificates \
sudo \
curl \
rsync \
openssh-client \
git \
xz-utils \
&& apt-get upgrade -y \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Optional GPU user-space acceleration libraries for users who pass through
# host GPU devices. The default image remains CPU-only.
ARG INSTALL_GPU_LIBS=0
RUN if [ "$INSTALL_GPU_LIBS" = "1" ]; then \
apt-get update -y --fix-missing --no-install-recommends \
&& apt-get install -y --no-install-recommends \
libva2 \
vainfo \
mesa-va-drivers \
&& if apt-cache show intel-media-va-driver-non-free >/dev/null 2>&1; then \
apt-get install -y --no-install-recommends intel-media-va-driver-non-free; \
else \
echo "intel-media-va-driver-non-free is not available from the configured Debian repositories; skipping Intel non-free VA-API driver."; \
fi \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*; \
else \
echo "Skipping optional GPU user-space acceleration libraries (INSTALL_GPU_LIBS=0)."; \
fi
# UTF-8
RUN localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8
ENV LANG=en_US.utf8
@@ -41,24 +62,14 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /apptoo
# Every sudo group user does not need a password
RUN echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
# Create a new group for the hermeswebui and hermeswebuitoo users
RUN groupadd -g 1024 hermeswebui \
&& groupadd -g 1025 hermeswebuitoo
# The hermeswebui (resp. hermeswebuitoo) user will have UID 1024 (resp. 1025),
# be part of the hermeswebui (resp. hermeswebuitoo) and users groups and be sudo capable (passwordless)
RUN useradd -u 1024 -d /home/hermeswebui -g hermeswebui -s /bin/bash -m hermeswebui \
&& usermod -G users hermeswebui \
&& adduser hermeswebui sudo
RUN useradd -u 1025 -d /home/hermeswebuitoo -g hermeswebuitoo -s /bin/bash -m hermeswebuitoo \
&& usermod -G users hermeswebuitoo \
&& adduser hermeswebuitoo sudo
RUN chown -R hermeswebuitoo:hermeswebuitoo /apptoo
USER root
# Create the unprivileged runtime user. The entrypoint starts as root only for
# UID/GID alignment and filesystem preparation, then execs the server as this user.
RUN groupadd -g 1024 hermeswebui \
&& useradd -u 1024 -d /home/hermeswebui -g hermeswebui -G users -s /bin/bash -m hermeswebui \
&& mkdir -p /app /uv_cache /workspace \
&& chown -R hermeswebui:hermeswebui /home/hermeswebui /app /uv_cache /workspace \
&& chmod 0755 /home/hermeswebui \
&& chmod 1777 /app /uv_cache /workspace
COPY --chmod=555 docker_init.bash /hermeswebui_init.bash
@@ -75,9 +86,7 @@ USER root
# The init script will skip the download when uv is already on PATH.
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh
USER hermeswebuitoo
COPY --chown=hermeswebuitoo:hermeswebuitoo . /apptoo
COPY --chown=root:root . /apptoo
# Bake the git version tag into the image so the settings badge works even
# when .git is not present (it is excluded by .dockerignore).
@@ -95,5 +104,8 @@ EXPOSE 8787
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8787/health || exit 1
# docker_init.bash performs root-only bind-mount setup, then drops to hermeswebui
# before starting the WebUI server. The production image does not ship sudo.
USER root
CMD ["/hermeswebui_init.bash"]

873
README.md
View File

@@ -45,6 +45,20 @@ This gives you nearly **1:1 parity with Hermes CLI from a convenient web UI** wh
---
## Contents
- [Why Hermes](#why-hermes) — what it is and how it compares
- [Quick start](#quick-start) — clone + `bootstrap.py` / `start.sh` / `ctl.sh`
- [Features](#features) — chat, sessions, workspace, voice, profiles, security, themes, panels, mobile
- [Configuration & access](#configuration--access) — auto-discovery, overrides, remote/Tailscale/phone, manual launch
- [Docker](#docker) — single- and multi-container deploys
- [Running tests](#running-tests)
- [Architecture](#architecture) — backend/frontend layout, state dir
- [Docs](#docs) — the full documentation index
- [Contributors](#contributors)
---
## Why Hermes
Most AI tools reset every session. They don't know who you are, what you worked on, or what
@@ -68,7 +82,7 @@ What makes it different from other agentic tools:
the results back into its own memory
- **Self-hosted** — your conversations, your memory, your hardware
**vs. the field** *(landscape is actively shifting — see [HERMES.md](HERMES.md) for the full breakdown)*:
**vs. the field** *(landscape is actively shifting — see [docs/why-hermes.md](docs/why-hermes.md) for the full breakdown)*:
| | OpenClaw | Claude Code | Codex CLI | OpenCode | Hermes |
|---|---|---|---|---|---|
@@ -89,7 +103,7 @@ with memory, cron, and messaging. The key differences: Hermes writes and saves i
automatically as a core behavior (OpenClaw's skill system centers on a community marketplace);
Hermes is more stable across updates (OpenClaw has documented release regressions and ClawHub
has had security incidents involving malicious skills); and Hermes runs natively in the Python
ecosystem. See [HERMES.md](HERMES.md) for the full side-by-side.
ecosystem. See [docs/why-hermes.md](docs/why-hermes.md) for the full side-by-side.
---
@@ -121,6 +135,20 @@ For self-hosted VM or homelab installs, `ctl.sh` wraps the common daemon lifecyc
`ctl.sh start` runs the bootstrap in foreground/no-browser mode behind the daemon wrapper, writes logs to `~/.hermes/webui.log`, and respects `.env` plus inline overrides such as `HERMES_WEBUI_HOST=0.0.0.0 ./ctl.sh start`.
> **Stopping the server.** Each launch method has its own stop path because only `ctl.sh start` writes a PID file (`~/.hermes/webui.pid`):
>
> | Launch method | How to stop |
> |---|---|
> | `python3 bootstrap.py` or `./start.sh` | **Ctrl-C** in the terminal (both run in the foreground) |
> | `./ctl.sh start` | `./ctl.sh stop` (sends SIGTERM, waits, then SIGKILL) |
> | Detached `bootstrap.py` (no `--foreground`) | Find the PID via `lsof -i :8787` (or `ss -tlnp`) and `kill` it |
>
> `./ctl.sh stop` cannot stop a server launched by `bootstrap.py` or `start.sh` directly — it only manages processes it started itself.
### Advanced: dynamic recall prefill & Gateway-backed chat
Two optional, self-hosted-deployment features — attaching dynamic **session-recall prefill** to browser turns (Joplin/Obsidian/Notion/llm-wiki routers), and routing browser chat through a running **Hermes Gateway** — are documented in [`docs/advanced-chat-setup.md`](docs/advanced-chat-setup.md). Most users need neither.
The bootstrap will:
1. Detect Hermes Agent and, if missing, attempt the official installer (`curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`).
@@ -132,7 +160,218 @@ The bootstrap will:
> Native Windows is not supported for this bootstrap yet. Use Linux, macOS, or WSL2.
> For Windows / WSL auto-start at login, see [`docs/wsl-autostart.md`](docs/wsl-autostart.md).
A community-maintained native Windows setup is documented at [@markwang2658/hermes-windows-native-guide](https://github.com/markwang2658/hermes-windows-native-guide) (companion setup repo: [@markwang2658/hermes-windows-native](https://github.com/markwang2658/hermes-windows-native)). Notes from the community report in [#1952](https://github.com/nesquena/hermes-webui/issues/1952):
- **Memory:** community-measured ~330 MB native vs ~1080 MB with WSL2+Docker (varies by configuration).
- **What works:** chat, workspace browser, session management, all themes.
- **Known limitations:** some POSIX-style file paths surface in the workspace browser; bash-assuming agent tools may not work natively.
- **Native Windows setup:** install Python 3.11+, then from the hermes-agent root in PowerShell: `python -m venv venv``pip install -r requirements.txt``pwsh .\start.ps1` (it auto-discovers `venv\Scripts\python.exe`).
- **WSL2 relationship:** not a prerequisite — a WSL2-built venv (`venv/bin/python`, ELF) isn't invokable by native Windows Python, so use the native setup above. WSL2 stays useful as a parallel install if you want the full `bootstrap.py` + Linux runtime.
If provider setup is still incomplete after install, the onboarding wizard will point you to finish it with `hermes model` instead of trying to replicate the full CLI setup in-browser.
For a step-by-step walkthrough of the wizard, provider choices, local model server Base URLs, and safe re-runs, see [`docs/onboarding.md`](docs/onboarding.md).
If an AI assistant is helping with install, reinstall, bootstrap, provider setup, or first-run support, have it read [`docs/onboarding-agent-checklist.md`](docs/onboarding-agent-checklist.md) before running commands or inspecting logs.
---
## Features
### Chat and agent
- Streaming responses via SSE (tokens appear as they are generated)
- Multi-provider model support -- any Hermes API provider (OpenAI, Anthropic, Google, DeepSeek, Nous Portal, OpenRouter, MiniMax, Xiaomi MiMo, Z.AI); dynamic model dropdown populated from configured keys
- Send a message while one is processing -- it queues automatically
- Edit any past user message inline and regenerate from that point
- Retry the last assistant response with one click
- Cancel a running task directly from the composer footer (Stop button next to Send)
- Tool call cards inline -- each shows the tool name, args, and result snippet; expand/collapse all toggle for multi-tool turns
- Subagent delegation cards -- child agent activity shown with distinct icon and indented border
- Mermaid diagram rendering inline (flowcharts, sequence diagrams, gantt charts)
- Thinking/reasoning display -- collapsible gold-themed cards for Claude extended thinking and o3 reasoning blocks
- Approval card for dangerous shell commands (allow once / session / always / deny)
- SSE auto-reconnect on network blips (SSH tunnel resilience)
- File attachments persist across page reloads and are stored outside the active workspace by default (`~/.hermes/webui/attachments/<session_id>/`, or `HERMES_WEBUI_ATTACHMENT_DIR/<session_id>/` when configured)
- Message timestamps (HH:MM next to each message, full date on hover)
- Code block copy button with "Copied!" feedback
- Syntax highlighting via Prism.js (Python, JS, bash, JSON, SQL, and more)
- Safe HTML rendering in AI responses (bold, italic, code converted to markdown)
- rAF-throttled token streaming for smoother rendering during long responses
- Context usage indicator in composer footer -- token count, cost, and fill bar (model-aware)
### Sessions
- Create, rename, duplicate, delete, search by title and message content
- Session actions via `⋯` dropdown per session — pin, move to project, archive, duplicate, delete
- Pin/star sessions to the top of the sidebar (gold indicator)
- Archive sessions (hide without deleting, toggle to show)
- Session projects -- named groups with colors for organizing sessions
- Session tags -- add #tag to titles for colored chips and click-to-filter
- Grouped by Today / Yesterday / Earlier in the sidebar (collapsible date groups)
- Download as Markdown transcript, full JSON export, or import from JSON
- Sessions persist across page reloads and SSH tunnel reconnects
- Browser tab title reflects the active session name
- CLI session bridge -- CLI sessions from hermes-agent's SQLite store appear in the sidebar with a gold "cli" badge; click to import with full history and reply normally
- Token/cost display -- input tokens, output tokens, estimated cost shown per conversation (toggle in Settings or `/usage` command)
### Workspace file browser
- Directory tree with expand/collapse (single-click toggles, double-click navigates)
- Breadcrumb navigation with clickable path segments
- Preview text, code, Markdown (rendered), and images inline
- Chat links using `workspace://path/to/file` open files in the right-side preview pane
- Edit, create, delete, and rename files; create folders
- Binary file download (auto-detected from server)
- File preview auto-closes on directory navigation (with unsaved-edit guard)
- Git detection -- branch name and dirty file count badge in workspace header
- Right panel is drag-resizable
- Syntax highlighted code preview (Prism.js)
### Voice input
- Microphone button in the composer (Web Speech API)
- Tap to record, tap again or send to stop
- Live interim transcription appears in the textarea
- Auto-stops after ~2s of silence
- Appends to existing textarea content (doesn't replace)
- Hidden when browser doesn't support Web Speech API (Chrome, Edge, Safari)
### Profiles
- Profile chip in the **composer footer** -- dropdown showing all profiles with gateway status and model info
- Gateway status dots (green = running), model info, skill count per profile
- Profiles management panel -- create, switch, and delete profiles from the sidebar
- Clone config from active profile on create
- Optional custom endpoint fields on create -- Base URL and API key written into the profile's `config.yaml` at creation time, so Ollama, LMStudio, and other local endpoints can be configured without editing files manually
- Seamless switching -- no server restart; reloads config, skills, memory, cron, models
- Per-session profile tracking (records which profile was active at creation)
### Authentication and security
- Optional password auth -- off by default, zero friction for localhost
- Enable via `HERMES_WEBUI_PASSWORD` env var or Settings panel
- Optional passkeys/WebAuthn -- register from Settings -> System after signing in with a password; the login page only shows passkey sign-in after at least one passkey exists
- After registering at least one passkey, Settings -> System can remove the password and keep passkey-only sign-in enabled. Password auth remains the bootstrap/recovery path until you choose to go passwordless; passkeys are same-origin and stored locally in the WebUI state directory
- Signed HMAC HTTP-only cookie with 24h TTL
- Minimal dark-themed login page at `/login`
- Security headers on all responses (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
- 20MB POST body size limit
- CDN resources pinned with SRI integrity hashes
### Themes
- Appearance is split into two axes: Theme (`system`, `dark`, `light`) and Skin
(`default`, `ares`, `mono`, `slate`, `poseidon`, `sisyphus`, `charizard`,
`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
`.dark` class, not a `data-theme` custom-theme axis — see [THEMES.md](THEMES.md)
### Settings and configuration
- **Hermes Control Center** (sidebar launcher button) -- Conversation tab (export/import/clear), Preferences tab (model, send key, theme, language, all toggles), System tab (version, password)
- Send key: Enter (default) or Ctrl/Cmd+Enter
- Show/hide CLI sessions toggle (enabled by default)
- Token usage display toggle (off by default, also via `/usage` command)
- Control Center always opens on the Conversation tab; resets on close
- Unsaved changes guard -- discard/save prompt when closing with unpersisted changes
- Cron completion alerts -- toast notifications and unread badge on Tasks tab
- Background agent error alerts -- banner when a non-active session encounters an error
### Slash commands
- Type `/` in the composer for autocomplete dropdown
- Built-in: `/help`, `/clear`, `/compress [focus topic]`, `/compact` (alias), `/model <name>`, `/workspace <name>`, `/new`, `/usage`, `/theme`
- Arrow keys navigate, Tab/Enter select, Escape closes
- Unrecognized commands pass through to the agent
### Panels
- **Chat** -- session list, search, pin, archive, projects, new conversation
- **Tasks** -- view, create, edit, run, pause/resume, delete cron jobs; run history; completion alerts
- **Skills** -- list all skills by category, search, preview, create/edit/delete; linked files viewer
- **Memory** -- view and edit MEMORY.md and USER.md inline
- **Profiles** -- create, switch, delete agent profiles; clone config
- **Todos** -- live task list from the current session
- **Spaces** -- add, rename, remove workspaces; quick-switch from topbar
### Mobile responsive
- Hamburger sidebar -- slide-in overlay on mobile (<640px)
- Sidebar top tabs stay available on mobile; no fixed bottom nav stealing chat height
- Files slide-over panel from right edge
- Touch targets minimum 44px on all interactive elements
- Full-height chat/composer on phones without bottom-nav spacing
- Desktop layout completely unchanged
---
## Configuration & access
`start.sh` auto-detects almost everything; the subsections below cover the knobs for when it can't, and how to reach the UI remotely.
### What start.sh discovers automatically
| Thing | How it finds it |
|---|---|
| Hermes agent dir | `HERMES_WEBUI_AGENT_DIR` env, then `$HERMES_HOME/hermes-agent` (Windows default `%LOCALAPPDATA%\hermes\hermes-agent`, POSIX default `~/.hermes/hermes-agent`), then sibling `../hermes-agent` |
| Python executable | Agent venv first, then `.venv` in this repo, then system `python3` |
| State directory | `HERMES_WEBUI_STATE_DIR` env, then `$HERMES_HOME/webui` (Windows default `%LOCALAPPDATA%\hermes\webui`, POSIX default `~/.hermes/webui`) |
| Default workspace | `HERMES_WEBUI_DEFAULT_WORKSPACE` env, then `~/workspace`, then state dir |
| Port | `HERMES_WEBUI_PORT` env or first argument, default `8787` |
If discovery finds everything, nothing else is required.
---
### Overrides (only needed if auto-detection misses)
```bash
export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent
export HERMES_WEBUI_PYTHON=/path/to/python
export HERMES_WEBUI_PORT=9000
export HERMES_WEBUI_AUTO_INSTALL=1 # enable auto-install of agent deps (disabled by default)
./start.sh
```
Or inline:
```bash
HERMES_WEBUI_AGENT_DIR=/custom/path ./start.sh 9000
```
Full list of environment variables:
| Variable | Default | Description |
|---|---|---|
| `HERMES_WEBUI_AGENT_DIR` | auto-discovered | Path to the hermes-agent checkout |
| `HERMES_WEBUI_PYTHON` | auto-discovered | Python executable |
| `HERMES_WEBUI_HOST` | `127.0.0.1` | Bind address (`0.0.0.0` for all IPv4, `::` for all IPv6, `::1` for IPv6 loopback) |
| `HERMES_WEBUI_PORT` | `8787` | Port |
| `HERMES_WEBUI_STATE_DIR` | `$HERMES_HOME/webui` (Windows default `%LOCALAPPDATA%\hermes\webui`, POSIX default `~/.hermes/webui`) | Where sessions and state are stored |
| `HERMES_WEBUI_DEFAULT_WORKSPACE` | `~/workspace` | Default workspace |
| `HERMES_WEBUI_DEFAULT_MODEL` | *(provider default)* | Optional model override; leave unset to use the active Hermes provider default |
| `HERMES_WEBUI_PASSWORD` | *(unset)* | Set to enable password authentication |
| `HERMES_WEBUI_CSP_CONNECT_EXTRA` | *(unset)* | Optional space-separated `http(s)://` or `ws(s)://` origins to append to the report-only CSP `connect-src` directive for reverse-proxy or tunnel deployments |
| `HERMES_WEBUI_EXTENSION_DIR` | *(unset)* | Optional local directory served at `/extensions/`; must point to an existing directory before extension injection is enabled |
| `HERMES_WEBUI_EXTENSION_SCRIPT_URLS` | *(unset)* | Optional comma-separated same-origin script URLs to inject; see [WebUI Extensions](docs/EXTENSIONS.md) |
| `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` | *(unset)* | Optional comma-separated same-origin stylesheet URLs to inject; see [WebUI Extensions](docs/EXTENSIONS.md) |
| `HERMES_HOME` | Windows: `%LOCALAPPDATA%\hermes`; POSIX: `~/.hermes` | Base directory for Hermes state (affects all paths) |
| `HERMES_CONFIG_PATH` | `$HERMES_HOME/config.yaml` | Path to Hermes config file |
| `HERMES_WEBUI_AGENT_CACHE_MAX` | `25` | Max live agent instances kept warm in the in-memory LRU. Each pins a full conversation transcript, so this is the dominant lever on resident memory — lower it on installs with many long sessions to cap RAM (at the cost of more cold reloads) |
| `HERMES_WEBUI_SESSIONS_MAX` | `100` | Max compact `Session` objects held in the in-memory LRU. Lighter than the agent cache; lower it on installs with hundreds of sessions |
---
### Remote access (SSH tunnel, Tailscale, phone)
The server binds to `127.0.0.1` by default. To reach it from another machine use an SSH tunnel (`ssh -N -L 8787:127.0.0.1:8787 user@host`, which `start.sh` prints for you over SSH), or join your server and phone to a [Tailscale](https://tailscale.com) network and browse to `http://<server-tailscale-ip>:8787` with `HERMES_WEBUI_HOST=0.0.0.0` + `HERMES_WEBUI_PASSWORD` set. Full walkthrough (incl. a community ARM64-Android field report): [`docs/remote-access.md`](docs/remote-access.md).
### Manual launch (without start.sh)
If you prefer to launch the server directly:
```bash
cd /path/to/hermes-agent # or wherever sys.path can find Hermes modules
HERMES_WEBUI_PORT=8787 venv/bin/python /path/to/hermes-webui/server.py
```
Note: use the agent venv Python (or any Python environment that has the Hermes agent dependencies installed). System Python will be missing `openai`, `httpx`, and other required packages.
Health check:
```bash
curl http://127.0.0.1:8787/health
```
---
@@ -155,6 +394,8 @@ docker compose up -d
# Open http://localhost:8787
```
Run Compose as the user who owns your Hermes home. `sudo docker compose up -d` can make `${HOME}` expand to the root user's home, so Docker mounts the wrong `.hermes` directory instead of your real `~/.hermes` and the WebUI starts with `config.yaml (not found, using defaults)`. Prefer adding your user to the Docker group and running `docker compose up -d`; if you must use sudo, set absolute paths first, for example `HERMES_HOME=/home/you/.hermes HERMES_WORKSPACE=/home/you/workspace sudo -E docker compose up -d`, then verify with `docker compose config`.
The container auto-detects your UID/GID from the mounted `~/.hermes` volume so files written by the agent stay readable by you on the host.
To enable password protection (required if you expose the port outside `127.0.0.1`):
@@ -205,6 +446,8 @@ docker compose -f docker-compose.three-container.yml up -d
Both compose files use **named Docker volumes** by default, which solves the UID/GID problem by construction. If you need bind mounts to share an existing host directory, see [`docs/docker.md`](docs/docker.md) for the full migration recipe.
> **Known limitation (#681)**: in the two-container setup, tools triggered from the WebUI run in the **WebUI container**, not the agent container. If you need git/node/etc. on the WebUI's filesystem, either use the single-container setup, extend the WebUI Dockerfile, or use the community [all-in-one image](https://github.com/sunnysktsang/hermes-suite).
>
> **Source boundary note (#2453)**: the multi-container setup mounts `hermes-agent-src` read-only into the WebUI by default. This prevents WebUI-side source rewrites but is still an implementation-coupling bridge, not a stable Agent API boundary. See [`docs/rfcs/agent-source-boundary.md`](docs/rfcs/agent-source-boundary.md) for the current source/API decoupling inventory.
### Common failure modes
@@ -216,6 +459,8 @@ Both compose files use **named Docker volumes** by default, which solves the UID
| `git: command not found` in chat | Two-container architectural limit (#681) | Use single-container or extend Dockerfile |
| WebUI can't find agent source | `hermes-agent-src` volume misconfigured | Use the named volumes from compose files as-is |
| Podman shared `.hermes` fails | Podman 3.4 `keep-id` limitation | Use Podman 4+ or single-container |
| Host API at `localhost` fails from WebUI | Container `localhost` means the container, not your host (#3012) | Use `http://host.docker.internal:<port>` on Docker Desktop, or `http://host.containers.internal:<port>` on Podman |
| WebUI can't see `~/.hermes` after `sudo docker compose` | `${HOME}` expanded to the root user's home (#3006) | Run Compose as your user, or pass absolute `HERMES_HOME`/`HERMES_WORKSPACE` with `sudo -E` |
For the deep dive on each of these, see [`docs/docker.md`](docs/docker.md).
@@ -225,131 +470,6 @@ For the deep dive on each of these, see [`docs/docker.md`](docs/docker.md).
---
## What start.sh discovers automatically
| Thing | How it finds it |
|---|---|
| Hermes agent dir | `HERMES_WEBUI_AGENT_DIR` env, then `~/.hermes/hermes-agent`, then sibling `../hermes-agent` |
| Python executable | Agent venv first, then `.venv` in this repo, then system `python3` |
| State directory | `HERMES_WEBUI_STATE_DIR` env, then `~/.hermes/webui-mvp` |
| Default workspace | `HERMES_WEBUI_DEFAULT_WORKSPACE` env, then `~/workspace`, then state dir |
| Port | `HERMES_WEBUI_PORT` env or first argument, default `8787` |
If discovery finds everything, nothing else is required.
---
## Overrides (only needed if auto-detection misses)
```bash
export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent
export HERMES_WEBUI_PYTHON=/path/to/python
export HERMES_WEBUI_PORT=9000
export HERMES_WEBUI_AUTO_INSTALL=1 # enable auto-install of agent deps (disabled by default)
./start.sh
```
Or inline:
```bash
HERMES_WEBUI_AGENT_DIR=/custom/path ./start.sh 9000
```
Full list of environment variables:
| Variable | Default | Description |
|---|---|---|
| `HERMES_WEBUI_AGENT_DIR` | auto-discovered | Path to the hermes-agent checkout |
| `HERMES_WEBUI_PYTHON` | auto-discovered | Python executable |
| `HERMES_WEBUI_HOST` | `127.0.0.1` | Bind address |
| `HERMES_WEBUI_PORT` | `8787` | Port |
| `HERMES_WEBUI_STATE_DIR` | `~/.hermes/webui-mvp` | Where sessions and state are stored |
| `HERMES_WEBUI_DEFAULT_WORKSPACE` | `~/workspace` | Default workspace |
| `HERMES_WEBUI_DEFAULT_MODEL` | `openai/gpt-5.4-mini` | Default model |
| `HERMES_WEBUI_PASSWORD` | *(unset)* | Set to enable password authentication |
| `HERMES_WEBUI_EXTENSION_DIR` | *(unset)* | Optional local directory served at `/extensions/`; must point to an existing directory before extension injection is enabled |
| `HERMES_WEBUI_EXTENSION_SCRIPT_URLS` | *(unset)* | Optional comma-separated same-origin script URLs to inject; see [WebUI Extensions](docs/EXTENSIONS.md) |
| `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` | *(unset)* | Optional comma-separated same-origin stylesheet URLs to inject; see [WebUI Extensions](docs/EXTENSIONS.md) |
| `HERMES_HOME` | `~/.hermes` | Base directory for Hermes state (affects all paths) |
| `HERMES_CONFIG_PATH` | `~/.hermes/config.yaml` | Path to Hermes config file |
---
## Accessing from a remote machine
The server binds to `127.0.0.1` by default (loopback only). If you are running
Hermes on a VPS or remote server, use an SSH tunnel from your local machine:
```bash
ssh -N -L <local-port>:127.0.0.1:<remote-port> <user>@<server-host>
```
Example:
```bash
ssh -N -L 8787:127.0.0.1:8787 user@your.server.com
```
Then open `http://localhost:8787` in your local browser.
`start.sh` will print this command for you automatically when it detects you
are running over SSH.
---
## Accessing on your phone with Tailscale
[Tailscale](https://tailscale.com) is a zero-config mesh VPN built on
WireGuard. Install it on your server and your phone, and they join the same
private network -- no port forwarding, no SSH tunnels, no public exposure.
The Hermes Web UI is fully responsive with a mobile-optimized layout
(hamburger sidebar, sidebar top tabs in the drawer, touch-friendly controls),
so it works well as a daily-driver agent interface from your phone.
**Setup:**
1. Install [Tailscale](https://tailscale.com/download) on your server and
your iPhone/Android.
2. Start the WebUI listening on all interfaces with password auth enabled:
```bash
HERMES_WEBUI_HOST=0.0.0.0 HERMES_WEBUI_PASSWORD=your-secret ./start.sh
```
3. Open `http://<server-tailscale-ip>:8787` in your phone's browser
(find your server's Tailscale IP in the Tailscale app or with
`tailscale ip -4` on the server).
That's it. Traffic is encrypted end-to-end by WireGuard, and password auth
protects the UI at the application level. You can add it to your home screen
for an app-like experience.
> **Tip:** If using Docker, set `HERMES_WEBUI_HOST=0.0.0.0` in your
> `docker-compose.yml` environment (already the default) and set
> `HERMES_WEBUI_PASSWORD`.
---
## Manual launch (without start.sh)
If you prefer to launch the server directly:
```bash
cd /path/to/hermes-agent # or wherever sys.path can find Hermes modules
HERMES_WEBUI_PORT=8787 venv/bin/python /path/to/hermes-webui/server.py
```
Note: use the agent venv Python (or any Python environment that has the Hermes agent dependencies installed). System Python will be missing `openai`, `httpx`, and other required packages.
Health check:
```bash
curl http://127.0.0.1:8787/health
```
---
## Running tests
Tests discover the repo and the Hermes agent dynamically -- no hardcoded paths.
@@ -365,309 +485,246 @@ Or using the agent venv explicitly:
/path/to/hermes-agent/venv/bin/python -m pytest tests/ -v
```
Tests run against an isolated server on port 8788 with a separate state directory.
Production data and real cron jobs are never touched. Current count: **3309 tests**
across 100+ test files.
---
## Features
### Chat and agent
- Streaming responses via SSE (tokens appear as they are generated)
- Multi-provider model support -- any Hermes API provider (OpenAI, Anthropic, Google, DeepSeek, Nous Portal, OpenRouter, MiniMax, Z.AI); dynamic model dropdown populated from configured keys
- Send a message while one is processing -- it queues automatically
- Edit any past user message inline and regenerate from that point
- Retry the last assistant response with one click
- Cancel a running task directly from the composer footer (Stop button next to Send)
- Tool call cards inline -- each shows the tool name, args, and result snippet; expand/collapse all toggle for multi-tool turns
- Subagent delegation cards -- child agent activity shown with distinct icon and indented border
- Mermaid diagram rendering inline (flowcharts, sequence diagrams, gantt charts)
- Thinking/reasoning display -- collapsible gold-themed cards for Claude extended thinking and o3 reasoning blocks
- Approval card for dangerous shell commands (allow once / session / always / deny)
- SSE auto-reconnect on network blips (SSH tunnel resilience)
- File attachments persist across page reloads
- Message timestamps (HH:MM next to each message, full date on hover)
- Code block copy button with "Copied!" feedback
- Syntax highlighting via Prism.js (Python, JS, bash, JSON, SQL, and more)
- Safe HTML rendering in AI responses (bold, italic, code converted to markdown)
- rAF-throttled token streaming for smoother rendering during long responses
- Context usage indicator in composer footer -- token count, cost, and fill bar (model-aware)
### Sessions
- Create, rename, duplicate, delete, search by title and message content
- Session actions via `⋯` dropdown per session — pin, move to project, archive, duplicate, delete
- Pin/star sessions to the top of the sidebar (gold indicator)
- Archive sessions (hide without deleting, toggle to show)
- Session projects -- named groups with colors for organizing sessions
- Session tags -- add #tag to titles for colored chips and click-to-filter
- Grouped by Today / Yesterday / Earlier in the sidebar (collapsible date groups)
- Download as Markdown transcript, full JSON export, or import from JSON
- Sessions persist across page reloads and SSH tunnel reconnects
- Browser tab title reflects the active session name
- CLI session bridge -- CLI sessions from hermes-agent's SQLite store appear in the sidebar with a gold "cli" badge; click to import with full history and reply normally
- Token/cost display -- input tokens, output tokens, estimated cost shown per conversation (toggle in Settings or `/usage` command)
### Workspace file browser
- Directory tree with expand/collapse (single-click toggles, double-click navigates)
- Breadcrumb navigation with clickable path segments
- Preview text, code, Markdown (rendered), and images inline
- Edit, create, delete, and rename files; create folders
- Binary file download (auto-detected from server)
- File preview auto-closes on directory navigation (with unsaved-edit guard)
- Git detection -- branch name and dirty file count badge in workspace header
- Right panel is drag-resizable
- Syntax highlighted code preview (Prism.js)
### Voice input
- Microphone button in the composer (Web Speech API)
- Tap to record, tap again or send to stop
- Live interim transcription appears in the textarea
- Auto-stops after ~2s of silence
- Appends to existing textarea content (doesn't replace)
- Hidden when browser doesn't support Web Speech API (Chrome, Edge, Safari)
### Profiles
- Profile chip in the **composer footer** -- dropdown showing all profiles with gateway status and model info
- Gateway status dots (green = running), model info, skill count per profile
- Profiles management panel -- create, switch, and delete profiles from the sidebar
- Clone config from active profile on create
- Optional custom endpoint fields on create -- Base URL and API key written into the profile's `config.yaml` at creation time, so Ollama, LMStudio, and other local endpoints can be configured without editing files manually
- Seamless switching -- no server restart; reloads config, skills, memory, cron, models
- Per-session profile tracking (records which profile was active at creation)
### Authentication and security
- Optional password auth -- off by default, zero friction for localhost
- Enable via `HERMES_WEBUI_PASSWORD` env var or Settings panel
- Signed HMAC HTTP-only cookie with 24h TTL
- Minimal dark-themed login page at `/login`
- Security headers on all responses (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
- 20MB POST body size limit
- CDN resources pinned with SRI integrity hashes
### Themes
- 7 built-in themes: Dark (default), Light, Slate, Solarized Dark, Monokai, Nord, OLED
- Switch via Settings panel dropdown (instant live preview) or `/theme` command
- Persists across reloads (server-side in settings.json + localStorage for flicker-free loading)
- Custom themes: define a `:root[data-theme="name"]` CSS block and it works — see [THEMES.md](THEMES.md)
### Settings and configuration
- **Hermes Control Center** (sidebar launcher button) -- Conversation tab (export/import/clear), Preferences tab (model, send key, theme, language, all toggles), System tab (version, password)
- Send key: Enter (default) or Ctrl/Cmd+Enter
- Show/hide CLI sessions toggle (enabled by default)
- Token usage display toggle (off by default, also via `/usage` command)
- Control Center always opens on the Conversation tab; resets on close
- Unsaved changes guard -- discard/save prompt when closing with unpersisted changes
- Cron completion alerts -- toast notifications and unread badge on Tasks tab
- Background agent error alerts -- banner when a non-active session encounters an error
### Slash commands
- Type `/` in the composer for autocomplete dropdown
- Built-in: `/help`, `/clear`, `/compress [focus topic]`, `/compact` (alias), `/model <name>`, `/workspace <name>`, `/new`, `/usage`, `/theme`
- Arrow keys navigate, Tab/Enter select, Escape closes
- Unrecognized commands pass through to the agent
### Panels
- **Chat** -- session list, search, pin, archive, projects, new conversation
- **Tasks** -- view, create, edit, run, pause/resume, delete cron jobs; run history; completion alerts
- **Skills** -- list all skills by category, search, preview, create/edit/delete; linked files viewer
- **Memory** -- view and edit MEMORY.md and USER.md inline
- **Profiles** -- create, switch, delete agent profiles; clone config
- **Todos** -- live task list from the current session
- **Spaces** -- add, rename, remove workspaces; quick-switch from topbar
### Mobile responsive
- Hamburger sidebar -- slide-in overlay on mobile (<640px)
- Sidebar top tabs stay available on mobile; no fixed bottom nav stealing chat height
- Files slide-over panel from right edge
- Touch targets minimum 44px on all interactive elements
- Full-height chat/composer on phones without bottom-nav spacing
- Desktop layout completely unchanged
Tests run against an isolated server with a separate state directory.
Production data and real cron jobs are never touched. Current snapshot:
**~7,150 tests collected** across **~700 test files**, run in CI on Python 3.11,
3.12, and 3.13 (3 parallel shards each).
---
## Architecture
No build step, no framework, no bundler — a Python standard-library HTTP server
and vanilla JS. The backend lives in `api/`, the frontend in `static/`.
**Backend (`api/`)**
```
server.py HTTP routing shell + auth middleware (~154 lines)
server.py HTTP routing shell + auth middleware
api/
auth.py Optional password authentication, signed cookies (~201 lines)
config.py Discovery, globals, model detection, reloadable config (~1110 lines)
helpers.py HTTP helpers, security headers (~175 lines)
models.py Session model + CRUD + CLI bridge (~377 lines)
onboarding.py First-run onboarding wizard, OAuth provider support (~507 lines)
profiles.py Profile state management, hermes_cli wrapper (~411 lines)
routes.py All GET + POST route handlers (~2250 lines)
state_sync.py /insights sync — message_count to state.db (~113 lines)
streaming.py SSE engine, run_agent, cancel support (~660 lines)
updates.py Self-update check and release notes (~257 lines)
upload.py Multipart parser, file upload handler (~82 lines)
workspace.py File ops, workspace helpers, git detection (~288 lines)
static/
index.html HTML template (~600 lines)
style.css All CSS incl. mobile responsive, themes (~1050 lines)
ui.js DOM helpers, renderMd, tool cards, context indicator (~1740 lines)
workspace.js File preview, file ops, git badge (~286 lines)
sessions.js Session CRUD, collapsible groups, search, reload recovery (~800 lines)
messages.js send(), SSE handlers, live streaming, session recovery (~655 lines)
panels.js Cron, skills, memory, profiles, settings (~1438 lines)
commands.js Slash command autocomplete (~267 lines)
boot.js Mobile nav, voice input, boot IIFE (~524 lines)
tests/
conftest.py Isolated test server (port 8788)
61 test files 961 test functions
Dockerfile python:3.12-slim container image
docker-compose.yml Compose with named volume and optional auth
.github/workflows/ CI: multi-arch Docker build + GitHub Release on tag
auth.py Optional password authentication, signed cookies, passkeys
config.py Discovery, globals, model detection, reloadable config
helpers.py HTTP helpers, security headers
models.py Session model + CRUD + CLI/state.db bridge
onboarding.py First-run onboarding wizard, OAuth provider support
profiles.py Profile state management, hermes_cli wrapper
routes.py All GET + POST route handlers (if/elif dispatch, no decorators)
state_sync.py /insights sync — message_count to state.db
streaming.py SSE engine, run_agent, cancellation, compression
updates.py Self-update check and release notes
upload.py Multipart parser, file upload handler
workspace.py File ops, workspace helpers, git detection
```
State lives outside the repo at `~/.hermes/webui-mvp/` by default
**Frontend (`static/`)**
```
index.html HTML template
style.css All CSS incl. mobile responsive, themes + skins
ui.js DOM helpers, renderMd, tool cards, context indicator
workspace.js File preview, file ops, git badge, central api() fetch wrapper
sessions.js Session CRUD, collapsible groups, search, reload recovery
messages.js send(), SSE handlers, live streaming, session recovery
panels.js Cron, skills, memory, profiles, settings (Control Center)
commands.js Slash command autocomplete
boot.js Mobile nav, voice input, theme/skin boot, bfcache handler
```
**Tests + packaging**
```
tests/ Pytest suite (~7,150 tests; isolated server/state fixtures)
pyproject.toml Tooling config (ruff lint gate) — not a packaged distribution
Dockerfile python:3.12-slim container image
docker-compose.yml Compose with named volume and optional auth
.github/workflows/ CI: ruff + sharded pytest, browser smoke, Docker smoke,
multi-arch Docker build + GitHub Release on tag
```
State lives outside the repo at `~/.hermes/webui/` by default
(sessions, workspaces, settings, projects, last_workspace). Override with `HERMES_WEBUI_STATE_DIR`.
Full design notes and the endpoint catalog are in [`ARCHITECTURE.md`](ARCHITECTURE.md).
---
## Compatibility
The version shown in the WebUI runtime status is the **WebUI version only** (build/image/tag currently running). It is not a full compatibility map.
The WebUI is still coupled to Hermes Agent internals for runtime execution, provider/model access, and state/schema usage until the stable agent boundary work in [#1925](https://github.com/nesquena/hermes-webui/issues/1925) and [#2491](https://github.com/nesquena/hermes-webui/issues/2491) land. In practice, the WebUI imports Agent modules directly (`api/config.py`, `api/providers.py`, `api/streaming.py`) and reads Agent state layout directly, so version skew can cause import or behavior drift.
**Compatibility policy**
- WebUI release branches are tested against the matching Hermes Agent release available at that WebUI release time.
- **Upgrade both together**: upgrade or pin WebUI and hermes-agent together (same release train/version/date), especially before enabling production traffic.
- Running pinned older/newer combinations is **untested and unsupported** until the stable API boundary work in [#1925](https://github.com/nesquena/hermes-webui/issues/1925) / [#2491](https://github.com/nesquena/hermes-webui/issues/2491) is in place.
- Record the full `hermes-agent` + `hermes-webui` versions in issue reports when upgrade mismatches are suspected.
**Docker users**: pin both image tags (or corresponding pinned source revisions) rather than using `latest` on one side and a fixed tag on the other. When upgrading the multi-container setup, follow the agent-image upgrade procedure in [`docs/docker.md`](docs/docker.md) (which requires dropping the `hermes-agent-src` volume before recreating). The current source-boundary status is tracked in [`docs/rfcs/agent-source-boundary.md`](docs/rfcs/agent-source-boundary.md).
---
## Docs
- `HERMES.md` -- why Hermes, mental model, and detailed comparison to Claude Code / Codex / OpenCode / Cursor
- `ROADMAP.md` -- feature roadmap and sprint history
- `ARCHITECTURE.md` -- system design, all API endpoints, implementation notes
- `TESTING.md` -- manual browser test plan and automated coverage reference
- `CHANGELOG.md` -- release notes per sprint
- `SPRINTS.md` -- forward sprint plan with CLI + Claude parity targets
- `THEMES.md` -- theme system documentation, custom theme guide
- `docs/troubleshooting.md` -- diagnostic flows for common failures (e.g. "AIAgent not available")
**Start here**
- [`docs/why-hermes.md`](docs/why-hermes.md) — why Hermes, the mental model, and a detailed comparison to Claude Code / Codex / OpenCode / Cursor
- [`docs/onboarding.md`](docs/onboarding.md) — first-run wizard, provider setup, local model server Base URLs, and safe re-runs
- [`docs/troubleshooting.md`](docs/troubleshooting.md) — diagnostic flows for common failures (e.g. "AIAgent not available")
## Contributors
**Using & customizing**
- [`THEMES.md`](THEMES.md) — theme + skin system, custom theme guide
- [`docs/workspace-git.md`](docs/workspace-git.md) — the workspace Git controls
- [`docs/EXTENSIONS.md`](docs/EXTENSIONS.md) — administrator-controlled WebUI extension injection
Hermes WebUI is built with help from the open-source community. Every PR — whether merged directly or incorporated via batch release — shapes the project, and we're grateful to everyone who has taken the time to contribute.
**Deploying & operating**
- [`docs/remote-access.md`](docs/remote-access.md) — SSH tunnel, Tailscale, and phone access (incl. a community ARM64-Android field report)
- [`docs/advanced-chat-setup.md`](docs/advanced-chat-setup.md) — optional dynamic recall-prefill and Gateway-backed browser chat for self-hosted deployments
- [`docs/docker.md`](docs/docker.md) — Docker compose setup, common failures, and bind-mount migration
- [`docs/supervisor.md`](docs/supervisor.md) — launchd, systemd, supervisord, runit, and s6 process-supervisor setup
- [`docs/wsl-autostart.md`](docs/wsl-autostart.md) — WSL2 auto-start at Windows login
- [`docs/onboarding-agent-checklist.md`](docs/onboarding-agent-checklist.md) — safety rules and pass/fail checks for assistant-led install/reinstall support
**66 contributors have shipped code that landed in a release tag** as of v0.50.245. The full credit roll lives in [`CONTRIBUTORS.md`](CONTRIBUTORS.md). The highlights:
**Contributing & design**
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — contribution style, PR expectations, and local verification
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — system design, all API endpoints, implementation notes
- [`TESTING.md`](TESTING.md) — manual browser test plan and automated coverage reference
- [`DESIGN.md`](DESIGN.md) — design tokens and the calm-console direction
- [`docs/UIUX-GUIDE.md`](docs/UIUX-GUIDE.md) — UI/UX principles sourced from the design docs and visual inventories
- [`docs/CONTRACTS.md`](docs/CONTRACTS.md) — project contract/RFC/design index for contributors and agents
- [`docs/rfcs/README.md`](docs/rfcs/README.md) — RFC index for larger architecture and durability proposals
### Top contributors (by merged-PR count)
| # | Contributor | PRs | First → latest release |
|---|---|---:|---|
| 1 | [@franksong2702](https://github.com/franksong2702) | 22 | `v0.50.49``v0.50.245` |
| 2 | [@bergeouss](https://github.com/bergeouss) | 18 | `v0.50.49``v0.50.240` |
| 3 | [@aronprins](https://github.com/aronprins) | 8 | `v0.47.0``v0.50.77` |
| 4 | [@iRonin](https://github.com/iRonin) | 6 | `v0.41.0` |
| 5 | [@24601](https://github.com/24601) | 6 | `v0.50.201` |
| 6 | [@KingBoyAndGirl](https://github.com/KingBoyAndGirl) | 4 | `v0.50.232``v0.50.237` |
| 7 | [@renheqiang](https://github.com/renheqiang) | 4 | `v0.50.93` |
| 8 | [@ccqqlo](https://github.com/ccqqlo) | 3 | `v0.50.83``v0.50.207` |
| 9 | [@deboste](https://github.com/deboste) | 3 | `v0.16.1` |
| 10 | [@frap129](https://github.com/frap129) | 3 | `v0.50.157``v0.50.166` |
See [`CONTRIBUTORS.md`](CONTRIBUTORS.md) for the full ranked list of all 66 contributors, including everyone with one or two merged PRs and the special-thanks roll for design and architectural contributions.
### Notable contributions
**[@aronprins](https://github.com/aronprins)** — v0.50.0 UI overhaul (PR #242)
The biggest single contribution to the project: a complete UI redesign that moved model/profile/workspace controls into the composer footer, replaced the gear-icon settings panel with the Hermes Control Center (tabbed modal), removed the activity bar in favor of inline composer status, redesigned the session list with a `⋯` action dropdown, and added the workspace panel state machine. 26 commits, thoroughly designed and iterated through multiple review rounds.
**[@iRonin](https://github.com/iRonin)** — Security hardening sprint (PRs #196#204)
Six consecutive security and reliability PRs: session memory leak fix (expired token pruning), Content-Security-Policy + Permissions-Policy headers, 30-second slow-client connection timeout, optional HTTPS/TLS support via environment variables, upstream branch tracking fix for self-update, and CLI session support in the file browser API. This is the kind of focused, high-quality security work that makes a self-hosted tool trustworthy.
**[@DavidSchuchert](https://github.com/DavidSchuchert)** — German translation (PR #190)
Complete German locale (`de`) covering all UI strings, settings labels, commands, and system messages — and in doing so, stress-tested the i18n system and exposed several elements that weren't yet translatable, which got fixed as part of the same PR.
**[@Jordan-SkyLF](https://github.com/Jordan-SkyLF)** — Live streaming, session recovery, workspace fallback (PRs #366, #367)
Three interlocking improvements: workspace fallback resolution so the server recovers gracefully when the configured workspace is deleted or unavailable; live reasoning cards that upgrade the generic thinking spinner to a real-time reasoning display as the model thinks; and durable session state recovery via `localStorage` so in-flight tool cards, partial assistant output, and the live SSE stream all survive a full page reload or session switch.
### Feature contributions
**[@gabogabucho](https://github.com/gabogabucho)** — Spanish locale + onboarding wizard (PRs #275, #285)
Full Spanish (`es`) locale covering all 175 UI strings, plus the one-shot bootstrap onboarding wizard that guides new users through provider setup on first launch — the feature most responsible for new users actually getting started.
**[@bergeouss](https://github.com/bergeouss)** — Provider management UI + gateway sync + Docker hardening (18 PRs, `v0.50.49``v0.50.240`)
Real-time gateway session sync (Telegram/Discord/Slack into the WebUI sidebar via SSE), the provider management UI for adding/editing custom providers from Settings, the two-container Docker setup docs, OAuth provider status detection, profile isolation hardening (per-profile `.env` secrets), and the bulk of what users see when they touch Settings → Providers.
**[@ccqqlo](https://github.com/ccqqlo)** — Terminal approval UX + custom model discovery + mobile close button (PRs #224, #225, #238, #333)
A run of focused quality-of-life improvements: terminal tool approval prompts that stay visible long enough to actually be read, restored custom model API key discovery, and the redundant mobile close button fix that had been confusing users on narrow screens.
**[@kevin-ho](https://github.com/kevin-ho)** — OLED theme (PR #168)
Added the 7th built-in theme: pure black backgrounds with warm accents tuned to reduce burn-in risk. Small diff, big impact for anyone on an OLED display.
**[@Bobby9228](https://github.com/Bobby9228)** — Mobile Profiles button + Android Chrome fixes (PRs #253, #263, #265)
Added the Profiles entry to the mobile navigation flow, making profile switching reachable on phones, plus a set of Android Chrome-specific fixes for the profile dropdown.
**[@franksong2702](https://github.com/franksong2702)** — Most prolific external contributor (22 PRs, `v0.50.49``v0.50.245`)
The session title guard, breadcrumb workspace navigation, mobile workspace panel sliver fix (#1300), composer footer container queries, streaming session sidebar exemption (#1327), session sidecar repair, cron output preservation (#1295), profile default workspace persistence, and a long tail of polish across the session sidebar, mobile responsive layout, and workspace state machine.
**[@betamod](https://github.com/betamod)** — Security hardening (PR #171)
A comprehensive security audit PR covering CSRF protection, SSRF guards, XSS escaping improvements, and the env race condition between concurrent agent sessions — foundational security work that shipped in v0.39.0.
**[@TaraTheStar](https://github.com/TaraTheStar)** — Bot name + thinking blocks + login refactor (PRs #132, #176, #181)
Made the assistant display name configurable throughout the UI, added thinking/reasoning block display in chat, and refactored the login page to use template variables instead of inline string replacement.
**[@thadreber-web](https://github.com/thadreber-web)** — CLI session bridge (PR #56)
The original CLI session bridge: reads CLI sessions from the agent's SQLite state store and surfaces them in the WebUI sidebar. This was the first bridge between the CLI and WebUI session worlds.
**[@deboste](https://github.com/deboste)** — Reverse proxy auth + mobile responsive layout + model routing (PRs #3, #4, #5)
Three of the very first community PRs: fixed EventSource/fetch to use the URL origin for reverse proxy setups, corrected model provider routing from config, and added mobile responsive layout with dvh viewport fix. Early foundation work.
### Bug fix and security contributions
**[@Hinotoi-agent](https://github.com/Hinotoi-agent)** — Profile .env secret isolation (PR #351)
Fixed API key leakage between profiles on switch — switching from a profile with `OPENAI_API_KEY` to one without it left the key in the process environment for the duration of the session, effectively leaking credentials. A subtle and important security fix.
**[@lawrencel1ng](https://github.com/lawrencel1ng)** — Bandit security fixes B310/B324/B110 + QuietHTTPServer (PR #354)
Systematic bandit security scan fixes: URL scheme validation before `urlopen`, MD5 `usedforsecurity=False`, and 40+ bare `except: pass` blocks replaced with proper logging — plus `QuietHTTPServer` to stop client-disconnect log spam from SSE streams.
**[@lx3133584](https://github.com/lx3133584)** — CSRF fix for reverse proxy on non-standard ports (PR #360)
Fixed CSRF rejection for deployments behind Nginx Proxy Manager or similar on non-standard ports — a real-world blocker for anyone hosting on a port other than 80/443.
**[@DelightRun](https://github.com/DelightRun)** — session_search fix for WebUI sessions (PR #356)
The `session_search` tool silently returned "Session database not available" in every WebUI session. Tracked down the missing `SessionDB` injection in the streaming path and fixed it.
**[@shaoxianbilly](https://github.com/shaoxianbilly)** — Unicode filename downloads (PR #378)
Fixed `UnicodeEncodeError` crashes when downloading workspace files with Chinese, Japanese, or other non-ASCII names. Implemented proper `Content-Disposition` header with RFC 5987 `filename*=UTF-8''...` encoding.
**[@huangzt](https://github.com/huangzt)** — Cancel interrupts agent (PR #244)
Made the Cancel button actually interrupt the running agent and clean up UI state, rather than just hiding the button while the agent kept running.
**[@tgaalman](https://github.com/tgaalman)** — Thinking card fix (PR #169)
Fixed top-level reasoning fields being missed in the thinking card display — an edge case in how Claude's extended thinking blocks surface in the API response.
**[@smurmann](https://github.com/smurmann)** — Custom provider routing fix (PR #189)
Fixed model routing for slash-prefixed custom provider models, which were being misrouted in the model selector. A precise fix for a real edge case in multi-provider setups.
**[@jeffscottward](https://github.com/jeffscottward)** — Claude Haiku model ID fix (PR #145)
Caught and corrected the Claude Haiku model ID (`3-5``4-5`) immediately after the Anthropic release — the kind of quick community catch that keeps the model dropdown accurate.
**[@kcclaw001](https://github.com/kcclaw001)** — Credential redaction in API responses (PR #243)
Added credential redaction to all API response paths so API keys, tokens, and other secrets in session data or error messages are masked before reaching the browser.
**[@mbac](https://github.com/mbac)** — Phantom "Custom" provider group fix (PR #191)
Removed the phantom "Custom" optgroup that appeared in the model dropdown even when no custom provider was configured — a small but consistently confusing UI noise issue.
**[@andrewy-wizard](https://github.com/andrewy-wizard)** — Chinese localization (PR #177)
Added Simplified Chinese (`zh`) locale to the WebUI. One of the first non-English locales and the most-used non-English locale in the codebase.
**[@mmartial](https://github.com/mmartial)** — Docker UID/GID matching (PR #237)
Added Docker support for running as an arbitrary UID/GID matching the host user, eliminating permission issues with bind-mounted volumes — essential for Docker deployments where the host user isn't UID 1000.
**[@vCillusion](https://github.com/vCillusion)** — pip package resolution fix (PR #76)
Fixed agent dependency resolution to prefer packages from the venv's site-packages over the agent directory itself, preventing shadowing bugs when developing locally.
**[@carlytwozero](https://github.com/carlytwozero)** — API key pass-through for non-Anthropic providers (PR #78)
Fixed `api_key` not being passed to `AIAgent` for non-Anthropic `/anthropic` providers — a quiet regression that silently broke any non-default provider.
**[@mangodxd](https://github.com/mangodxd)** — Type hints cleanup (PR #115)
Added missing type hints across 10 files and corrected 9 inaccurate existing ones — the kind of maintenance work that makes the codebase easier to reason about.
**[@Argonaut790](https://github.com/Argonaut790)** — HTML entity decode + Traditional Chinese locale (PR #239)
Fixed double-escaping of HTML entities in `renderMd()` — LLM output containing `&lt;code&gt;` was being escaped a second time, rendering as literal text instead of the intended markdown. The same PR also completed the Simplified Chinese translation (40+ missing keys) and added a full Traditional Chinese (`zh-Hant`) locale.
**[@indigokarasu](https://github.com/indigokarasu)** — Visual redesign proposal: icon rail + design token system + 7 themes (PR #213)
A CSS-only redesign of the full UI — proper design tokens (`--bg-primary`, `--text-info`, spacing scale), an icon rail sidebar replacing the emoji tab strip, consistent form cards, breadcrumb nav, and 7 built-in themes as custom properties. The PR didn't merge as-is but directly shaped the design language and theme architecture that shipped in v0.50.0.
**[@zenc-cp](https://github.com/zenc-cp)** — Anti-hallucination guard for ReAct loop (PR #133)
Added a streaming token buffer and post-run message scrub to `streaming.py` to detect and strip fake tool execution JSON that weaker models write inline instead of calling tools properly. A three-layer approach: ephemeral anti-hallucination prompt, live token filtering, and session history cleanup. The pattern influenced later streaming.py improvements.
**Release history & plan**
- [`CHANGELOG.md`](CHANGELOG.md) — release notes per version
- [`ROADMAP.md`](ROADMAP.md) — feature roadmap and sprint history
- [`SPRINTS.md`](SPRINTS.md) — forward sprint plan with CLI + Claude parity targets
- [`CONTRIBUTORS.md`](CONTRIBUTORS.md) — the full community credit roll
---
Want to contribute? See [ARCHITECTURE.md](ARCHITECTURE.md) for the codebase layout and [TESTING.md](TESTING.md) for how to run the test suite. The best contributions are focused, well-tested, and solve a real problem — exactly what every person on this list did.
## Contributors
Hermes WebUI is built with help from the open-source community. Every PR — whether merged directly, absorbed into a batch release, or salvaged from a larger proposal — shapes the project, and we're grateful to everyone who has taken the time to contribute.
Over **190 contributors** have shipped code that landed in a release tag. The full,
continuously-updated credit roll — including everyone with one or two PRs and the
special-thanks roll for design and architectural work — lives in
[`CONTRIBUTORS.md`](CONTRIBUTORS.md). A snapshot of the most prolific contributors:
### Top contributors (by PR count, including absorbed/batch-released work)
| # | Contributor | PRs | First → latest release |
|---|---|---:|---|
| 1 | [@franksong2702](https://github.com/franksong2702) | 148 | `v0.49.3``v0.51.153` |
| 2 | [@Michaelyklam](https://github.com/Michaelyklam) | 117 | `v0.50.240``v0.51.139` |
| 3 | [@bergeouss](https://github.com/bergeouss) | 70 | `v0.48.0``v0.51.46` |
| 4 | [@ai-ag2026](https://github.com/ai-ag2026) | 67 | `v0.50.279``v0.51.190` |
| 5 | [@dso2ng](https://github.com/dso2ng) | 25 | `v0.50.227``v0.51.153` |
| 6 | [@AJV20](https://github.com/AJV20) | 24 | `v0.51.93``v0.51.188` |
| 7 | [@starship-s](https://github.com/starship-s) | 19 | `v0.50.123``v0.51.153` |
| 8 | [@jasonjcwu](https://github.com/jasonjcwu) | 16 | `v0.50.227``v0.51.132` |
| 9 | [@dobby-d-elf](https://github.com/dobby-d-elf) | 15 | `v0.51.38``v0.51.161` |
| 10 | [@Jordan-SkyLF](https://github.com/Jordan-SkyLF) | 12 | `v0.50.18``v0.51.66` |
See [`CONTRIBUTORS.md`](CONTRIBUTORS.md) for the full ranked list of all 194 contributors, including everyone with one or two PRs and the special-thanks roll for design and architectural contributions.
### Notable contributions
**[@franksong2702](https://github.com/franksong2702)** — Most prolific external contributor (148 PRs, `v0.49.3``v0.51.153`)
Across the longest tenure of any external contributor: the session title guard (#301), breadcrumb workspace navigation (#302), embedded workspace terminal (#1099), worktree-backed session creation (#2053), onboarding documentation (#2052), composer footer container queries, streaming-session sidebar exemption (#1327), session sidecar repair, cron output preservation (#1295), profile default workspace persistence, manual `/compress` async start/status endpoints (#2128), worktree status surface (#2109) + guarded remove (#2156) for the lifecycle umbrella #2057, session post-render dedup (#2166), native-WebUI fast path (#2170), tail-window response trim (#2171), stale-stream guard extension (#2158), CSP report collector (#2160), and a long tail of polish across mobile/responsive, the session sidebar, and the workspace state machine.
**[@Michaelyklam](https://github.com/Michaelyklam)** — Most prolific contributor of recent releases (117 PRs, `v0.50.240``v0.51.139`)
Production Docker hardening (#1921, drops sudo-capable staging user), profile-scoped skills endpoints (#1903), gateway PID resolution under profile-scoped HERMES_HOME (#1901), profile-aware AIAgent cache (#1898/#1904), backslash LaTeX delimiters (#1848), Codex quota error surfacing (#1770), shell-route HTML 503 (#1836), stale Kanban client recovery (#1828), context auto-compression toast lifetime (#1988), `/goal` command (#1866), Kanban detail-view scrolling (#1916), CLI session tool metadata preservation (#1778), Traditional Chinese kanban locale backfill (#1979), v0.51.51 mobile Insights bucketing/layout (#2120/#2121), Hermes run adapter RFC (#2105 for #1925), fork-from-here absolute index (#2198 for #2184), opencode-go custom-provider overlap routing (#2204 for #1894).
**[@bergeouss](https://github.com/bergeouss)** — Provider management UI + Docker hardening (70 PRs, `v0.48.0``v0.51.46`)
Provider management UI for adding/editing custom providers from Settings, OAuth provider status detection (#1552), two-container Docker setup, profile isolation hardening (per-profile `.env` secrets), the bulk of what users see when they touch Settings → Providers, Reveal-in-Finder context menu (#1551), gateway status card (#1552), auto-assign session to active project filter (#1550), "What's new?" link in update banner (#1549), OpenRouter free-tier live fetch (#1548), credential pool 401 self-heal (#1553), inline provider chip + group model count in model picker (#1644).
**[@ai-ag2026](https://github.com/ai-ag2026)** — Session recovery + audit infrastructure (67 PRs, `v0.50.279``v0.51.190`)
Autonomous-AI contributor (Hermes Agent-driven) focused on durability: `state.db`-backed sidecar reconciliation (#2041), orphan `.json.bak` recovery on startup (#2035), read-only session recovery audit endpoints (#2036, #2040), active run lifecycle in `/health` (#2039), crash-safe turn-journal RFC at `docs/rfcs/turn-journal.md` (#2042), append-only turn-journal helper (#2059), lifecycle events layer (#2062), `Content-Security-Policy-Report-Only` header (#2084), per-cron toast toggle (#2100), fork-session compression lineage isolation (#2014).
**[@dso2ng](https://github.com/dso2ng)** — Session lineage + diagnostics (25 PRs, `v0.50.227``v0.51.153`)
`/api/session/lineage-report/<sid>` endpoint for bounded session graph diagnostics (#2012), stale Mermaid render error cleanup (#1337), `session_source="fork"` continuation-chain isolation (#2063), lazy lineage-report fetch on sidebar badge expand (#2130), and a long tail of frontend reliability fixes around session loading.
**[@jasonjcwu](https://github.com/jasonjcwu)** — Composer + transcript polish (16 PRs, `v0.50.227``v0.51.132`)
Sidebar collapse via active-rail click (#2054, fuses #1884 + #1924), composer chip lightbox (#1758), title fixes for tool-heavy first turns, silent compress-status during session switch (#2185), concurrent-send loss fix (#2186), in-transcript steer message badges (#2187), and a string of frontend polish fixes.
**[@Jordan-SkyLF](https://github.com/Jordan-SkyLF)** — Live streaming + UX polish (12 PRs, `v0.50.18``v0.51.66`)
Original sprint of workspace fallback resolution, live reasoning cards (#366, #367, #394#397), then a recent burst: manual "Refresh usage" button on the Provider quota card (#2150), cancelled-turn status classification (#2151), Firefox sidebar scroll stabilization (#2200), early provisional session titles (#2202), target-aware "What's new?" update-banner links (#2207), and MCP tools overflow fix in Settings (#2210).
**[@aronprins](https://github.com/aronprins)** — `v0.50.0` UI overhaul (PR #242, plus 9 follow-ups)
The biggest single contribution to the project: a complete UI redesign that moved model/profile/workspace controls into the composer footer, replaced the gear-icon settings panel with the Hermes Control Center (tabbed modal), removed the activity bar in favor of inline composer status, redesigned the session list with a `⋯` action dropdown, and added the workspace panel state machine. Plus chat transcript redesign (#587), sidebar declutter (#584), three-column layout refactor (#899), light/dark theme + accent skins (#627), and shared `confirm()`/`prompt()` dialog replacement (PR #251 extracted from #242).
**[@iRonin](https://github.com/iRonin)** — Security hardening sprint (PRs #196#204)
Six consecutive, focused security PRs: session memory leak fix (expired token pruning), CSP + Permissions-Policy headers, 30-second slow-client connection timeout, optional HTTPS/TLS support via environment variables, upstream branch tracking fix for self-update, and CLI session support in the file-browser API. The kind of focused, high-quality security work that makes a self-hosted tool trustworthy.
**[@lucasrc](https://github.com/lucasrc)** — Auth-hardening trilogy (PRs #2191, #2192, #2193)
Three coordinated security PRs that all landed in v0.51.57: thread-safe login rate limiter with PBKDF2 key separation, password-hash cache invalidation on Settings save, and the full 64-char HMAC-SHA256 session signature with a backwards-compatible migration bridge. The kind of cleanly-decomposed security work that's reviewable as three independent pieces.
**[@LumenYoung](https://github.com/LumenYoung)** — Streaming hot-path correctness (8 PRs, `v0.51.47``v0.51.99`)
The original stale-stream writeback guard (#2136 — the bug class the next two releases extended), gateway-state alive-null classification (#2075), compression-banner anchor alignment (#2182), and context-progress ring auto-refresh on compression complete (#2188). Each PR opened a small surgical fix in one of the most fragile subsystems in the codebase.
**[@dobby-d-elf](https://github.com/dobby-d-elf)** — Frontend reliability + motion polish (15 PRs, `v0.51.38``v0.51.161`)
Workspace fallback on deleted directories (#2138), iPhone PWA bottom-scroll fix (#2143), the new "Activity: X tools" composer footer shimmer animation (#2203), and follow-up animation tuning (#2212).
**[@JKJameson](https://github.com/JKJameson)** — Composer + session polish (10 PRs)
Persistent composer draft per session (#1956), and a long tail of polish across the composer and session sidebar.
**[@gabogabucho](https://github.com/gabogabucho)** — Spanish locale + onboarding wizard
Full Spanish (`es`) locale covering all UI strings, plus the one-shot bootstrap onboarding wizard that guides new users through provider setup on first launch.
**[@deboste](https://github.com/deboste)** — Reverse-proxy auth + mobile responsive layout (PRs #3, #4, #5)
Three of the very first community PRs: fixed EventSource/fetch to use URL origin for reverse-proxy setups, corrected model provider routing from config, and added mobile responsive layout with dvh viewport fix. Early foundation work.
**[@indigokarasu](https://github.com/indigokarasu)** — Visual redesign proposal (PR #213)
A CSS-only redesign of the full UI — proper design tokens, an icon rail sidebar replacing the emoji tab strip, consistent form cards, breadcrumb nav, and 7 built-in themes as custom properties. The PR didn't merge as-is but shaped the design language and theme architecture that shipped in v0.50.0.
**[@zenc-cp](https://github.com/zenc-cp)** — Anti-hallucination guard for the ReAct loop (PR #133)
A three-layer approach (ephemeral anti-hallucination prompt, live token filtering, session-history cleanup) that the streaming pipeline still uses.
**[@Hinotoi-agent](https://github.com/Hinotoi-agent)** — Profile + session security (PRs #351, #2048)
Profile `.env` secret isolation fix (PR #351) preventing API key leakage between profiles, and session-import workspace validation (PR #2048) blocking a crafted-JSON file-read against `/`.
**[@Sanjays2402](https://github.com/Sanjays2402)** — Endless-scroll + Start-jump race fix (PR #1949)
A generation-token + mutex pair fixing the v0.51.30 race between endless-scroll prefetch and Start-jump's `_ensureAllMessagesLoaded`. The naive same-flag-check approach (proposed in #1942 and #1962) was a no-op for the post-await race — Sanjays2402's fix was the correct shape.
**[@fxd-jason](https://github.com/fxd-jason)** — Real-time approval + clarify via SSE (PRs #1350, #1355)
Replaced 1.5s HTTP polling with SSE long-connections for both approval and clarify, cutting latency from up to 1.5s to near-instant. Got all the correctness details right (atomic subscribe + snapshot, notify-inside-lock, head-of-queue payload, trailing event re-emission).
**[@happy5318](https://github.com/happy5318)** — Custom provider model dedup (PR #1947)
Fixed the same model from different named custom providers being silently deduplicated in the picker, with Opus catching a race in the original tests that needed augmentation.
**[@NocGeek](https://github.com/NocGeek)** — Streaming scroll + manual cron output persistence (7 PRs)
Streaming scroll viewport stability when tool/queue cards insert (#1360), manual cron-run output and metadata persistence (#1372, split from held #1352).
**[@DavidSchuchert](https://github.com/DavidSchuchert)** — German translation (PR #190)
Complete German locale (`de`) covering all UI strings, settings labels, commands, and system messages — and stress-tested the i18n system, exposing several elements that weren't yet translatable and getting them fixed as part of the same PR.
**[@Bobby9228](https://github.com/Bobby9228)** — Mobile Profiles button (PR #265)
Added the Profiles entry to the mobile navigation flow, making profile switching reachable on phones.
**[@kevin-ho](https://github.com/kevin-ho)** — OLED theme (PR #168)
The 7th built-in theme: pure black backgrounds with warm accents tuned to reduce burn-in risk.
**[@andrewy-wizard](https://github.com/andrewy-wizard)** — Chinese localization (PR #177)
Initial Simplified Chinese (`zh`) locale. One of the first non-English locales.
**[@DelightRun](https://github.com/DelightRun)** — `session_search` fix for WebUI sessions (PR #356)
Tracked down the missing `SessionDB` injection in the streaming path that was silently breaking the tool for every WebUI session.
**[@lawrencel1ng](https://github.com/lawrencel1ng)** — Bandit security fixes (PR #354)
Systematic bandit-scan fixes: URL scheme validation before `urlopen`, MD5 `usedforsecurity=False`, and 40+ bare `except: pass` blocks replaced with proper logging.
**[@shaoxianbilly](https://github.com/shaoxianbilly)** — Unicode filename downloads (PR #378)
Proper `Content-Disposition` with RFC 5987 `filename*=UTF-8''...` encoding so non-ASCII filenames download without crashing.
**[@lx3133584](https://github.com/lx3133584)** — CSRF fix for reverse proxy (PR #360)
A real-world blocker for anyone hosting behind Nginx Proxy Manager or similar on a port other than 80/443.
**[@betamod](https://github.com/betamod)** — Security audit (PR #171)
A comprehensive CSRF / SSRF / XSS / env-race-condition audit that shipped in v0.39.0.
**[@TaraTheStar](https://github.com/TaraTheStar)** — Bot name + thinking blocks + login refactor (PRs #132, #176, #181)
Configurable assistant display name, thinking/reasoning block display, and a login page refactor.
---
## Repo

View File

@@ -2,7 +2,7 @@
> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
>
> Last updated: v0.51.13 (May 6, 2026) — 4642 tests collected — single-PR composer UX (#1758)
> Last updated: v0.51.192 (May 31, 2026) — ~7,150 tests collected. Recent themes: forward-looking ruff lint gate (#3273), Windows upgrade state-stranding hotfix (#2905), per-model context_length default-only guard (#3256), gateway-configured banner + tooltip i18n, and an ongoing concentric PR-triage cadence.
> Test source: `pytest tests/ --collect-only -q`
> Per-version detail: see [CHANGELOG.md](./CHANGELOG.md)
@@ -18,7 +18,7 @@
| **Sessions + projects + search** | ✅ CRUD, content search, projects, tags, archive, fork, import |
| **Mobile + Docker + auth** | ✅ Hamburger nav, slide-overs, password auth, GHCR images |
| **Auxiliary surfaces** | ✅ Workspace tree + edit, cron CRUD, skills CRUD, memory write, MCP server UI |
| **Visual polish** | ✅ 8 themes (incl. light/system/OLED/Sienna), Mermaid, KaTeX, syntax highlighting |
| **Visual polish** | ✅ Light/dark/system themes × 11 skins (default, ares, mono, slate, poseidon, sisyphus, charizard, sienna, catppuccin, nous, geist-contrast), Mermaid, KaTeX, syntax highlighting |
| **Native distribution** | ✅ macOS desktop app (universal arm64+x86_64 DMG, signed) — separate repo |
Remaining gaps and forward work live in [Forward Work](#forward-work) below.
@@ -29,15 +29,17 @@ Remaining gaps and forward work live in [Forward Work](#forward-work) below.
| Layer | Files | Status |
|---|---|---|
| Python server | `server.py` (~165 lines) + `api/` modules (~20k lines) | Thin shell + auth middleware + business logic |
| HTML template | `static/index.html` (~600 lines) | Served from disk |
| CSS | `static/style.css` (~3k lines) | Themes, mobile responsive, KaTeX, table styles |
| JavaScript | `static/{ui,sessions,messages,workspace,panels,boot,commands,icons,i18n,login,onboarding}.js` (~26k lines) | 11 modules served as static files |
| Python server | `server.py` + `api/` modules | Thin HTTP shell + auth middleware over the `api/` business logic (config, sessions, streaming, profiles, routes, onboarding, workspace, updates, upload) |
| HTML template | `static/index.html` | Served from disk |
| CSS | `static/style.css` | Themes + skins, mobile responsive, KaTeX, table styles |
| JavaScript | `static/{ui,sessions,messages,workspace,panels,boot,commands,icons,i18n,login,onboarding}.js` | Vanilla-JS modules served as static files — no bundler |
| Service worker | `static/sw.js` | Offline shell cache, version-pinned assets |
| Docker | `Dockerfile`, `docker-compose.yml` | `python:3.12-slim`, multi-arch (amd64+arm64), HEALTHCHECK |
| CI/CD | `.github/workflows/release.yml` | Auto-release + GHCR publish on tag push |
| CI/CD | `.github/workflows/` | ruff lint + sharded pytest + browser smoke + Docker smoke on every PR; auto-release + GHCR publish on tag push |
| Test isolation | `tests/_pytest_port.py` | Per-worktree port + state-dir derivation, no collisions |
> Per-file line counts drift every release; see `ARCHITECTURE.md` for the current module map and `git ls-files` for exact sizes.
---
## Feature parity checklist
@@ -168,7 +170,7 @@ Remaining gaps and forward work live in [Forward Work](#forward-work) below.
### Slash commands
- [x] Command registry + autocomplete dropdown
- [x] Built-ins: `/help`, `/clear`, `/model`, `/workspace`, `/new`, `/usage`, `/theme`, `/compact`, `/queue`, `/interrupt`, `/steer`, `/btw`, `/reasoning`, `/skills`, `/toolsets`
- [x] Built-ins: `/help`, `/clear`, `/model`, `/workspace`, `/new`, `/usage`, `/theme`, `/compact`, `/queue`, `/interrupt`, `/steer`, `/goal`, `/btw`, `/reasoning`, `/skills`, `/toolsets`
- [x] Transparent pass-through for unrecognized commands
### Security

View File

@@ -1,20 +1,109 @@
# Hermes Web UI: Browser Testing Plan
> This document is for manual browser testing by you or by a Claude browser agent.
> It covers user-facing features of the UI through v0.50.21 and later releases.
> It covers user-facing features of the UI across current releases.
> Each section is written as a step-by-step test procedure with expected outcomes.
> A browser agent (e.g. Claude with Chrome access) can execute this plan directly.
>
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
>
> Automated coverage: 3648 tests collected via `pytest tests/ --collect-only -q`. Tests run on every PR via GitHub Actions on Python 3.11, 3.12, and 3.13. The suite covers the bootstrap/static wizard, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, CSS regression coverage for thinking/tool card animation, streaming session persistence, mobile layout breakpoints, locale parity across 9 languages, and ~700 issue/PR-pinned regression tests.
> Automated coverage: ~7,150 tests collected via `pytest tests/ --collect-only -q`. Tests run on every PR via GitHub Actions on Python 3.11, 3.12, and 3.13 (3 parallel shards each), alongside a ruff lint gate, a headless browser smoke test, and a Docker smoke test. The suite covers the bootstrap/static wizard, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, CSS regression coverage for thinking/tool card animation, streaming session persistence, mobile layout breakpoints, locale parity across 11 languages, and hundreds of issue/PR-pinned regression tests.
> Run: `pytest tests/ -v --timeout=60`
>
> Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash.
---
## Static JS runtime lint (brick-class regression guard)
Some JS bugs throw a `TypeError`/`ReferenceError` only when a specific function
actually runs in the browser — `node --check` (lazy syntax check), source-presence
tests, and even executing the file all miss them. Issue **#3162** was exactly this:
a `const` binding reassigned inside `_ensureMessagesLoaded` bricked "load conversation
messages" on every mobile message (v0.51.161166).
The guard is a curated, zero-false-positive ESLint config (`eslint.runtime-guard.config.mjs`)
that runs ONLY runtime-error rules (`no-const-assign`, `no-import-assign`) over
`static/**/*.js`. It is NOT a style linter and has no formatting rules.
```bash
# one-time dev setup (ESLint is a dev-only tool; the app stays pure Python + vanilla JS):
npm install --no-save --before=<a-date-≥48h-ago> eslint # package-age guard
# run the guard:
npm run lint:runtime
# or directly:
npx eslint --no-config-lookup -c eslint.runtime-guard.config.mjs "static/**/*.js"
```
## Python lint gate (ruff) — forward-looking, new-code-only
The Python twin of the ESLint runtime guard. A curated `ruff` ruleset
(`[tool.ruff]` in `pyproject.toml`) catches latent-bug shapes — unused imports
(F401), undefined/unused names (F841/F821), redefinitions (F811), mutable default
args (B006), raise-without-from (B904), loop-variable capture in closures (B023) —
**plus** real syntax/runtime errors (E9). It is **not** a style/formatting linter:
the pure-style families (line-length, whitespace) are intentionally OFF so the gate
never demands a reformat of existing code.
The existing tree carries a cosmetic backlog (mostly unused-import F401) that is
deliberately **not** reformatted. So the gate is enforced **only on the lines a
change adds or modifies** (`scripts/ruff_lint.py --diff`), which keeps new code
clean without touching the backlog. Cleaning the backlog is a separate,
maintainer-run, safe-fixes-only decision (tracked in #3273).
```bash
# one-time dev setup (ruff is a dev-only tool):
pip install ruff # or: uv tool install ruff / uvx ruff ...
# the gate (only flags violations on lines you added/changed vs origin/master):
python3 scripts/ruff_lint.py --diff origin/master
# whole-tree backlog report (informational — never blocks):
python3 scripts/ruff_lint.py --all
```
`tests/test_ruff_forward_lint.py` holds the **whole tree** free of E9 (real
syntax/runtime) findings and verifies the curated config shape; it runs in-suite
when ruff is present and **skips gracefully** when it isn't — so environments
without ruff aren't blocked, while CI (which installs ruff) enforces it. The
diff-scoped gate runs as the `lint` job in `.github/workflows/tests.yml` and is
also part of the maintainer pre-release pre-gate.
## Automated browser smoke (runtime brick-class gate)
The ESLint guard above catches `const`-reassign / import-assign statically. The
**browser smoke** catches the same brick class *dynamically* — plus anything else
that throws only when a real browser executes the page (e.g. a `function X(){}` /
`window.X = {}` name collision like #2715/#2771, which ESLint can't see).
`tests/browser_smoke.py` boots the real `server.py` (agent-free, on an ephemeral
port, with an isolated temp state dir) and loads the key pages in headless
Chromium, failing if **any** console error or uncaught JS exception fires on load.
It runs in CI (`.github/workflows/browser-smoke.yml`) on every PR and push to
master, and locally:
```bash
pip install playwright && python -m playwright install chromium
python tests/browser_smoke.py
```
It is intentionally **credential-free**: it strips every `*_API_KEY` from the
environment before launching the server, needs no secrets, and does not drive a
real model (it verifies the app *loads and initializes* cleanly — the brick class
that breaks the page for everyone). A full chat golden-path E2E (send → stream →
render → switch → reload) lives in the maintainer's private QA harness, which has
the agent + a mock LLM provider available.
`tests/test_static_js_runtime_lint.py` runs this automatically when eslint is present
and **skips gracefully** (clear message) when it isn't — so environments without the
node toolchain aren't blocked, while the release gate (which installs eslint) enforces it.
To widen the guard, fix the pre-existing intentional hits first (as of 2026-05-30:
`no-dupe-keys` ×92 i18n locale-fallback, `no-func-assign` ×2 panel override,
`no-redeclare` ×1) then promote the rule into the config.
---
## How to Use This Document
Each test has:
@@ -533,7 +622,8 @@ FAIL: Sidebar causes layout overflow or blocks chat.
### T11.3: Structured Log Output
SETUP: SSH access to the server.
STEPS:
1. In a terminal: tail -f /tmp/webui-mvp.log
1. In a terminal: tail -f ~/.hermes/webui/bootstrap-8787.log
(or tail -f ~/.hermes/webui.log when launched through `ctl.sh`)
2. In browser: perform any action (load page, send message, click file)
EXPECT:
- Log entries appear in terminal as JSON: {"ts":"...","method":"GET","path":"/health","status":200,"ms":0.1}
@@ -557,7 +647,7 @@ FAIL: Multiple messages sent while one is in flight.
### T12.2: Upload Failure Shows Status
SETUP: Active session.
STEPS:
1. Try to attach a file larger than 20MB (if available)
1. Try to attach a file larger than the configured upload limit (20MB by default; overridden by `HERMES_WEBUI_MAX_UPLOAD_MB` if set)
EXPECT:
- Status bar shows an error message about file size or the upload is rejected
- The chat is not broken (can still send messages)
@@ -577,7 +667,7 @@ FAIL: Browser freezes, crash, or security issue.
## Automated Test Coverage Reference
These behaviors are verified by pytest (run: venv/bin/python -m pytest webui-mvp/tests/ -v):
These behaviors are verified by pytest (run: venv/bin/python -m pytest tests/ -v):
Sprint 1 tests (test_sprint1.py):
- Server health, session CRUD (create/load/update/delete/sort)
@@ -1835,8 +1925,8 @@ Bridged CLI sessions:
---
*Last updated: v0.51.13, May 6, 2026*
*Total automated tests collected: 4642*
*Last updated: v0.51.192, May 31, 2026*
*Total automated tests collected: ~7,150 (run `pytest tests/ --collect-only -q` for the exact current count)*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*

256
THEMES.md
View File

@@ -1,19 +1,32 @@
# Hermes Web UI — Themes
Hermes Web UI supports pluggable color themes. Seven themes ship built-in, and
you can create your own with pure CSS — no Python changes needed.
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: 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
without losing your favorite accent — pure CSS, no Python changes needed.
---
## Switching Themes
## Switching Appearance
**Settings panel:** Click the gear icon, select a theme from the dropdown. The
preview is instant — the UI updates as you click through options.
**Settings panel:** Click the gear icon**Appearance**. The **Theme** card
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 dark` or `/theme light` in the composer.
**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`, `geist-contrast`, `zeus`). It updates the matching axis and leaves the other one
alone.
**Themes persist** across page reloads and server restarts (stored in
`settings.json` server-side, with `localStorage` for flicker-free loading).
**Persistence:** Both choices are stored in `localStorage` for flicker-free
loading, and saved server-side via `POST /api/settings` (under `theme` and
`skin` keys in `settings.json`).
---
@@ -21,125 +34,138 @@ preview is instant — the UI updates as you click through options.
| Theme | Description |
|-------|-------------|
| **Dark** (default) | Deep navy/indigo with muted blue accents. Easy on the eyes for long sessions. |
| **Light** | Warm off-white with dark text. High contrast for bright environments. |
| **Slate** | Warm charcoal, lighter than Dark. Easier on the eyes for extended use. |
| **Solarized Dark** | Ethan Schoonover's classic dark palette. Teal background, warm accents. |
| **Monokai** | Warm dark theme inspired by the Monokai editor scheme. Green/pink accents. |
| **Nord** | Arctic blue-gray palette from the Nord color system. Calm and minimal. |
| **OLED** | True black (#000) backgrounds for OLED displays. Minimizes glow and burn-in risk. |
| **Custom themes** | Any string accepted by `settings.json`, `POST /api/settings`, and `/theme` if added to the picker/command list. Pure CSS variables only. |
| **System** (default) | Follows the OS `prefers-color-scheme` preference and updates live. |
| **Dark** | Deep dark surfaces, low-glare for long sessions. |
| **Light** | Bright surfaces with dark text, high contrast for daylight environments. |
The theme is applied as a class on `<html>`: `.dark` is present for dark mode,
absent for light. System mode tracks the OS preference at runtime.
---
## Built-in Skins
| Skin | Description |
|------|-------------|
| **Default** | The original Hermes gold accent. Warm and understated. |
| **Ares** | Fiery red. High-energy and assertive. |
| **Mono** | Neutral gray. Distraction-free, for deep focus. |
| **Slate** | Slate blue-gray. Subtle and grown-up. |
| **Poseidon** | Ocean blue. Calm and focused for long sessions. |
| **Sisyphus** | Vivid purple. Distinctive without being loud. |
| **Charizard** | Warm orange. Energetic and easy on the eyes. |
| **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. |
| **Zeus** | OLED-near-black dark surfaces that keep the default gold accent. Dark-focused; falls back to the default light palette in light mode. |
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
skin clears the attribute).
---
## Creating a Custom Skin
A skin is a small CSS block that overrides the accent variables for both the
light and dark variants:
```css
/* Light variant */
:root[data-skin="my-skin"] {
--accent: #2E7D32; /* Active states, links, primary buttons */
--accent-hover: #1B5E20; /* Hover */
--accent-bg: rgba(46,125,50,0.08); /* Soft tinted backgrounds */
--accent-bg-strong: rgba(46,125,50,0.15); /* Highlighted backgrounds */
--accent-text: #1B5E20; /* Text on accent bg */
}
/* Dark variant — usually lighter or more saturated for contrast */
:root.dark[data-skin="my-skin"] {
--accent: #66BB6A;
--accent-hover: #43A047;
--accent-bg: rgba(102,187,106,0.08);
--accent-bg-strong: rgba(102,187,106,0.15);
--accent-text: #66BB6A;
}
```
Two ways to ship it:
1. **In the repo (built-in):** add the block to `static/style.css`, register it
in the Settings skin picker (`static/index.html`) and in the `/theme` command
list (`static/commands.js`), then open a PR.
2. **Self-hosted (no fork):** use the WebUI extensions surface — see
`docs/EXTENSIONS.md`. Drop your CSS in `HERMES_WEBUI_EXTENSION_DIR` and
declare it in `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS`. No code changes
needed; the skin attribute can be set from your own JS.
### Tips
- **Test both themes.** A skin that pops on Dark can be illegible on Light.
Always check `:root[data-skin]` (light) *and* `:root.dark[data-skin]` (dark).
- **Pick contrasting `--accent-text` on `--accent-bg`.** The strong variant
appears behind small labels and chips; weak contrast there reads as blur.
- **The logo gradient uses `--accent` automatically**, so it adapts to your
skin without any extra work.
- **No server changes needed.** The `skin` setting in `settings.json` accepts
any string, so your custom skin name persists without code changes once you
load the CSS.
---
## Creating a Custom Theme
A theme is a CSS block that overrides the color variables. Add it to
`static/style.css` (or a separate file that you link after the main stylesheet).
A full custom *theme* (a different overall mood, not just an accent change) is
a larger task than a skin: it has to redefine the core palette variables
(`--bg`, `--surface`, `--text`, `--border`, `--code-bg`, and friends) for one
or both modes. The contract is defined in the top `:root` and `:root.dark`
blocks of `static/style.css` — start there.
### Step 1: Define your theme block
Every color in the UI comes from these CSS variables:
```css
:root[data-theme="your-theme-name"] {
/* Core palette */
--bg: #1a1a2e; /* Main background */
--sidebar: #16213e; /* Sidebar background */
--border: rgba(255,255,255,0.08); /* Subtle borders */
--border2: rgba(255,255,255,0.14); /* Stronger borders */
--text: #e8e8f0; /* Primary text color */
--muted: #8888aa; /* Secondary/muted text */
--accent: #e94560; /* Accent color (errors, warnings, delete) */
--blue: #7cb9ff; /* Primary action color (links, active states) */
--gold: #c9a84c; /* Secondary accent (pinned items, gold highlights) */
--code-bg: #0d1117; /* Code block background */
/* Surface and chrome (required for full theme polish) */
--surface: #1a2535; /* Dropdowns, popups, toast, approval card */
--topbar-bg: rgba(22,33,62,.98); /* Topbar background */
--main-bg: rgba(26,26,46,0.5); /* Main chat area background */
--input-bg: rgba(255,255,255,.04); /* Input/button subtle backgrounds */
--hover-bg: rgba(255,255,255,.06); /* Hover state backgrounds */
--focus-ring: rgba(124,185,255,.35); /* Focus border color */
--focus-glow: rgba(124,185,255,.08); /* Focus box-shadow glow */
/* Typography (required for readable text across themes) */
--strong: #fff; /* Bold text in messages */
--em: #c9c9e8; /* Italic text in messages */
--code-text: #f0c27f; /* Inline code text color */
--code-inline-bg: rgba(0,0,0,.35); /* Inline code background */
--pre-text: #e2e8f0; /* Code block text color */
}
```
The **core palette** controls the overall mood. The **surface/chrome** and
**typography** variables are part of the standard theme contract — define all
of them for a complete theme.
For **light themes**, you also need `:root[data-theme="name"]` overrides
for elements that use `rgba(255,255,255,.XX)` hover/border effects (these
are invisible on light backgrounds). See the built-in light theme for the
full pattern — it overrides ~45 selectors for proper dark-on-light contrast
on hover states, borders, chips, role labels, session items, and
interactive elements.
### Step 2: Add it to the theme picker (optional)
To make your theme appear in the Settings dropdown, add an `<option>` to the
theme `<select>` in `static/index.html`:
```html
<option value="your-theme-name">Your Theme Name</option>
```
And update the `/theme` command's valid theme list in `static/commands.js`.
### Step 3: Test it
Switch to your theme via `/theme your-theme-name` or the Settings panel.
Check these areas:
- Sidebar session list (hover states, active state, project borders)
- Message bubbles (user vs assistant styling)
- Code blocks (background contrast, copy button visibility)
- Tool cards (running indicator, expand/collapse)
- Settings panel and login page
- Mobile layout (hamburger sidebar, bottom nav)
### Tips
- **Light themes** need scrollbar and selection overrides, plus the full
text/code set (`--strong`, `--em`, `--code-text`, `--code-inline-bg`,
`--pre-text`) or they will look broken.
- The **logo gradient** uses `--accent` automatically, so it adapts to your
theme without extra work.
- **Prism.js syntax highlighting** uses its own CDN stylesheet (Tomorrow theme).
It works well on dark themes; on light themes the contrast is acceptable but
not perfect. Custom Prism theme support is planned for a future update.
- **No server changes needed.** The `theme` setting in `settings.json` accepts
any string — your custom theme name will persist without code changes.
Most of the time, a custom **skin** is what you actually want. Reach for a
custom theme only when the existing Light/Dark modes don't fit (for example,
a high-contrast accessibility theme or an OLED black variant).
---
## How Themes Work Internally
## Font Size
1. Each theme is a `:root[data-theme="name"]` CSS block that overrides variables.
2. Switching themes sets `document.documentElement.dataset.theme = name` in JS.
3. A tiny inline `<script>` in `<head>` reads `localStorage` before the
stylesheet loads — this prevents a flash of the wrong theme on page load.
4. The theme preference is saved server-side via `POST /api/settings` and
loaded on boot via `GET /api/settings`.
5. The `/theme` command and Settings dropdown both update the DOM, localStorage,
and server settings simultaneously.
Right under Theme/Skin in **Settings → Appearance**: `Small`, `Default`,
`Large`. Applied as `data-font-size` on `<html>` and scales the WebUI's root
font size. Persists alongside theme and skin.
---
## Contributing a Theme
## How It Works Internally
To contribute a new built-in theme:
1. **Theme:** `document.documentElement.classList.toggle('dark', isDark)`
light mode removes the class. System mode tracks
`matchMedia('(prefers-color-scheme: dark)')`.
2. **Skin:** `document.documentElement.dataset.skin = name` (or remove the
attribute for `default`).
3. **Font size:** `document.documentElement.dataset.fontSize = size` (or
remove for `default`).
4. **No flash on load:** a tiny inline `<script>` in `<head>` reads
`localStorage` before the stylesheet does, so the right look is applied
before paint.
5. **Server sync:** preferences are saved via `POST /api/settings` and
rehydrated on boot via `GET /api/settings`.
1. Add your `:root[data-theme="name"]` block to `static/style.css`
2. Add the `<option>` to the Settings panel in `static/index.html`
3. Add the theme name to the valid list in `cmdTheme()` in `static/commands.js`
4. Test on desktop and mobile
5. Open a PR — themes are pure CSS additions with no backend changes needed
---
## Contributing a Skin
Skins are the easiest extension point — pure CSS, no Python, no JS logic. To
contribute one upstream:
1. Add your `:root[data-skin="name"]` and `:root.dark[data-skin="name"]`
blocks to `static/style.css`.
2. Register it in the Settings skin picker in `static/index.html` and in the
skin list used by `cmdTheme()` in `static/commands.js`.
3. Test on desktop and mobile across both Light and Dark themes.
4. Open a PR — skins are pure CSS additions with no backend changes needed.
For a custom *theme* (overriding the base palette), prefer opening an issue
first to discuss scope, since it touches many selectors.

View File

@@ -1,28 +1,255 @@
"""Hermes agent/gateway heartbeat payload helpers (#716).
"""Hermes agent/gateway heartbeat payload helpers (#716, #1879).
The WebUI process is not always paired with a long-running Hermes gateway. Some
setups use WebUI only, while self-hosted messaging deployments run a separate
Hermes gateway daemon that records runtime metadata in the Hermes Agent home.
This module turns those existing safe runtime signals into a small UI-facing
heartbeat without shelling out or adding psutil as a hard dependency.
Cross-container note (#1879): ``gateway.status.get_running_pid()`` uses
``fcntl.flock`` and ``os.kill(pid, 0)``, both of which require the caller to
share a PID namespace with the gateway process. In multi-container deployments
where the WebUI runs separately from ``hermes-agent`` and only a Hermes data
volume is shared, those checks always return ``None`` and the dashboard
incorrectly shows "Gateway not running". To stay accurate without forcing a
``pid: "service:hermes-agent"`` compose workaround, we accept a recent
``updated_at`` timestamp on ``gateway_state.json`` (combined with
``gateway_state == "running"``) as an equivalent live-process signal. Older
gateway builds do not refresh that file periodically, so a stale
``gateway_state == "running"`` record is treated as inconclusive rather than a
confirmed outage.
"""
from __future__ import annotations
import importlib
import json
import os
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib import error as urllib_error
from urllib import request as urllib_request
_GATEWAY_PID_FILE = "gateway.pid"
_GATEWAY_RUNTIME_STATUS_FILE = "gateway_state.json"
# Two cron ticks (~60s each). Chosen to avoid false negatives during brief
# gateway restarts while still surfacing a true outage within a couple of
# minutes. Override is intentionally not exposed: keep the check deterministic
# and identical across deployments so support diagnostics are reproducible.
GATEWAY_FRESHNESS_THRESHOLD_S: float = 120.0
def _checked_at() -> str:
return datetime.now(timezone.utc).isoformat()
def _runtime_status_is_fresh(
runtime_status: dict[str, Any] | None,
*,
now: datetime | None = None,
threshold_s: float = GATEWAY_FRESHNESS_THRESHOLD_S,
) -> bool:
"""Return ``True`` when ``gateway_state.json`` looks freshly written.
"Fresh" means the gateway self-reported ``running`` and the ``updated_at``
ISO-8601 timestamp is no older than ``threshold_s`` seconds. This is the
cross-container liveness signal used when ``get_running_pid()`` returns
``None`` purely because of PID-namespace isolation (#1879).
Any unparseable input is treated as "not fresh" — a stale or missing
timestamp must never report alive.
"""
if not isinstance(runtime_status, dict):
return False
if runtime_status.get("gateway_state") != "running":
return False
raw_updated_at = runtime_status.get("updated_at")
if not isinstance(raw_updated_at, str) or not raw_updated_at:
return False
# ``datetime.fromisoformat`` accepts the exact format gateway/status.py
# writes (``datetime.now(timezone.utc).isoformat()``). We deliberately
# don't pull in dateutil — keeping this stdlib-only matches the rest of
# this module.
try:
updated_at = datetime.fromisoformat(raw_updated_at)
except (TypeError, ValueError):
return False
if updated_at.tzinfo is None:
# A naive timestamp could mean anything across containers / hosts.
# Refuse to interpret it rather than assume UTC.
return False
reference = now if now is not None else datetime.now(timezone.utc)
age_s = (reference - updated_at).total_seconds()
if age_s < 0:
# Clock skew between containers can produce small negatives. A future
# timestamp is still a "fresh" signal — the gateway clearly wrote it
# very recently — so accept it. A wildly-future timestamp (> threshold
# in the future) is rejected to avoid trusting a broken clock.
return -age_s <= threshold_s
return age_s <= threshold_s
def _runtime_status_is_stale_stopped(
runtime_status: dict[str, Any] | None,
*,
now: datetime | None = None,
threshold_s: float = GATEWAY_FRESHNESS_THRESHOLD_S,
) -> bool:
"""Return ``True`` for an old clean-stop root gateway state.
A user may run only profile-scoped gateways while a root
``gateway_state.json`` from an older, intentionally stopped gateway remains
on disk (#1944). Treat that stale stopped file like "no root gateway
configured" so the heartbeat banner does not keep warning about a service
the user is not running. Fresh stopped state still reports down.
"""
if not isinstance(runtime_status, dict):
return False
if runtime_status.get("gateway_state") != "stopped":
return False
raw_updated_at = runtime_status.get("updated_at")
if not isinstance(raw_updated_at, str) or not raw_updated_at:
return False
try:
updated_at = datetime.fromisoformat(raw_updated_at)
except (TypeError, ValueError):
return False
if updated_at.tzinfo is None:
return False
reference = now if now is not None else datetime.now(timezone.utc)
age_s = (reference - updated_at).total_seconds()
return age_s > threshold_s
def _runtime_status_is_stale_running(
runtime_status: dict[str, Any] | None,
*,
now: datetime | None = None,
threshold_s: float = GATEWAY_FRESHNESS_THRESHOLD_S,
) -> bool:
"""Return ``True`` when the gateway last self-reported running, but stale.
WebUI often runs in a separate container from the gateway. In that shape PID
checks can be impossible, and older gateway versions only update
``gateway_state.json`` on lifecycle/platform changes. A stale ``running``
file therefore means "not enough information from WebUI" rather than
"gateway is down".
"""
if not isinstance(runtime_status, dict):
return False
if runtime_status.get("gateway_state") != "running":
return False
raw_updated_at = runtime_status.get("updated_at")
if not isinstance(raw_updated_at, str) or not raw_updated_at:
return False
try:
updated_at = datetime.fromisoformat(raw_updated_at)
except (TypeError, ValueError):
return False
if updated_at.tzinfo is None:
return False
reference = now if now is not None else datetime.now(timezone.utc)
age_s = (reference - updated_at).total_seconds()
return age_s > threshold_s
def _gateway_status_module():
"""Load gateway.status lazily so tests and WebUI-only installs stay isolated."""
return importlib.import_module("gateway.status")
def _gateway_root_pid_path() -> Path | None:
"""Return the root Hermes gateway PID path.
Gateway runtime files are root-level singletons. A profile-scoped WebUI
process may have HERMES_HOME=<root>/profiles/<name>, but gateway.pid,
gateway.lock, and gateway_state.json still live under <root>.
When the root-level gateway.pid is absent (profile-scoped gateway
deployments write it under <root>/profiles/<name>/), fall back to the
active profile's directory so the gateway is detected correctly.
"""
try:
from hermes_constants import get_default_hermes_root
root_pid = get_default_hermes_root() / _GATEWAY_PID_FILE
if root_pid.exists():
return root_pid
try:
from api.profiles import get_active_hermes_home
profile_pid = Path(get_active_hermes_home()) / _GATEWAY_PID_FILE
if profile_pid.exists():
return profile_pid
except Exception:
pass
return root_pid
except Exception:
return None
def _read_runtime_status_path(path: Path) -> dict[str, Any] | None:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return None
if isinstance(payload, dict):
return payload
return None
def _read_gateway_runtime_status(gateway_status: Any, pid_path: Path | None) -> dict[str, Any] | None:
read_runtime_status = gateway_status.read_runtime_status
if pid_path is not None:
try:
return read_runtime_status(pid_path=pid_path)
except TypeError:
try:
return read_runtime_status(pid_path)
except TypeError:
if getattr(gateway_status, "__name__", "") == "gateway.status" or hasattr(
gateway_status,
"_read_json_file",
):
runtime_status_file = str(
getattr(gateway_status, "_RUNTIME_STATUS_FILE", _GATEWAY_RUNTIME_STATUS_FILE)
)
runtime_status = _read_runtime_status_path(pid_path.with_name(runtime_status_file))
if runtime_status is not None:
return runtime_status
return read_runtime_status()
def _gateway_running_pid(gateway_status: Any, pid_path: Path | None) -> int | None:
get_running_pid = gateway_status.get_running_pid
if pid_path is not None:
try:
return get_running_pid(pid_path=pid_path, cleanup_stale=False)
except TypeError:
try:
return get_running_pid(pid_path, cleanup_stale=False)
except TypeError:
pass
try:
return get_running_pid(cleanup_stale=False)
except TypeError:
# Older agent versions may not expose cleanup_stale. Keep compatibility.
return get_running_pid()
def _runtime_detail_subset(runtime_status: dict[str, Any] | None) -> dict[str, Any]:
"""Return only non-sensitive runtime fields for the browser.
@@ -63,6 +290,168 @@ def _runtime_detail_subset(runtime_status: dict[str, Any] | None) -> dict[str, A
return details
# Remote-gateway probe (#3281)
# ------------------------------------------------------------------
# In multi-container Docker deployments the WebUI container does not ship the
# ``gateway`` Python package. The lazy ``importlib.import_module("gateway.status")``
# therefore raises ``ModuleNotFoundError`` and the payload falls through to
# ``gateway_not_configured`` even though ``HERMES_API_URL`` points at a perfectly
# reachable remote gateway. The Tasks/Cron banner then shows a spurious amber
# "Gateway not configured" warning.
#
# When a gateway base URL is set in any supported env var, we treat that as an
# explicit declaration that the gateway lives elsewhere, and probe it over HTTP
# before touching any local filesystem / module signal. The probe result is
# cached briefly so a dashboard rerender that fans out to multiple panels does
# not hammer the gateway.
_REMOTE_PROBE_TIMEOUT_S: float = 2.0
_REMOTE_PROBE_CACHE_TTL_S: float = 5.0
_REMOTE_PROBE_PATHS: tuple[str, ...] = ("/health/detailed", "/health", "/v1/health")
# A gateway health payload is small JSON; cap the 2xx body read so a large or
# slow-trickled remote response can't hang /api/health/agent or balloon memory.
_REMOTE_PROBE_BODY_LIMIT_BYTES: int = 64 * 1024
_remote_probe_lock = threading.Lock()
_remote_probe_cache: dict[str, Any] = {"url": None, "expires_at": 0.0, "result": None}
def _remote_gateway_base_url() -> str | None:
"""Return an explicit remote gateway base URL, or None for local-only setups.
Priority: GATEWAY_HEALTH_URL > HERMES_GATEWAY_HEALTH_URL > HERMES_API_URL
> HERMES_WEBUI_GATEWAY_BASE_URL.
Returns ``None`` when no env var is set so the caller falls through to
local PID/state checks.
Any of these env vars may legitimately point AT a health endpoint
(e.g. ``GATEWAY_HEALTH_URL=http://host:8642/health``). Since the probe
appends ``/health/detailed`` etc. to the returned base, strip a trailing
health-path suffix first so we don't build ``/health/health/detailed``
(mirrors the normalization in api/updates.py).
"""
for var in (
"GATEWAY_HEALTH_URL",
"HERMES_GATEWAY_HEALTH_URL",
"HERMES_API_URL",
"HERMES_WEBUI_GATEWAY_BASE_URL",
):
val = os.environ.get(var, "").strip()
if val:
base = val.rstrip("/")
for suffix in ("/health/detailed", "/health", "/v1/health", "/status"):
if base.endswith(suffix):
base = base[: -len(suffix)].rstrip("/")
break
return base
return None
def _http_probe(url: str, timeout_s: float) -> tuple[bool, int | None, str | None, bytes | None]:
"""GET ``url`` and return (ok, status_code, error_name, body).
``ok`` is True only for a 2xx response. 5xx and network errors are not OK.
4xx is also treated as "responded" (the gateway is up, just answering 404
on this particular path) so the caller can move on to the next path.
``body`` is the raw response bytes for 2xx responses, None otherwise.
"""
req = urllib_request.Request(url, method="GET")
try:
with urllib_request.urlopen(req, timeout=timeout_s) as resp: # noqa: S310 - trusted env var URL
status = getattr(resp, "status", None) or resp.getcode()
ok = 200 <= int(status) < 300
# Cap the body read: we only need a small JSON health payload, and an
# unbounded resp.read() on a large/trickled 2xx body could hang the
# /api/health/agent handler or balloon memory. Read one byte over the
# cap so the caller can detect (and skip) an oversized body.
body = resp.read(_REMOTE_PROBE_BODY_LIMIT_BYTES + 1) if ok else None
return (ok, int(status), None, body)
except urllib_error.HTTPError as exc:
return (False, int(exc.code), "HTTPError", None)
except Exception as exc: # urllib_error.URLError, socket.timeout, ssl, etc.
return (False, None, type(exc).__name__, None)
def _probe_remote_gateway(base_url: str, *, now: float | None = None) -> dict[str, Any]:
"""Return an agent-health payload dict for a remote gateway base URL.
Result is cached for ``_REMOTE_PROBE_CACHE_TTL_S`` seconds per base_url.
"""
current = time.monotonic() if now is None else now
with _remote_probe_lock:
if (
_remote_probe_cache.get("url") == base_url
and _remote_probe_cache.get("expires_at", 0.0) > current
and _remote_probe_cache.get("result") is not None
):
cached = _remote_probe_cache["result"]
# Refresh checked_at so the UI shows a current timestamp without
# actually re-hitting the gateway.
return {**cached, "checked_at": _checked_at()}
last_status: int | None = None
last_error: str | None = None
for path in _REMOTE_PROBE_PATHS:
ok, status, err, body = _http_probe(base_url + path, _REMOTE_PROBE_TIMEOUT_S)
if ok:
details: dict[str, Any] = {
"state": "alive",
"reason": "remote_gateway",
"endpoint": base_url + path,
"status_code": status,
}
if body and len(body) <= _REMOTE_PROBE_BODY_LIMIT_BYTES:
try:
data = json.loads(body)
if isinstance(data, dict) and "gateway_state" in data:
details["gateway_state"] = data["gateway_state"]
except (json.JSONDecodeError, UnicodeDecodeError):
pass
# An over-cap body (len > limit, i.e. the +1 sentinel byte was read)
# is treated as "alive but no parseable gateway_state" — we still
# report the gateway as up, just without the detailed state.
payload = {
"alive": True,
"checked_at": _checked_at(),
"details": details,
}
break
# Remember the most informative failure signal we saw.
if status is not None:
last_status = status
if err is not None:
last_error = err
else:
details: dict[str, Any] = {
"state": "down",
"reason": "remote_gateway_unreachable",
"endpoint": base_url,
}
if last_status is not None:
details["status_code"] = last_status
if last_error is not None:
details["error"] = last_error
payload = {
"alive": False,
"checked_at": _checked_at(),
"details": details,
}
with _remote_probe_lock:
_remote_probe_cache["url"] = base_url
_remote_probe_cache["expires_at"] = current + _REMOTE_PROBE_CACHE_TTL_S
_remote_probe_cache["result"] = payload
return payload
def _reset_remote_probe_cache_for_tests() -> None:
"""Test hook: clear the in-process remote-probe cache."""
with _remote_probe_lock:
_remote_probe_cache["url"] = None
_remote_probe_cache["expires_at"] = 0.0
_remote_probe_cache["result"] = None
def build_agent_health_payload() -> dict[str, Any]:
"""Return `{alive, checked_at, details}` for the Hermes gateway/agent.
@@ -73,6 +462,16 @@ def build_agent_health_payload() -> dict[str, Any]:
probably not configured with a separate gateway process.
"""
checked_at = _checked_at()
# Multi-container deployments (#3281): when HERMES_API_URL is set the
# gateway lives in another container/host. Probe it over HTTP before
# touching local module/pid/state-file signals, otherwise a missing
# ``gateway`` Python package in this image masquerades as
# "gateway_not_configured" and produces a spurious banner.
remote_base = _remote_gateway_base_url()
if remote_base is not None:
return _probe_remote_gateway(remote_base)
try:
gateway_status = _gateway_status_module()
except Exception as exc:
@@ -86,17 +485,16 @@ def build_agent_health_payload() -> dict[str, Any]:
},
}
gateway_pid_path = _gateway_root_pid_path()
runtime_status = None
try:
runtime_status = gateway_status.read_runtime_status()
runtime_status = _read_gateway_runtime_status(gateway_status, gateway_pid_path)
except Exception:
runtime_status = None
try:
running_pid = gateway_status.get_running_pid(cleanup_stale=False)
except TypeError:
# Older agent versions may not expose cleanup_stale. Keep compatibility.
running_pid = gateway_status.get_running_pid()
running_pid = _gateway_running_pid(gateway_status, gateway_pid_path)
except Exception:
running_pid = None
@@ -111,6 +509,45 @@ def build_agent_health_payload() -> dict[str, Any]:
},
}
# Cross-container fallback (#1879): when ``get_running_pid()`` cannot see
# the gateway because we're in a different PID namespace, a recent
# ``updated_at`` on ``gateway_state.json`` is a reliable equivalent signal
# since the gateway writes it on every tick. We only trust this fallback
# when the gateway also self-reports ``gateway_state == "running"`` so
# crash-without-cleanup scenarios still surface as "down".
if _runtime_status_is_fresh(runtime_status):
return {
"alive": True,
"checked_at": checked_at,
"details": {
"state": "alive",
"reason": "cross_container_freshness",
**safe_details,
},
}
if _runtime_status_is_stale_stopped(runtime_status):
return {
"alive": None,
"checked_at": checked_at,
"details": {
"state": "unknown",
"reason": "gateway_stale_stopped_state",
**safe_details,
},
}
if _runtime_status_is_stale_running(runtime_status):
return {
"alive": None,
"checked_at": checked_at,
"details": {
"state": "unknown",
"reason": "gateway_stale_running_state",
**safe_details,
},
}
if isinstance(runtime_status, dict):
return {
"alive": False,

View File

@@ -9,6 +9,9 @@ logger = logging.getLogger(__name__)
MESSAGING_SOURCES = {
'discord',
'email',
'wecom',
'wecom_callback',
'slack',
'telegram',
'weixin',
@@ -22,6 +25,9 @@ SOURCE_LABELS = {
'cli': 'CLI',
'cron': 'Cron',
'discord': 'Discord',
'email': 'Email',
'wecom': 'WeCom',
'wecom_callback': 'WeCom Callback',
'slack': 'Slack',
'telegram': 'Telegram',
'tool': 'Tool',
@@ -114,6 +120,26 @@ def _as_positive_int(value) -> int:
return 0
def _as_score(*values) -> float:
"""First numerically-coercible value as a float, else 0.0.
Used to score lineage tips by recency. ``last_message_at`` comes from
``MAX(timestamp)`` and is normally a numeric epoch, but older/non-standard
state.db schemas can store an ISO-8601 *text* timestamp. Rather than letting
a non-numeric value raise ValueError (which previously escaped the DB
try-block and dropped all lineage metadata), fall through to the next
candidate (e.g. ``started_at``).
"""
for value in values:
if value in (None, ""):
continue
try:
return float(value)
except (TypeError, ValueError):
continue
return 0.0
def _count_user_turns(row: dict) -> int:
user_turns = row.get("actual_user_message_count")
if user_turns is None:
@@ -140,14 +166,16 @@ def is_cli_session_row(row: dict) -> bool:
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 "webui" in {source, source_tag, raw_source, source_name, source_label}:
return False
if source == "messaging":
return False
if source == "cli":
return True
if source_tag == "cli" or raw_source == "cli" or source_name == "cli" or source_label == "cli":
return True
@@ -199,6 +227,8 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
"""
if not parent or not child:
return False
if str(child.get('session_source') or '').strip().lower() == 'fork':
return False
parent_source = str(parent.get('source') or '').strip().lower()
child_source = str(child.get('source') or '').strip().lower()
if parent_source and child_source and parent_source != child_source:
@@ -269,23 +299,50 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
children.sort(key=lambda row: row.get('started_at') or 0, reverse=True)
def compression_tip(row: dict) -> tuple[dict | None, int]:
current = row
seen = {row['id']}
"""Return the freshest importable continuation descendant for ``row``.
Compression parents can have multiple continuation-looking children when
a stale segment is resumed after a newer compressed branch already
exists. Picking the newest *direct* child can hide the branch whose
deeper descendant has the actual latest activity. Walk all reachable
continuation descendants and select by real message activity instead.
"""
latest_importable = row if (row.get('actual_message_count') or 0) > 0 else None
segment_count = 1
for _ in range(len(rows_by_id) + 1):
candidates = [
child for child in children_by_parent.get(current['id'], [])
if child['id'] not in seen and _is_continuation_session(current, child)
]
if not candidates:
return latest_importable, segment_count
current = candidates[0]
seen.add(current['id'])
segment_count = 0
best_depth = 1
best_score = (
_as_score(latest_importable.get('last_activity'), latest_importable.get('started_at'))
if latest_importable
else 0
)
stack: list[tuple[dict, int]] = [(row, 1)]
seen: set[str] = set()
while stack:
current, depth = stack.pop()
current_id = current.get('id')
if not current_id or current_id in seen:
continue
seen.add(current_id)
segment_count += 1
if (current.get('actual_message_count') or 0) > 0:
current_score = _as_score(current.get('last_activity'), current.get('started_at'))
if (
(current.get('actual_message_count') or 0) > 0
and (current_score > best_score or (current_score == best_score and depth >= best_depth))
):
latest_importable = current
return latest_importable, segment_count
best_depth = depth
best_score = current_score
for child in children_by_parent.get(current_id, []):
child_id = child.get('id')
if not child_id or child_id in seen:
continue
if not _is_continuation_session(current, child):
continue
stack.append((child, depth + 1))
return latest_importable, max(segment_count, 1)
projected = []
for row in rows:
@@ -328,7 +385,7 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
projected.append(merged)
projected.sort(
key=lambda row: row.get('last_activity') or row.get('started_at') or 0,
key=lambda row: _as_score(row.get('last_activity'), row.get('started_at')),
reverse=True,
)
return projected
@@ -336,7 +393,7 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
def read_importable_agent_session_rows(
db_path: Path,
limit: int = 200,
limit: int | None = 200,
log=None,
exclude_sources: tuple[str, ...] | None = ("cron", "webui"),
) -> list[dict]:
@@ -379,6 +436,7 @@ def read_importable_agent_session_rows(
return []
parent_expr = _optional_col('parent_session_id', session_cols)
session_source_expr = _optional_col('session_source', session_cols)
ended_expr = _optional_col('ended_at', session_cols)
end_reason_expr = _optional_col('end_reason', session_cols)
user_id_expr = _optional_col('user_id', session_cols)
@@ -389,14 +447,76 @@ 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)"
)
# Older/minimal state.db schemas can have NO ``messages`` table at all,
# or a ``messages`` table without a ``session_id`` / ``timestamp`` column.
# The projection SQL below joins ``messages`` and aggregates
# ``MAX(m.timestamp)`` unconditionally, so on those schemas the query
# raised ``sqlite3.OperationalError`` — which the caller
# (``get_cli_sessions``) swallows into an empty list, silently hiding
# ALL imported/CLI/agent sessions from the sidebar. Detect the columns
# and degrade gracefully (mirrors ``read_session_lineage_metadata``):
# only join/aggregate ``messages`` when it's actually usable, otherwise
# fall back to the per-session ``s.message_count`` / ``s.started_at``. (#3762)
messages_has_session_id = 'session_id' in message_cols
messages_has_timestamp = 'timestamp' in message_cols
use_messages_join = messages_has_session_id
count_col = 'id' if 'id' in message_cols else 'session_id'
# Defensive index prime (#3887). The candidate-ordering query below sorts
# sessions by a correlated ``MAX(mx.timestamp)`` subquery over ``messages``.
# That is fast only when the agent's standard
# ``idx_messages_session ON messages(session_id, timestamp)`` index exists.
# A normally-migrated hermes-agent state.db has it, but a db that lost its
# migrations (older hermes-agent, or a hand-rebuilt/reimported db) does
# not — and the subquery then degrades to a full ``messages`` scan per
# candidate session, stalling ``/api/sessions`` for seconds on every
# refresh (the 5s-TTL cache never settles). Priming the index is a no-op
# (~free) when it already exists, and self-heals an affected db in
# milliseconds. Best-effort: degrade silently on a read-only db or any
# error so the listing never fails because of the prime.
if messages_has_session_id and messages_has_timestamp:
try:
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_messages_session "
"ON messages(session_id, timestamp)"
)
conn.commit()
except sqlite3.Error:
pass # read-only db / locked / older schema — degrade gracefully
if use_messages_join:
actual_count_expr = f"COUNT(m.{count_col})"
if 'role' in message_cols:
user_message_count_expr = "COUNT(CASE WHEN LOWER(m.role) = 'user' THEN 1 END)"
else:
user_message_count_expr = f"COUNT(m.{count_col})"
last_activity_expr = "MAX(m.timestamp)" if messages_has_timestamp else "NULL"
join_clause = "LEFT JOIN messages m ON m.session_id = s.id"
group_by_clause = "GROUP BY s.id"
else:
# No usable messages table: use the denormalized per-session counts
# and ``started_at`` so the rows still surface in the sidebar.
actual_count_expr = "s.message_count"
user_message_count_expr = "s.message_count"
last_activity_expr = "NULL"
join_clause = ""
group_by_clause = ""
if use_messages_join and messages_has_timestamp:
order_by_clause = "ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC"
candidate_order_clause = (
"ORDER BY COALESCE(\n"
" (SELECT MAX(mx.timestamp) FROM messages mx WHERE mx.session_id = s.id),\n"
" s.started_at\n"
" ) DESC,\n"
" s.started_at DESC"
)
else:
order_by_clause = "ORDER BY s.started_at DESC"
candidate_order_clause = "ORDER BY s.started_at DESC"
where_clauses = ["s.source IS NOT NULL"]
params: list[str] = []
params: list[object] = []
if exclude_sources:
excluded = tuple(str(source) for source in exclude_sources if source)
if excluded:
@@ -404,10 +524,10 @@ def read_importable_agent_session_rows(
where_clauses.append(f"s.source NOT IN ({placeholders})")
params.extend(excluded)
cur.execute(
f"""
select_sql = f"""
SELECT s.id, s.title, s.model, s.message_count,
s.started_at, s.source,
{session_source_expr},
{user_id_expr},
{chat_id_expr},
{chat_type_expr},
@@ -419,17 +539,53 @@ def read_importable_agent_session_rows(
{parent_expr},
{ended_expr},
{end_reason_expr},
COUNT(m.id) AS actual_message_count,
{actual_count_expr} 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
WHERE {' AND '.join(where_clauses)}
GROUP BY s.id
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
""",
params,
)
{last_activity_expr} AS last_activity
"""
if limit is not None:
result_limit = max(0, int(limit))
if result_limit == 0:
return []
# The sidebar only needs a small visible window. Bound the expensive
# messages join to a recent-activity candidate set instead of
# aggregating every historical Hermes state.db session before
# slicing in Python. The candidate ordering must include the latest
# message timestamp, not only ``started_at``: long-lived CLI sessions
# can be resumed days later and should still surface at the top.
# Oversampling preserves room for hidden compression segments or
# other rows filtered after projection.
candidate_limit = max(result_limit * 8, result_limit)
cur.execute(
f"""
WITH candidates AS (
SELECT s.id
FROM sessions s
WHERE {' AND '.join(where_clauses)}
{candidate_order_clause}
LIMIT ?
)
{select_sql}
FROM sessions s
JOIN candidates c ON c.id = s.id
{join_clause}
{group_by_clause}
{order_by_clause}
""",
[*params, candidate_limit],
)
else:
cur.execute(
f"""
{select_sql}
FROM sessions s
{join_clause}
WHERE {' AND '.join(where_clauses)}
{group_by_clause}
{order_by_clause}
""",
params,
)
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)]
@@ -439,6 +595,175 @@ def read_importable_agent_session_rows(
def _lineage_report_row(row: dict, role: str) -> dict:
updated_at = row.get('ended_at') if row.get('ended_at') is not None else row.get('started_at')
return {
'session_id': row.get('id'),
'role': role,
'title': row.get('title'),
'source': row.get('source'),
'started_at': row.get('started_at'),
'updated_at': updated_at,
'end_reason': row.get('end_reason'),
'active': row.get('ended_at') is None,
'archived': False,
}
def _empty_lineage_report(session_id: str, *, found: bool = False) -> dict:
return {
'mutation': False,
'found': found,
'session_id': session_id,
'lineage_key': session_id,
'tip_session_id': session_id,
'total_segments': 0,
'materialized_segments': 0,
'segments': [],
'children': [],
'manual_review': False,
}
def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops: int = 20) -> dict:
"""Return a bounded, read-only lifecycle report for a session lineage.
This helper intentionally reports only facts that can be derived from
``state.db.sessions`` without mutating WebUI JSON, archiving rows, or
deleting historical segments. It mirrors the sidebar continuation rules so
a future UI/PR can explain which rows are hidden compression/cli-close
segments and which child-session branches remain distinct.
"""
sid = str(session_id or '').strip()
if not sid:
return _empty_lineage_report('')
db_path = Path(db_path)
if not db_path.exists():
return _empty_lineage_report(sid)
try:
with closing(sqlite3.connect(str(db_path))) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("PRAGMA table_info(sessions)")
session_cols = {row[1] for row in cur.fetchall()}
required = {'id', 'parent_session_id', 'end_reason'}
if not required.issubset(session_cols):
return _empty_lineage_report(sid)
source_expr = _optional_col('source', session_cols)
session_source_expr = _optional_col('session_source', session_cols)
title_expr = _optional_col('title', session_cols)
started_expr = _optional_col('started_at', session_cols, '0')
ended_expr = _optional_col('ended_at', session_cols)
end_reason_expr = _optional_col('end_reason', session_cols)
parent_expr = _optional_col('parent_session_id', session_cols)
def fetch_one(row_id: str | None) -> dict | None:
if not row_id:
return None
cur.execute(
f"""
SELECT s.id,
{source_expr},
{session_source_expr},
{title_expr},
{started_expr},
{parent_expr},
{ended_expr},
{end_reason_expr}
FROM sessions s
WHERE s.id = ?
""",
(row_id,),
)
row = cur.fetchone()
return dict(row) if row else None
target = fetch_one(sid)
if not target:
return _empty_lineage_report(sid)
segments = [target]
current = target
seen = {sid}
manual_review = False
for _hop in range(max(0, int(max_hops))):
parent_id = current.get('parent_session_id')
parent = fetch_one(parent_id)
if not parent or parent_id in seen:
manual_review = bool(parent_id and parent_id in seen)
break
if not _is_continuation_session(parent, current):
break
segments.append(parent)
seen.add(parent_id)
current = parent
else:
manual_review = True
segment_ids = {row['id'] for row in segments}
child_rows: list[dict] = []
parent_ids = [row['id'] for row in segments]
children_by_parent: dict[str, list[dict]] = {pid: [] for pid in parent_ids}
if parent_ids:
placeholders = ','.join('?' * len(parent_ids))
cur.execute(
f"""
SELECT s.id,
{source_expr},
{session_source_expr},
{title_expr},
{started_expr},
{parent_expr},
{ended_expr},
{end_reason_expr}
FROM sessions s
WHERE s.parent_session_id IN ({placeholders})
""",
parent_ids,
)
for child_row in cur.fetchall():
child = dict(child_row)
parent_id = child.get('parent_session_id')
if parent_id in children_by_parent:
children_by_parent[parent_id].append(child)
for parent in segments:
parent_children = children_by_parent.get(parent['id'], [])
parent_children.sort(key=lambda row: row.get('started_at') or 0, reverse=True)
for child in parent_children:
if child['id'] in segment_ids:
continue
if _is_continuation_session(parent, child):
# A continuation outside the selected path means the
# lineage is branched or the caller selected an older
# segment. Report manual review rather than proposing
# destructive cleanup candidates.
manual_review = True
continue
child_rows.append(child)
except Exception:
return _empty_lineage_report(sid)
root_id = segments[-1]['id'] if segments else sid
tip_id = segments[0]['id'] if segments else sid
return {
'mutation': False,
'found': True,
'session_id': sid,
'lineage_key': root_id,
'tip_session_id': tip_id,
'total_segments': len(segments),
'materialized_segments': len(segments),
'segments': [
_lineage_report_row(row, 'tip' if idx == 0 else 'hidden_segment')
for idx, row in enumerate(segments)
],
'children': [_lineage_report_row(row, 'child_session') for row in child_rows],
'manual_review': manual_review,
}
def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[str]) -> dict[str, dict]:
"""Return compression-lineage metadata for known WebUI sidebar sessions.
@@ -463,6 +788,9 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st
session_cols = {row[1] for row in cur.fetchall()}
if 'parent_session_id' not in session_cols or 'end_reason' not in session_cols:
return {}
session_source_expr = _optional_col('session_source', session_cols)
source_expr = _optional_col('source', session_cols)
message_count_expr = _optional_col('message_count', session_cols, '0')
# Scoped fetch via PRIMARY KEY + idx_sessions_parent rather than a
# full table scan. The sessions table grows unbounded over time
# (1000+ rows is normal, 10000+ for power users), and this function
@@ -471,7 +799,9 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st
#
# Fetch the wanted ids first, then chase parent_session_id chains
# in batches until no new ids appear. Each batch hits PRIMARY KEY
# so it's effectively O(N) lookups.
# so it's effectively O(N) lookups. Then walk continuation children
# from the materialized ancestors so branchy compression lineages can
# mark the real freshest tip, not just the newest direct sibling.
#
# IN-clause is chunked to 500 to stay under SQLITE_MAX_VARIABLE_NUMBER
# on older sqlite (Python 3.9 ships sqlite 3.31 which defaults to 999;
@@ -496,9 +826,9 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st
placeholders = ','.join('?' * len(chunk))
cur.execute(
f"""
SELECT id, source, title, started_at, parent_session_id, ended_at, end_reason
FROM sessions
WHERE id IN ({placeholders})
SELECT s.id, {source_expr}, {session_source_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr}
FROM sessions s
WHERE s.id IN ({placeholders})
""",
chunk,
)
@@ -509,15 +839,156 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st
parent_id = rows.get(sid, {}).get('parent_session_id')
if parent_id and parent_id not in rows and parent_id not in to_fetch:
to_fetch.add(parent_id)
# Fetch descendants from the discovered ancestors using the parent
# index. This keeps the sidebar read scoped while still giving the
# collapse metadata enough information to choose the active branch.
to_expand = set(rows)
expanded: set[str] = set()
for _hop in range(20):
frontier = [sid for sid in to_expand if sid not in expanded]
if not frontier:
break
to_expand = set()
for i in range(0, len(frontier), IN_CHUNK):
chunk = frontier[i:i + IN_CHUNK]
placeholders = ','.join('?' * len(chunk))
cur.execute(
f"""
SELECT s.id, {source_expr}, {session_source_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr}
FROM sessions s
WHERE s.parent_session_id IN ({placeholders})
""",
chunk,
)
for row in cur.fetchall():
child = dict(row)
rows[child['id']] = child
parent_id = child.get('parent_session_id')
parent = rows.get(str(parent_id)) if parent_id else None
if parent and child['id'] not in expanded and _is_continuation_session(parent, child):
to_expand.add(child['id'])
expanded.update(frontier)
message_stats: dict[str, dict] = {}
cur.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'messages'")
has_messages_table = cur.fetchone() is not None
# Older/minimal state.db schemas can have a `messages` table WITHOUT a
# `timestamp` column (or with a non-numeric one). Detect the columns
# rather than gating on table existence alone: require `session_id`,
# and only select MAX(timestamp) when that column is actually present
# so the query can't raise and collapse the whole lineage metadata.
messages_has_session_id = False
messages_has_timestamp = False
if has_messages_table:
cur.execute("PRAGMA table_info(messages)")
_message_cols = {row[1] for row in cur.fetchall()}
messages_has_session_id = 'session_id' in _message_cols
messages_has_timestamp = 'timestamp' in _message_cols
use_messages_query = has_messages_table and messages_has_session_id
row_ids = list(rows)
if use_messages_query:
last_at_expr = "MAX(timestamp) AS last_message_at" if messages_has_timestamp else "NULL AS last_message_at"
for i in range(0, len(row_ids), IN_CHUNK):
chunk = row_ids[i:i + IN_CHUNK]
placeholders = ','.join('?' * len(chunk))
cur.execute(
f"""
SELECT session_id, COUNT(*) AS actual_message_count, {last_at_expr}
FROM messages
WHERE session_id IN ({placeholders})
GROUP BY session_id
""",
chunk,
)
for row in cur.fetchall():
message_stats[row['session_id']] = dict(row)
for sid, row in rows.items():
stats = message_stats.get(sid) or {}
if use_messages_query:
row['actual_message_count'] = int(stats.get('actual_message_count') or 0)
else:
row['actual_message_count'] = int(row.get('message_count') or 0)
row['last_message_at'] = stats.get('last_message_at')
except Exception:
return {}
children_by_parent: dict[str, list[dict]] = {}
for row in rows.values():
parent_id = row.get('parent_session_id')
if parent_id:
children_by_parent.setdefault(parent_id, []).append(row)
def continuation_root_and_depth(sid: str) -> tuple[str, int]:
root_id = sid
current_id = sid
depth = 1
seen = {sid}
while True:
current = rows.get(current_id)
raw_parent_id = current.get('parent_session_id') if current else None
parent_id = str(raw_parent_id) if raw_parent_id else ''
if not parent_id:
break
parent = rows.get(parent_id)
if not parent or parent_id in seen:
break
if not _is_continuation_session(parent, current):
break
root_id = parent_id
current_id = parent_id
seen.add(parent_id)
depth += 1
return root_id, depth
def freshest_continuation_tip(root_id: str) -> tuple[str, int]:
best_id = root_id
best_depth = 1
segment_count = 0
best_score = _as_score(rows.get(root_id, {}).get('last_message_at'), rows.get(root_id, {}).get('started_at'))
stack: list[tuple[str, int]] = [(root_id, 1)]
seen: set[str] = set()
while stack:
current_id, depth = stack.pop()
if current_id in seen:
continue
seen.add(current_id)
current = rows.get(current_id)
if not current:
continue
segment_count += 1
actual_count = int(current.get('actual_message_count') or 0)
score = _as_score(current.get('last_message_at'), current.get('started_at'))
if actual_count > 0 and (score > best_score or (score == best_score and depth >= best_depth)):
best_id = current_id
best_depth = depth
best_score = score
for child in children_by_parent.get(current_id, []):
if _is_continuation_session(current, child):
stack.append((child['id'], depth + 1))
return best_id, max(segment_count, best_depth)
lineage_tip_cache: dict[str, tuple[str, int]] = {}
metadata: dict[str, dict] = {}
for sid in wanted:
row = rows.get(sid)
if not row:
continue
state_title = str(row.get('title') or '').strip()
if state_title:
metadata.setdefault(sid, {})['_state_db_title'] = state_title
state_source = str(row.get('source') or '').strip().lower()
if state_source:
entry = metadata.setdefault(sid, {})
entry['_state_db_source'] = state_source
source_meta = normalize_agent_session_source(state_source)
entry['_state_db_source_tag'] = state_source
entry['_state_db_raw_source'] = source_meta.get('raw_source')
entry['_state_db_session_source'] = source_meta.get('session_source')
entry['_state_db_source_label'] = source_meta.get('source_label')
parent_id = row.get('parent_session_id')
parent_row = rows.get(parent_id) if parent_id else None
if parent_id and parent_row:
@@ -527,31 +998,24 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st
entry['relationship_type'] = 'child_session'
entry['parent_title'] = parent_row.get('title')
entry['parent_source'] = parent_row.get('source')
parent_source = str(parent_row.get('source') or '').strip().lower()
child_source = str(row.get('source') or '').strip().lower()
if parent_source and child_source and parent_source != child_source:
entry['_cross_surface_child_session'] = True
parent_root = _continuation_root_id(rows, parent_id)
if parent_root:
entry['_parent_lineage_root_id'] = parent_root
continue
root_id = sid
current_id = sid
segment_count = 1
seen = {sid}
while True:
current = rows.get(current_id)
parent_id = current.get('parent_session_id') if current else None
parent = rows.get(parent_id) if parent_id else None
if not parent or parent_id in seen:
break
if not _is_continuation_session(parent, current):
break
root_id = parent_id
current_id = parent_id
seen.add(parent_id)
segment_count += 1
root_id, segment_count = continuation_root_and_depth(sid)
if root_id != sid:
entry = metadata.setdefault(sid, {})
entry['_lineage_root_id'] = root_id
entry['_compression_segment_count'] = segment_count
if root_id not in lineage_tip_cache:
lineage_tip_cache[root_id] = freshest_continuation_tip(root_id)
tip_id, tip_depth = lineage_tip_cache[root_id]
entry['_lineage_tip_id'] = tip_id
entry['_compression_segment_count'] = max(segment_count, tip_depth)
return metadata

View File

@@ -1,7 +1,7 @@
"""
Hermes Web UI -- Optional password authentication.
Off by default. Enable by setting HERMES_WEBUI_PASSWORD env var
or configuring a password in the Settings panel.
Hermes Web UI -- optional authentication.
Off by default. Enable by setting HERMES_WEBUI_PASSWORD, configuring a
password in Settings, or registering passkeys and then going passwordless.
"""
import hashlib
import hmac
@@ -9,23 +9,81 @@ import http.cookies
import json
import logging
import os
import re
import secrets
import tempfile
import threading
import time
from api.config import STATE_DIR, load_settings
logger = logging.getLogger(__name__)
# Default session TTL — 30 days. Kept as a module-level constant for backwards
# compatibility with downstream code and regression tests that import it.
# At runtime, prefer ``_resolve_session_ttl()`` which honours the env var and
# settings.json overrides; this constant is the floor / fallback.
SESSION_TTL = 86400 * 30 # 30 days
def _resolve_session_ttl() -> int:
"""Resolve session TTL from env > settings > default.
Priority mirrors get_password_hash(): HERMES_WEBUI_SESSION_TTL env var
first, then settings.json, falling back to ``SESSION_TTL`` (30 days).
Clamped to [60s, 1 year] to prevent runaway cookies or self-lockout.
"""
env_v = os.getenv('HERMES_WEBUI_SESSION_TTL', '').strip()
if env_v.isdigit():
val = int(env_v)
if 60 <= val <= 86400 * 365:
return val
s = load_settings()
v = s.get('session_ttl_seconds')
if isinstance(v, int) and 60 <= v <= 86400 * 365:
return v
return SESSION_TTL
# ── Public paths (no auth required) ─────────────────────────────────────────
PUBLIC_PATHS = frozenset({
'/login', '/health', '/favicon.ico',
'/login', '/health', '/favicon.ico', '/sw.js',
'/api/auth/login', '/api/auth/status',
'/api/auth/passkey/options', '/api/auth/passkey/login',
'/manifest.json', '/manifest.webmanifest',
'/session/manifest.json', '/session/manifest.webmanifest',
})
COOKIE_NAME = 'hermes_session'
SESSION_TTL = 86400 * 30 # 30 days
CSRF_HEADER_NAME = 'X-Hermes-CSRF-Token'
# RFC 6265 cookie-name token: a non-empty run of token chars
# (no controls, whitespace, or separators such as ';', '=', ',').
_COOKIE_NAME_RE = re.compile(r"^[-!#$%&'*+.^_`|~0-9A-Za-z]+$")
def _resolve_cookie_name() -> str:
"""Resolve the auth session cookie name from env > default.
Honours ``HERMES_WEBUI_COOKIE_NAME`` so multiple WebUI instances sharing a
hostname (different ports) can use distinct cookie names instead of
trampling each other's session — browsers scope cookies by host, not
host+port (RFC 6265). Falls back to ``COOKIE_NAME`` when the env var is
unset, empty, or not a valid RFC 6265 token.
"""
name = os.getenv('HERMES_WEBUI_COOKIE_NAME', '').strip()
if not name:
return COOKIE_NAME
if _COOKIE_NAME_RE.match(name):
return name
logger.warning(
'Ignoring invalid HERMES_WEBUI_COOKIE_NAME=%r; falling back to %r '
'(name must be a valid RFC 6265 token)', name, COOKIE_NAME,
)
return COOKIE_NAME
_SESSIONS_FILE = STATE_DIR / '.sessions.json'
@@ -75,125 +133,418 @@ def _save_sessions(sessions: dict[str, float]) -> None:
# Active sessions: token -> expiry timestamp (persisted across restarts via STATE_DIR)
_sessions = _load_sessions()
_SESSIONS_LOCK = threading.Lock()
# ── Login rate limiter ──────────────────────────────────────────────────────
_login_attempts = {} # ip -> [timestamp, ...]
_LOGIN_ATTEMPTS_FILE = STATE_DIR / '.login_attempts.json'
_LOGIN_MAX_ATTEMPTS = 5
_LOGIN_WINDOW = 60 # seconds
def _load_login_attempts() -> dict[str, list[float]]:
"""Load persisted login attempts from STATE_DIR, pruning expired entries."""
try:
if _LOGIN_ATTEMPTS_FILE.exists():
data = json.loads(_LOGIN_ATTEMPTS_FILE.read_text(encoding='utf-8'))
if not isinstance(data, dict):
raise ValueError('malformed login-attempts file — expected dict')
now = time.time()
attempts: dict[str, list[float]] = {}
for ip, raw_times in data.items():
if not isinstance(ip, str) or not isinstance(raw_times, list):
continue
fresh = [
float(t)
for t in raw_times
if isinstance(t, (int, float)) and now - float(t) < _LOGIN_WINDOW
]
if fresh:
attempts[ip] = fresh
return attempts
except Exception as e:
logger.debug("Failed to load login attempts file, starting fresh: %s", e)
return {}
def _save_login_attempts(attempts: dict[str, list[float]]) -> None:
"""Atomically persist login attempts to STATE_DIR/.login_attempts.json (0600)."""
try:
_LOGIN_ATTEMPTS_FILE.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=_LOGIN_ATTEMPTS_FILE.parent, suffix='.login_attempts.tmp')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(attempts, f)
os.chmod(tmp, 0o600)
os.replace(tmp, _LOGIN_ATTEMPTS_FILE)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
except Exception as e:
logger.debug("Failed to persist login attempts: %s", e)
_login_attempts = _load_login_attempts() # ip -> [timestamp, ...]
_LOGIN_ATTEMPTS_LOCK = threading.Lock()
def _check_login_rate(ip: str) -> bool:
"""Return True if the IP is allowed to attempt login."""
now = time.time()
attempts = _login_attempts.get(ip, [])
# Prune old attempts
attempts = [t for t in attempts if now - t < _LOGIN_WINDOW]
_login_attempts[ip] = attempts
return len(attempts) < _LOGIN_MAX_ATTEMPTS
"""Return True if the IP is allowed to attempt login (thread-safe)."""
with _LOGIN_ATTEMPTS_LOCK:
now = time.time()
attempts = _login_attempts.get(ip, [])
# Prune old attempts
attempts = [t for t in attempts if now - t < _LOGIN_WINDOW]
if attempts:
_login_attempts[ip] = attempts
else:
_login_attempts.pop(ip, None)
_save_login_attempts(_login_attempts)
return len(attempts) < _LOGIN_MAX_ATTEMPTS
def _record_login_attempt(ip: str) -> None:
now = time.time()
attempts = _login_attempts.get(ip, [])
attempts.append(now)
_login_attempts[ip] = attempts
"""Record a login attempt for rate limiting (thread-safe)."""
with _LOGIN_ATTEMPTS_LOCK:
now = time.time()
attempts = _login_attempts.get(ip, [])
attempts.append(now)
_login_attempts[ip] = attempts
_save_login_attempts(_login_attempts)
def _signing_key():
"""Return a random signing key, generating and persisting one on first call."""
key_file = STATE_DIR / '.signing_key'
def _clear_login_attempts(ip: str) -> None:
"""Clear failed login attempts after a successful login (thread-safe)."""
with _LOGIN_ATTEMPTS_LOCK:
if ip in _login_attempts:
_login_attempts.pop(ip, None)
_save_login_attempts(_login_attempts)
def _load_key(filename: str) -> bytes:
"""Load a 32-byte key from STATE_DIR, generating and persisting one if missing."""
key_file = STATE_DIR / filename
try:
if key_file.exists():
raw = key_file.read_bytes()
if len(raw) >= 32:
return raw[:32]
except Exception:
logger.debug("Failed to read or access signing key file, using in-memory key")
# Generate a new random key
except OSError:
logger.debug("Failed to read key %s", filename)
key = secrets.token_bytes(32)
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
key_file.write_bytes(key)
key_file.chmod(0o600)
except Exception:
logger.debug("Failed to persist signing key, using in-memory key only")
except OSError:
logger.debug("Failed to persist key %s", filename)
return key
def _hash_password(password):
_PBKDF2_KEY_CACHE: bytes | None = None
_SIGNING_KEY_CACHE: bytes | None = None
def _pbkdf2_key() -> bytes:
global _PBKDF2_KEY_CACHE
if _PBKDF2_KEY_CACHE is None:
_PBKDF2_KEY_CACHE = _load_key('.pbkdf2_key')
return _PBKDF2_KEY_CACHE
def _signing_key() -> bytes:
global _SIGNING_KEY_CACHE
if _SIGNING_KEY_CACHE is None:
_SIGNING_KEY_CACHE = _load_key('.signing_key')
return _SIGNING_KEY_CACHE
def _hash_password(password, *, salt: bytes | None = None) -> str:
"""PBKDF2-SHA256 with 600k iterations (OWASP recommendation).
Salt is the persisted random signing key, which is secret and unique per
Salt is the persisted PBKDF2 key, which is secret and unique per
installation. This keeps the stored hash format a plain hex string
(no format change to settings.json) while replacing the predictable
STATE_DIR-derived salt from the original implementation."""
salt = _signing_key()
STATE_DIR-derived salt from the original implementation.
The *salt* parameter exists solely to support transparent migration
of password hashes that were computed with a different key (e.g. the
old `.signing_key`). Normal callers should never pass it.
"""
if salt is None:
salt = _pbkdf2_key()
dk = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 600_000)
return dk.hex()
_AUTH_HASH_LOCK = threading.Lock()
_AUTH_HASH_COMPUTED: bool = False
_AUTH_HASH_CACHE: str | None = None
def _invalidate_password_hash_cache() -> None:
"""Invalidate the in-process password hash cache so the next call to
get_password_hash() re-reads from settings.json or the env var."""
global _AUTH_HASH_COMPUTED, _AUTH_HASH_CACHE
with _AUTH_HASH_LOCK:
_AUTH_HASH_COMPUTED = False
_AUTH_HASH_CACHE = None
def get_password_hash() -> str | None:
"""Return the active password hash, or None if auth is disabled.
Priority: env var > settings.json."""
env_pw = os.getenv('HERMES_WEBUI_PASSWORD', '').strip()
if env_pw:
return _hash_password(env_pw)
settings = load_settings()
return settings.get('password_hash') or None
Priority: env var > settings.json.
The hash is computed once and cached for the lifetime of the process.
PBKDF2-600k takes ~1 s and is called on nearly every HTTP request via
check_auth → is_auth_enabled, so caching avoids wasting a full second
of CPU per request after the first one.
Thread-safe: double-checked locking ensures that under a burst of
concurrent requests only one thread computes PBKDF2, while the fast
path (after initialisation) requires zero locks.
"""
global _AUTH_HASH_COMPUTED, _AUTH_HASH_CACHE
# Fast path — no lock needed once cache is populated.
if _AUTH_HASH_COMPUTED:
return _AUTH_HASH_CACHE
with _AUTH_HASH_LOCK:
# Re-check inside lock — another thread may have populated while
# we were waiting to acquire.
if _AUTH_HASH_COMPUTED:
return _AUTH_HASH_CACHE
env_pw = os.getenv('HERMES_WEBUI_PASSWORD', '').strip()
if env_pw:
result = _hash_password(env_pw)
else:
result = load_settings().get('password_hash') or None
_AUTH_HASH_CACHE = result
_AUTH_HASH_COMPUTED = True
return result
def is_auth_enabled() -> bool:
def is_password_auth_enabled() -> bool:
"""True if a password is configured (env var or settings)."""
return get_password_hash() is not None
def verify_password(plain) -> bool:
"""Verify a plaintext password against the stored hash."""
def _passkey_feature_flag_enabled() -> bool:
"""Return True if the passkey/WebAuthn surface is enabled for this deployment.
Passkey support is opt-in default-off behind a feature flag so deployments
that don't want the WebAuthn surface (or whose RP-ID setup isn't ready for
non-localhost hosts) can disable it entirely with no UI surface, no
endpoints, no credential storage. To enable:
- Set ``HERMES_WEBUI_PASSKEY=1`` in the environment, OR
- Set ``webui_passkey_enabled: true`` in the per-profile config.yaml
With the flag off, ``are_passkeys_enabled()`` always returns False even if
credentials were registered in the past, and ``/login`` shows password-only.
"""
env_value = os.getenv("HERMES_WEBUI_PASSKEY", "")
if env_value:
return env_value.strip().lower() in {"1", "true", "yes", "on"}
try:
from api.config import get_config
cfg = get_config()
if isinstance(cfg, dict):
raw = cfg.get("webui_passkey_enabled")
if isinstance(raw, bool):
return raw
if isinstance(raw, str):
return raw.strip().lower() in {"1", "true", "yes", "on"}
except Exception:
pass
return False
def are_passkeys_enabled() -> bool:
"""True if the passkey feature flag is on AND at least one local passkey credential is registered."""
if not _passkey_feature_flag_enabled():
return False
try:
from api.passkeys import passkeys_available
return passkeys_available()
except Exception as exc:
logger.debug("Failed to inspect passkey availability: %s", exc)
return False
def is_auth_enabled() -> bool:
"""True if password auth or passkey-only auth is configured."""
return is_password_auth_enabled() or are_passkeys_enabled()
def verify_password(plain: str) -> bool:
"""Verify a plaintext password against the stored hash.
Supports transparent migration of password hashes that were computed
with the old `.signing_key` salt. When the two keys differ and the
legacy-salted hash matches, the password is transparently re-hashed
with the current `.pbkdf2_key` and persisted to settings.json.
"""
expected = get_password_hash()
if not expected:
return False
return hmac.compare_digest(_hash_password(plain), expected)
# Fast path: current PBKDF2 key
if hmac.compare_digest(_hash_password(plain), expected):
return True
# Migration: some hashes were computed with `.signing_key` before the
# PBKDF2 key was separated. Try the legacy salt; if it matches,
# transparently upgrade so the next login uses the fast path.
legacy_salt = _signing_key()
current_salt = _pbkdf2_key()
if legacy_salt != current_salt:
if hmac.compare_digest(_hash_password(plain, salt=legacy_salt), expected):
from api.config import save_settings
save_settings({'_set_password': plain})
# Password re-hashed and persisted to disk using the current salt.
# Cache invalidation is handled by fix 2/3 (#2192) which adds the
# _invalidate_password_hash_cache() call inside save_settings().
return True
return False
def create_session() -> str:
"""Create a new auth session. Returns signed cookie value."""
token = secrets.token_hex(32)
_sessions[token] = time.time() + SESSION_TTL
_save_sessions(_sessions)
sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:32]
with _SESSIONS_LOCK:
_sessions[token] = time.time() + _resolve_session_ttl()
_save_sessions(_sessions)
sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()
return f"{token}.{sig}"
def _prune_expired_sessions():
"""Remove all expired session entries to prevent unbounded memory growth."""
now = time.time()
expired = [t for t, exp in _sessions.items() if now > exp]
if expired:
for token in expired:
_sessions.pop(token, None)
_save_sessions(_sessions)
with _SESSIONS_LOCK:
expired = [t for t, exp in _sessions.items() if now > exp]
if expired:
for token in expired:
_sessions.pop(token, None)
_save_sessions(_sessions)
def verify_session(cookie_value) -> bool:
def verify_session(cookie_value: str) -> bool:
"""Verify a signed session cookie. Returns True if valid and not expired."""
if not cookie_value or '.' not in cookie_value:
return False
_prune_expired_sessions() # lazy cleanup on every verification attempt
token, sig = cookie_value.rsplit('.', 1)
expected_sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:32]
if not hmac.compare_digest(sig, expected_sig):
return False
expiry = _sessions.get(token)
if not expiry or time.time() > expiry:
_sessions.pop(token, None)
full_sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()
# Accept both new (64-char) and legacy (32-char truncated) signatures so
# existing sessions survive the upgrade without a forced global logout.
# The legacy branch can be removed once session TTLs have expired (~30 days).
valid = hmac.compare_digest(sig, full_sig) or (
len(sig) == 32 and hmac.compare_digest(sig, full_sig[:32])
)
if not valid:
return False
with _SESSIONS_LOCK:
expiry = _sessions.get(token)
if not expiry or time.time() > expiry:
_sessions.pop(token, None)
_save_sessions(_sessions)
return False
return True
def _session_token_from_cookie_value(cookie_value: str) -> str | None:
"""Return the raw server-side session token from a signed cookie value."""
if not cookie_value or '.' not in cookie_value:
return None
token, _sig = cookie_value.rsplit('.', 1)
return token or None
def sign_profile_cookie_value(profile_name: str, session_cookie_value: str | None) -> str:
"""Return a profile cookie value authenticated for one WebUI session.
The active-profile cookie is client-controlled, so when auth is enabled it
must not be trusted as a bare profile name. Binding the selected profile to
the HttpOnly session token prevents a client from forging
``hermes_profile=<other-profile>`` and bypassing profile visibility guards.
"""
if not session_cookie_value or not verify_session(session_cookie_value):
raise ValueError("active auth session is required to sign profile cookie")
token = _session_token_from_cookie_value(session_cookie_value)
if not token:
raise ValueError("active auth session is required to sign profile cookie")
sig = hmac.new(
_signing_key(),
f"profile:{token}:{profile_name}".encode(),
hashlib.sha256,
).hexdigest()
return f"{profile_name}.{sig}"
def verify_profile_cookie_value(cookie_value: str, session_cookie_value: str | None) -> str | None:
"""Verify a session-bound profile cookie and return its profile name."""
if not cookie_value or '.' not in cookie_value:
return None
if not session_cookie_value or not verify_session(session_cookie_value):
return None
profile_name, sig = cookie_value.rsplit('.', 1)
token = _session_token_from_cookie_value(session_cookie_value)
if not profile_name or not token or not sig:
return None
# Defense-in-depth: validate the profile-name pattern here too, not only in
# get_profile_cookie(), so any future caller of this verifier can't return an
# unvalidated name. (#4023 Opus hardening.)
from api.profiles import _PROFILE_ID_RE
if profile_name != 'default' and not _PROFILE_ID_RE.fullmatch(profile_name):
return None
expected = hmac.new(
_signing_key(),
f"profile:{token}:{profile_name}".encode(),
hashlib.sha256,
).hexdigest()
if hmac.compare_digest(str(sig), expected):
return profile_name
return None
def csrf_token_for_session(cookie_value: str) -> str | None:
"""Return the CSRF token bound to an authenticated WebUI session.
The browser can read this token from the authenticated shell and echoes it
in ``X-Hermes-CSRF-Token`` on unsafe API requests. The token is derived
from the HttpOnly session cookie's server-side token, so it automatically
rotates on login and is invalidated when the auth session expires or logs
out. Callers must still verify the auth session before trusting it.
"""
token = _session_token_from_cookie_value(cookie_value)
if not token:
return None
return hmac.new(_signing_key(), f"csrf:{token}".encode(), hashlib.sha256).hexdigest()
def verify_csrf_token(cookie_value: str, csrf_token: str) -> bool:
"""Verify a submitted CSRF token against the authenticated session."""
if not cookie_value or not csrf_token or not verify_session(cookie_value):
return False
expected = csrf_token_for_session(cookie_value)
return bool(expected and hmac.compare_digest(str(csrf_token), expected))
def invalidate_session(cookie_value) -> None:
"""Remove a session token."""
if cookie_value and '.' in cookie_value:
token = cookie_value.rsplit('.', 1)[0]
if token in _sessions:
_sessions.pop(token, None)
_save_sessions(_sessions)
with _SESSIONS_LOCK:
if token in _sessions:
_sessions.pop(token, None)
_save_sessions(_sessions)
def parse_cookie(handler) -> str | None:
@@ -206,7 +557,7 @@ def parse_cookie(handler) -> str | None:
cookie.load(cookie_header)
except http.cookies.CookieError:
return None
morsel = cookie.get(COOKIE_NAME)
morsel = cookie.get(_resolve_cookie_name())
return morsel.value if morsel else None
@@ -224,10 +575,12 @@ def check_auth(handler, parsed) -> bool:
return True
# Not authorized
if parsed.path.startswith('/api/'):
body = b'{"error":"Authentication required"}'
handler.send_response(401)
handler.send_header('Content-Type', 'application/json')
handler.send_header('Content-Length', str(len(body)))
handler.end_headers()
handler.wfile.write(b'{"error":"Authentication required"}')
handler.wfile.write(body)
else:
handler.send_response(302)
# Pass the original path as ?next= so login.js redirects back after auth.
@@ -257,29 +610,77 @@ def check_auth(handler, parsed) -> bool:
# `?`, `&`, `=`) gets percent-encoded.
_next = _urlparse.quote(_path_with_query, safe='/')
handler.send_header('Location', 'login?next=' + _next)
handler.send_header('Content-Length', '0')
handler.end_headers()
return False
def _is_loopback(addr: str) -> bool:
"""Return True if *addr* is a loopback address (127.x.x.x, ::1, or ::ffff:127.x.x.x)."""
import ipaddress as _ipaddress
try:
ip = _ipaddress.ip_address(addr)
if ip.is_loopback:
return True
# Python < 3.12: is_loopback is False for ::ffff:127.x.x.x (gh-117566)
if hasattr(ip, 'ipv4_mapped') and ip.ipv4_mapped is not None:
return ip.ipv4_mapped.is_loopback
return False
except ValueError:
return False
def _is_secure_context(handler=None) -> bool:
"""Return True if cookies should carry the Secure flag.
Priority order:
1. ``HERMES_WEBUI_SECURE`` env var: 1/true/yes -> True; 0/false/no -> False.
2. Direct TLS socket (handler.request.getpeercert present) -> True.
3. ``HERMES_WEBUI_TRUST_FORWARDED_PROTO=1`` opt-in: trust
``X-Forwarded-Proto: https`` header from a known reverse proxy.
4. Otherwise -> False (loopback or non-loopback, plain HTTP is not secure).
.. warning::
``X-Forwarded-Proto`` is only trustworthy behind a reverse proxy.
It is ignored unless ``HERMES_WEBUI_TRUST_FORWARDED_PROTO=1`` is
set explicitly, preventing header-injection attacks on plain-HTTP
deployments.
"""
env = os.getenv('HERMES_WEBUI_SECURE', '').strip().lower()
if env in ('1', 'true', 'yes'):
return True
if env in ('0', 'false', 'no'):
return False
if handler is not None:
if getattr(handler.request, 'getpeercert', None) is not None:
return True
trust_fwd = os.getenv('HERMES_WEBUI_TRUST_FORWARDED_PROTO', '').strip().lower()
if trust_fwd in ('1', 'true', 'yes'):
if handler.headers.get('X-Forwarded-Proto', '') == 'https':
return True
return False
def set_auth_cookie(handler, cookie_value) -> None:
"""Set the auth cookie on the response."""
cookie = http.cookies.SimpleCookie()
cookie[COOKIE_NAME] = cookie_value
cookie[COOKIE_NAME]['httponly'] = True
cookie[COOKIE_NAME]['samesite'] = 'Lax'
cookie[COOKIE_NAME]['path'] = '/'
cookie[COOKIE_NAME]['max-age'] = str(SESSION_TTL)
# Set Secure flag when connection is HTTPS
if getattr(handler.request, 'getpeercert', None) is not None or handler.headers.get('X-Forwarded-Proto', '') == 'https':
cookie[COOKIE_NAME]['secure'] = True
handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString())
name = _resolve_cookie_name()
cookie[name] = cookie_value
cookie[name]['httponly'] = True
cookie[name]['samesite'] = 'Lax'
cookie[name]['path'] = '/'
cookie[name]['max-age'] = str(_resolve_session_ttl())
if _is_secure_context(handler):
cookie[name]['secure'] = True
handler.send_header('Set-Cookie', cookie[name].OutputString())
def clear_auth_cookie(handler) -> None:
"""Clear the auth cookie on the response."""
cookie = http.cookies.SimpleCookie()
cookie[COOKIE_NAME] = ''
cookie[COOKIE_NAME]['httponly'] = True
cookie[COOKIE_NAME]['path'] = '/'
cookie[COOKIE_NAME]['max-age'] = '0'
handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString())
name = _resolve_cookie_name()
cookie[name] = ''
cookie[name]['httponly'] = True
cookie[name]['path'] = '/'
cookie[name]['max-age'] = '0'
handler.send_header('Set-Cookie', cookie[name].OutputString())

1248
api/background_process.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -9,8 +9,11 @@ from __future__ import annotations
import queue
import threading
import time
import uuid
from typing import Optional
from api.session_events import publish_session_list_changed
DEFAULT_TIMEOUT_SECONDS = 120
_lock = threading.Lock()
@@ -25,12 +28,13 @@ _clarify_sse_subscribers: dict[str, list[queue.Queue]] = {}
class _ClarifyEntry:
"""One pending clarify request inside a session."""
__slots__ = ("event", "data", "result")
__slots__ = ("event", "data", "result", "clarify_id")
def __init__(self, data: dict):
self.event = threading.Event()
self.data = data
self.result: Optional[str] = None
self.clarify_id: str = data.get("clarify_id", "") or uuid.uuid4().hex[:12]
def register_gateway_notify(session_key: str, cb) -> None:
@@ -50,6 +54,8 @@ def unregister_gateway_notify(session_key: str) -> None:
with _lock:
_gateway_notify_cbs.pop(session_key, None)
entries = _clear_queue_locked(session_key)
if entries:
publish_session_list_changed("attention_cleared")
for entry in entries:
entry.event.set()
@@ -58,6 +64,8 @@ def clear_pending(session_key: str) -> int:
"""Clear any pending clarify prompts for the session without removing the callback."""
with _lock:
entries = _clear_queue_locked(session_key)
if entries:
publish_session_list_changed("attention_cleared")
for entry in entries:
entry.event.set()
return len(entries)
@@ -120,6 +128,11 @@ def submit_pending(session_key: str, data: dict) -> _ClarifyEntry:
== list(data.get("choices_offered") or [])
):
entry = last
# Dedup re-uses the existing entry with its original clarify_id.
# If a future caller pre-populates clarify_id in data, it is
# silently discarded here — the original entry's id wins.
# Today no caller sets clarify_id (it's generated by __init__),
# so this is a non-issue.
cb = _gateway_notify_cbs.get(session_key)
# Keep _pending aligned to the oldest unresolved entry.
_pending[session_key] = gw_queue[0].data
@@ -128,14 +141,20 @@ def submit_pending(session_key: str, data: dict) -> _ClarifyEntry:
cb(dict(entry.data))
except Exception:
pass
# Safe to call while holding _lock: publish() only takes the
# leaf _SESSION_EVENTS_LOCK and never re-acquires this lock.
publish_session_list_changed("attention_pending")
return entry
entry = _ClarifyEntry(data)
# Ensure clarify_id is present in the serialised data the frontend receives.
entry.data["clarify_id"] = entry.clarify_id
gw_queue.append(entry)
_pending[session_key] = gw_queue[0].data
cb = _gateway_notify_cbs.get(session_key)
# Notify SSE subscribers from inside _lock for ordering guarantees.
_clarify_sse_notify(session_key, dict(gw_queue[0].data), len(gw_queue))
publish_session_list_changed("attention_pending")
if cb:
try:
cb(data)
@@ -159,6 +178,15 @@ def has_pending(session_key: str) -> bool:
return bool(_gateway_queues.get(session_key))
def pending_count(session_key: str) -> int:
"""Return the number of unresolved clarify prompts for a session."""
with _lock:
queue = _gateway_queues.get(session_key) or []
if queue:
return len(queue)
return 1 if _pending.get(session_key) else 0
def resolve_clarify(session_key: str, response: str, resolve_all: bool = False) -> int:
"""Resolve the oldest pending clarify request for a session."""
with _lock:
@@ -173,9 +201,38 @@ def resolve_clarify(session_key: str, response: str, resolve_all: bool = False)
else:
_clear_queue_locked(session_key)
_clarify_sse_notify(session_key, None, 0)
publish_session_list_changed("attention_resolved")
count = 0
for entry in entries:
entry.result = response
entry.event.set()
count += 1
return count
def resolve_clarify_by_id(session_key: str, clarify_id: str, response: str) -> bool:
"""Resolve a specific pending clarify request by its stable id.
Returns True if the id was found and resolved, False otherwise.
"""
with _lock:
q = _gateway_queues.get(session_key)
if not q:
_pending.pop(session_key, None)
return False
for i, entry in enumerate(q):
if entry.clarify_id == clarify_id:
q.pop(i)
if q:
_pending[session_key] = q[0].data
_clarify_sse_notify(session_key, dict(q[0].data), len(q))
else:
_clear_queue_locked(session_key)
_clarify_sse_notify(session_key, None, 0)
# Safe to call while holding _lock: publish() only takes the
# leaf _SESSION_EVENTS_LOCK and never re-acquires this lock.
publish_session_list_changed("attention_resolved")
entry.result = response
entry.event.set()
return True
return False

View File

@@ -6,6 +6,7 @@ so the frontend can still load with WEBUI_ONLY commands.
"""
from __future__ import annotations
import logging
import threading
from typing import Any
logger = logging.getLogger(__name__)
@@ -20,6 +21,41 @@ _NEVER_EXPOSE: frozenset[str] = frozenset({
})
# Narrow agent-side execution allowlist for /api/commands/exec.
_AGENT_COMMAND_ALIASES = {
'reload_mcp': 'reload-mcp',
'reload_skills': 'reload-skills',
'codex_runtime': 'codex-runtime',
}
_ALLOWED_AGENT_COMMANDS = frozenset({'reload-mcp', 'reload-skills', 'codex-runtime'})
_RELOAD_MCP_LOCK = threading.Lock()
_RELOAD_SKILLS_LOCK = threading.Lock()
_CODEX_RUNTIME_LOCK = threading.Lock()
def _parse_agent_command(command: str) -> tuple[str, str]:
"""Return ``(canonical_name, arg_string)`` from slash-command text."""
raw = str(command or "").strip()
if not raw:
raise ValueError("command is required")
cmd_text = raw[1:] if raw.startswith("/") else raw
cmd_parts = cmd_text.split(maxsplit=1)
cmd_base = (cmd_parts[0] if cmd_parts else "").strip().lower()
if not cmd_base:
raise ValueError("command is required")
return _AGENT_COMMAND_ALIASES.get(cmd_base, cmd_base), cmd_parts[1] if len(cmd_parts) > 1 else ""
def _normalize_agent_command_name(command: str) -> str:
"""Normalize slash text to a canonical command name."""
canonical, _arg_string = _parse_agent_command(command)
return canonical
def list_commands(_registry=None) -> list[dict[str, Any]]:
"""Return COMMAND_REGISTRY entries as JSON-friendly dicts.
@@ -53,4 +89,216 @@ def list_commands(_registry=None) -> list[dict[str, Any]]:
'cli_only': bool(cmd.cli_only),
'gateway_only': bool(cmd.gateway_only),
})
# Include plugin-registered slash commands
try:
from hermes_cli.plugins import get_plugin_commands
plugin_cmds = get_plugin_commands() or {}
existing_names = {c['name'] for c in out}
for cmd_name, cmd_info in plugin_cmds.items():
if cmd_name in existing_names or cmd_name in _NEVER_EXPOSE:
continue
out.append({
'name': cmd_name,
'description': str(cmd_info.get('description', 'Plugin command')),
'category': 'Plugin',
'aliases': [],
'args_hint': str(cmd_info.get('args_hint', '')),
'subcommands': [],
'cli_only': False,
'gateway_only': False,
})
except Exception:
pass
return out
def execute_agent_command(command: str) -> str:
"""Execute a narrow allowlist of agent-side runtime commands."""
canonical, arg_string = _parse_agent_command(command)
if canonical not in _ALLOWED_AGENT_COMMANDS:
raise KeyError(canonical)
if canonical == 'reload-mcp':
return _run_reload_mcp_command()
if canonical == 'reload-skills':
return _run_reload_skills_command()
if canonical == 'codex-runtime':
return _run_codex_runtime_command(arg_string)
raise KeyError(canonical)
def _run_codex_runtime_command(arg_string: str) -> str:
"""Execute Hermes' shared Codex runtime switch for the active profile."""
try:
from hermes_cli.codex_runtime_switch import apply, parse_args
except Exception as exc:
logger.warning("Codex runtime switch unavailable", exc_info=True)
raise RuntimeError("Codex runtime switch unavailable") from exc
new_value, errors = parse_args(arg_string)
if errors:
return "\n".join(str(error) for error in errors)
with _CODEX_RUNTIME_LOCK:
try:
from api import config as webui_config
active_config = webui_config.get_config()
def _persist_config(config_data: dict) -> None:
webui_config._save_yaml_config_file(
webui_config._get_config_path(),
config_data,
)
webui_config.reload_config()
status = apply(active_config, new_value, persist_callback=_persist_config)
except Exception as exc:
logger.warning("Failed to execute /codex-runtime", exc_info=True)
raise RuntimeError("Failed to update Codex runtime") from exc
return str(getattr(status, "message", "") or "(no output)")
def _run_reload_mcp_command() -> str:
"""Execute the MCP reconnect path and return a short user-facing summary."""
with _RELOAD_MCP_LOCK:
try:
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _servers, _lock
except Exception as exc:
logger.warning("Failed to import MCP runtime for /reload-mcp", exc_info=True)
raise RuntimeError("MCP runtime unavailable") from exc
try:
with _lock:
old_servers = set(_servers.keys())
shutdown_mcp_servers()
new_tools = discover_mcp_tools()
with _lock:
connected_servers = set(_servers.keys())
except Exception as exc:
logger.warning("Failed to reload MCP servers", exc_info=True)
raise RuntimeError("Failed to reload MCP servers") from exc
added = connected_servers - old_servers
removed = old_servers - connected_servers
reconnected = connected_servers & old_servers
lines = ["Reloaded MCP servers from configuration."]
if reconnected:
lines.append(f"Reconnected: {', '.join(sorted(reconnected))}")
if added:
lines.append(f"Added: {', '.join(sorted(added))}")
if removed:
lines.append(f"Removed: {', '.join(sorted(removed))}")
if connected_servers:
lines.append(f"{len(new_tools or [])} tool(s) available across {len(connected_servers)} server(s)")
else:
lines.append("No MCP servers connected")
if not reconnected and not added and not removed:
lines.append("Tooling state was already current")
return "\n".join(lines)
def _run_reload_skills_command() -> str:
"""Re-scan the installed skills directory and summarize the diff."""
with _RELOAD_SKILLS_LOCK:
try:
from agent.skill_commands import reload_skills
except Exception as exc:
logger.warning("Failed to import skills runtime for /reload-skills", exc_info=True)
raise RuntimeError("Skills runtime unavailable") from exc
try:
result = reload_skills() or {}
except Exception as exc:
logger.warning("Failed to reload skills", exc_info=True)
raise RuntimeError("Failed to reload skills") from exc
added = result.get("added", [])
removed = result.get("removed", [])
unchanged = result.get("unchanged", [])
total = int(result.get("total", 0) or 0)
def _names(items: Any) -> list[str]:
out: list[str] = []
for item in items or []:
if isinstance(item, dict):
name = str(item.get("name", "")).strip()
else:
name = str(item).strip()
if name:
out.append(name)
return out
added_names = _names(added)
removed_names = _names(removed)
lines = [
"Reloaded skills from disk.",
f"Added: {len(added_names)}",
f"Removed: {len(removed_names)}",
f"Unchanged: {len(list(unchanged or []))}",
f"Total skills: {total}",
]
if added_names:
lines.append(f"Added skills: {', '.join(sorted(added_names))}")
if removed_names:
lines.append(f"Removed skills: {', '.join(sorted(removed_names))}")
return "\n".join(lines)
def execute_plugin_command(command: str) -> str:
"""Execute a plugin-registered slash command and return printable output.
Unknown commands raise ``KeyError`` so the HTTP layer can return 404.
Plugin handler failures are returned as output text instead of surfacing as
transport errors, matching Hermes' existing slash-command UX.
"""
raw = str(command or "").strip()
if not raw:
raise ValueError("command is required")
cmd_text = raw[1:] if raw.startswith("/") else raw
cmd_parts = cmd_text.split(maxsplit=1)
cmd_base = (cmd_parts[0] if cmd_parts else "").strip().lower()
cmd_arg = cmd_parts[1] if len(cmd_parts) > 1 else ""
if not cmd_base:
raise ValueError("command is required")
try:
from hermes_cli.plugins import (
get_plugin_command_handler,
resolve_plugin_command_result,
)
except ImportError as exc:
logger.warning("Plugin command runtime unavailable", exc_info=True)
raise RuntimeError("plugin command runtime unavailable") from exc
try:
handler = get_plugin_command_handler(cmd_base)
except Exception as exc:
logger.warning("Plugin command lookup failed for %r", cmd_base, exc_info=True)
raise RuntimeError("plugin command lookup failed") from exc
if not handler:
raise KeyError(cmd_base)
try:
result = resolve_plugin_command_result(handler(cmd_arg))
return str(result or "(no output)")
except Exception as exc:
# Don't leak raw exception str (paths, env, internal state) to the
# user-facing chat. Type name is enough for the user to know what
# class of failure occurred; full traceback lives in the server log.
logger.warning("Plugin command %r execution failed", cmd_base, exc_info=True)
return f"Plugin command error: {type(exc).__name__}"

134
api/compression_anchor.py Normal file
View File

@@ -0,0 +1,134 @@
"""
Shared helpers for session compression anchor metadata.
Manual compression anchoring versus automatic compression paths
===============================================================
When ``auto_compression=True`` is passed to ``visible_messages_for_anchor()``,
the function accepts a broader set of message content types (including
provider-style ``input_text`` / ``output_text`` parts) and metadata markers
(``reasoning``, ``thinking``, etc.) from any non-tool role. This enables the
streaming auto-compression path to determine which messages should anchor
compression UI metadata without being limited to the legacy manual-compression
rules.
When ``auto_compression=False`` (the default), the function applies the
historical manual-compression rules: only plain ``text`` content parts from
non-assistant roles are counted.
Why this module exists
======================
Compression anchoring needs to identify which messages in a session transcript
are semantically significant enough to seed the compression UI metadata (e.g.,
message count, token budget display). The original implementation hard-coded
these rules in multiple places. This module consolidates the logic so that:
1. Manual compression anchoring (CLI/legacy path) uses the stricter ruleset.
2. Automatic compression (streaming/agent path) can leverage the relaxed ruleset
when it knows it is handling provider-style messages.
Callers specify ``auto_compression=True`` when the messages may originate from
an automatic/compression-aware pipeline, and ``False`` (default) for manual
compression contexts.
"""
def _content_text(content, *, part_types):
if isinstance(content, list):
return "\n".join(
str(part.get("text") or part.get("content") or "")
for part in content
if isinstance(part, dict) and part.get("type") in part_types
).strip()
return str(content or "").strip()
def _content_has_part_type(content, part_types):
if not isinstance(content, list):
return False
return any(
isinstance(part, dict) and part.get("type") in part_types
for part in content
)
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]")
or text.startswith("[session arc summary")
)
def _is_context_compression_marker(message):
"""Backward-compatible alias for callers that have not switched yet."""
return is_context_compression_marker(message)
def visible_messages_for_anchor(messages, *, auto_compression: bool = False):
"""Return transcript messages that can anchor compression UI metadata.
Manual compression historically only counted plain ``text`` content parts
for non-assistant messages, while the streaming auto-compression path also
accepted provider-style ``input_text`` / ``output_text`` parts and metadata
markers on any non-tool role. Keep that difference explicit at the call site
instead of carrying two near-identical helper implementations.
"""
out = []
text_part_types = {"text", "input_text", "output_text"} if auto_compression else {"text"}
for message in messages or []:
if not isinstance(message, dict):
continue
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"))
text = _content_text(content, part_types=text_part_types)
if auto_compression:
has_tool_calls = bool(
isinstance(message.get("tool_calls"), list) and message.get("tool_calls")
)
has_tool_use = _content_has_part_type(content, {"tool_use"})
has_reasoning = bool(message.get("reasoning"))
if not text:
has_reasoning = has_reasoning or _content_has_part_type(
content,
{"thinking", "reasoning"},
)
if text or has_attachments or has_tool_calls or has_tool_use or has_reasoning:
out.append(message)
continue
if role == "assistant":
has_tool_calls = bool(
isinstance(message.get("tool_calls"), list) and message.get("tool_calls")
)
has_tool_use = _content_has_part_type(content, {"tool_use"})
has_reasoning = bool(message.get("reasoning")) or _content_has_part_type(
content,
{"thinking", "reasoning"},
)
if text or has_attachments or has_tool_calls or has_tool_use or has_reasoning:
out.append(message)
continue
if text or has_attachments:
out.append(message)
return out

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ import json
import logging
import os
import urllib.request
from urllib.parse import urlparse
from urllib.parse import urlparse, urlunparse
logger = logging.getLogger(__name__)
@@ -61,6 +61,44 @@ 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
host = parsed.hostname.lower()
if ":" in host and not host.startswith("["):
host = f"[{host}]"
netloc = host
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
@@ -132,8 +170,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}
@@ -143,9 +180,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
@@ -186,9 +221,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:
@@ -197,8 +236,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}
@@ -207,5 +248,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}

489
api/gateway_chat.py Normal file
View File

@@ -0,0 +1,489 @@
"""Default-off Hermes Gateway bridge for browser-originated chat turns."""
from __future__ import annotations
import json
import logging
import os
import threading
import time
import urllib.error
import urllib.request
from typing import Any
from api.config import (
CANCEL_FLAGS,
STREAMS,
STREAMS_LOCK,
STREAM_LAST_EVENT_ID,
STREAM_LIVE_TOOL_CALLS,
STREAM_PARTIAL_TEXT,
STREAM_REASONING_TEXT,
_get_session_agent_lock,
register_active_run,
unregister_active_run,
update_active_run,
)
from api.helpers import _redact_text, redact_session_data
from api.models import get_session, merge_session_messages_append_only
from api.run_journal import RunJournalWriter
logger = logging.getLogger(__name__)
_WEBUI_CHAT_BACKEND_ENV = "HERMES_WEBUI_CHAT_BACKEND"
_WEBUI_GATEWAY_BASE_URL_ENV = "HERMES_WEBUI_GATEWAY_BASE_URL"
_WEBUI_GATEWAY_API_KEY_ENV = "HERMES_WEBUI_GATEWAY_API_KEY"
_GATEWAY_CHAT_BACKENDS = {"gateway", "api_server", "api-server"}
def webui_chat_backend_mode(config_data=None, environ: dict[str, str] | None = None) -> str:
"""Return the explicitly selected browser chat backend.
The default remains the in-process WebUI runtime. Only explicit gateway
values opt browser chat into the Hermes API server bridge; generic truthy
strings are deliberately ignored so deployments do not change execution
ownership by accident.
"""
source = os.environ if environ is None else environ
cfg = config_data if isinstance(config_data, dict) else {}
raw = str(
source.get(_WEBUI_CHAT_BACKEND_ENV)
or cfg.get("webui_chat_backend")
or ""
).strip().lower()
if raw in _GATEWAY_CHAT_BACKENDS:
return "gateway"
return "legacy"
def webui_gateway_chat_enabled(config_data=None, environ: dict[str, str] | None = None) -> bool:
return webui_chat_backend_mode(config_data, environ) == "gateway"
def _gateway_base_url(config_data=None, environ: dict[str, str] | None = None) -> str:
source = os.environ if environ is None else environ
cfg = config_data if isinstance(config_data, dict) else {}
raw = str(
source.get(_WEBUI_GATEWAY_BASE_URL_ENV)
or cfg.get("webui_gateway_base_url")
or "http://127.0.0.1:8642"
).strip()
return raw.rstrip("/") or "http://127.0.0.1:8642"
def _gateway_api_key(environ: dict[str, str] | None = None) -> str:
source = os.environ if environ is None else environ
return str(
source.get(_WEBUI_GATEWAY_API_KEY_ENV)
or source.get("API_SERVER_KEY")
or ""
).strip()
def gateway_chat_config_status(config_data=None, environ: dict[str, str] | None = None) -> dict:
"""Return redacted Gateway-backed chat configuration status."""
mode = webui_chat_backend_mode(config_data, environ)
base_url = _gateway_base_url(config_data, environ)
return {
"enabled": mode == "gateway",
"backend": mode,
"base_url_configured": bool(base_url),
"api_key_configured": bool(_gateway_api_key(environ)),
}
def _gateway_http_error_event(exc: urllib.error.HTTPError, err_body: str, *, api_key_configured: bool) -> dict:
safe = _redact_text(err_body or str(exc))[:500]
if exc.code == 401:
return {
"label": "Gateway authentication failed",
"type": "gateway_auth_error",
"message": "Gateway rejected the WebUI API key (HTTP 401).",
"hint": (
"Set HERMES_WEBUI_GATEWAY_API_KEY to the same value as the Hermes Gateway "
"API_SERVER_KEY, or disable HERMES_WEBUI_CHAT_BACKEND=gateway."
if not api_key_configured
else "Check that HERMES_WEBUI_GATEWAY_API_KEY matches the Hermes Gateway API_SERVER_KEY."
),
}
return {
"label": "Gateway request failed",
"type": "gateway_http_error",
"message": f"Gateway returned HTTP {exc.code}.",
"hint": safe or "Check the configured Gateway API server.",
}
def _gateway_sse_delta(payload: dict) -> str:
"""Extract assistant text from an OpenAI-compatible streaming chunk."""
try:
choices = payload.get("choices") or []
if not choices:
return ""
choice = choices[0] or {}
delta = choice.get("delta") or {}
content = delta.get("content")
if isinstance(content, str):
return content
message = choice.get("message") or {}
content = message.get("content")
return content if isinstance(content, str) else ""
except Exception:
return ""
def _gateway_stream_usage(payload: dict) -> dict:
usage = payload.get("usage") if isinstance(payload, dict) else None
if not isinstance(usage, dict):
return {}
return {
"input_tokens": int(usage.get("prompt_tokens") or usage.get("input_tokens") or 0),
"output_tokens": int(usage.get("completion_tokens") or usage.get("output_tokens") or 0),
"estimated_cost": usage.get("estimated_cost") or usage.get("estimated_cost_usd") or 0,
}
def _gateway_tool_progress_event(payload: dict) -> tuple[str, dict] | None:
"""Translate Hermes Gateway tool-progress SSE payloads to WebUI events."""
if not isinstance(payload, dict):
return None
name = str(payload.get("tool") or payload.get("name") or payload.get("function_name") or "").strip()
if not name or name.startswith("_"):
return None
status = str(payload.get("status") or "running").strip().lower()
tid = payload.get("toolCallId") or payload.get("tool_call_id") or payload.get("id")
is_complete = status in {"completed", "complete", "success", "error", "failed"}
event_payload = {
"event_type": "tool.completed" if is_complete else "tool.started",
"name": name,
"preview": payload.get("label") or payload.get("preview"),
"args": payload.get("args") if isinstance(payload.get("args"), dict) else {},
"is_error": status in {"error", "failed"},
}
if tid:
event_payload["tid"] = str(tid)
return ("tool_complete" if is_complete else "tool"), event_payload
def _stream_writeback_is_current(session: Any, stream_id: str) -> bool:
return bool(stream_id and getattr(session, "active_stream_id", None) == stream_id)
def _clear_gateway_pending_state(session: Any, stream_id: str) -> None:
if not _stream_writeback_is_current(session, stream_id):
return
session.active_stream_id = None
session.pending_user_message = None
session.pending_attachments = None
session.pending_started_at = None
session.save()
def _run_gateway_chat_streaming(
session_id,
msg_text,
model,
workspace,
stream_id,
attachments=None,
*,
model_provider=None,
):
"""Bridge a WebUI chat turn through Hermes Gateway's API server.
This default-off path keeps the browser contract unchanged: /api/chat/start
still returns a local stream_id and /api/chat/stream still receives WebUI SSE
event names. The worker translates OpenAI-compatible streaming chunks from
the configured Gateway API server into those local events and persists the
final user/assistant turn back into the WebUI session.
"""
q = STREAMS.get(stream_id)
if q is None:
return
register_active_run(
stream_id,
session_id=session_id,
started_at=time.time(),
phase="gateway-starting",
workspace=str(workspace),
model=model,
provider=model_provider,
backend="gateway",
)
try:
run_journal = RunJournalWriter(session_id, stream_id)
except Exception:
run_journal = None
logger.debug("Failed to initialize gateway run journal for stream %s", stream_id, exc_info=True)
cancel_event = threading.Event()
with STREAMS_LOCK:
CANCEL_FLAGS[stream_id] = cancel_event
STREAM_PARTIAL_TEXT[stream_id] = ""
STREAM_REASONING_TEXT[stream_id] = ""
STREAM_LIVE_TOOL_CALLS[stream_id] = []
def put_gateway_event(event, data):
if cancel_event.is_set() and event not in ("cancel", "error", "apperror"):
return
event_id = None
if run_journal is not None:
try:
journaled = run_journal.append_sse_event(event, data)
event_id = (journaled or {}).get("event_id") if isinstance(journaled, dict) else None
if event_id:
STREAM_LAST_EVENT_ID[stream_id] = event_id
except Exception:
logger.debug("Failed to append gateway event %s for stream %s", event, stream_id, exc_info=True)
if event_id and hasattr(q, "note_last_event_id"):
try:
q.note_last_event_id(event_id)
except Exception:
logger.debug("Failed to note gateway event_id %s for stream %s", event_id, stream_id, exc_info=True)
try:
queue_item = (event, data, event_id) if event_id and hasattr(q, "subscribe_with_snapshot") else (event, data)
q.put_nowait(queue_item)
except Exception:
logger.debug("Failed to put gateway event to queue")
s = None
final_text = ""
usage = {"input_tokens": 0, "output_tokens": 0, "estimated_cost": 0}
try:
s = get_session(session_id)
from api.config import get_config # imported lazily to avoid config-cycle churn
cfg = get_config()
try:
from api.streaming import (
_load_webui_prefill_context,
_prefill_messages_with_webui_context,
_normalize_prefill_messages_before_user_turn,
_public_prefill_context_status,
_webui_ephemeral_system_prompt,
)
prefill_context = _load_webui_prefill_context(cfg)
# #3324: the WebUI session/delivery context (connected platforms,
# home channels, delivery hints, session framing) is now carried in
# the ephemeral system prompt rather than a prefill `user` message.
# The gateway-backed path must build the SAME system prompt so that
# context is not silently dropped on Gateway-routed WebUI chats.
_gateway_system_prompt = _webui_ephemeral_system_prompt(
None,
surface_context={
"source": "webui",
"session_id": session_id,
"profile": getattr(s, "profile", None),
"workspace": s.workspace if s is not None else str(workspace),
},
config_data=cfg,
)
prefill_messages = _prefill_messages_with_webui_context(prefill_context, cfg)
prefill_messages = _normalize_prefill_messages_before_user_turn(prefill_messages)
prefill_messages = [
{"role": "system", "content": _gateway_system_prompt},
*prefill_messages,
]
put_gateway_event("context_status", {
"session_id": session_id,
"prefill": _public_prefill_context_status(prefill_context),
})
except Exception:
logger.debug("Failed to load WebUI gateway prefill context", exc_info=True)
prefill_messages = []
base_url = _gateway_base_url(cfg)
api_key = _gateway_api_key()
url = f"{base_url}/v1/chat/completions"
headers = {
"Content-Type": "application/json",
"Accept": "text/event-stream",
"X-Hermes-Session-Id": session_id,
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
# Scope Gateway long-term continuity to this WebUI conversation
# without exposing the browser's auth cookie or CSRF material.
headers["X-Hermes-Session-Key"] = f"webui:{session_id}"
message_content: Any = str(msg_text or "")
if attachments:
try:
from api.streaming import _build_native_multimodal_message
message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg)
except Exception:
logger.debug("Failed to build gateway multimodal attachment payload", exc_info=True)
message_content = str(msg_text or "")
body = {
"model": model or "default",
"stream": True,
"messages": [*prefill_messages, {"role": "user", "content": message_content}],
}
if model_provider:
body["provider"] = model_provider
req = urllib.request.Request(
url,
data=json.dumps(body).encode("utf-8"),
headers=headers,
method="POST",
)
update_active_run(stream_id, phase="gateway-request")
last_payload = {}
sse_event = "message"
with urllib.request.urlopen(req, timeout=600) as resp:
for raw_line in resp:
if cancel_event.is_set():
put_gateway_event("cancel", {"message": "Cancelled by user"})
return
line = raw_line.decode("utf-8", errors="replace").strip()
if not line:
sse_event = "message"
continue
if line.startswith("event:"):
sse_event = line[6:].strip() or "message"
continue
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
try:
payload = json.loads(data)
except json.JSONDecodeError:
continue
if sse_event == "hermes.tool.progress":
translated = _gateway_tool_progress_event(payload)
if translated:
event_name, event_payload = translated
if stream_id in STREAM_LIVE_TOOL_CALLS:
if event_name == "tool":
STREAM_LIVE_TOOL_CALLS[stream_id].append({
"name": event_payload.get("name"),
"args": event_payload.get("args") or {},
"done": False,
**({"tid": event_payload.get("tid")} if event_payload.get("tid") else {}),
})
else:
for shared_tc in reversed(STREAM_LIVE_TOOL_CALLS[stream_id]):
if shared_tc.get("done"):
continue
if (
event_payload.get("tid") and shared_tc.get("tid") == event_payload.get("tid")
) or shared_tc.get("name") == event_payload.get("name"):
shared_tc["done"] = True
shared_tc["is_error"] = bool(event_payload.get("is_error"))
break
put_gateway_event(event_name, event_payload)
update_active_run(stream_id, phase="gateway-tool", latest_tool=event_payload.get("name"))
sse_event = "message"
continue
last_payload = payload
delta = _gateway_sse_delta(payload)
if delta:
final_text += delta
if stream_id in STREAM_PARTIAL_TEXT:
STREAM_PARTIAL_TEXT[stream_id] += delta
put_gateway_event("token", {"text": delta})
usage.update({k: v for k, v in _gateway_stream_usage(payload).items() if v})
usage.update({k: v for k, v in _gateway_stream_usage(last_payload).items() if v})
assistant_text = final_text.strip()
if not assistant_text:
put_gateway_event("apperror", {
"label": "Gateway returned no response",
"type": "gateway_empty_response",
"message": "Gateway returned no assistant message for this turn.",
"hint": "Check that Hermes Gateway API server is running and reachable.",
})
return
with _get_session_agent_lock(session_id):
s = get_session(session_id)
if not _stream_writeback_is_current(s, stream_id):
return
now = time.time()
# Preserve subsecond ordering for gateway-backed turns. Using an
# integer seconds timestamp gives the user and assistant rows the
# same sort key; later transcript merges can then fall back to
# role/content ordering instead of turn order.
assistant_ts = now + 0.000001
user_msg = {"role": "user", "content": str(msg_text or ""), "timestamp": now}
if attachments:
user_msg["attachments"] = list(attachments)
assistant_msg = {"role": "assistant", "content": assistant_text, "timestamp": assistant_ts}
previous_context = list(getattr(s, "context_messages", None) or getattr(s, "messages", None) or [])
s.context_messages = previous_context + [user_msg, assistant_msg]
try:
from api.streaming import _is_context_compression_marker
display_context = [
msg
for msg in previous_context
if not _is_context_compression_marker(msg)
]
except Exception:
logger.debug("Failed to filter gateway display context markers", exc_info=True)
display_context = previous_context
display = merge_session_messages_append_only(
list(getattr(s, "messages", None) or []),
display_context,
)
try:
from api.streaming import _merge_display_messages_after_agent_result
s.messages = _merge_display_messages_after_agent_result(
display,
previous_context,
s.context_messages,
str(msg_text or ""),
)
except Exception:
logger.debug("Failed to merge gateway display transcript", exc_info=True)
# Avoid duplicating the eager-save checkpointed user message.
if display:
latest = display[-1]
if isinstance(latest, dict) and latest.get("role") == "user":
latest_text = " ".join(str(latest.get("content") or "").split())
msg_norm = " ".join(str(msg_text or "").split())
if latest_text == msg_norm:
display = display[:-1]
s.messages = display + [user_msg, assistant_msg]
s.active_stream_id = None
s.pending_user_message = None
s.pending_attachments = None
s.pending_started_at = None
s.workspace = str(workspace)
s.model = model
s.model_provider = model_provider
s.save()
gateway_session_payload = s.compact() | {"messages": s.messages, "tool_calls": []}
put_gateway_event("done", {"session": redact_session_data(gateway_session_payload), "usage": usage})
put_gateway_event("stream_end", {"session_id": session_id})
except urllib.error.HTTPError as exc:
try:
err_body = exc.read(2048).decode("utf-8", errors="replace")
except Exception:
err_body = ""
put_gateway_event(
"apperror",
_gateway_http_error_event(exc, err_body, api_key_configured=bool(_gateway_api_key())),
)
except Exception as exc:
safe = _redact_text(str(exc))[:500]
put_gateway_event("apperror", {
"label": "Gateway request failed",
"type": "gateway_error",
"message": safe or "Gateway request failed.",
"hint": "Check HERMES_WEBUI_GATEWAY_BASE_URL and Gateway API server health.",
})
finally:
if s is not None:
try:
with _get_session_agent_lock(session_id):
_clear_gateway_pending_state(get_session(session_id), stream_id)
except Exception:
logger.debug("Failed to clear gateway stream state", exc_info=True)
with STREAMS_LOCK:
CANCEL_FLAGS.pop(stream_id, None)
STREAM_PARTIAL_TEXT.pop(stream_id, None)
STREAM_REASONING_TEXT.pop(stream_id, None)
STREAM_LIVE_TOOL_CALLS.pop(stream_id, None)
STREAM_LAST_EVENT_ID.pop(stream_id, None)
STREAMS.pop(stream_id, None)
unregister_active_run(stream_id)

View File

@@ -13,8 +13,10 @@ import json
import logging
import os
import queue
import sqlite3
import threading
import time
from contextlib import closing
from pathlib import Path
from api.config import HOME
@@ -34,6 +36,113 @@ def _snapshot_hash(sessions: list) -> str:
return hashlib.md5(key.encode(), usedforsecurity=False).hexdigest()
# Sources excluded from the WebUI sidebar projection. Must match the default
# ``exclude_sources`` used by ``read_importable_agent_session_rows`` so the
# cheap change-detection scan below sees exactly the same row set as the
# expensive projection (otherwise cron message churn would defeat the gate).
_WATCHER_EXCLUDED_SOURCES = ("cron", "webui")
def _cheap_change_fingerprint(db_path: Path) -> str | None:
"""Compute a cheap change-detection fingerprint without the messages JOIN.
The expensive projection (``read_importable_agent_session_rows``) runs a CTE
plus a per-session ``MAX(messages.timestamp)`` aggregation over an oversampled
candidate set every poll. On a large ``state.db`` (hundreds of sessions, tens
of thousands of messages) that is ~10x the cost of a single ``sessions``-table
scan, and the watcher runs it forever on a 5s timer even when nothing changed
(issue #3506).
This computes a fingerprint from a ``sessions``-table-only scan (no messages
JOIN), scoped to the same non-cron/webui rows as the projection. To guarantee
it never skips a change the projection would reflect, it hashes **every
sessions-table column the projection reads or uses for visibility/collapse**
-- not just the columns surfaced to the sidebar. That matters because the
projection collapses compression lineage and hides/shows rows based on
``parent_session_id`` / ``ended_at`` / ``end_reason`` / ``source``, so a change
to one of those alters *which rows* appear even when no displayed field on a
given row moved.
The one projection input that does not live in the ``sessions`` table is the
per-session message aggregate (``COUNT`` / ``MAX(messages.timestamp)`` ->
``last_activity``). That is fully proxied by ``sessions.message_count``: the
agent's state layer bumps ``message_count`` on every appended message and
rewrites it to the absolute count on truncate/rewind/compaction, so a message
insert or delete (the only events that can move ``MAX(timestamp)``) always
changes ``message_count``. The fingerprint is therefore a strict superset of
the projection's change surface (it also fires on out-of-order inserts that
would not raise ``MAX(timestamp)``).
Returns the fingerprint string, or ``None`` on any error / a pre-source
schema so the caller falls back to running the expensive projection rather
than risk skipping a change.
"""
# Columns the projection reads from the ``sessions`` table. ``id``/``source``
# are always present (``source`` is required for the projection to run at
# all); the rest are optional on older agent schemas and filtered below.
_PROJECTION_SESSION_COLS = (
'id', 'source', 'session_source', 'title', 'model', 'message_count',
'started_at', 'ended_at', 'end_reason', 'parent_session_id', 'archived',
'user_id', 'chat_id', 'chat_type', 'thread_id', 'session_key',
'origin_chat_id', 'origin_user_id', 'platform',
)
try:
with closing(sqlite3.connect(str(db_path))) as conn:
cur = conn.cursor()
cur.execute("PRAGMA table_info(sessions)")
cols = {row[1] for row in cur.fetchall()}
if 'source' not in cols:
return None
selectable = [c for c in _PROJECTION_SESSION_COLS if c in cols]
placeholders = ", ".join("?" for _ in _WATCHER_EXCLUDED_SOURCES)
cur.execute(
f"SELECT {', '.join(selectable)} FROM sessions "
f"WHERE source IS NOT NULL AND source NOT IN ({placeholders}) "
f"ORDER BY id",
list(_WATCHER_EXCLUDED_SOURCES),
)
h = hashlib.md5(usedforsecurity=False)
for row in cur.fetchall():
h.update(repr(row).encode('utf-8', 'replace'))
h.update(b'\x1e')
# A same-count transcript rewrite (SessionDB.replace_messages used by
# /retry, /undo, /compress) deletes + reinserts messages with new
# timestamps but can leave sessions.message_count unchanged — so the
# sessions-only scan above would miss it and the watcher would skip a
# projection whose last_activity (MAX(messages.timestamp)) actually
# moved. Fold in a PER-SESSION message aggregate, scoped to the same
# non-excluded sessions as the projection. It must be per-session
# (grouped), NOT a single global MAX: rewriting an OLDER, non-newest
# session moves that session's last_activity but not the global max,
# so a global aggregate would still miss it (#3536 review round 2).
# cron/webui churn is excluded by the JOIN filter so it still does
# NOT trigger a re-projection. This is one GROUP BY over the already-
# filtered set — far cheaper than the projection's oversampled
# correlated CTE — so it preserves the cheap-fingerprint property.
if 'messages' in {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()}:
try:
msg_rows = conn.execute(
"SELECT s.id, COUNT(m.id), "
"COUNT(CASE WHEN LOWER(m.role) = 'user' THEN 1 END), "
"COALESCE(MAX(m.timestamp), 0) "
"FROM sessions s LEFT JOIN messages m ON m.session_id = s.id "
f"WHERE s.source IS NOT NULL AND s.source NOT IN ({placeholders}) "
"GROUP BY s.id ORDER BY s.id",
list(_WATCHER_EXCLUDED_SOURCES),
).fetchall()
for mrow in msg_rows:
h.update(repr(mrow).encode('utf-8', 'replace'))
h.update(b'\x1e')
except sqlite3.Error:
# messages table shape unknown → don't trust the fingerprint;
# signal the caller to run the full projection.
return None
return h.hexdigest()
except Exception:
return None
# ── DB resolution (shared pattern with state_sync.py) ──────────────────────
def _get_state_db_path() -> Path:
@@ -98,6 +207,10 @@ class GatewayWatcher:
self._thread: threading.Thread | None = None
self._last_hash: str = ''
self._last_sessions: list = []
# Cheap sessions-only fingerprint from the previous poll. When it is
# unchanged we skip the expensive messages-JOIN projection entirely
# (issue #3506). Empty string forces the first poll to run the full read.
self._last_cheap_fp: str = ''
def start(self):
"""Start the watcher daemon thread."""
@@ -183,13 +296,30 @@ class GatewayWatcher:
"""Main polling loop. Runs in a daemon thread."""
while not self._stop_event.is_set():
try:
sessions = _get_agent_sessions_from_db()
current_hash = _snapshot_hash(sessions)
# Phase 1: cheap sessions-only fingerprint. The expensive
# messages-JOIN projection (_get_agent_sessions_from_db) only
# runs when this fingerprint actually changes, so an idle server
# with a large state.db stops re-aggregating tens of thousands
# of message rows every 5 seconds (issue #3506). A None
# fingerprint (error / unreadable db) forces the full read so we
# never silently skip a real change.
db_path = _get_state_db_path()
cheap_fp = _cheap_change_fingerprint(db_path) if db_path.exists() else ''
if cheap_fp is not None and cheap_fp == self._last_cheap_fp:
# Nothing changed in the sidebar-visible session set; skip
# the expensive projection and the notify entirely.
pass
else:
# Phase 2: only now pay for the full projection.
sessions = _get_agent_sessions_from_db()
current_hash = _snapshot_hash(sessions)
if cheap_fp is not None:
self._last_cheap_fp = cheap_fp
if current_hash != self._last_hash:
self._last_hash = current_hash
self._last_sessions = sessions
self._notify_subscribers(sessions)
if current_hash != self._last_hash:
self._last_hash = current_hash
self._last_sessions = sessions
self._notify_subscribers(sessions)
except Exception:
logger.debug("Error in gateway watcher poll loop", exc_info=True)

608
api/goals.py Normal file
View File

@@ -0,0 +1,608 @@
"""WebUI bridge for Hermes persistent session goals."""
from __future__ import annotations
import copy
import logging
import re
import time
from pathlib import Path
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
try: # Exposed as a module attribute so tests can monkeypatch it directly.
from hermes_cli.goals import ( # type: ignore
CONTINUATION_PROMPT_TEMPLATE,
DEFAULT_MAX_TURNS,
GoalManager as _NativeGoalManager,
GoalState,
judge_goal,
)
except Exception: # pragma: no cover - depends on installed hermes-agent
CONTINUATION_PROMPT_TEMPLATE = "" # type: ignore
DEFAULT_MAX_TURNS = 20 # type: ignore
_NativeGoalManager = None # type: ignore
GoalState = None # type: ignore
judge_goal = None # type: ignore
GoalManager = _NativeGoalManager # type: ignore
_DB_CACHE: dict[str, Any] = {}
def _default_max_turns() -> int:
"""Return the configured /goal turn budget, defaulting to Hermes' 20 turns."""
try:
from api import config as _config
cfg = getattr(_config, "cfg", {}) or {}
goals_cfg = cfg.get("goals", {}) if isinstance(cfg, dict) else {}
if not isinstance(goals_cfg, dict):
return int(DEFAULT_MAX_TURNS or 20)
return max(1, int(goals_cfg.get("max_turns", DEFAULT_MAX_TURNS or 20) or 20))
except Exception:
return int(DEFAULT_MAX_TURNS or 20)
def _meta_key(session_id: str) -> str:
return f"goal:{session_id}"
def _profile_db(profile_home: str | Path):
"""Return a SessionDB pinned to *profile_home*, without reading HERMES_HOME.
The upstream Hermes GoalManager persists through hermes_cli.goals.load_goal(),
which resolves SessionDB from process-global HERMES_HOME. WebUI sessions are
profile-scoped and can run concurrently, so the WebUI bridge uses an explicit
state.db path whenever the caller provides the session's profile home.
"""
home = Path(profile_home).expanduser().resolve()
key = str(home)
cached = _DB_CACHE.get(key)
if cached is not None:
return cached
try:
from hermes_state import SessionDB # type: ignore
db = SessionDB(db_path=home / "state.db")
except Exception as exc: # pragma: no cover - import/env dependent
logger.debug("GoalManager profile DB unavailable for %s: %s", home, exc)
return None
_DB_CACHE[key] = db
return db
class _ProfileGoalManager:
"""Small WebUI-local GoalManager adapter with explicit profile persistence."""
def __init__(self, session_id: str, *, profile_home: str | Path, default_max_turns: int = 20):
if GoalState is None:
raise RuntimeError("Hermes goal state unavailable")
self.session_id = session_id
self.profile_home = Path(profile_home).expanduser().resolve()
self.default_max_turns = int(default_max_turns or DEFAULT_MAX_TURNS or 20)
self._state = self._load()
@property
def state(self):
return self._state
def _load(self):
db = _profile_db(self.profile_home)
if db is None or not self.session_id:
return None
try:
raw = db.get_meta(_meta_key(self.session_id))
except Exception as exc:
logger.debug("GoalManager profile get_meta failed: %s", exc)
return None
if not raw:
return None
try:
return GoalState.from_json(raw) # type: ignore[union-attr]
except Exception as exc:
logger.warning("GoalManager profile state parse failed for %s: %s", self.session_id, exc)
return None
def _save(self, state) -> None:
db = _profile_db(self.profile_home)
if db is None or not self.session_id or state is None:
return
try:
db.set_meta(_meta_key(self.session_id), state.to_json())
except Exception as exc:
logger.debug("GoalManager profile set_meta failed: %s", exc)
def is_active(self) -> bool:
return self._state is not None and self._state.status == "active"
def has_goal(self) -> bool:
return self._state is not None and self._state.status in ("active", "paused")
def status_line(self) -> str:
s = self._state
if s is None or s.status in ("cleared",):
return "No active goal. Set one with /goal <text>."
turns = f"{s.turns_used}/{s.max_turns} turns"
if s.status == "active":
return f"⊙ Goal (active, {turns}): {s.goal}"
if s.status == "paused":
extra = f"{s.paused_reason}" if s.paused_reason else ""
return f"⏸ Goal (paused, {turns}{extra}): {s.goal}"
if s.status == "done":
return f"✓ Goal done ({turns}): {s.goal}"
return f"Goal ({s.status}, {turns}): {s.goal}"
def set(self, goal: str, *, max_turns: Optional[int] = None):
goal = (goal or "").strip()
if not goal:
raise ValueError("goal text is empty")
state = GoalState( # type: ignore[operator]
goal=goal,
status="active",
turns_used=0,
max_turns=int(max_turns) if max_turns else self.default_max_turns,
created_at=time.time(),
last_turn_at=0.0,
)
self._state = state
self._save(state)
return state
def pause(self, reason: str = "user-paused"):
if not self._state:
return None
self._state.status = "paused"
self._state.paused_reason = reason
self._save(self._state)
return self._state
def resume(self, *, reset_budget: bool = True):
if not self._state:
return None
self._state.status = "active"
self._state.paused_reason = None
if reset_budget:
self._state.turns_used = 0
self._save(self._state)
return self._state
def clear(self) -> None:
if self._state is None:
return
self._state.status = "cleared"
self._save(self._state)
self._state = None
def evaluate_after_turn(self, last_response: str, *, user_initiated: bool = True) -> Dict[str, Any]:
state = self._state
if state is None or state.status != "active":
return {
"status": state.status if state else None,
"should_continue": False,
"continuation_prompt": None,
"verdict": "inactive",
"reason": "no active goal",
"message": "",
}
state.turns_used += 1
state.last_turn_at = time.time()
if judge_goal is None:
verdict, reason = "continue", "goal judge unavailable"
else:
verdict, reason = judge_goal(state.goal, str(last_response or ""))
state.last_verdict = verdict
state.last_reason = reason
if verdict == "done":
state.status = "done"
self._save(state)
return {
"status": "done",
"should_continue": False,
"continuation_prompt": None,
"verdict": "done",
"reason": reason,
"message": f"✓ Goal achieved: {reason}",
}
if state.turns_used >= state.max_turns:
state.status = "paused"
state.paused_reason = f"turn budget exhausted ({state.turns_used}/{state.max_turns})"
self._save(state)
return {
"status": "paused",
"should_continue": False,
"continuation_prompt": None,
"verdict": "continue",
"reason": reason,
"message": (
f"⏸ Goal paused — {state.turns_used}/{state.max_turns} turns used. "
"Use /goal resume to keep going, or /goal clear to stop."
),
}
self._save(state)
return {
"status": "active",
"should_continue": True,
"continuation_prompt": self.next_continuation_prompt(),
"verdict": "continue",
"reason": reason,
"message": f"↻ Continuing toward goal ({state.turns_used}/{state.max_turns}): {reason}",
}
def next_continuation_prompt(self) -> Optional[str]:
if not self._state or self._state.status != "active":
return None
return CONTINUATION_PROMPT_TEMPLATE.format(goal=self._state.goal)
def _manager(session_id: str, *, profile_home: str | Path | None = None):
if GoalManager is None:
return None
if profile_home and GoalManager is _NativeGoalManager and GoalState is not None:
try:
return _ProfileGoalManager(
session_id=session_id,
profile_home=profile_home,
default_max_turns=_default_max_turns(),
)
except Exception as exc:
logger.debug("Profile-scoped GoalManager unavailable: %s", exc)
return None
return GoalManager(session_id=session_id, default_max_turns=_default_max_turns())
def _state_payload(state: Any) -> Optional[Dict[str, Any]]:
if state is None:
return None
return {
"goal": getattr(state, "goal", "") or "",
"status": getattr(state, "status", "") or "",
"turns_used": int(getattr(state, "turns_used", 0) or 0),
"max_turns": int(getattr(state, "max_turns", 0) or 0),
"last_verdict": getattr(state, "last_verdict", None),
"last_reason": getattr(state, "last_reason", None),
"paused_reason": getattr(state, "paused_reason", None),
}
def _payload(
*,
ok: bool = True,
action: str,
message: str,
state: Any = None,
error: str | None = None,
kickoff_prompt: str | None = None,
decision: Dict[str, Any] | None = None,
message_key: str | None = None,
message_args: list[Any] | None = None,
) -> Dict[str, Any]:
body: Dict[str, Any] = {
"ok": bool(ok),
"action": action,
"message": message,
"goal": _state_payload(state),
}
if error:
body["error"] = error
if kickoff_prompt:
body["kickoff_prompt"] = kickoff_prompt
if decision is not None:
body["decision"] = decision
if message_key:
body["message_key"] = message_key
if message_args is not None:
body["message_args"] = [a for a in message_args if a is not None]
return body
def _goal_status_payload(state: Any, *, default_message: str | None = None) -> Dict[str, Any]:
"""Build localized-status style payload fields from a goal state."""
if default_message is None:
default_message = "No active goal. Set one with /goal <text>."
if state is None:
return {"message": default_message, "message_key": "goal_status_none"}
status = str(getattr(state, "status", "") or "").strip()
if status in ("cleared",):
return {"message": default_message, "message_key": "goal_status_none"}
turns_used = int(getattr(state, "turns_used", 0) or 0)
max_turns = int(getattr(state, "max_turns", 0) or 0)
goal = str(getattr(state, "goal", "") or "")
if status == "active":
return {
"message": f"⊙ Goal (active, {turns_used}/{max_turns} turns): {goal}",
"message_key": "goal_status_active",
"message_args": [turns_used, max_turns, goal],
}
if status == "paused":
reason = str(getattr(state, "paused_reason", "") or "")
return {
"message": f"⏸ Goal (paused, {turns_used}/{max_turns}{'' + reason if reason else ''}): {goal}",
"message_key": "goal_status_paused",
"message_args": [turns_used, max_turns, reason, goal],
}
if status == "done":
return {
"message": f"✓ Goal done ({turns_used}/{max_turns}): {goal}",
"message_key": "goal_status_done",
"message_args": [turns_used, max_turns, goal],
}
return {
"message": f"Goal ({status}, {turns_used}/{max_turns}): {goal}",
"message_args": [status, turns_used, max_turns, goal],
}
def _extract_goal_turns_from_message(message: str) -> tuple[int, int]:
"""Best-effort extraction for continuation messages like '(1/20)'."""
if not message:
return 0, 0
match = re.search(r"\((\d+)\s*/\s*(\d+)\)", message)
if not match:
return 0, 0
try:
return int(match.group(1)), int(match.group(2))
except Exception:
return 0, 0
def _goal_decision_payload(
decision: Dict[str, Any],
state: Any,
) -> Dict[str, Any]:
"""Attach goal message i18n key/args to an evaluation decision."""
if not isinstance(decision, dict):
return decision
status = str(decision.get("status") or "").strip()
reason = str(decision.get("reason") or "").strip()
turns_used = int(getattr(state, "turns_used", 0) or 0)
max_turns = int(getattr(state, "max_turns", 0) or 0)
if (turns_used, max_turns) == (0, 0):
turns_used, max_turns = _extract_goal_turns_from_message(str(decision.get("message") or ""))
if status == "done":
return {
**decision,
"message_key": "goal_achieved",
"message_args": [reason],
}
if status == "paused":
return {
**decision,
"message_key": "goal_paused_budget_exhausted",
"message_args": [turns_used, max_turns],
}
if decision.get("should_continue"):
return {
**decision,
"message_key": "goal_continuing",
"message_args": [turns_used, max_turns, reason],
}
return decision
def goal_state_snapshot(session_id: str, *, profile_home: str | Path | None = None) -> Any:
"""Return a deep copy of current goal state for rollback before kickoff."""
mgr = _manager(str(session_id or ""), profile_home=profile_home)
if mgr is None:
return None
return copy.deepcopy(getattr(mgr, "state", None))
def restore_goal_state(session_id: str, snapshot: Any, *, profile_home: str | Path | None = None) -> None:
"""Restore a prior goal state after kickoff stream creation fails."""
mgr = _manager(str(session_id or ""), profile_home=profile_home)
if mgr is None:
return
if snapshot is None:
try:
mgr.clear()
except Exception:
pass
return
if isinstance(mgr, _ProfileGoalManager):
mgr._state = snapshot
mgr._save(snapshot)
return
try:
from hermes_cli.goals import save_goal # type: ignore
save_goal(str(session_id or ""), snapshot)
except Exception as exc: # pragma: no cover - native fallback only
logger.debug("Goal state restore failed for %s: %s", session_id, exc)
def goal_command_payload(
session_id: str,
args: str = "",
*,
stream_running: bool = False,
profile_home: str | Path | None = None,
) -> Dict[str, Any]:
"""Return the WebUI response payload for a /goal command.
Mirrors the gateway command semantics:
- /goal or /goal status shows status
- /goal pause pauses
- /goal resume resumes without auto-starting a turn
- /goal clear|stop|done clears
- /goal <text> sets a new active goal and returns kickoff_prompt so the
caller can start the first normal user-role turn immediately.
"""
sid = str(session_id or "").strip()
if not sid:
return _payload(ok=False, action="error", error="missing_session", message="session_id required")
mgr = _manager(sid, profile_home=profile_home)
if mgr is None:
return _payload(ok=False, action="error", error="unavailable", message="Goals unavailable on this session.")
text = str(args or "").strip()
lower = text.lower()
if not text or lower == "status":
state = getattr(mgr, "state", None)
status_payload = _goal_status_payload(state)
return _payload(action="status", state=state, **status_payload)
if lower == "pause":
state = mgr.pause(reason="user-paused")
if state is None:
return _payload(
ok=False,
action="pause",
error="no_goal",
message="No goal set.",
message_key="goal_no_goal",
)
return _payload(
action="pause",
message=f"⏸ Goal paused: {state.goal}",
message_key="goal_paused",
message_args=[str(state.goal)],
state=state,
)
if lower == "resume":
state = mgr.resume()
if state is None:
return _payload(
ok=False,
action="resume",
error="no_goal",
message="No goal to resume.",
message_key="goal_no_goal",
)
return _payload(
action="resume",
message=(
f"▶ Goal resumed: {state.goal}\n"
"Send a new message, or type continue, to kick it off."
),
message_key="goal_resumed",
message_args=[str(state.goal)],
state=state,
)
if lower in ("clear", "stop", "done"):
had = bool(mgr.has_goal())
mgr.clear()
return _payload(
action="clear",
message="Goal cleared." if had else "No active goal.",
message_key="goal_cleared" if had else "goal_no_goal",
state=getattr(mgr, "state", None),
)
if stream_running:
return _payload(
ok=False,
action="set",
error="agent_running",
message=(
"Agent is running — use /goal status / pause / clear mid-run, "
"or /stop before setting a new goal."
),
)
try:
state = mgr.set(text)
except ValueError as exc:
return _payload(ok=False, action="set", error="invalid_goal", message=f"Invalid goal: {exc}")
return _payload(
action="set",
message=(
f"⊙ Goal set ({state.max_turns}-turn budget): {state.goal}\n"
"I'll keep working until the goal is done, you pause/clear it, or the budget is exhausted.\n"
"Controls: /goal status · /goal pause · /goal resume · /goal clear"
),
message_key="goal_set",
message_args=[state.max_turns, state.goal],
state=state,
kickoff_prompt=state.goal,
)
def has_active_goal(
session_id: str,
*,
profile_home: str | Path | None = None,
) -> bool:
"""Return True when the session has an active standing goal to evaluate."""
sid = str(session_id or "").strip()
if not sid:
return False
mgr = _manager(sid, profile_home=profile_home)
if mgr is None:
return False
try:
return bool(mgr.is_active())
except Exception as exc:
logger.debug("goal active-state check failed for session=%s: %s", sid, exc)
return False
def evaluate_goal_after_turn(
session_id: str,
last_response: str,
*,
user_initiated: bool = True,
profile_home: str | Path | None = None,
) -> Dict[str, Any]:
"""Evaluate a completed turn against the standing goal, if any."""
sid = str(session_id or "").strip()
if not sid:
return {
"status": None,
"should_continue": False,
"continuation_prompt": None,
"verdict": "inactive",
"reason": "missing session_id",
"message": "",
}
mgr = _manager(sid, profile_home=profile_home)
if mgr is None:
return {
"status": None,
"should_continue": False,
"continuation_prompt": None,
"verdict": "inactive",
"reason": "goals unavailable",
"message": "",
}
try:
if not mgr.is_active():
return {
"status": getattr(getattr(mgr, "state", None), "status", None),
"should_continue": False,
"continuation_prompt": None,
"verdict": "inactive",
"reason": "no active goal",
"message": "",
}
decision = mgr.evaluate_after_turn(str(last_response or ""), user_initiated=user_initiated)
except Exception as exc:
logger.debug("goal evaluation failed for session=%s: %s", sid, exc)
return {
"status": None,
"should_continue": False,
"continuation_prompt": None,
"verdict": "error",
"reason": f"goal evaluation failed: {type(exc).__name__}",
"message": "",
}
if not isinstance(decision, dict):
decision = {}
decision.setdefault("should_continue", False)
decision.setdefault("continuation_prompt", None)
decision.setdefault("message", "")
decision = dict(decision)
decision = _goal_decision_payload(decision, getattr(mgr, "state", None))
return decision

View File

@@ -2,10 +2,27 @@
Hermes Web UI -- HTTP helper functions.
"""
import json as _json
import logging
import os
import re as _re
import ssl
from pathlib import Path
from api.config import IMAGE_EXTS, MD_EXTS
logger = logging.getLogger(__name__)
# Treat stalled/closed HTTP clients as normal disconnects. Long-lived SSE
# connections often end this way when a browser tab sleeps, a phone switches
# networks, or Tailscale leaves the socket half-closed.
_CLIENT_DISCONNECT_ERRORS = (
BrokenPipeError,
ConnectionResetError,
ConnectionAbortedError,
TimeoutError,
ssl.SSLError,
)
def require(body: dict, *fields) -> None:
"""Phase D: Validate required fields. Raises ValueError with clean message."""
@@ -35,20 +52,81 @@ def safe_resolve(root: Path, requested: str) -> Path:
return resolved
_CSP_CONNECT_BASE = (
"'self' http://127.0.0.1:* http://localhost:* http://ipc.localhost "
"ws://127.0.0.1:* ws://localhost:*"
)
_CSP_EXTRA_CONNECT_RE = _re.compile(
r"^(?:https?|wss?)://(?:\*\.)?[A-Za-z0-9._~-]+(?::(?P<port>\d{1,5}|\*))?$"
)
_CSP_HEADER_NAME = 'Content-Security-Policy'
_CSP_SHARED_POLICY_TEMPLATE = (
"default-src 'self' https://*.cloudflareaccess.com; "
"object-src 'none'; "
"frame-ancestors 'none'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://static.cloudflareinsights.com blob:; "
"worker-src blob: 'self' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
"img-src 'self' data: https: blob:; "
"font-src 'self' data: https://fonts.gstatic.com; "
"media-src 'self' data: blob:; "
"connect-src {connect_src}; "
"manifest-src 'self' https://*.cloudflareaccess.com; "
"base-uri 'self'; form-action 'self'"
)
def _valid_csp_extra_connect_source(source: str) -> bool:
match = _CSP_EXTRA_CONNECT_RE.fullmatch(source)
if not match:
return False
port = match.group("port")
if not port or port == "*":
return True
try:
return 1 <= int(port) <= 65535
except ValueError:
return False
def _csp_extra_connect_src() -> str:
raw = os.getenv("HERMES_WEBUI_CSP_CONNECT_EXTRA", "").strip()
if not raw:
return ""
sources = raw.split()
if not sources or any(not _valid_csp_extra_connect_source(src) for src in sources):
logger.warning("Ignoring invalid HERMES_WEBUI_CSP_CONNECT_EXTRA value")
return ""
return " " + " ".join(sources)
def _csp_connect_src(extra_connect_src: str = "") -> str:
return f"{_CSP_CONNECT_BASE} https://cdn.jsdelivr.net{extra_connect_src}"
def _build_csp_enforced_policy(extra_connect_src: str | None = None) -> str:
if extra_connect_src is None:
extra_connect_src = _csp_extra_connect_src()
return _CSP_SHARED_POLICY_TEMPLATE.format(
connect_src=_csp_connect_src(extra_connect_src)
)
def _build_csp_report_only_policy(extra_connect_src: str | None = None) -> str:
return (
_build_csp_enforced_policy(extra_connect_src)
+ "; report-uri /api/csp-report; report-to csp-endpoint"
)
def _security_headers(handler):
"""Add security headers to every response."""
extra_connect_src = _csp_extra_connect_src()
handler._csp_extra_connect_src = extra_connect_src
handler.send_header('X-Content-Type-Options', 'nosniff')
handler.send_header('X-Frame-Options', 'DENY')
handler.send_header('Referrer-Policy', 'same-origin')
handler.send_header(
'Content-Security-Policy',
"default-src 'self' https://*.cloudflareaccess.com; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
"img-src 'self' data: https: blob:; font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com; connect-src 'self'; "
"manifest-src 'self' https://*.cloudflareaccess.com; "
"base-uri 'self'; form-action 'self'"
)
handler.send_header(_CSP_HEADER_NAME, _build_csp_enforced_policy(extra_connect_src))
handler.send_header(
'Permissions-Policy',
'camera=(), microphone=(self), geolocation=(), clipboard-write=(self)'
@@ -64,6 +142,25 @@ def _accepts_gzip(handler) -> bool:
return 'gzip' in ae
def _safe_write(handler, body: bytes) -> None:
"""Write response body, ignoring expected client disconnect errors.
Logs disconnects at debug level so they are observable without
polluting stdout/stderr during normal operation (SSE reconnects,
tab closes, mobile network switches, etc.).
"""
try:
handler.end_headers()
handler.wfile.write(body)
except _CLIENT_DISCONNECT_ERRORS as exc:
import logging
logging.getLogger("hermes.webui").debug(
"Client disconnected mid-response (%s): %s",
type(exc).__name__,
getattr(handler, "path", "?"),
)
def j(handler, payload, status: int=200, extra_headers: dict=None) -> None:
"""Send a JSON response.
@@ -88,8 +185,7 @@ def j(handler, payload, status: int=200, extra_headers: dict=None) -> None:
if extra_headers:
for k, v in extra_headers.items():
handler.send_header(k, v)
handler.end_headers()
handler.wfile.write(body)
_safe_write(handler, body)
def t(handler, payload, status: int=200, content_type: str='text/plain; charset=utf-8') -> None:
@@ -100,8 +196,7 @@ def t(handler, payload, status: int=200, content_type: str='text/plain; charset=
handler.send_header('Content-Length', str(len(body)))
handler.send_header('Cache-Control', 'no-store')
_security_headers(handler)
handler.end_headers()
handler.wfile.write(body)
_safe_write(handler, body)
MAX_BODY_BYTES = 20 * 1024 * 1024 # 20MB limit for non-upload POST bodies
@@ -111,7 +206,8 @@ MAX_BODY_BYTES = 20 * 1024 * 1024 # 20MB limit for non-upload POST bodies
def _build_redact_fn():
"""Return a redactor backed by hermes-agent plus local fallback patterns."""
# Minimal fallback covering the most common credential prefixes.
# Fallback mirrors the agent's known credential prefixes so WebUI API
# responses remain a hard redaction boundary even without hermes-agent.
# Keep this active even when hermes-agent is importable so API responses do
# not regress if the agent redactor misses a token shape.
_CRED_RE = _re.compile(
@@ -123,10 +219,34 @@ def _build_redact_fn():
r"|ghu_[A-Za-z0-9]{10,}" # GitHub user-to-server token
r"|ghs_[A-Za-z0-9]{10,}" # GitHub server-to-server token
r"|ghr_[A-Za-z0-9]{10,}" # GitHub refresh token
r"|xox[baprs]-[A-Za-z0-9-]{10,}" # Slack tokens
r"|AIza[A-Za-z0-9_-]{30,}" # Google API keys
r"|pplx-[A-Za-z0-9]{10,}" # Perplexity
r"|fal_[A-Za-z0-9_-]{10,}" # Fal.ai
r"|fc-[A-Za-z0-9]{10,}" # Firecrawl
r"|bb_live_[A-Za-z0-9_-]{10,}" # BrowserBase
r"|gAAAA[A-Za-z0-9_=-]{20,}" # Codex encrypted tokens
r"|AKIA[A-Z0-9]{16}" # AWS Access Key ID
r"|xox[baprs]-[A-Za-z0-9-]{10,}" # Slack tokens
r"|hf_[A-Za-z0-9]{10,}" # HuggingFace token
r"|SG\.[A-Za-z0-9_-]{10,}" # SendGrid API key
r"|sk_live_[A-Za-z0-9]{10,}" # Stripe secret key (live)
r"|sk_test_[A-Za-z0-9]{10,}" # Stripe secret key (test)
r"|rk_live_[A-Za-z0-9]{10,}" # Stripe restricted key
r"|SG\.[A-Za-z0-9_-]{10,}" # SendGrid API key
r"|hf_[A-Za-z0-9]{10,}" # HuggingFace token
r"|r8_[A-Za-z0-9]{10,}" # Replicate API token
r"|npm_[A-Za-z0-9]{10,}" # npm access token
r"|pypi-[A-Za-z0-9_-]{10,}" # PyPI API token
r"|dop_v1_[A-Za-z0-9]{10,}" # DigitalOcean PAT
r"|doo_v1_[A-Za-z0-9]{10,}" # DigitalOcean OAuth
r"|am_[A-Za-z0-9_-]{10,}" # AgentMail API key
r"|sk_[A-Za-z0-9_]{10,}" # ElevenLabs TTS key
r"|tvly-[A-Za-z0-9]{10,}" # Tavily search API key
r"|exa_[A-Za-z0-9]{10,}" # Exa search API key
r"|gsk_[A-Za-z0-9]{10,}" # Groq Cloud API key
r"|syt_[A-Za-z0-9]{10,}" # Matrix access token
r"|retaindb_[A-Za-z0-9]{10,}" # RetainDB API key
r"|hsk-[A-Za-z0-9]{10,}" # Hindsight API key
r"|mem0_[A-Za-z0-9]{10,}" # Mem0 Platform API key
r"|brv_[A-Za-z0-9]{10,}" # ByteRover API key
r")(?![A-Za-z0-9_-])"
)
_AUTH_HDR_RE = _re.compile(r"(Authorization:\s*Bearer\s+)(\S+)", _re.IGNORECASE)
@@ -178,6 +298,103 @@ def _build_redact_fn():
_redact_fn_cached = _build_redact_fn()
_SENSITIVE_CASE_MARKERS = (
"sk-",
"ghp_",
"github_pat_",
"gho_",
"ghu_",
"ghs_",
"ghr_",
"AKIA",
"xoxb-",
"xoxa-",
"xoxp-",
"xoxr-",
"xoxs-",
"AIza",
"pplx-",
"fal_",
"fc-",
"bb_live_",
"gAAAA",
"sk_live_",
"sk_test_",
"rk_live_",
"SG.",
"hf_",
"r8_",
"npm_",
"pypi-",
"dop_v1_",
"doo_v1_",
"am_",
"sk_",
"tvly-",
"exa_",
"gsk_",
"syt_",
"retaindb_",
"hsk-",
"mem0_",
"brv_",
"eyJ",
"-----BEGIN",
)
_SENSITIVE_LOWER_MARKERS = (
"authorization: bearer ",
"private key",
"postgres://",
"postgresql://",
"mysql://",
"mongodb://",
"redis://",
"amqp://",
"://", # stage-348 Opus SHOULD-FIX: catch http(s)/ws(s)/ftp URL userinfo + sensitive query params (#2171 follow-up)
"access_token",
"refresh_token",
"id_token",
"api_key",
"apikey",
"client_secret",
"auth_token",
"raw_secret",
"secret_input",
"key_material",
"x-amz-signature",
"token=",
"secret=",
"password=",
"authorization=",
"key=",
'"token"',
'"secret"',
'"password"',
'"bearer"',
)
_SENSITIVE_TELEGRAM_MARKER_RE = _re.compile(r"(?:bot)?\d{8,}:[-A-Za-z0-9_]{30,}")
_SENSITIVE_DISCORD_MARKER_RE = _re.compile(r"<@!?\d{17,20}>")
_SENSITIVE_PHONE_MARKER_RE = _re.compile(r"(?<![A-Za-z0-9])\+[1-9]\d{6,14}(?![A-Za-z0-9])")
def _might_contain_sensitive_text(text: str) -> bool:
"""Cheap prefilter before the full agent+fallback redaction pass."""
if not isinstance(text, str) or not text:
return False
if any(marker in text for marker in _SENSITIVE_CASE_MARKERS):
return True
lower = text.lower()
if any(marker in lower for marker in _SENSITIVE_LOWER_MARKERS):
return True
if ":" in text and _SENSITIVE_TELEGRAM_MARKER_RE.search(text):
return True
if "<@" in text and _SENSITIVE_DISCORD_MARKER_RE.search(text):
return True
if "+" in text and _SENSITIVE_PHONE_MARKER_RE.search(text):
return True
return False
def _redact_text(text: str, *, _enabled: bool | None = None) -> str:
"""Redact sensitive text from API responses. Respects api_redact_enabled setting.
@@ -193,6 +410,8 @@ def _redact_text(text: str, *, _enabled: bool | None = None) -> str:
_enabled = bool(load_settings().get("api_redact_enabled", True))
if not _enabled:
return text
if not _might_contain_sensitive_text(text):
return text
return _redact_fn_cached(text)
@@ -212,9 +431,10 @@ def _redact_value(v, *, _enabled: bool | None = None):
def redact_session_data(session_dict: dict) -> dict:
"""Redact credentials from message content and tool_call data before API response.
"""Redact credentials from message content, tool data, and session sidecars.
Applies to: messages[], tool_calls[], and title.
Applies to: messages[], tool_calls[], todo_state, runtime_journal_snapshot,
and title.
The underlying session file is not modified; redaction is response-layer only.
Reads the ``api_redact_enabled`` setting ONCE for the entire response and
@@ -232,13 +452,38 @@ def redact_session_data(session_dict: dict) -> dict:
result['messages'] = _redact_value(result['messages'], _enabled=_enabled)
if 'tool_calls' in result:
result['tool_calls'] = _redact_value(result['tool_calls'], _enabled=_enabled)
if 'todo_state' in result:
result['todo_state'] = _redact_value(result['todo_state'], _enabled=_enabled)
if 'runtime_journal_snapshot' in result:
result['runtime_journal_snapshot'] = _redact_value(
result['runtime_journal_snapshot'],
_enabled=_enabled,
)
return result
def read_body(handler) -> dict:
"""Read and JSON-parse a POST request body (capped at 20MB)."""
length = int(handler.headers.get('Content-Length', 0))
raw_length = handler.headers.get('Content-Length', 0)
try:
length = int(raw_length)
except (TypeError, ValueError):
try:
handler.close_connection = True
except Exception:
pass
raise ValueError(f'Invalid Content-Length: {raw_length!r}')
if length < 0:
try:
handler.close_connection = True
except Exception:
pass
raise ValueError(f'Invalid Content-Length: {length}')
if length > MAX_BODY_BYTES:
try:
handler.close_connection = True
except Exception:
pass
raise ValueError(f'Request body too large ({length} bytes, max {MAX_BODY_BYTES})')
raw = handler.rfile.read(length) if length else b'{}'
try:
@@ -250,10 +495,47 @@ def read_body(handler) -> dict:
# ── Profile cookie helpers (issue #798) ─────────────────────────────────────
PROFILE_COOKIE_NAME = 'hermes_profile'
_PROFILE_COOKIE_ENV = 'HERMES_WEBUI_PROFILE_COOKIE_NAME'
_LEGACY_PROFILE_COOKIE_ENV = 'WEBUI_PROFILE_COOKIE_NAME'
_legacy_profile_cookie_warned = False
def get_profile_cookie_name() -> str:
"""Return the cookie name used to persist the active WebUI profile.
Honours ``HERMES_WEBUI_PROFILE_COOKIE_NAME`` so multiple WebUI instances
sharing a hostname (different ports) can use distinct profile-cookie names
instead of trampling each other; browsers scope cookies by host, not
host+port (RFC 6265). The original ``WEBUI_PROFILE_COOKIE_NAME`` is still
honoured as a deprecated fallback (warned once per process, since this is
called on every request).
"""
name = os.getenv(_PROFILE_COOKIE_ENV, '').strip()
if name:
return name
legacy = os.getenv(_LEGACY_PROFILE_COOKIE_ENV, '').strip()
if legacy:
global _legacy_profile_cookie_warned
if not _legacy_profile_cookie_warned:
logger.warning(
'%s is deprecated; use %s instead.',
_LEGACY_PROFILE_COOKIE_ENV,
_PROFILE_COOKIE_ENV,
)
_legacy_profile_cookie_warned = True
return legacy
return PROFILE_COOKIE_NAME
def get_profile_cookie(handler) -> str | None:
"""Extract the hermes_profile cookie value from the request, or None."""
"""Extract and authenticate the active-profile cookie value.
When WebUI auth is enabled, the profile cookie is treated as an
authorization input for profile-scoped routes. Require it to be signed for
the current auth session so clients cannot forge ``hermes_profile`` to
impersonate another profile. In no-auth deployments, keep the historical
plain profile-name cookie behavior.
"""
cookie_header = handler.headers.get('Cookie', '')
if not cookie_header:
return None
@@ -263,18 +545,33 @@ def get_profile_cookie(handler) -> str | None:
cookie.load(cookie_header)
except _hc.CookieError:
return None
morsel = cookie.get(PROFILE_COOKIE_NAME)
if morsel and morsel.value:
# Validate against profile-name pattern before trusting
from api.profiles import _PROFILE_ID_RE
val = morsel.value
if val == 'default' or _PROFILE_ID_RE.fullmatch(val):
return val
return None
cookie_name = get_profile_cookie_name()
morsel = cookie.get(cookie_name)
if not (morsel and morsel.value):
return None
from api.profiles import _PROFILE_ID_RE
def _valid_profile_name(val: str) -> bool:
return val == 'default' or bool(_PROFILE_ID_RE.fullmatch(val))
raw_val = morsel.value
try:
from api.auth import is_auth_enabled, parse_cookie, verify_profile_cookie_value
if is_auth_enabled():
val = verify_profile_cookie_value(raw_val, parse_cookie(handler))
return val if val and _valid_profile_name(val) else None
except Exception:
logger.warning("Failed to verify active profile cookie", exc_info=True)
return None
# No-auth mode: the cookie is a per-browser UI preference, not an authz
# boundary, so retain the legacy plain profile-name format.
return raw_val if _valid_profile_name(raw_val) else None
def build_profile_cookie(name: str) -> str:
"""Build a Set-Cookie header value for the hermes_profile cookie.
def build_profile_cookie(name: str, handler=None) -> str:
"""Build a Set-Cookie header value for the active-profile cookie.
Always persist the selected profile in the cookie, including 'default'.
Clearing the cookie causes the backend to fall back to process-global
@@ -287,8 +584,29 @@ def build_profile_cookie(name: str) -> str:
"""
import http.cookies as _hc
cookie = _hc.SimpleCookie()
cookie[PROFILE_COOKIE_NAME] = name
cookie[PROFILE_COOKIE_NAME]['path'] = '/'
cookie[PROFILE_COOKIE_NAME]['httponly'] = True
cookie[PROFILE_COOKIE_NAME]['samesite'] = 'Lax'
return cookie[PROFILE_COOKIE_NAME].OutputString()
cookie_name = get_profile_cookie_name()
value = name
# Guard against a future call site silently emitting an UNSIGNED profile
# cookie while auth is enabled (which a client could then... not forge, but
# it would weaken the binding). If auth is on we require a handler so the
# cookie is bound to the session. (#4023 Opus hardening.)
try:
from api.auth import is_auth_enabled
_auth_on = is_auth_enabled()
except Exception:
_auth_on = False
if _auth_on and handler is None:
raise RuntimeError("build_profile_cookie requires a request handler when auth is enabled (to bind the profile cookie to the session)")
if handler is not None:
try:
from api.auth import is_auth_enabled, parse_cookie, sign_profile_cookie_value
if is_auth_enabled():
value = sign_profile_cookie_value(name, parse_cookie(handler))
except Exception as exc:
logger.warning("Failed to sign active profile cookie", exc_info=True)
raise RuntimeError("could not sign active profile cookie") from exc
cookie[cookie_name] = value
cookie[cookie_name]['path'] = '/'
cookie[cookie_name]['httponly'] = True
cookie[cookie_name]['samesite'] = 'Lax'
return cookie[cookie_name].OutputString()

View File

@@ -1,9 +1,14 @@
"""Read-only Hermes Kanban bridge for the WebUI.
"""Hermes Kanban bridge for the WebUI.
This module exposes a small WebUI-native API under ``/api/kanban/*`` while
keeping Hermes Agent's ``hermes_cli.kanban_db`` as the only source of truth.
The first integration is deliberately read-only; write/move semantics can be
added in later focused PRs.
This module exposes a full CRUD API under ``/api/kanban/*`` while keeping
Hermes Agent's ``hermes_cli.kanban_db`` as the only source of truth.
Supported operations:
- Task CRUD (create, read, patch, bulk update, archive)
- Multi-board management (list, create, archive, switch)
- Task dependency links (create, delete)
- SSE live event stream for real-time updates
- Comments and worker dispatch integration
"""
from __future__ import annotations
@@ -20,6 +25,7 @@ _TASK_PREFIX = "/api/kanban/tasks/"
def _kb():
"""Lazily import hermes_cli.kanban_db to avoid circular imports at module load."""
from hermes_cli import kanban_db as kb
return kb
@@ -73,12 +79,29 @@ def _normalise_board_or_raise(raw):
def _conn(board=None):
"""Initialize the kanban DB for the given board slug and return a context manager
that yields a sqlite connection and CLOSES it on exit.
Must be ``kb.connect_closing`` — a raw ``kb.connect()`` connection used as
``with _conn(...) as conn:`` only gets sqlite3's transaction-scope context
manager, which never closes the file descriptor. In this long-lived server
that leaks one FD per request and pins stale WAL snapshots (FDs to deleted
``-wal``/``-shm`` files), which starves SQLite checkpoints on the shared
kanban DB and aggravates probe⇄checkpoint contention for every process.
"""
kb = _kb()
kb.init_db(board=board)
closing = getattr(kb, "connect_closing", None)
if closing is not None:
return closing(board=board)
# Older kanban_db builds (and lightweight test doubles) without
# connect_closing: fall back to the raw connection; sqlite3's own
# context manager at least scopes the transaction.
return kb.connect(board=board)
def _obj_dict(value):
"""Coerce a dataclass or arbitrary object to a plain dict; returns None unchanged."""
if value is None:
return None
if is_dataclass(value):
@@ -89,6 +112,7 @@ def _obj_dict(value):
def _task_dict(task):
"""Convert a task to a JSON-serialisable dict, annotating it with computed age_seconds and progress fields."""
data = _obj_dict(task)
if not data:
return data
@@ -103,6 +127,7 @@ def _task_dict(task):
def _latest_event_id(conn) -> int:
"""Return the highest event id in task_events, falling back to 0 when the table is empty."""
try:
row = conn.execute("SELECT COALESCE(MAX(id), 0) AS latest FROM task_events").fetchone()
return int(row["latest"] or 0)
@@ -111,6 +136,7 @@ def _latest_event_id(conn) -> int:
def _bool_query(parsed, name: str, default: bool = False) -> bool:
"""Extract a boolean query param, treating 1/true/yes/on (case-insensitive) as True."""
raw = (parse_qs(parsed.query or "").get(name) or [None])[0]
if raw is None:
return default
@@ -118,11 +144,13 @@ def _bool_query(parsed, name: str, default: bool = False) -> bool:
def _str_query(parsed, name: str):
"""Extract a string query param, returning None when the param is absent or blank."""
raw = (parse_qs(parsed.query or "").get(name) or [None])[0]
return str(raw).strip() or None if raw is not None else None
def _int_query(parsed, name: str, default=None, *, minimum=None, maximum=None):
"""Extract an integer query param, clamped to [minimum, maximum] when those bounds are provided."""
raw = _str_query(parsed, name)
if raw is None:
return default
@@ -138,6 +166,7 @@ def _int_query(parsed, name: str, default=None, *, minimum=None, maximum=None):
def _task_link_counts(conn, tasks):
"""Return a dict mapping each task id to its {parents, children} dependency link counts."""
counts = {task.id: {"parents": 0, "children": 0} for task in tasks}
try:
rows = conn.execute("SELECT parent_id, child_id FROM task_links").fetchall()
@@ -150,6 +179,7 @@ def _task_link_counts(conn, tasks):
def _comment_counts(conn):
"""Return a dict mapping each task id to its total comment count across the board."""
try:
rows = conn.execute(
"SELECT task_id, COUNT(*) AS n FROM task_comments GROUP BY task_id"
@@ -160,6 +190,7 @@ def _comment_counts(conn):
def _board_payload(parsed):
"""Build the full board JSON payload: kanban columns with tasks, filter state, and latest_event_id."""
board = _resolve_board(parsed)
kb = _kb()
tenant = _str_query(parsed, "tenant")
@@ -225,6 +256,7 @@ def _board_payload(parsed):
def _validate_status(status: str) -> str:
"""Validate a status string against BOARD_COLUMNS, raising ValueError for unrecognised values."""
value = str(status or "").strip().lower()
allowed = set(BOARD_COLUMNS) | {"archived"}
if value not in allowed:
@@ -299,6 +331,7 @@ def _set_status_direct(conn, task_id: str, new_status: str) -> bool:
def _create_task_payload(body: dict, *, board=None):
"""Create a new task from a parsed request body and return the task dict in a read_only envelope."""
title = str(body.get("title") or "").strip()
if not title:
raise ValueError("title is required")
@@ -331,6 +364,7 @@ def _create_task_payload(body: dict, *, board=None):
def _patch_task(conn, task_id: str, body: dict):
"""Apply a partial update to a task, routing status transitions through structured verbs (complete, block, archive)."""
kb = _kb()
task = kb.get_task(conn, task_id)
if not task:
@@ -420,6 +454,7 @@ def _patch_task(conn, task_id: str, body: dict):
def _patch_task_payload(task_id: str, body: dict, *, board=None):
"""Validate task_id, open a connection, and delegate field-level updates to _patch_task."""
task_id = str(task_id or "").strip()
if not task_id:
raise ValueError("task_id is required")
@@ -430,6 +465,7 @@ def _patch_task_payload(task_id: str, body: dict, *, board=None):
def _comment_payload(task_id: str, body: dict, *, board=None):
"""Add a comment to a task and return the new comment_id in a read_only envelope."""
task_id = str(task_id or "").strip()
comment_body = str(body.get("body") or "").strip()
if not task_id:
@@ -445,6 +481,7 @@ def _comment_payload(task_id: str, body: dict, *, board=None):
def _link_tasks_payload(body: dict, *, unlink: bool = False, board=None):
"""Create or delete a parent-child dependency link between two tasks."""
parent_id = str(body.get("parent_id") or "").strip()
child_id = str(body.get("child_id") or "").strip()
if not parent_id or not child_id:
@@ -462,6 +499,7 @@ def _link_tasks_payload(body: dict, *, unlink: bool = False, board=None):
return {"ok": True, "parent_id": parent_id, "child_id": child_id, "read_only": False}
def _links_for(conn, task_id: str) -> dict:
"""Return {parents: [...], children: [...]} dependency id lists for a task."""
kb = _kb()
return {
"parents": kb.parent_ids(conn, task_id),
@@ -470,6 +508,7 @@ def _links_for(conn, task_id: str) -> dict:
def _task_detail_payload(task_id: str, *, board=None):
"""Return the full task detail: task dict, comments, events, dependency links, and run history."""
kb = _kb()
with _conn(board=board) as conn:
task = kb.get_task(conn, task_id)
@@ -486,6 +525,7 @@ def _task_detail_payload(task_id: str, *, board=None):
def _events_payload(parsed):
"""Return paginated task events from the board's event log, starting after the ?since= cursor."""
board = _resolve_board(parsed)
since = _int_query(parsed, "since", 0, minimum=0)
limit = _int_query(parsed, "limit", 200, minimum=1, maximum=200)
@@ -518,6 +558,7 @@ def _events_payload(parsed):
def _config_payload(*, board=None):
"""Return kanban configuration: column names, known assignees, and lane/display settings from hermes_cli.config."""
kb = _kb()
try:
with _conn(board=board) as conn:
@@ -546,6 +587,7 @@ def _config_payload(*, board=None):
def _stats_payload(*, board=None):
"""Return per-status and per-assignee task counts for the board."""
kb = _kb()
with _conn(board=board) as conn:
if hasattr(kb, "board_stats"):
@@ -564,6 +606,7 @@ def _stats_payload(*, board=None):
def _assignees_payload(*, board=None):
"""Return the list of known assignees derived from task history."""
kb = _kb()
with _conn(board=board) as conn:
try:
@@ -577,6 +620,7 @@ def _assignees_payload(*, board=None):
def _task_log_payload(parsed, task_id: str):
"""Return the raw worker log content and on-disk metadata for a task's dispatcher run."""
board = _resolve_board(parsed)
kb = _kb()
tail = _int_query(parsed, "tail", None, minimum=1, maximum=2_000_000)
@@ -602,6 +646,7 @@ def _task_log_payload(parsed, task_id: str):
def _bulk_tasks_payload(body: dict, *, board=None):
"""Apply a common mutation (archive/status/assignee/priority) to multiple task ids in a single transaction."""
ids = [str(i).strip() for i in (body.get("ids") or []) if str(i).strip()]
if not ids:
raise ValueError("ids is required")
@@ -639,6 +684,7 @@ def _bulk_tasks_payload(body: dict, *, board=None):
def _dispatch_payload(parsed):
"""Trigger a single-pass kanban dispatcher run and return the dispatch result."""
board = _resolve_board(parsed)
kb = _kb()
dry_run = _bool_query(parsed, "dry_run", False)
@@ -656,6 +702,7 @@ def _dispatch_payload(parsed):
def _task_action_payload(task_id: str, body: dict, action: str, *, board=None):
"""Execute a named action (block or unblock) on a task and return the updated task dict."""
kb = _kb()
task_id = str(task_id or "").strip()
if not task_id:
@@ -702,10 +749,12 @@ def _board_meta_dict(meta):
def _board_counts_for_slug(slug):
"""Per-status task counts for a board, used to populate the board
switcher with a live "12 tasks" badge. Mirrors the agent dashboard's
``_board_counts`` helper. Best-effort — empty dict if the board's
sqlite is missing (which can happen on a freshly-created board before
the first task is added)."""
``_board_counts`` helper. Returns an empty dict for boards whose
sqlite file has not been materialized yet (freshly-created boards
with no tasks)."""
kb = _kb()
if not kb.board_exists(slug):
return {}
try:
conn = kb.connect(board=slug)
except Exception:
@@ -738,6 +787,18 @@ def _list_boards_payload(parsed):
current = kb.get_current_board()
except Exception:
current = "default"
visible_slugs = {(_board_meta_dict(meta).get("slug")) for meta in boards}
default_slug = getattr(kb, "DEFAULT_BOARD", "default")
if current not in visible_slugs:
# The on-disk active-board pointer can outlive an archived/deleted board
# when another CLI/WebUI process removes it. Surface a valid current
# board instead of letting the frontend pin every subsequent request to
# a ghost slug and fail with an opaque 404.
try:
kb.clear_current_board()
except Exception:
pass
current = default_slug
out = []
for raw_meta in boards:
meta = _board_meta_dict(raw_meta)
@@ -1003,7 +1064,7 @@ def _handle_events_sse_stream(handler, parsed):
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
handler.send_header("Cache-Control", "no-cache")
handler.send_header("X-Accel-Buffering", "no")
handler.send_header("Connection", "keep-alive")
handler.send_header("Connection", "close")
handler.end_headers()
# Send an initial frame so the client knows the connection is open
@@ -1053,7 +1114,20 @@ def _handle_events_sse_stream(handler, parsed):
return True
def handle_kanban_get(handler, parsed) -> bool:
def handle_kanban_get(handler, parsed) -> bool | None:
"""Dispatch a Kanban GET. Three-valued return:
- ``False`` — no Kanban path matched; caller should emit a 404
(``_kanban_unknown_endpoint``) for genuinely stale-bundle requests.
- ``None`` — a path matched and the inner handler already sent a
response via ``bad(...)`` / ``j(...)`` (which both return ``None``).
The caller MUST NOT emit another response.
- ``True`` — a path matched and the inner handler succeeded.
Treat any falsy-but-not-False return (``0``, ``''``, etc.) as a bug and
audit the new return path; the caller uses ``is False`` identity check
to distinguish unmatched paths from already-responded paths (#1843).
"""
path = parsed.path
try:
# Multi-board management endpoints — these do NOT take a board arg
@@ -1103,7 +1177,9 @@ def handle_kanban_get(handler, parsed) -> bool:
return bad(handler, str(exc), status=409)
def handle_kanban_post(handler, parsed, body) -> bool:
def handle_kanban_post(handler, parsed, body) -> bool | None:
"""Dispatch a Kanban POST. See ``handle_kanban_get`` for the
three-valued ``True | None | False`` contract (#1843)."""
path = parsed.path
try:
# Multi-board management endpoints — `_create_board_payload` and
@@ -1154,7 +1230,9 @@ def handle_kanban_post(handler, parsed, body) -> bool:
return False
def handle_kanban_patch(handler, parsed, body) -> bool:
def handle_kanban_patch(handler, parsed, body) -> bool | None:
"""Dispatch a Kanban PATCH. See ``handle_kanban_get`` for the
three-valued ``True | None | False`` contract (#1843)."""
path = parsed.path
try:
# /boards/<slug> routes operate on the on-disk board collection
@@ -1190,7 +1268,9 @@ def handle_kanban_patch(handler, parsed, body) -> bool:
return False
def handle_kanban_delete(handler, parsed, body) -> bool:
def handle_kanban_delete(handler, parsed, body) -> bool | None:
"""Dispatch a Kanban DELETE. See ``handle_kanban_get`` for the
three-valued ``True | None | False`` contract (#1843)."""
path = parsed.path
try:
# Same routing reorder as PATCH: /boards/<slug> path-routed first,

File diff suppressed because it is too large Load Diff

View File

@@ -56,9 +56,34 @@ ANTHROPIC_PUBLIC_LINK_ERROR = "Claude Code credential linking failed. Check serv
_OAUTH_FLOWS: dict[str, dict[str, Any]] = {}
_OAUTH_FLOWS_LOCK = threading.Lock()
_ANTHROPIC_ENV_KEYS = ("ANTHROPIC_TOKEN", "ANTHROPIC_API_KEY")
def _clear_process_anthropic_env_values() -> None:
"""Clear Anthropic process env fallbacks under the streaming env lock."""
from api.streaming import _ENV_LOCK
with _ENV_LOCK:
for key in _ANTHROPIC_ENV_KEYS:
os.environ.pop(key, None)
def resolve_runtime_provider_with_anthropic_env_lock(resolver, *args, **kwargs):
"""Resolve runtime credentials under the Anthropic onboarding env lock.
Request paths must resolve Anthropic env fallbacks per outbound request,
not cache ANTHROPIC_TOKEN or ANTHROPIC_API_KEY across onboarding. Sharing
the process-env lock prevents a chat stream from observing one stale
Anthropic env value while onboarding has already cleared the other.
"""
from api.streaming import _ENV_LOCK
with _ENV_LOCK:
return resolver(*args, **kwargs)
def _normalize_onboarding_oauth_provider(provider: str) -> str:
"""Normalize Anthropic aliases (claude, claude-code) to 'anthropic'; defaults to 'openai-codex' when blank."""
provider = str(provider or "").strip().lower()
if provider in _ANTHROPIC_PROVIDER_ALIASES:
return "anthropic"
@@ -66,6 +91,7 @@ def _normalize_onboarding_oauth_provider(provider: str) -> str:
def _get_active_hermes_home() -> Path:
"""Return the active Hermes profile home directory, falling back to ~/.hermes when profile resolution fails."""
try:
from api.profiles import get_active_hermes_home
@@ -132,6 +158,7 @@ def _write_auth_json(data: dict[str, Any], auth_path: Path | None = None) -> Pat
def _now_iso() -> str:
"""Return the current UTC time as an ISO-8601 string ending in Z."""
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
@@ -206,6 +233,7 @@ def _persist_codex_credentials(hermes_home: Path, token_data: dict[str, Any]) ->
# Backward-compatible wrapper used by older code/tests.
def _save_codex_credentials(token_data):
"""Backward-compatible wrapper: persist Codex OAuth tokens to the active-profile auth.json."""
return _persist_codex_credentials(_get_active_hermes_home(), token_data)
@@ -234,18 +262,22 @@ def _read_claude_code_credentials() -> dict[str, Any] | None:
def _clear_anthropic_env_values(hermes_home: Path) -> None:
"""Clear Anthropic API/setup-token env values in the active profile only."""
"""Clear Anthropic API/setup-token env values in the active profile only.
The .env write path already clears os.environ while holding the streaming
env lock. Keep a locked process-env clear here too so import/write failures
cannot leave or partially clear stale Anthropic fallbacks.
"""
try:
from api.providers import _write_env_file
_write_env_file(
Path(hermes_home) / ".env",
{"ANTHROPIC_TOKEN": None, "ANTHROPIC_API_KEY": None},
{key: None for key in _ANTHROPIC_ENV_KEYS},
)
except Exception as exc:
logger.warning("Failed to clear Anthropic env values: %s", exc)
os.environ.pop("ANTHROPIC_TOKEN", None)
os.environ.pop("ANTHROPIC_API_KEY", None)
_clear_process_anthropic_env_values()
def _link_anthropic_credentials(hermes_home: Path) -> None:
@@ -310,6 +342,7 @@ def _link_anthropic_credentials(hermes_home: Path) -> None:
def _anthropic_public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
"""Build the browser-safe start payload for an Anthropic credential-linking flow, omitting server-side secrets."""
payload: dict[str, Any] = {
"ok": True,
"provider": "anthropic",
@@ -329,6 +362,7 @@ def _anthropic_public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[
def _anthropic_public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
"""Build the browser-safe status payload for an Anthropic flow, replacing internal error with a safe string."""
payload: dict[str, Any] = {
"ok": True,
"provider": "anthropic",
@@ -341,6 +375,7 @@ def _anthropic_public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict
def _spawn_anthropic_credential_worker(flow_id: str) -> None:
"""Launch a daemon thread that polls for Claude Code credentials and transitions the Anthropic flow to success."""
worker = threading.Thread(
target=_run_anthropic_credential_worker, args=(flow_id,), daemon=True,
)
@@ -434,6 +469,7 @@ def _remove_anthropic_link_marker(hermes_home: Path) -> None:
# ── Codex protocol ──────────────────────────────────────────────────────────
def _json_request(url: str, payload: dict[str, Any], *, form: bool = False) -> dict[str, Any]:
"""POST a JSON or form-encoded payload to url and return the parsed JSON response."""
if form:
data = urllib.parse.urlencode(payload).encode("utf-8")
content_type = "application/x-www-form-urlencoded"
@@ -451,10 +487,12 @@ def _json_request(url: str, payload: dict[str, Any], *, form: bool = False) -> d
def _request_codex_user_code() -> dict[str, Any]:
"""Request a new device-auth user code and device_auth_id from the Codex endpoint."""
return _json_request(CODEX_USER_CODE_URL, {"client_id": CODEX_CLIENT_ID})
def _poll_codex_authorization(device_auth_id: str, user_code: str) -> dict[str, Any] | None:
"""Poll the Codex device token endpoint; returns None on 403/404 (not yet authorized) or raises otherwise."""
try:
return _json_request(
CODEX_DEVICE_TOKEN_URL,
@@ -467,6 +505,7 @@ def _poll_codex_authorization(device_auth_id: str, user_code: str) -> dict[str,
def _exchange_codex_authorization(authorization_code: str, code_verifier: str) -> dict[str, Any]:
"""Exchange a Codex authorization code and PKCE verifier for access/refresh tokens."""
return _json_request(
CODEX_TOKEN_URL,
{
@@ -481,6 +520,7 @@ def _exchange_codex_authorization(authorization_code: str, code_verifier: str) -
def _codex_public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
"""Build the browser-safe start payload for a Codex device-code flow, including user_code and verification_uri."""
return {
"ok": True,
"provider": "openai-codex",
@@ -494,6 +534,7 @@ def _codex_public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str,
def _codex_public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
"""Build the browser-safe status payload for a Codex flow, capping error strings at 200 characters."""
payload = {
"ok": True,
"provider": "openai-codex",
@@ -506,6 +547,7 @@ def _codex_public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str
def _public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
"""Dispatch to the provider-specific start payload builder based on flow['provider']."""
provider = flow.get("provider", "openai-codex")
if provider == "anthropic":
return _anthropic_public_start_payload(flow_id, flow)
@@ -513,6 +555,7 @@ def _public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
def _public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
"""Dispatch to the provider-specific status payload builder based on flow['provider']."""
provider = flow.get("provider", "openai-codex")
if provider == "anthropic":
return _anthropic_public_status_payload(flow_id, flow)
@@ -520,6 +563,7 @@ def _public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]
def _drop_sensitive_flow_fields(flow: dict[str, Any]) -> None:
"""Remove device codes, authorization codes, and token material from a flow dict in place."""
for key in (
"device_auth_id",
"authorization_code",
@@ -532,6 +576,7 @@ def _drop_sensitive_flow_fields(flow: dict[str, Any]) -> None:
def _cleanup_oauth_flows(now: float | None = None) -> None:
"""Expire pending flows past their deadline and purge terminal flows older than 300 seconds from memory."""
now = now or time.time()
cutoff = now - 300
with _OAUTH_FLOWS_LOCK:
@@ -545,11 +590,13 @@ def _cleanup_oauth_flows(now: float | None = None) -> None:
def _spawn_codex_oauth_worker(flow_id: str) -> None:
"""Launch a daemon thread that drives the Codex device-code polling and token exchange loop."""
worker = threading.Thread(target=_run_codex_oauth_worker, args=(flow_id,), daemon=True)
worker.start()
def _set_flow_status(flow_id: str, status: str, **fields: Any) -> None:
"""Update a flow's status under the lock, then strip sensitive fields on terminal transitions."""
with _OAUTH_FLOWS_LOCK:
flow = _OAUTH_FLOWS.get(flow_id)
if not flow:
@@ -562,6 +609,7 @@ def _set_flow_status(flow_id: str, status: str, **fields: Any) -> None:
def _run_codex_oauth_worker(flow_id: str) -> None:
"""Drive the Codex device-code polling loop until the user authorizes, the flow cancels, or it expires."""
while True:
with _OAUTH_FLOWS_LOCK:
flow = dict(_OAUTH_FLOWS.get(flow_id) or {})
@@ -698,6 +746,7 @@ def start_onboarding_oauth_flow(body: dict[str, Any] | None) -> dict[str, Any]:
def poll_onboarding_oauth_flow(flow_id: str) -> dict[str, Any]:
"""Return the current browser-safe status for an in-flight OAuth flow, expiring it if past its deadline."""
_cleanup_oauth_flows()
fid = str(flow_id or "").strip()
if not fid:
@@ -714,6 +763,7 @@ def poll_onboarding_oauth_flow(flow_id: str) -> dict[str, Any]:
def cancel_onboarding_oauth_flow(body: dict[str, Any] | None) -> dict[str, Any]:
"""Cancel a pending OAuth flow by flow_id and return the final status payload."""
fid = str((body or {}).get("flow_id") or "").strip()
if not fid:
raise ValueError("flow_id is required")
@@ -735,8 +785,10 @@ def cancel_onboarding_oauth_flow(body: dict[str, Any] | None) -> dict[str, Any]:
# Backward-compatible names from the abandoned spike. They intentionally do not
# expose provider device secrets to callers anymore.
def start_codex_device_code():
"""Backward-compatible shim: start a Codex device-code flow via start_onboarding_oauth_flow."""
return start_onboarding_oauth_flow({"provider": "openai-codex"})
def poll_codex_token(device_code, interval=5):
"""Backward-compatible stub that always yields an error directing callers to the /api/onboarding/oauth/poll endpoint."""
yield {"status": "error", "error": "Use /api/onboarding/oauth/poll with flow_id"}

View File

@@ -139,6 +139,15 @@ _SUPPORTED_PROVIDER_SETUPS = {
"models": list(_PROVIDER_MODELS.get("deepseek", [])),
"category": "specialized",
},
"xiaomi": {
"label": "Xiaomi MiMo",
"env_var": "XIAOMI_API_KEY",
"default_model": "mimo-v2.5-pro",
"default_base_url": "https://api.xiaomimimo.com/v1",
"requires_base_url": False,
"models": list(_PROVIDER_MODELS.get("xiaomi", [])),
"category": "specialized",
},
"zai": {
"label": "Z.AI / GLM (智谱)",
"env_var": "GLM_API_KEY",
@@ -303,6 +312,44 @@ class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
_PROBE_OPENER = urllib.request.build_opener(_NoRedirectHandler())
_DNS_ONLY_TEST_TLDS = frozenset({"invalid", "test", "example"})
def _hostname_uses_reserved_dns_tld(hostname: str | None) -> bool:
host = str(hostname or "").strip().rstrip(".").lower()
if not host or "." not in host:
return False
return host.rsplit(".", 1)[-1] in _DNS_ONLY_TEST_TLDS
def _exception_chain_text(exc) -> str:
parts: list[str] = []
seen: set[int] = set()
cur = exc
while cur is not None and id(cur) not in seen:
seen.add(id(cur))
parts.append(str(cur))
cur = getattr(cur, "__cause__", None) or getattr(cur, "__context__", None)
return " ".join(parts).lower()
def _probe_failure_is_dns(exc, hostname: str | None) -> bool:
if isinstance(exc, socket.gaierror):
return True
text = _exception_chain_text(exc)
if any(
marker in text
for marker in (
"getaddrinfo",
"gaierror",
"name or service not known",
"temporary failure in name resolution",
"nodename nor servname provided",
"no address associated with hostname",
)
):
return True
return _hostname_uses_reserved_dns_tld(hostname)
def probe_provider_endpoint(
@@ -407,7 +454,7 @@ def probe_provider_endpoint(
reason = exc.reason
if isinstance(reason, socket.timeout) or "timed out" in str(reason).lower():
return {"ok": False, "error": "timeout", "detail": f"connection timed out after {timeout:g}s"}
if isinstance(reason, socket.gaierror):
if _probe_failure_is_dns(reason, parsed.hostname):
return {
"ok": False,
"error": "dns",
@@ -424,6 +471,12 @@ def probe_provider_endpoint(
except (TimeoutError, socket.timeout):
return {"ok": False, "error": "timeout", "detail": f"connection timed out after {timeout:g}s"}
except Exception as exc: # pragma: no cover — defensive net
if _probe_failure_is_dns(exc, parsed.hostname):
return {
"ok": False,
"error": "dns",
"detail": f"could not resolve host '{parsed.hostname}'",
}
logger.debug("probe_provider_endpoint unexpected error", exc_info=True)
return {"ok": False, "error": "unreachable", "detail": str(exc)[:200]}

405
api/passkeys.py Normal file
View File

@@ -0,0 +1,405 @@
"""Passkey/WebAuthn helpers for Hermes WebUI.
Default-off: passkeys are only advertised after an authenticated user registers
one from Settings. Password auth remains the bootstrap/recovery mechanism.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import secrets
import tempfile
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from api.config import STATE_DIR
try: # optional at import-time; endpoints return a clear error if unavailable
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
except Exception: # pragma: no cover - exercised by source tests instead
InvalidSignature = Exception # type: ignore[assignment]
hashes = serialization = ec = None # type: ignore[assignment]
_CREDENTIALS_FILE = STATE_DIR / "passkeys.json"
_CHALLENGES_FILE = STATE_DIR / ".passkey_challenges.json"
_CHALLENGE_TTL = 90
_MAX_CHALLENGES = 128
_MAX_CHALLENGES_PER_CONTEXT = 8
_CHALLENGES_LOCK = threading.Lock()
_RP_NAME = "Hermes WebUI"
class PasskeyError(ValueError):
"""Raised for user-correctable WebAuthn failures."""
class PasskeyRateLimitError(PasskeyError):
"""Raised when too many outstanding WebAuthn challenges are pending."""
def _b64u(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64u_decode(value: str | bytes) -> bytes:
if isinstance(value, bytes):
value = value.decode("ascii")
value = str(value).strip()
value += "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(value.encode("ascii"))
def _json_load(path: Path, default: Any) -> Any:
try:
if path.exists():
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return default
return default
def _atomic_write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, sort_keys=True)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _load_credentials() -> list[dict[str, Any]]:
data = _json_load(_CREDENTIALS_FILE, [])
if not isinstance(data, list):
return []
return [c for c in data if isinstance(c, dict) and isinstance(c.get("id"), str)]
def _save_credentials(creds: list[dict[str, Any]]) -> None:
_atomic_write_json(_CREDENTIALS_FILE, creds)
def registered_credentials() -> list[dict[str, Any]]:
"""Return public credential metadata only; never expose public keys."""
out = []
for c in _load_credentials():
out.append({
"id": c.get("id"),
"label": c.get("label") or "Passkey",
"created_at": c.get("created_at"),
"last_used_at": c.get("last_used_at"),
"sign_count": c.get("sign_count", 0),
})
return out
def passkeys_available() -> bool:
return bool(_load_credentials())
def _load_challenges() -> dict[str, dict[str, Any]]:
# May prune and rewrite the challenge file; callers that mutate the store
# must hold _CHALLENGES_LOCK across load→mutate→write.
raw = _json_load(_CHALLENGES_FILE, {})
if not isinstance(raw, dict):
return {}
now = time.time()
clean = {
k: v for k, v in raw.items()
if isinstance(k, str) and isinstance(v, dict) and now - float(v.get("ts", 0)) < _CHALLENGE_TTL
}
if clean != raw:
_atomic_write_json(_CHALLENGES_FILE, clean)
return clean
def _oldest_challenge_key(data: dict[str, dict[str, Any]], keys: list[str]) -> str | None:
if not keys:
return None
return min(keys, key=lambda k: float(data.get(k, {}).get("ts", 0)))
def _evict_oldest_challenges(data: dict[str, dict[str, Any]], kind: str, rp_id: str, origin: str) -> None:
"""Keep the challenge store bounded while admitting the newest challenge."""
while True:
same_context = [
k for k, v in data.items()
if v.get("kind") == kind and v.get("rp_id") == rp_id and v.get("origin") == origin
]
if len(same_context) < _MAX_CHALLENGES_PER_CONTEXT:
break
oldest = _oldest_challenge_key(data, same_context)
if oldest is None:
break
data.pop(oldest, None)
while len(data) >= _MAX_CHALLENGES:
oldest = _oldest_challenge_key(data, list(data))
if oldest is None:
break
data.pop(oldest, None)
def _store_challenge(challenge: str, kind: str, rp_id: str, origin: str) -> None:
with _CHALLENGES_LOCK:
data = _load_challenges()
_evict_oldest_challenges(data, kind, rp_id, origin)
data[challenge] = {"kind": kind, "rp_id": rp_id, "origin": origin, "ts": time.time()}
_atomic_write_json(_CHALLENGES_FILE, data)
def _consume_challenge(challenge: str, kind: str) -> dict[str, Any]:
with _CHALLENGES_LOCK:
data = _load_challenges()
entry = data.pop(challenge, None)
_atomic_write_json(_CHALLENGES_FILE, data)
if not entry or entry.get("kind") != kind:
raise PasskeyError("Passkey challenge expired. Try again.")
return entry
def _host_without_port(host: str) -> str:
host = (host or "localhost").strip().split(",", 1)[0]
if host.startswith("[") and "]" in host:
return host[1:host.index("]")]
return host.rsplit(":", 1)[0] if ":" in host else host
def rp_context(handler) -> tuple[str, str]:
host = _host_without_port(handler.headers.get("Host", "localhost"))
proto = handler.headers.get("X-Forwarded-Proto", "").split(",", 1)[0].strip().lower()
if proto not in {"http", "https"}:
try:
from api.auth import _is_secure_context
proto = "https" if _is_secure_context(handler) else "http"
except AttributeError:
proto = "http"
return host, f"{proto}://{handler.headers.get('Host', host)}"
def registration_options(handler) -> dict[str, Any]:
rp_id, _origin = rp_context(handler)
challenge = _b64u(secrets.token_bytes(32))
_store_challenge(challenge, "register", rp_id, _origin)
return {
"challenge": challenge,
"rp": {"name": _RP_NAME, "id": rp_id},
"user": {"id": _b64u(hashlib.sha256(rp_id.encode()).digest()[:16]), "name": "Hermes WebUI", "displayName": "Hermes WebUI"},
"pubKeyCredParams": [{"type": "public-key", "alg": -7}],
"authenticatorSelection": {"residentKey": "preferred", "userVerification": "preferred"},
"timeout": 60000,
"attestation": "none",
"excludeCredentials": [{"type": "public-key", "id": c["id"]} for c in registered_credentials()],
}
def authentication_options(handler) -> dict[str, Any]:
creds = registered_credentials()
if not creds:
raise PasskeyError("No passkeys are registered.")
rp_id, origin = rp_context(handler)
challenge = _b64u(secrets.token_bytes(32))
_store_challenge(challenge, "login", rp_id, origin)
return {
"challenge": challenge,
"rpId": rp_id,
"allowCredentials": [{"type": "public-key", "id": c["id"]} for c in creds],
"timeout": 60000,
"userVerification": "preferred",
}
@dataclass
class _Cbor:
data: bytes
pos: int = 0
def read(self, n: int) -> bytes:
if self.pos + n > len(self.data):
raise PasskeyError("Malformed CBOR data")
out = self.data[self.pos:self.pos + n]
self.pos += n
return out
def item(self) -> Any:
initial = self.read(1)[0]
major, addl = initial >> 5, initial & 0x1F
val = self._val(addl)
if major == 0:
return val
if major == 1:
return -1 - val
if major == 2:
return self.read(val)
if major == 3:
return self.read(val).decode("utf-8")
if major == 4:
return [self.item() for _ in range(val)]
if major == 5:
return {self.item(): self.item() for _ in range(val)}
if major == 7:
if val == 20:
return False
if val == 21:
return True
if val == 22:
return None
raise PasskeyError("Unsupported CBOR data")
def _val(self, addl: int) -> int:
if addl < 24:
return addl
if addl == 24:
return self.read(1)[0]
if addl == 25:
return int.from_bytes(self.read(2), "big")
if addl == 26:
return int.from_bytes(self.read(4), "big")
if addl == 27:
return int.from_bytes(self.read(8), "big")
raise PasskeyError("Indefinite CBOR values are not supported")
def _cbor_loads(data: bytes) -> Any:
parser = _Cbor(data)
value = parser.item()
if parser.pos != len(data):
raise PasskeyError("Trailing CBOR data")
return value
def _client_data(encoded: str, expected_type: str, challenge_kind: str) -> tuple[dict[str, Any], dict[str, Any], bytes]:
raw = _b64u_decode(encoded)
try:
data = json.loads(raw.decode("utf-8"))
except Exception as exc:
raise PasskeyError("Malformed client data") from exc
if data.get("type") != expected_type:
raise PasskeyError("Unexpected passkey response type")
challenge = data.get("challenge")
if not isinstance(challenge, str):
raise PasskeyError("Missing passkey challenge")
entry = _consume_challenge(challenge, challenge_kind)
if data.get("origin") != entry.get("origin"):
raise PasskeyError("Passkey origin mismatch")
return data, entry, raw
def _parse_auth_data(auth_data: bytes, rp_id: str) -> dict[str, Any]:
if len(auth_data) < 37:
raise PasskeyError("Malformed authenticator data")
rp_hash = auth_data[:32]
expected = hashlib.sha256(rp_id.encode("idna")).digest()
if not hmac.compare_digest(rp_hash, expected):
raise PasskeyError("Passkey RP ID mismatch")
flags = auth_data[32]
if not (flags & 0x01):
raise PasskeyError("Passkey user presence was not verified")
sign_count = int.from_bytes(auth_data[33:37], "big")
return {"flags": flags, "sign_count": sign_count, "rest": auth_data[37:]}
def _public_key_from_cose(cose: dict[Any, Any]):
if ec is None or serialization is None:
raise PasskeyError("Passkey support requires the cryptography package")
alg = cose.get(3)
kty = cose.get(1)
crv = cose.get(-1)
x = cose.get(-2)
y = cose.get(-3)
if alg != -7 or kty != 2 or crv != 1 or not isinstance(x, bytes) or not isinstance(y, bytes):
raise PasskeyError("Only ES256 passkeys are supported")
numbers = ec.EllipticCurvePublicNumbers(int.from_bytes(x, "big"), int.from_bytes(y, "big"), ec.SECP256R1())
return numbers.public_key()
def finish_registration(payload: dict[str, Any], handler) -> dict[str, Any]:
response = payload.get("response") or {}
_client, entry, _client_raw = _client_data(response.get("clientDataJSON", ""), "webauthn.create", "register")
att_obj = _cbor_loads(_b64u_decode(response.get("attestationObject", "")))
if not isinstance(att_obj, dict) or not isinstance(att_obj.get("authData"), bytes):
raise PasskeyError("Malformed attestation object")
parsed = _parse_auth_data(att_obj["authData"], entry["rp_id"])
if not (parsed["flags"] & 0x40):
raise PasskeyError("Passkey credential data missing")
rest = parsed["rest"]
if len(rest) < 18:
raise PasskeyError("Malformed credential data")
cred_len = int.from_bytes(rest[16:18], "big")
credential_id = rest[18:18 + cred_len]
cose_bytes = rest[18 + cred_len:]
cose_key = _cbor_loads(cose_bytes)
public_key = _public_key_from_cose(cose_key)
pem = public_key.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode("ascii")
cred_id = _b64u(credential_id)
label = str(payload.get("label") or "Passkey").strip()[:80] or "Passkey"
creds = [c for c in _load_credentials() if c.get("id") != cred_id]
creds.append({
"id": cred_id,
"label": label,
"public_key_pem": pem,
"sign_count": parsed["sign_count"],
"created_at": time.time(),
"last_used_at": None,
})
_save_credentials(creds)
return {"ok": True, "credential": {"id": cred_id, "label": label}}
def finish_login(payload: dict[str, Any], handler) -> dict[str, Any]:
if serialization is None or hashes is None:
raise PasskeyError("Passkey support requires the cryptography package")
response = payload.get("response") or {}
cred_id = payload.get("id") or payload.get("rawId")
if not isinstance(cred_id, str):
raise PasskeyError("Missing passkey credential id")
creds = _load_credentials()
idx = next((i for i, c in enumerate(creds) if c.get("id") == cred_id), -1)
if idx < 0:
raise PasskeyError("Unknown passkey")
_client, entry, client_raw = _client_data(response.get("clientDataJSON", ""), "webauthn.get", "login")
auth_data = _b64u_decode(response.get("authenticatorData", ""))
parsed = _parse_auth_data(auth_data, entry["rp_id"])
signature = _b64u_decode(response.get("signature", ""))
public_key = serialization.load_pem_public_key(str(creds[idx].get("public_key_pem", "")).encode("ascii"))
signed = auth_data + hashlib.sha256(client_raw).digest()
try:
public_key.verify(signature, signed, ec.ECDSA(hashes.SHA256()))
except InvalidSignature as exc:
raise PasskeyError("Passkey signature verification failed") from exc
old_count = int(creds[idx].get("sign_count") or 0)
if parsed["sign_count"] and old_count and parsed["sign_count"] <= old_count:
raise PasskeyError("Passkey sign counter did not advance")
creds[idx]["sign_count"] = parsed["sign_count"] or old_count
creds[idx]["last_used_at"] = time.time()
_save_credentials(creds)
return {"ok": True, "credential_id": cred_id}
def delete_credential(credential_id: str) -> dict[str, Any]:
creds = _load_credentials()
kept = [c for c in creds if c.get("id") != credential_id]
if len(kept) == len(creds):
raise PasskeyError("Passkey not found")
_save_credentials(kept)
return {"ok": True, "credentials": registered_credentials()}
def clear_credentials() -> None:
"""Remove all registered passkeys when the user disables all auth."""
if _CREDENTIALS_FILE.exists():
_save_credentials([])

77
api/paths.py Normal file
View File

@@ -0,0 +1,77 @@
"""Shared path helpers for Hermes WebUI.
Keep low-level filesystem defaults here instead of in ``api.config`` so modules
that need the default Hermes home can import them without triggering config's
larger startup side effects.
"""
import os
from pathlib import Path
HOME = Path.home()
def _hermes_home_has_webui_state(base: Path) -> bool:
"""Return True when *base* holds real WebUI state under its ``webui/`` dir.
Used only on Windows to detect a pre-v0.51.134 install at the legacy
``%USERPROFILE%\\.hermes`` location so we don't strand the user's existing
sessions/pins/settings when the default moved to ``%LOCALAPPDATA%\\hermes``
(#2905).
We intentionally check ONLY WebUI-owned artifacts (the ``webui/`` subtree),
NOT agent-owned files like ``config.yaml`` / ``auth.json``. The agent has
defaulted to ``%LOCALAPPDATA%\\hermes`` on Windows since before #2897, so a
long-time agent user who never ran WebUI at the legacy location would have a
stray ``auth.json`` there — keying on that would wrongly divert a *fresh*
WebUI install to the legacy dir. Only ``webui/`` state is what actually
gets stranded by the move, so it is the correct and narrow signal.
Cheap stat-only checks; never raises.
"""
try:
if not base.is_dir():
return False
markers = (
base / "webui" / "sessions", # WebUI session store
base / "webui" / "settings.json", # WebUI UI settings + pins
base / "webui", # WebUI state dir at all
)
return any(m.exists() for m in markers)
except OSError:
return False
def _platform_default_hermes_home() -> Path:
"""Return the platform-aware default Hermes home when HERMES_HOME is unset.
Native Windows Hermes Agent installs default to %LOCALAPPDATA%\\hermes,
while POSIX installs use ~/.hermes.
Windows migration safety (#2905): v0.51.134 moved the Windows default from
``%USERPROFILE%\\.hermes`` to ``%LOCALAPPDATA%\\hermes`` to match the agent.
Upgrading users whose WebUI state still lives at the old location saw an
empty app (sessions/pins/settings "lost" — actually just at an address the
new build no longer reads). To avoid stranding that data, prefer the
legacy ``%USERPROFILE%\\.hermes`` ONLY when it is populated AND the new
``%LOCALAPPDATA%\\hermes`` location is not yet established. This is a
non-destructive, self-healing fallback: no files are moved, and once the
new location has state (fresh installs, or users who set HERMES_HOME) the
legacy path is never preferred. Explicit HERMES_HOME / HERMES_WEBUI_STATE_DIR
overrides take precedence upstream and are unaffected.
"""
if os.name == "nt":
local_app_data = os.getenv("LOCALAPPDATA", "").strip()
if local_app_data:
new_home = Path(local_app_data) / "hermes"
legacy_home = HOME / ".hermes"
# Only fall back to the legacy home if it actually holds state and
# the new location has not been established yet — the exact
# post-upgrade fingerprint from #2905.
if (
legacy_home != new_home
and not _hermes_home_has_webui_state(new_home)
and _hermes_home_has_webui_state(legacy_home)
):
return legacy_home
return new_home
return HOME / ".hermes"

141
api/plugin_providers.py Normal file
View File

@@ -0,0 +1,141 @@
"""Helpers for model-provider plugins (``plugins/model-providers/<name>/``).
The Hermes agent discovers these via ``providers.list_providers()`` and exposes
them in the CLI model picker. WebUI must mirror that registry instead of
relying only on the static ``_PROVIDER_DISPLAY`` / ``_PROVIDER_MODELS`` tables.
Bundled agent profiles (gemini, nous, custom, …) also live in
``list_providers()``. WebUI already handles those via static tables and
dedicated code paths — only *plugin-only* slugs (e.g. user-installed yandex)
should take the plugin discovery path.
"""
from __future__ import annotations
import logging
import threading
from typing import Any
logger = logging.getLogger(__name__)
_PROFILES_LOCK = threading.Lock()
_PROFILES_BY_NAME: dict[str, Any] | None = None
_WEBUI_STATIC_PROVIDER_IDS: frozenset[str] | None = None
def _webui_static_provider_ids() -> frozenset[str]:
"""Provider slugs already owned by WebUI static tables / special cases."""
global _WEBUI_STATIC_PROVIDER_IDS
if _WEBUI_STATIC_PROVIDER_IDS is not None:
return _WEBUI_STATIC_PROVIDER_IDS
try:
from api.config import _PROVIDER_DISPLAY, _PROVIDER_MODELS
static = (
frozenset(_PROVIDER_DISPLAY.keys())
| frozenset(_PROVIDER_MODELS.keys())
| frozenset({"custom"})
)
except Exception:
static = frozenset({"custom"})
_WEBUI_STATIC_PROVIDER_IDS = static
return static
def _load_profiles_by_name() -> dict[str, Any]:
try:
from providers import list_providers
except Exception:
logger.debug("providers package unavailable for plugin discovery", exc_info=True)
return {}
result: dict[str, Any] = {}
try:
for profile in list_providers():
name = str(getattr(profile, "name", "") or "").strip().lower()
if name:
result[name] = profile
except Exception:
logger.debug("Failed to enumerate model-provider plugins", exc_info=True)
return {}
return result
def plugin_model_provider_profiles() -> dict[str, Any]:
"""Return registered model-provider profiles keyed by canonical slug."""
global _PROFILES_BY_NAME
cached = _PROFILES_BY_NAME
if cached is not None:
return cached
with _PROFILES_LOCK:
if _PROFILES_BY_NAME is None:
_PROFILES_BY_NAME = _load_profiles_by_name()
return _PROFILES_BY_NAME
def invalidate_plugin_model_provider_cache() -> None:
"""Clear cached plugin discovery (e.g. after config reload)."""
global _PROFILES_BY_NAME
with _PROFILES_LOCK:
_PROFILES_BY_NAME = None
def plugin_model_provider_ids() -> frozenset[str]:
"""Slugs from ``list_providers()`` that are not already WebUI-static."""
static = _webui_static_provider_ids()
return frozenset(
pid for pid in plugin_model_provider_profiles().keys() if pid not in static
)
def plugin_model_provider_display_name(provider_id: str) -> str | None:
profile = plugin_model_provider_profiles().get((provider_id or "").strip().lower())
if profile is None:
return None
return str(getattr(profile, "display_name", "") or getattr(profile, "name", "") or "").strip() or None
def plugin_model_provider_api_key_env_var(provider_id: str) -> str | None:
"""Return the primary API-key env var for a plugin provider, if any."""
profile = plugin_model_provider_profiles().get((provider_id or "").strip().lower())
if profile is None:
return None
env_vars = getattr(profile, "env_vars", ()) or ()
for var in env_vars:
upper = str(var).upper()
if upper.endswith("_BASE_URL") or upper.endswith("_URL"):
continue
if upper.endswith("_FOLDER_ID"):
continue
return str(var)
return None
def effective_provider_env_var(provider_id: str, static_map: dict[str, str]) -> str | None:
pid = (provider_id or "").strip().lower()
if not pid:
return None
if pid in static_map:
return static_map[pid]
if not is_plugin_model_provider(pid):
return None
return plugin_model_provider_api_key_env_var(pid)
def effective_provider_display_name(provider_id: str, static_map: dict[str, str]) -> str:
pid = (provider_id or "").strip().lower()
if pid in static_map:
return static_map[pid]
if is_plugin_model_provider(pid):
plugin_name = plugin_model_provider_display_name(pid)
if plugin_name:
return plugin_name
return pid.replace("-", " ").title()
def is_plugin_model_provider(provider_id: str) -> bool:
"""True for plugin-only providers (not already in WebUI static tables)."""
pid = (provider_id or "").strip().lower()
if not pid or pid in _webui_static_provider_ids():
return False
return pid in plugin_model_provider_profiles()

185
api/plugins.py Normal file
View File

@@ -0,0 +1,185 @@
"""
Plugin discovery and static serving for Hermes Web UI.
Scans ~/.hermes/plugins/<name>/dashboard/ for manifest.json files,
matching the official Hermes dashboard plugin format.
Each plugin may have:
dashboard/
manifest.json -- tab definition and entry point
dist/
index.js -- plugin JS bundle (IIFE)
style.css -- optional plugin stylesheet
plugin_api.py -- optional backend API (not used in WebUI MVP)
"""
import json
import logging
import os
import re
from pathlib import Path
logger = logging.getLogger(__name__)
# Valid dashboard-plugin name: a safe slug (it becomes a URL path component and
# a settings key). Lowercase alnum + - / _, 1-64 chars, must start with a letter.
_VALID_PLUGIN_NAME = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
# Valid tab.path: a clean same-origin absolute path. Must start with a single
# '/' (NOT '//' — a leading '//' is a protocol-relative URL that would resolve
# to a remote origin when assigned to iframe.src), then only safe path chars —
# no quotes, whitespace, control chars, query ('?') or fragment ('#').
_VALID_PLUGIN_TAB_PATH = re.compile(r"^/(?!/)[A-Za-z0-9._~/-]{0,255}$")
# plugin_name -> manifest dict (as loaded from manifest.json)
PLUGIN_MANIFESTS: dict[str, dict] = {}
# plugin_name -> resolved static root dir
_PLUGIN_STATIC_ROOTS: dict[str, Path] = {}
def _get_plugin_base() -> Path:
return Path(os.environ.get("HERMES_WEBUI_PLUGINS_DIR", str(Path.home() / ".hermes" / "plugins")))
def load_plugins() -> None:
"""Scan plugin directories and load manifest.json for each dashboard plugin."""
plugin_base = _get_plugin_base()
if not plugin_base.is_dir():
logger.debug("No plugins directory at %s", plugin_base)
return
for entry in sorted(plugin_base.iterdir()):
if not entry.is_dir():
continue
manifest_path = entry / "dashboard" / "manifest.json"
if not manifest_path.is_file():
continue
try:
manifest = json.loads(manifest_path.read_text())
except Exception:
logger.exception("Failed to parse manifest for plugin %s", entry.name)
continue
name = manifest.get("name") or entry.name
# Validate the plugin name: it becomes a URL path component
# (/dashboard-plugins/<name>/...) and a settings key. Restrict to a safe
# slug so a manifest like name:"../foo" can't make the URL-space ambiguous.
if not _VALID_PLUGIN_NAME.match(str(name)):
logger.warning("Skipping plugin with invalid name %r (must match %s)", name, _VALID_PLUGIN_NAME.pattern)
continue
tab = manifest.get("tab", {})
tab_path = tab.get("path", f"/{name}")
# Validate tab.path: it's a same-origin route the plugin page is served
# at AND a value passed into client-side navigation. Require a clean
# absolute path — no quotes/control chars/query/fragment — so a hostile
# manifest can't shadow odd routes or inject via the path.
if not _VALID_PLUGIN_TAB_PATH.match(str(tab_path)):
logger.warning("Skipping plugin %s with invalid tab.path %r (must match %s)", name, tab_path, _VALID_PLUGIN_TAB_PATH.pattern)
continue
if name in PLUGIN_MANIFESTS:
logger.warning("Duplicate plugin name skipped: %s (already loaded)", name)
continue
if tab_path in (m.get("tab", {}).get("path") for m in PLUGIN_MANIFESTS.values()):
logger.warning("Plugin %s tab.path %r conflicts with another plugin; skipped", name, tab_path)
continue
PLUGIN_MANIFESTS[name] = manifest
logger.info("Loaded dashboard plugin: %s (label=%s)", name, manifest.get("label", ""))
# Pre-compute static root for fast serving (points to dashboard/)
dashboard_dir = entry / "dashboard"
if dashboard_dir.is_dir():
_PLUGIN_STATIC_ROOTS[name] = dashboard_dir.resolve()
def serve_plugin_static(plugin_name: str, rel_path: str) -> tuple[bytes, str] | None:
"""
Serve a built static asset from a plugin's dashboard/dist/ (or static/) dir.
Returns (file_bytes, content_type) on success, None on not found.
Security: _PLUGIN_STATIC_ROOTS points at the plugin's whole dashboard/ dir
(the page route needs that), but the asset route must NOT expose plugin
source/config — e.g. dashboard/plugin_api.py, manifest.json, .env. So we
constrain served files to the built-asset subtrees (dist/ or static/), reject
dotfiles, and require a known static extension.
"""
root = _PLUGIN_STATIC_ROOTS.get(plugin_name)
if not root:
return None
safe = (root / rel_path.lstrip("/")).resolve()
try:
safe.relative_to(root)
except ValueError:
return None # path traversal attempt
# Only built-asset subtrees are servable (not the dashboard root itself,
# which holds plugin_api.py / manifest.json / config).
rel = safe.relative_to(root)
if not rel.parts or rel.parts[0] not in ("dist", "static"):
return None
# No dotfiles (.env, .git, etc.) anywhere in the path.
if any(part.startswith(".") for part in rel.parts):
return None
if not safe.is_file():
return None
# Allowlist of static asset extensions — refuse source/config (.py, .json,
# .toml, .env, .sh, ...) even if somehow placed under dist/.
ext = os.path.splitext(rel_path.lower())[1]
_STATIC_EXTS = {
".js", ".css", ".html", ".png", ".jpg", ".jpeg", ".gif", ".svg",
".ico", ".webp", ".woff", ".woff2", ".ttf", ".otf", ".map", ".txt",
}
if ext not in _STATIC_EXTS:
return None
data = safe.read_bytes()
content_type = {
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".ico": "image/x-icon",
}.get(ext, "application/octet-stream")
return data, content_type
def get_plugin_metadata() -> list[dict]:
"""
Return a list of plugin metadata suitable for the Settings → Plugins tab.
Each entry includes name, key, version, description, and tab info for linking.
Per-plugin enabled state is stored in settings.json under `dashboard_plugins`.
A plugin is enabled only if the user has explicitly toggled it on (default off).
"""
from api.config import load_settings
plugin_settings = load_settings().get("dashboard_plugins", {})
plugins = []
for name, manifest in sorted(PLUGIN_MANIFESTS.items()):
tab = manifest.get("tab", {})
path = tab.get("path", f"/{name}")
plugins.append({
"name": manifest.get("label") or manifest.get("name") or name,
"key": name,
"version": manifest.get("version", "0.0.0"),
"description": manifest.get("description", ""),
"tab": {
"path": path,
"label": tab.get("label") or manifest.get("label") or name,
},
"enabled": bool(plugin_settings.get(name, False)),
"hooks": [],
})
return plugins

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

160
api/request_diagnostics.py Normal file
View File

@@ -0,0 +1,160 @@
"""Slow request diagnostics for latency-sensitive browser API paths."""
from __future__ import annotations
import json
import logging
import os
import sys
import threading
import time
import traceback
import uuid
from typing import Any
DEFAULT_SLOW_REQUEST_SECONDS = 5.0
MAX_STACK_FRAMES_PER_THREAD = 40
def _slow_request_seconds() -> float:
raw = os.getenv("HERMES_WEBUI_SLOW_REQUEST_SECONDS", "").strip()
if not raw:
return DEFAULT_SLOW_REQUEST_SECONDS
try:
value = float(raw)
except ValueError:
return DEFAULT_SLOW_REQUEST_SECONDS
return max(0.0, value)
class RequestDiagnostics:
"""Track request stages and emit a watchdog record if a request wedges."""
def __init__(
self,
method: str,
path: str,
*,
logger: logging.Logger | None = None,
timeout_seconds: float | None = None,
auto_start: bool = True,
) -> None:
self.request_id = uuid.uuid4().hex[:10]
self.method = str(method or "-")
self.path = str(path or "-").split("?", 1)[0]
self.logger = logger or logging.getLogger(__name__)
self.timeout_seconds = _slow_request_seconds() if timeout_seconds is None else max(0.0, float(timeout_seconds))
self.started_monotonic = time.monotonic()
self.started_wall = time.time()
self._lock = threading.Lock()
self._stages: list[dict[str, Any]] = []
self._current_stage = "start"
self._current_stage_started = self.started_monotonic
self._finished = False
self._watchdog_logged = False
self._timer: threading.Timer | None = None
if auto_start and self.timeout_seconds > 0:
self._timer = threading.Timer(self.timeout_seconds, self._on_timeout)
self._timer.daemon = True
self._timer.start()
@classmethod
def maybe_start(
cls,
method: str,
path: str,
*,
logger: logging.Logger | None = None,
) -> "RequestDiagnostics | None":
clean_path = str(path or "").split("?", 1)[0]
if (method.upper(), clean_path) not in {
("GET", "/api/sessions"),
("POST", "/api/chat/start"),
}:
return None
return cls(method, clean_path, logger=logger)
def stage(self, name: str) -> None:
now = time.monotonic()
clean = str(name or "unknown").strip() or "unknown"
with self._lock:
if self._finished:
return
self._stages.append(
{
"name": self._current_stage,
"ms": round((now - self._current_stage_started) * 1000, 1),
}
)
self._current_stage = clean
self._current_stage_started = now
def finish(self) -> None:
timer = None
record = None
with self._lock:
if self._finished:
return
self._finished = True
timer = self._timer
record = self._build_record_locked(include_stacks=False)
if timer is not None:
timer.cancel()
if record and self.timeout_seconds > 0 and record["elapsed_ms"] >= self.timeout_seconds * 1000:
self.logger.warning(
"Slow WebUI request completed: %s",
json.dumps(record, sort_keys=True),
)
def _on_timeout(self) -> None:
with self._lock:
if self._finished or self._watchdog_logged:
return
self._watchdog_logged = True
record = self._build_record_locked(include_stacks=True)
self.logger.warning(
"Slow WebUI request still running: %s",
json.dumps(record, sort_keys=True),
)
def _build_record_locked(self, *, include_stacks: bool) -> dict[str, Any]:
now = time.monotonic()
stages = list(self._stages)
stages.append(
{
"name": self._current_stage,
"ms": round((now - self._current_stage_started) * 1000, 1),
}
)
record: dict[str, Any] = {
"request_id": self.request_id,
"method": self.method,
"path": self.path,
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(self.started_wall)),
"elapsed_ms": round((now - self.started_monotonic) * 1000, 1),
"current_stage": self._current_stage,
"stages": stages,
}
if include_stacks:
record["thread_stacks"] = _thread_stack_snapshot()
return record
def _thread_stack_snapshot() -> list[dict[str, Any]]:
frames = sys._current_frames()
threads = {thread.ident: thread for thread in threading.enumerate()}
snapshot: list[dict[str, Any]] = []
for ident, frame in frames.items():
thread = threads.get(ident)
stack = traceback.format_stack(frame, limit=MAX_STACK_FRAMES_PER_THREAD)
snapshot.append(
{
"thread_id": ident,
"thread_name": thread.name if thread else "",
"daemon": bool(thread.daemon) if thread else None,
"stack": [line.rstrip() for line in stack],
}
)
snapshot.sort(key=lambda item: str(item.get("thread_name") or ""))
return snapshot

136
api/route_approvals.py Normal file
View File

@@ -0,0 +1,136 @@
"""Approval SSE state and helpers.
State-extraction prelude to the routes.py split tracked in #1907.
Extracts approval state, not handlers, by design.
"""
import queue
import threading
import uuid
from api.session_events import publish_session_list_changed
# Approval system (optional -- graceful fallback if agent not available)
try:
from tools.approval import (
submit_pending as _submit_pending_raw,
approve_session,
approve_permanent,
save_permanent_allowlist,
is_approved,
_pending,
_lock,
_permanent_approved,
_gateway_queues,
resolve_gateway_approval,
enable_session_yolo,
disable_session_yolo,
is_session_yolo_enabled,
)
except ImportError:
_submit_pending_raw = lambda *a, **k: None
approve_session = lambda *a, **k: None
approve_permanent = lambda *a, **k: None
save_permanent_allowlist = lambda *a, **k: None
is_approved = lambda *a, **k: True
resolve_gateway_approval = lambda *a, **k: 0
enable_session_yolo = lambda *a, **k: None
disable_session_yolo = lambda *a, **k: None
is_session_yolo_enabled = lambda *a, **k: False
_pending = {}
_lock = threading.Lock()
_permanent_approved = set()
_gateway_queues = {}
# ── Approval SSE subscribers (long-connection push) ──────────────────────────
_approval_sse_subscribers: dict[str, list[queue.Queue]] = {}
def _approval_sse_subscribe(session_id: str) -> queue.Queue:
"""Register an SSE subscriber for approval events on a given session."""
q = queue.Queue(maxsize=16)
with _lock:
_approval_sse_subscribers.setdefault(session_id, []).append(q)
return q
def _approval_sse_unsubscribe(session_id: str, q: queue.Queue) -> None:
"""Remove an SSE subscriber."""
with _lock:
subs = _approval_sse_subscribers.get(session_id)
if subs and q in subs:
subs.remove(q)
if not subs:
_approval_sse_subscribers.pop(session_id, None)
def _approval_sse_notify_locked(session_id: str, head: dict | None, total: int) -> None:
"""Push an approval event to all SSE subscribers for a session.
CALLER MUST HOLD `_lock`. Snapshots the subscriber list under the held
lock and then calls `q.put_nowait()` on each (which is itself thread-safe).
`head` is the approval entry currently at the head of the queue (the one
the UI should display) — NOT the just-appended entry. With multiple
parallel approvals (#527), the just-appended entry is at the TAIL, but
`/api/approval/pending` always returns the HEAD, so SSE must match.
`total` is the total number of pending approvals.
Pass `head=None` and `total=0` when the queue has just been emptied (e.g.
`_handle_approval_respond` popped the last entry) so the client knows to
hide its approval card.
"""
payload = {"pending": dict(head) if head else None, "pending_count": total}
subs = _approval_sse_subscribers.get(session_id, ())
for q in subs:
try:
q.put_nowait(payload)
except queue.Full:
pass # drop if subscriber is slow (bounded queue prevents memory leak)
def _approval_sse_notify(session_id: str, head: dict | None, total: int) -> None:
"""Convenience wrapper that takes `_lock` itself.
Use only from contexts that don't already hold `_lock`. Production call
sites (submit_pending, _handle_approval_respond) MUST hold the lock and
call `_approval_sse_notify_locked` directly to avoid a notify-ordering
race where a later append's notify can fire before an earlier append's
notify (resulting in stale `pending_count`).
"""
with _lock:
_approval_sse_notify_locked(session_id, head, total)
def submit_pending(session_key: str, approval: dict) -> None:
"""Append a pending approval to the per-session queue.
Wraps the agent's submit_pending to:
- Add a stable approval_id (uuid4 hex) so the respond endpoint can target
a specific entry even when multiple approvals are queued simultaneously.
- Change the storage from a single overwriting dict value to a list, so
parallel tool calls each get their own approval slot (fixes #527).
- Notify any connected SSE subscribers immediately.
"""
entry = dict(approval)
entry.setdefault("approval_id", uuid.uuid4().hex)
with _lock:
queue_list = _pending.setdefault(session_key, [])
# Replace a legacy non-list value if the agent version uses the old pattern.
if not isinstance(queue_list, list):
_pending[session_key] = [queue_list]
queue_list = _pending[session_key]
queue_list.append(entry)
total = len(queue_list)
head = queue_list[0] # /api/approval/pending always returns head
# Push to SSE subscribers from inside _lock so two parallel
# submit_pending calls can't deliver out-of-order (T2's later
# notify arriving before T1's earlier notify with a stale count).
_approval_sse_notify_locked(session_key, head, total)
publish_session_list_changed("attention_pending")
# NOTE: We do NOT call _submit_pending_raw here — that function overwrites
# _pending[session_key] with a single dict, which would undo the list we just
# built. The gateway blocking path uses _gateway_queues (a separate mechanism
# managed by check_all_command_guards / register_gateway_notify), which is
# unaffected by _pending. The _pending dict is only used for UI polling.

File diff suppressed because it is too large Load Diff

320
api/run_journal.py Normal file
View File

@@ -0,0 +1,320 @@
"""Append-only WebUI run event journal helpers.
This is the first #1925 journal/replay slice. It mirrors SSE events emitted by
the existing in-process streaming path without changing execution ownership.
"""
from __future__ import annotations
import json
import os
import re
import threading
import time
from pathlib import Path
from typing import Iterable
RUN_JOURNAL_DIR_NAME = "_run_journal"
_SAFE_ID_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
_WRITER_LOCKS: dict[tuple[str, str, str], threading.Lock] = {}
_WRITER_LOCKS_GUARD = threading.Lock()
_TERMINAL_SSE_EVENTS = {"done", "cancel", "apperror", "error", "stream_end"}
_FSYNC_MODE_ENV = "HERMES_WEBUI_RUN_JOURNAL_FSYNC"
_FSYNC_MODE_EAGER = "eager"
_FSYNC_MODE_TERMINAL_ONLY = "terminal-only"
def _default_session_dir() -> Path:
from api.models import SESSION_DIR
return Path(SESSION_DIR)
def _validate_id(value: str, field: str) -> str:
cleaned = str(value or "").strip()
if not cleaned or "/" in cleaned or "\\" in cleaned or not _SAFE_ID_RE.fullmatch(cleaned):
raise ValueError(f"invalid {field}")
return cleaned
def _run_path(session_id: str, run_id: str, session_dir: Path | None = None) -> Path:
sid = _validate_id(session_id, "session_id")
rid = _validate_id(run_id, "run_id")
root = Path(session_dir) if session_dir is not None else _default_session_dir()
return root / RUN_JOURNAL_DIR_NAME / sid / f"{rid}.jsonl"
def _lock_for(path: Path) -> threading.Lock:
key = (str(path.parent), path.name, str(os.getpid()))
with _WRITER_LOCKS_GUARD:
lock = _WRITER_LOCKS.get(key)
if lock is None:
lock = threading.Lock()
_WRITER_LOCKS[key] = lock
return lock
def _read_jsonl(path: Path) -> tuple[list[dict], list[dict]]:
events: list[dict] = []
malformed: list[dict] = []
try:
lines = path.read_text(encoding="utf-8").splitlines()
except FileNotFoundError:
return events, malformed
for line_no, raw in enumerate(lines, start=1):
if not raw.strip():
continue
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
malformed.append({"line": line_no, "raw": raw})
continue
if isinstance(parsed, dict):
events.append(parsed)
else:
malformed.append({"line": line_no, "raw": raw})
return events, malformed
def _next_seq(path: Path) -> int:
events, _malformed = _read_jsonl(path)
seqs = [int(event.get("seq") or 0) for event in events if isinstance(event.get("seq"), int)]
return (max(seqs) + 1) if seqs else 1
def _terminal_state_for_event(event_name: str, payload) -> str | None:
name = str(event_name or "")
if name == "done" or name == "stream_end":
if isinstance(payload, dict):
explicit_state = str(payload.get("terminal_state") or "").strip().lower()
if explicit_state in {"tool_limit_reached"}:
return explicit_state
return "completed"
if name == "cancel":
return "interrupted-by-user"
if name in {"apperror", "error"}:
err_type = str((payload or {}).get("type") or "").strip().lower() if isinstance(payload, dict) else ""
if err_type == "tool_limit_reached":
return "tool_limit_reached"
if err_type in {"cancelled", "canceled"}:
return "interrupted-by-user"
if err_type == "interrupted":
return "interrupted-by-crash"
return "errored"
return None
def _run_journal_fsync_mode() -> str:
raw = os.environ.get(_FSYNC_MODE_ENV, _FSYNC_MODE_TERMINAL_ONLY)
mode = str(raw or "").strip().lower()
if mode in {_FSYNC_MODE_EAGER, _FSYNC_MODE_TERMINAL_ONLY}:
return mode
return _FSYNC_MODE_TERMINAL_ONLY
def _should_fsync_event(terminal_state: str | None) -> bool:
if _run_journal_fsync_mode() == _FSYNC_MODE_EAGER:
return True
return bool(terminal_state)
def _fsync_parent_dir(path: Path) -> None:
try:
dir_fd = os.open(path.parent, getattr(os, "O_DIRECTORY", 0))
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError:
pass
def append_run_event(
session_id: str,
run_id: str,
event_name: str,
payload=None,
*,
session_dir: Path | None = None,
seq: int | None = None,
created_at: float | None = None,
) -> dict:
"""Append one durable run event and fsync it according to the journal policy."""
path = _run_path(session_id, run_id, session_dir=session_dir)
payload = payload if payload is not None else {}
event_name = str(event_name or "").strip()
if not event_name:
raise ValueError("event_name is required")
with _lock_for(path):
assigned_seq = int(seq) if seq is not None else _next_seq(path)
terminal_state = _terminal_state_for_event(event_name, payload)
event = {
"version": 1,
"event_id": f"{run_id}:{assigned_seq}",
"seq": assigned_seq,
"run_id": str(run_id),
"session_id": str(session_id),
"event": event_name,
"type": event_name,
"created_at": float(created_at if created_at is not None else time.time()),
"terminal": bool(terminal_state),
"terminal_state": terminal_state,
"payload": payload,
}
path.parent.mkdir(parents=True, exist_ok=True)
created_file = not path.exists()
line = json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n"
fd = os.open(path, os.O_CREAT | os.O_APPEND | os.O_WRONLY, 0o600)
with os.fdopen(fd, "a", encoding="utf-8") as fh:
fh.write(line)
fh.flush()
if _should_fsync_event(terminal_state):
os.fsync(fh.fileno())
if created_file:
_fsync_parent_dir(path)
return event
class RunJournalWriter:
"""Stateful writer for one WebUI stream/run."""
def __init__(self, session_id: str, run_id: str, *, session_dir: Path | None = None):
self.session_id = _validate_id(session_id, "session_id")
self.run_id = _validate_id(run_id, "run_id")
self.session_dir = Path(session_dir) if session_dir is not None else None
self._path = _run_path(self.session_id, self.run_id, session_dir=self.session_dir)
self._lock = _lock_for(self._path)
with self._lock:
self._next_seq = _next_seq(self._path)
def append_sse_event(self, event_name: str, payload=None) -> dict:
with self._lock:
seq = self._next_seq
self._next_seq += 1
return append_run_event(
self.session_id,
self.run_id,
event_name,
payload or {},
session_dir=self.session_dir,
seq=seq,
)
def read_run_events(
session_id: str,
run_id: str,
*,
after_seq: int | None = None,
max_seq: int | None = None,
session_dir: Path | None = None,
) -> dict:
path = _run_path(session_id, run_id, session_dir=session_dir)
events, malformed = _read_jsonl(path)
if after_seq is not None:
events = [event for event in events if int(event.get("seq") or 0) > int(after_seq)]
if max_seq is not None:
events = [event for event in events if int(event.get("seq") or 0) <= int(max_seq)]
return {
"session_id": str(session_id),
"run_id": str(run_id),
"events": events,
"malformed": malformed,
}
def _summary_from_events(session_id: str, run_id: str, events: Iterable[dict]) -> dict:
ordered = [event for event in events if isinstance(event, dict)]
last = ordered[-1] if ordered else None
terminal_events = [event for event in ordered if event.get("terminal")]
terminal = next(
(event for event in reversed(terminal_events) if event.get("event") != "stream_end"),
terminal_events[-1] if terminal_events else None,
)
status = terminal.get("terminal_state") if terminal else ("running" if ordered else "unknown")
return {
"session_id": str(session_id),
"run_id": str(run_id),
"stream_id": str(run_id),
"event_count": len(ordered),
"last_seq": int((last or {}).get("seq") or 0),
"last_event_id": (last or {}).get("event_id"),
"terminal": bool(terminal),
"terminal_state": status,
"last_event": (last or {}).get("event"),
}
def latest_run_summary(session_id: str, run_id: str, *, session_dir: Path | None = None) -> dict:
journal = read_run_events(session_id, run_id, session_dir=session_dir)
return _summary_from_events(session_id, run_id, journal.get("events") or [])
def find_run_summary(run_id: str, *, session_dir: Path | None = None) -> dict | None:
rid = _validate_id(run_id, "run_id")
root = Path(session_dir) if session_dir is not None else _default_session_dir()
journal_root = root / RUN_JOURNAL_DIR_NAME
for path in journal_root.glob(f"*/{rid}.jsonl"):
session_id = path.parent.name
events, _malformed = _read_jsonl(path)
summary = _summary_from_events(session_id, rid, events)
summary["path"] = str(path)
return summary
return None
def delete_run_journal(session_id: str, *, session_dir: Path | None = None) -> bool:
"""Remove the entire per-session run-journal directory (``_run_journal/{sid}/``).
The run journal stores one directory per session containing a ``{rid}.jsonl``
file per run, so removing the session's directory clears every run's full
request/response payloads. Invalid/empty ids and a missing directory are a
no-op so callers can invoke this unconditionally on delete. Returns ``True``
if a directory was removed, ``False`` otherwise.
"""
import shutil
sid = str(session_id or "").strip()
# Reject path-traversal ids: the regex below permits dots, so a bare "." or
# ".." would resolve `root / RUN_JOURNAL_DIR_NAME / sid` to the journal ROOT
# (or its parent) and rmtree the wrong directory. The route call site only
# passes real sids, but this is a public helper — guard it directly.
if sid in (".", "..") or not sid or "/" in sid or "\\" in sid or not _SAFE_ID_RE.fullmatch(sid):
return False
root = Path(session_dir) if session_dir is not None else _default_session_dir()
session_journal_dir = root / RUN_JOURNAL_DIR_NAME / sid
if not session_journal_dir.exists():
return False
shutil.rmtree(session_journal_dir, ignore_errors=True)
return not session_journal_dir.exists()
def stale_interrupted_event(session_id: str, run_id: str, *, after_seq: int | None = None) -> dict | None:
summary = latest_run_summary(session_id, run_id)
if summary.get("terminal") or not summary.get("event_count"):
return None
seq = int(summary.get("last_seq") or 0) + 1
if after_seq is not None and seq <= int(after_seq):
return None
payload = {
"type": "interrupted",
"recovery_control": True,
"message": "The live worker stopped before this run finished.",
"hint": "The transcript was restored to the last journaled event. Start a new turn if you still need the task to continue.",
"session_id": session_id,
"stream_id": run_id,
"journal_last_seq": summary.get("last_seq"),
}
return {
"version": 1,
"event_id": f"{run_id}:{seq}",
"seq": seq,
"run_id": run_id,
"session_id": session_id,
"event": "apperror",
"type": "apperror",
"created_at": time.time(),
"terminal": True,
"terminal_state": "lost-worker-bookkeeping",
"payload": payload,
"synthetic": True,
}

156
api/runner_client.py Normal file
View File

@@ -0,0 +1,156 @@
"""HTTP client boundary for a supervised Hermes WebUI runner backend.
This module intentionally contains no process-local run maps, stream queues,
cancellation registries, approval/clarify queues, or cached agent instances. It
is only a JSON-over-HTTP transport used by ``RunnerRuntimeAdapter`` when an
operator explicitly configures a runner endpoint.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
_RUNNER_BASE_URL_ENV = "HERMES_WEBUI_RUNNER_BASE_URL"
_RUNNER_API_KEY_ENV = "HERMES_WEBUI_RUNNER_API_KEY"
class RunnerClientError(RuntimeError):
"""Raised when a configured runner endpoint rejects or fails a request."""
def runner_client_configured(environ: dict[str, str] | None = None) -> bool:
source = os.environ if environ is None else environ
return bool(str(source.get(_RUNNER_BASE_URL_ENV) or "").strip())
class HttpRunnerClient:
"""Small JSON HTTP client for the external/supervised runner boundary."""
def __init__(self, *, base_url: str, api_key: str = ""):
self.base_url = str(base_url or "").strip().rstrip("/")
if not self.base_url:
raise ValueError("runner base_url is required")
# Hardening: the runner endpoint is operator-configured, but reject any
# non-HTTP(S) scheme so a misconfigured HERMES_WEBUI_RUNNER_BASE_URL
# (e.g. file:///etc/passwd or ftp://) can never be handed to urlopen.
_scheme = urllib.parse.urlsplit(self.base_url).scheme.lower()
if _scheme not in ("http", "https"):
raise ValueError(
f"runner base_url must be http(s); got scheme '{_scheme or '(none)'}'"
)
self.api_key = str(api_key or "").strip()
@classmethod
def from_env(cls, environ: dict[str, str] | None = None) -> "HttpRunnerClient":
source = os.environ if environ is None else environ
base_url = str(source.get(_RUNNER_BASE_URL_ENV) or "").strip()
if not base_url:
raise NotImplementedError("runner-local chat backend is not configured")
return cls(base_url=base_url, api_key=str(source.get(_RUNNER_API_KEY_ENV) or ""))
def start_run(self, request) -> dict[str, Any]:
return self._post("/v1/runs", {
"session_id": request.session_id,
"message": request.message,
"attachments": list(request.attachments or []),
"workspace": request.workspace,
"profile": request.profile,
"provider": request.provider,
"model": request.model,
"toolsets": list(request.toolsets or []),
"source": request.source,
"metadata": dict(request.metadata or {}),
})
def observe_run(self, run_id: str, *, cursor: str | None = None) -> dict[str, Any]:
query = ""
if cursor not in (None, ""):
query = "?cursor=" + urllib.parse.quote(str(cursor), safe="")
return self._get(f"/v1/runs/{urllib.parse.quote(str(run_id), safe='')}/events{query}")
def get_run(self, run_id: str) -> dict[str, Any]:
return self._get(f"/v1/runs/{urllib.parse.quote(str(run_id), safe='')}")
def cancel_run(self, run_id: str) -> dict[str, Any]:
return self._post(f"/v1/runs/{urllib.parse.quote(str(run_id), safe='')}/cancel", {})
def respond_approval(self, run_id: str, approval_id: str, choice: str) -> dict[str, Any]:
return self._post(
f"/v1/runs/{urllib.parse.quote(str(run_id), safe='')}/approvals/{urllib.parse.quote(str(approval_id), safe='')}/respond",
{"choice": choice},
)
def respond_clarify(self, run_id: str, clarify_id: str, response: str) -> dict[str, Any]:
return self._post(
f"/v1/runs/{urllib.parse.quote(str(run_id), safe='')}/clarifications/{urllib.parse.quote(str(clarify_id), safe='')}/respond",
{"response": response},
)
def queue_message(self, run_id: str, message: str, *, mode: str = "queue") -> dict[str, Any]:
return self._post(
f"/v1/runs/{urllib.parse.quote(str(run_id), safe='')}/messages",
{"message": message, "mode": mode},
)
def update_goal(self, session_id: str, action: str, text: str = "") -> dict[str, Any]:
return self._post(
f"/v1/sessions/{urllib.parse.quote(str(session_id), safe='')}/goal",
{"action": action, "text": text},
)
def _headers(self) -> dict[str, str]:
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "Hermes-WebUI-RunnerClient",
}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
return headers
def _get(self, path: str) -> dict[str, Any]:
req = urllib.request.Request(self.base_url + path, headers=self._headers(), method="GET")
return self._request_json(req)
def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
req = urllib.request.Request(
self.base_url + path,
data=json.dumps(payload).encode("utf-8"),
headers=self._headers(),
method="POST",
)
return self._request_json(req)
def _opener(self) -> urllib.request.OpenerDirector:
# Hardening: do NOT follow redirects. A misbehaving/compromised runner
# returning 3xx Location could otherwise smuggle the Bearer token to
# another host. Treat any redirect as an error instead.
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
return urllib.request.build_opener(_NoRedirect)
def _request_json(self, req: urllib.request.Request) -> dict[str, Any]:
try:
with self._opener().open(req, timeout=60) as resp:
raw = resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
try:
detail = exc.read(2048).decode("utf-8", errors="replace")
except Exception:
detail = ""
raise RunnerClientError(f"Runner returned HTTP {exc.code}: {detail[:500]}") from exc
except Exception as exc:
raise RunnerClientError(f"Runner request failed: {exc}") from exc
try:
payload = json.loads(raw or "{}")
except json.JSONDecodeError as exc:
raise RunnerClientError("Runner returned invalid JSON") from exc
if not isinstance(payload, dict):
raise RunnerClientError("Runner returned a non-object JSON payload")
return payload

431
api/runtime_adapter.py Normal file
View File

@@ -0,0 +1,431 @@
"""RuntimeAdapter seam for WebUI-owned run execution.
This is the #1925 RuntimeAdapter seam. The default WebUI chat path remains the
legacy direct route; enabling ``HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal``
routes through this protocol-translator facade over the same legacy execution
path plus the Slice 1 run journal. Slice 4 adds a default-off runner-local
selection point for tests and future runner backends, but live chat routes still
stay on the legacy path until a separate route-wiring slice is reviewed. This
module intentionally does not own AIAgent instances, cancellation flags,
approval callbacks, clarify callbacks, or new long-lived queues.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import os
from pathlib import Path
from typing import Any, Callable, Iterable, Literal, Protocol
_RUNTIME_ADAPTER_ENV = "HERMES_WEBUI_RUNTIME_ADAPTER"
_RUNTIME_ADAPTER_DIRECT = "legacy-direct"
_RUNTIME_ADAPTER_JOURNAL = "legacy-journal"
_RUNTIME_ADAPTER_RUNNER_LOCAL = "runner-local"
_VALID_RUNTIME_ADAPTER_MODES = {
_RUNTIME_ADAPTER_DIRECT,
_RUNTIME_ADAPTER_JOURNAL,
_RUNTIME_ADAPTER_RUNNER_LOCAL,
}
@dataclass(frozen=True)
class StartRunRequest:
session_id: str
message: str
attachments: list[dict[str, Any]] = field(default_factory=list)
workspace: str | None = None
profile: str | None = None
provider: str | None = None
model: str | None = None
toolsets: list[str] = field(default_factory=list)
source: str = "webui"
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class RunStartResult:
run_id: str
session_id: str
stream_id: str
status: str = "started"
started_at: float | None = None
cursor: str | None = None
active_controls: list[str] = field(default_factory=list)
payload: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class RunEventStream:
run_id: str
events: list[dict[str, Any]] = field(default_factory=list)
cursor: str | None = None
last_event_id: str | None = None
@dataclass(frozen=True)
class RunStatus:
run_id: str
session_id: str | None = None
status: str = "unknown"
last_event_id: str | None = None
terminal_state: str | None = None
active_controls: list[str] = field(default_factory=list)
pending_approval_id: str | None = None
pending_clarify_id: str | None = None
@dataclass(frozen=True, eq=True, unsafe_hash=False)
class ControlResult:
# NOTE: `payload: dict` makes this dataclass unhashable by design.
# `unsafe_hash=False` makes that explicit so future maintainers don't try
# to add `frozen=True`-implied hashability back (would silently break the
# moment any caller adds dict / list fields). Opus advisor stage-384 followup.
accepted: bool
status: str = "accepted"
event_id: str | None = None
safe_message: str | None = None
payload: dict[str, Any] = field(default_factory=dict)
class RuntimeAdapter(Protocol):
def start_run(self, request: StartRunRequest) -> RunStartResult: ...
def observe_run(self, run_id: str, *, cursor: str | None = None) -> RunEventStream: ...
def get_run(self, run_id: str) -> RunStatus: ...
def cancel_run(self, run_id: str) -> ControlResult: ...
def respond_approval(self, run_id: str, approval_id: str, choice: str) -> ControlResult: ...
def respond_clarify(self, run_id: str, clarify_id: str, response: str) -> ControlResult: ...
def queue_message(self, run_id: str, message: str, *, mode: str = "queue") -> ControlResult: ...
def update_goal(
self,
session_id: str,
action: Literal["set", "pause", "resume", "clear", "status", "edit"],
text: str = "",
) -> ControlResult: ...
def runtime_adapter_mode(environ: dict[str, str] | None = None) -> str:
"""Return the configured adapter mode, defaulting safely to legacy-direct."""
source = os.environ if environ is None else environ
raw = str(source.get(_RUNTIME_ADAPTER_ENV, _RUNTIME_ADAPTER_DIRECT) or "").strip().lower()
return raw if raw in _VALID_RUNTIME_ADAPTER_MODES else _RUNTIME_ADAPTER_DIRECT
def runtime_adapter_enabled(environ: dict[str, str] | None = None) -> bool:
return runtime_adapter_mode(environ) == _RUNTIME_ADAPTER_JOURNAL
def runtime_adapter_runner_enabled(environ: dict[str, str] | None = None) -> bool:
return runtime_adapter_mode(environ) == _RUNTIME_ADAPTER_RUNNER_LOCAL
def build_runtime_adapter(
*,
environ: dict[str, str] | None = None,
legacy_adapter_factory: Callable[[], RuntimeAdapter] | None = None,
runner_client_factory: Callable[[], Any] | None = None,
) -> RuntimeAdapter | None:
"""Build the configured RuntimeAdapter without changing route behavior.
``None`` means the safe default ``legacy-direct`` path should keep using the
existing direct route. ``legacy-journal`` is opt-in and delegates to the
supplied legacy factory. ``runner-local`` is also opt-in and only constructs
a ``RunnerRuntimeAdapter`` around an injected client; this function does not
create process-global runner state or wire live chat to the runner backend.
"""
mode = runtime_adapter_mode(environ)
if mode == _RUNTIME_ADAPTER_DIRECT:
return None
if mode == _RUNTIME_ADAPTER_JOURNAL:
if legacy_adapter_factory is None:
raise NotImplementedError("legacy-journal mode requires a legacy adapter factory")
return legacy_adapter_factory()
if runner_client_factory is None:
raise NotImplementedError("runner-local mode requires a runner client factory")
return RunnerRuntimeAdapter(client=runner_client_factory())
def _cursor_to_after_seq(cursor: str | None) -> int | None:
if cursor in (None, ""):
return None
try:
text = str(cursor)
if ":" in text:
text = text.rsplit(":", 1)[-1]
return max(0, int(text))
except (TypeError, ValueError):
return 0
def _active_control_result(value: Any) -> ControlResult:
"""Normalize legacy delegate responses without changing their payloads.
``status`` is an adapter-level summary used by current control tests and
future runtime backends. For legacy goal payloads it may mirror the goal
action (``set`` / ``pause`` / ``status``), while public route behavior keeps
using the payload itself to preserve existing HTTP response shapes.
"""
if isinstance(value, ControlResult):
return value
if isinstance(value, dict):
accepted = bool(value.get("ok", True))
return ControlResult(
accepted=accepted,
status=str(value.get("status") or value.get("action") or ("accepted" if accepted else "not-active")),
safe_message=value.get("message") if not accepted else None,
payload=dict(value),
)
accepted = bool(value)
return ControlResult(
accepted=accepted,
status="accepted" if accepted else "not-active",
safe_message=None if accepted else "Legacy control did not accept the request.",
)
def _runner_unsupported_control(name: str) -> ControlResult:
return ControlResult(
False,
status="unsupported",
safe_message=f"{name} is not supported by this runner backend.",
)
class RunnerRuntimeAdapter:
"""Protocol-translator facade for a future runner/sidecar backend.
Slice 4 moves runtime ownership behind a runner boundary, but the WebUI
adapter must remain a translator. This class deliberately delegates to an
injected client instead of owning process-local streams, cancellation flags,
approval queues, clarify queues, or cached agent instances itself.
"""
def __init__(self, *, client: Any):
self._client = client
def start_run(self, request: StartRunRequest) -> RunStartResult:
start_run = getattr(self._client, "start_run", None)
if start_run is None:
raise NotImplementedError("RunnerRuntimeAdapter.start_run requires a runner client")
payload = start_run(request)
if isinstance(payload, RunStartResult):
return payload
payload = dict(payload or {})
run_id = str(payload.get("run_id") or payload.get("stream_id") or "")
stream_id = str(payload.get("stream_id") or run_id)
session_id = str(payload.get("session_id") or request.session_id)
active_controls = payload.get("active_controls")
if not isinstance(active_controls, list):
active_controls = []
return RunStartResult(
run_id=run_id,
session_id=session_id,
stream_id=stream_id,
status=str(payload.get("status") or "started"),
started_at=payload.get("started_at"),
cursor=payload.get("cursor"),
active_controls=active_controls,
payload=payload,
)
def observe_run(self, run_id: str, *, cursor: str | None = None) -> RunEventStream:
observe_run = getattr(self._client, "observe_run", None)
if observe_run is None:
return RunEventStream(run_id=run_id, events=[], cursor=cursor, last_event_id=None)
result = observe_run(run_id, cursor=cursor)
if isinstance(result, RunEventStream):
return result
payload = dict(result or {})
events = list(payload.get("events") or [])
last_event_id = payload.get("last_event_id") or (events[-1].get("event_id") if events else None)
next_cursor = payload.get("cursor")
if next_cursor is None and events:
next_cursor = str(events[-1].get("seq") or "")
return RunEventStream(
run_id=str(payload.get("run_id") or run_id),
events=events,
cursor=str(next_cursor) if next_cursor is not None else cursor,
last_event_id=last_event_id,
)
def get_run(self, run_id: str) -> RunStatus:
get_run = getattr(self._client, "get_run", None)
if get_run is None:
return RunStatus(run_id=run_id)
result = get_run(run_id)
if isinstance(result, RunStatus):
return result
payload = dict(result or {})
active_controls = payload.get("active_controls")
if not isinstance(active_controls, list):
active_controls = []
return RunStatus(
run_id=str(payload.get("run_id") or run_id),
session_id=str(payload.get("session_id") or "") or None,
status=str(payload.get("status") or "unknown"),
last_event_id=payload.get("last_event_id"),
terminal_state=payload.get("terminal_state"),
active_controls=active_controls,
pending_approval_id=payload.get("pending_approval_id"),
pending_clarify_id=payload.get("pending_clarify_id"),
)
def cancel_run(self, run_id: str) -> ControlResult:
cancel_run = getattr(self._client, "cancel_run", None)
if cancel_run is None:
return _runner_unsupported_control("Cancel")
return _active_control_result(cancel_run(run_id))
def respond_approval(self, run_id: str, approval_id: str, choice: str) -> ControlResult:
respond_approval = getattr(self._client, "respond_approval", None)
if respond_approval is None:
return _runner_unsupported_control("Approval")
return _active_control_result(respond_approval(run_id, approval_id, choice))
def respond_clarify(self, run_id: str, clarify_id: str, response: str) -> ControlResult:
respond_clarify = getattr(self._client, "respond_clarify", None)
if respond_clarify is None:
return _runner_unsupported_control("Clarify")
return _active_control_result(respond_clarify(run_id, clarify_id, response))
def queue_message(self, run_id: str, message: str, *, mode: str = "queue") -> ControlResult:
queue_message = getattr(self._client, "queue_message", None)
if queue_message is None:
return _runner_unsupported_control("Queue")
return _active_control_result(queue_message(run_id, message, mode=mode))
def update_goal(
self,
session_id: str,
action: Literal["set", "pause", "resume", "clear", "status", "edit"],
text: str = "",
) -> ControlResult:
update_goal = getattr(self._client, "update_goal", None)
if update_goal is None:
return _runner_unsupported_control("Goal")
return _active_control_result(update_goal(session_id, action, text))
class LegacyJournalRuntimeAdapter:
"""Protocol-translator facade over the current legacy streaming path.
Delegates keep Slice 2 honest: this adapter has no worker thread, AIAgent
cache, cancellation registry, approval queue, or clarify queue of its own.
"""
def __init__(
self,
*,
start_run_delegate: Callable[[StartRunRequest], dict[str, Any]] | None = None,
cancel_delegate: Callable[[str], Any] | None = None,
approval_delegate: Callable[[str, str, str], Any] | None = None,
clarify_delegate: Callable[[str, str, str], Any] | None = None,
queue_delegate: Callable[[str, str, str], Any] | None = None,
goal_delegate: Callable[[str, str, str], Any] | None = None,
live_stream_lookup: Callable[[str], bool] | None = None,
session_dir: Path | None = None,
):
self._start_run_delegate = start_run_delegate
self._cancel_delegate = cancel_delegate
self._approval_delegate = approval_delegate
self._clarify_delegate = clarify_delegate
self._queue_delegate = queue_delegate
self._goal_delegate = goal_delegate
self._live_stream_lookup = live_stream_lookup or (lambda _run_id: False)
self._session_dir = Path(session_dir) if session_dir is not None else None
def start_run(self, request: StartRunRequest) -> RunStartResult:
if self._start_run_delegate is None:
raise NotImplementedError("LegacyJournalRuntimeAdapter.start_run requires a legacy delegate")
payload = dict(self._start_run_delegate(request) or {})
stream_id = str(payload.get("stream_id") or payload.get("run_id") or "")
run_id = str(payload.get("run_id") or stream_id)
session_id = str(payload.get("session_id") or request.session_id)
active_controls = payload.get("active_controls")
if not isinstance(active_controls, list):
active_controls = ["cancel"] if stream_id else []
return RunStartResult(
run_id=run_id,
session_id=session_id,
stream_id=stream_id,
status=str(payload.get("status") or "started"),
started_at=payload.get("started_at"),
cursor=payload.get("cursor"),
active_controls=active_controls,
payload=payload,
)
def observe_run(self, run_id: str, *, cursor: str | None = None) -> RunEventStream:
from api.run_journal import find_run_summary, read_run_events
summary = find_run_summary(run_id, session_dir=self._session_dir)
if not summary:
return RunEventStream(run_id=run_id, events=[], cursor=cursor, last_event_id=None)
journal = read_run_events(
str(summary.get("session_id") or ""),
run_id,
after_seq=_cursor_to_after_seq(cursor),
session_dir=self._session_dir,
)
events = list(journal.get("events") or [])
last_event_id = events[-1].get("event_id") if events else summary.get("last_event_id")
return RunEventStream(
run_id=run_id,
events=events,
cursor=str(events[-1].get("seq")) if events else cursor,
last_event_id=last_event_id,
)
def get_run(self, run_id: str) -> RunStatus:
from api.run_journal import find_run_summary
live = bool(self._live_stream_lookup(run_id))
summary = find_run_summary(run_id, session_dir=self._session_dir)
if live:
return RunStatus(
run_id=run_id,
session_id=str((summary or {}).get("session_id") or "") or None,
status="running",
last_event_id=(summary or {}).get("last_event_id"),
terminal_state=None,
active_controls=["cancel"],
)
if summary:
terminal_state = summary.get("terminal_state")
return RunStatus(
run_id=run_id,
session_id=str(summary.get("session_id") or "") or None,
status=str(terminal_state or "unknown"),
last_event_id=summary.get("last_event_id"),
terminal_state=terminal_state,
active_controls=[],
)
return RunStatus(run_id=run_id)
def cancel_run(self, run_id: str) -> ControlResult:
if self._cancel_delegate is None:
return ControlResult(False, status="unsupported", safe_message="Cancel is not wired for this adapter.")
return _active_control_result(self._cancel_delegate(run_id))
def respond_approval(self, run_id: str, approval_id: str, choice: str) -> ControlResult:
if self._approval_delegate is None:
return ControlResult(False, status="unsupported", safe_message="Approval is delegated to the legacy path.")
return _active_control_result(self._approval_delegate(run_id, approval_id, choice))
def respond_clarify(self, run_id: str, clarify_id: str, response: str) -> ControlResult:
if self._clarify_delegate is None:
return ControlResult(False, status="unsupported", safe_message="Clarify is delegated to the legacy path.")
return _active_control_result(self._clarify_delegate(run_id, clarify_id, response))
def queue_message(self, run_id: str, message: str, *, mode: str = "queue") -> ControlResult:
if self._queue_delegate is None:
return ControlResult(False, status="unsupported", safe_message="Queue is delegated to the legacy path.")
return _active_control_result(self._queue_delegate(run_id, message, mode))
def update_goal(
self,
session_id: str,
action: Literal["set", "pause", "resume", "clear", "status", "edit"],
text: str = "",
) -> ControlResult:
if self._goal_delegate is None:
return ControlResult(False, status="unsupported", safe_message="Goal is delegated to the legacy path.")
return _active_control_result(self._goal_delegate(session_id, action, text))

View File

@@ -0,0 +1,640 @@
"""Read-only sidebar discoverability audit for Hermes WebUI sessions.
This module does not repair or mutate session state. It cross-checks the four
places that decide whether a session can be found from the WebUI sidebar:
- JSON sidecars under the WebUI session directory
- ``_index.json`` sidebar metadata
- canonical ``state.db`` rows/messages
- the live ``api.models.all_sessions()`` sidebar response, when available
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sqlite3
from collections import Counter
from pathlib import Path
from typing import Iterable
def _safe_int(value, default: int = 0) -> int:
try:
if value is None:
return default
return int(value)
except (TypeError, ValueError):
return default
def _read_json(path: Path):
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
return None
def _message_count_from_payload(payload: dict) -> int:
messages = payload.get("messages")
if isinstance(messages, list):
return len(messages)
return _safe_int(payload.get("message_count"), 0)
def _record_from_mapping(mapping: dict, source_name: str) -> dict:
sid = str(mapping.get("session_id") or mapping.get("id") or "").strip()
if not sid:
return {}
return {
"session_id": sid,
"title": mapping.get("title"),
"message_count": _message_count_from_payload(mapping),
"source_tag": mapping.get("source_tag"),
"session_source": mapping.get("session_source"),
"source": mapping.get("source"),
"is_cli_session": mapping.get("is_cli_session"),
"parent_session_id": mapping.get("parent_session_id"),
"pre_compression_snapshot": bool(mapping.get("pre_compression_snapshot")),
"_lineage_root_id": mapping.get("_lineage_root_id"),
"archived": bool(mapping.get("archived")),
"project_id": mapping.get("project_id"),
"workspace": mapping.get("workspace"),
"_source_name": source_name,
}
def _read_sidecars(session_dir: Path) -> dict[str, dict]:
records: dict[str, dict] = {}
if not session_dir.exists():
return records
for path in sorted(p for p in session_dir.glob("*.json") if not p.name.startswith("_")):
payload = _read_json(path)
if not isinstance(payload, dict):
continue
record = _record_from_mapping(payload, "sidecar")
if record:
records[record["session_id"]] = record
return records
def _read_index(session_dir: Path) -> dict[str, dict]:
payload = _read_json(session_dir / "_index.json")
records: dict[str, dict] = {}
if not isinstance(payload, list):
return records
for entry in payload:
if not isinstance(entry, dict):
continue
record = _record_from_mapping(entry, "index")
if record:
records[record["session_id"]] = record
return records
def _optional_expr(name: str, columns: set[str], fallback: str = "NULL") -> str:
return name if name in columns else f"{fallback} AS {name}"
def _read_state_db(state_db_path: Path | None) -> dict[str, dict]:
if state_db_path is None or not state_db_path.exists():
return {}
try:
with sqlite3.connect(f"file:{state_db_path}?mode=ro", uri=True) as conn:
conn.row_factory = sqlite3.Row
tables = {row[0] for row in conn.execute("select name from sqlite_master where type='table'")}
if "sessions" not in tables:
return {}
session_cols = {row[1] for row in conn.execute("pragma table_info(sessions)")}
if "id" not in session_cols:
return {}
message_cols: set[str] = set()
if "messages" in tables:
message_cols = {row[1] for row in conn.execute("pragma table_info(messages)")}
title_expr = _optional_expr("title", session_cols)
source_expr = _optional_expr("source", session_cols)
parent_expr = _optional_expr("parent_session_id", session_cols)
msg_expr = _optional_expr("message_count", session_cols, "0")
workspace_expr = _optional_expr("workspace", session_cols)
rows = conn.execute(
f"""
SELECT id, {title_expr}, {source_expr}, {parent_expr}, {msg_expr}, {workspace_expr}
FROM sessions
"""
).fetchall()
message_counts: dict[str, int] = {}
if {"session_id"}.issubset(message_cols):
for row in conn.execute("SELECT session_id, COUNT(*) AS count FROM messages GROUP BY session_id"):
message_counts[str(row["session_id"])] = _safe_int(row["count"], 0)
records: dict[str, dict] = {}
for row in rows:
sid = str(row["id"] or "").strip()
if not sid:
continue
count = message_counts.get(sid, _safe_int(row["message_count"], 0))
records[sid] = {
"session_id": sid,
"title": row["title"],
"message_count": count,
"source": row["source"],
"source_tag": row["source"],
"session_source": row["source"],
"parent_session_id": row["parent_session_id"],
"workspace": row["workspace"],
"_source_name": "state_db",
}
return records
except Exception:
return {}
def _normalize_api_sessions(api_sessions: Iterable[dict] | None) -> dict[str, dict]:
records: dict[str, dict] = {}
if api_sessions is None:
try:
from api.models import all_sessions
api_sessions = all_sessions()
except Exception:
api_sessions = []
for entry in api_sessions or []:
if not isinstance(entry, dict):
continue
record = _record_from_mapping(entry, "api")
if record:
records[record["session_id"]] = record
return records
def _merged_field(sid: str, stores: list[dict[str, dict]], field: str):
for store in stores:
value = store.get(sid, {}).get(field)
if value not in (None, ""):
return value
return None
def _max_message_count(sid: str, stores: list[dict[str, dict]]) -> int:
return max((_safe_int(store.get(sid, {}).get("message_count"), 0) for store in stores), default=0)
def _lineage_root(sid: str, parent_by_id: dict[str, str | None]) -> str:
seen: set[str] = set()
current = sid
while current and current not in seen:
seen.add(current)
parent = parent_by_id.get(current)
if not parent:
return current
current = parent
return sid
def _webui_origin(*records: dict) -> bool:
values: list[str] = []
for record in records:
for key in ("source", "source_tag", "session_source"):
value = record.get(key)
if value is not None:
values.append(str(value).strip().lower())
return "webui" in values
def _computed_is_cli_session(row: dict) -> bool:
sources = {
str(row.get(key) or "").strip().lower()
for key in ("session_source", "source_tag", "raw_source", "source", "source_label")
}
if "webui" in sources:
return False
try:
from api.agent_sessions import is_cli_session_row
return is_cli_session_row(row)
except Exception:
source = str(row.get("session_source") or row.get("source_tag") or row.get("raw_source") or row.get("source") or "").strip().lower()
return source == "cli"
def _new_item(session_id: str, kind: str, category: str, recommendation: str, **extra) -> dict:
item = {
"session_id": session_id,
"kind": kind,
"category": category,
"recommendation": recommendation,
}
item.update(extra)
return item
def audit_session_discoverability(
session_dir: Path,
state_db_path: Path | None = None,
*,
api_sessions: Iterable[dict] | None = None,
) -> dict:
"""Return a read-only cross-store discoverability report.
The audit is intentionally diagnostic only. It reports cases where
messageful sessions have no visible API/sidebar representative, stale source
flags can put WebUI sessions into the CLI tab, and index/sidecar/state-db
drift can make a session harder to resolve.
"""
session_dir = Path(session_dir)
sidecars = _read_sidecars(session_dir)
index = _read_index(session_dir)
state = _read_state_db(state_db_path)
api = _normalize_api_sessions(api_sessions)
stores = [sidecars, index, state, api]
all_ids = set().union(*(store.keys() for store in stores))
parent_by_id: dict[str, str | None] = {}
for sid in all_ids:
parent = _merged_field(sid, stores, "parent_session_id")
parent_by_id[sid] = str(parent) if parent else None
api_lineage_ids: set[str] = set()
api_lineage_representative_by_id: dict[str, str] = {}
for sid, row in api.items():
explicit_root = row.get("_lineage_root_id")
if explicit_root:
root_id = str(explicit_root)
api_lineage_ids.add(root_id)
api_lineage_representative_by_id.setdefault(root_id, sid)
current = sid
seen: set[str] = set()
while current and current not in seen:
seen.add(current)
api_lineage_ids.add(current)
api_lineage_representative_by_id.setdefault(current, sid)
current = parent_by_id.get(current) or ""
items: list[dict] = []
for sid in sorted(all_ids):
message_count = _max_message_count(sid, stores)
present_in = {
"sidecar": sid in sidecars,
"index": sid in index,
"state_db": sid in state,
"api": sid in api,
}
sidecar = sidecars.get(sid, {})
index_row = index.get(sid, {})
state_row = state.get(sid, {})
api_row = api.get(sid, {})
webui_origin = _webui_origin(sidecar, index_row, state_row, api_row)
api_is_cli = api_row.get("is_cli_session") is True
api_computed_is_cli = _computed_is_cli_session(api_row) if api_row else False
index_is_cli = index_row.get("is_cli_session") is True
sidecar_is_cli = sidecar.get("is_cli_session") is True
lineage_root = _lineage_root(sid, parent_by_id)
api_representative = api_lineage_representative_by_id.get(sid) or api_lineage_representative_by_id.get(lineage_root)
api_lineage_extra = {
"represented_by_api_lineage": bool(api_representative),
"api_representative_session_id": api_representative,
}
if webui_origin and api_is_cli and api_computed_is_cli:
items.append(_new_item(
sid,
"source_misclassified",
"warning",
"normalize_api_source_flags",
message_count=message_count,
state_source=state_row.get("source"),
api_is_cli_session=api_row.get("is_cli_session"),
api_computed_is_cli_session=api_computed_is_cli,
index_is_cli_session=index_row.get("is_cli_session"),
sidecar_is_cli_session=sidecar.get("is_cli_session"),
present_in=present_in,
**api_lineage_extra,
))
elif webui_origin and (api_is_cli or index_is_cli or sidecar_is_cli):
items.append(_new_item(
sid,
"persisted_source_flag_stale",
"warning",
"rewrite_persisted_sidebar_source_flags_or_ignore_route_normalizes",
message_count=message_count,
state_source=state_row.get("source"),
api_is_cli_session=api_row.get("is_cli_session"),
api_computed_is_cli_session=api_computed_is_cli,
index_is_cli_session=index_row.get("is_cli_session"),
sidecar_is_cli_session=sidecar.get("is_cli_session"),
present_in=present_in,
**api_lineage_extra,
))
if message_count <= 0 or sid in api:
continue
is_hidden_snapshot = bool(sidecar.get("pre_compression_snapshot") or index_row.get("pre_compression_snapshot"))
if sid in api_lineage_ids or lineage_root in api_lineage_ids:
continue
if is_hidden_snapshot:
items.append(_new_item(
sid,
"lineage_missing_visible_representative",
"warning",
"repair_lineage_or_expose_tip",
message_count=message_count,
lineage_root=lineage_root,
present_in=present_in,
))
continue
if not present_in["sidecar"] and not present_in["index"] and present_in["state_db"]:
items.append(_new_item(
sid,
"state_db_messageful_missing_sidecar",
"warning",
"materialize_sidecar_or_archive_state_row",
message_count=message_count,
lineage_root=lineage_root,
present_in=present_in,
))
continue
items.append(_new_item(
sid,
"api_missing_messageful",
"warning",
"investigate_sidebar_filters_or_api_merge",
message_count=message_count,
lineage_root=lineage_root,
present_in=present_in,
))
summary = {
"sessions_seen": len(all_ids),
"messageful": sum(1 for sid in all_ids if _max_message_count(sid, stores) > 0),
"visible_api": len(api),
"warnings": sum(1 for item in items if item.get("category") == "warning"),
}
status = "warn" if summary["warnings"] else "ok"
return {
"status": status,
"summary": summary,
"stores": {
"sidecar": len(sidecars),
"index": len(index),
"state_db": len(state),
"api": len(api),
},
"items": items,
}
def _atomic_write_json(path: Path, payload) -> None:
tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, path)
def _backup_file(path: Path, backup_dir: Path, backed_up: dict[Path, str]) -> str | None:
if not path.exists():
return None
resolved = path.resolve()
if resolved in backed_up:
return backed_up[resolved]
backup_dir.mkdir(parents=True, exist_ok=True)
target = backup_dir / path.name
if target.exists():
stem = target.name
i = 1
while (backup_dir / f"{stem}.{i}").exists():
i += 1
target = backup_dir / f"{stem}.{i}"
shutil.copy2(path, target)
backed_up[resolved] = str(target)
return str(target)
def _plan_discoverability_repairs(report: dict) -> list[dict]:
actions: list[dict] = []
for item in report.get("items") or []:
sid = str(item.get("session_id") or "")
if not sid:
continue
if item.get("kind") == "persisted_source_flag_stale":
if item.get("sidecar_is_cli_session") is True:
actions.append({"session_id": sid, "action": "clear_sidecar_cli_flag"})
if item.get("index_is_cli_session") is True:
actions.append({"session_id": sid, "action": "clear_index_cli_flag"})
elif item.get("kind") == "state_db_messageful_missing_sidecar":
actions.append({"session_id": sid, "action": "materialize_sidecar_from_state_db"})
return actions
def _clear_sidecar_cli_flag(session_dir: Path, sid: str, backup_dir: Path, backed_up: dict[Path, str]) -> dict:
path = session_dir / f"{sid}.json"
payload = _read_json(path)
if not isinstance(payload, dict):
return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": False, "error": "sidecar_unreadable"}
if not _webui_origin(payload):
return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": False, "skipped": "not_webui_origin"}
if payload.get("is_cli_session") is not True:
return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": False, "skipped": "already_clear"}
backup = _backup_file(path, backup_dir, backed_up)
payload["is_cli_session"] = False
_atomic_write_json(path, payload)
return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": True, "backup": backup}
def _clear_index_cli_flag(session_dir: Path, sid: str, backup_dir: Path, backed_up: dict[Path, str]) -> dict:
path = session_dir / "_index.json"
payload = _read_json(path)
if not isinstance(payload, list):
return {"session_id": sid, "action": "clear_index_cli_flag", "applied": False, "error": "index_unreadable"}
changed = False
for entry in payload:
if not isinstance(entry, dict):
continue
if str(entry.get("session_id") or "") != sid:
continue
if not _webui_origin(entry):
continue
if entry.get("is_cli_session") is True:
entry["is_cli_session"] = False
changed = True
if not changed:
return {"session_id": sid, "action": "clear_index_cli_flag", "applied": False, "skipped": "already_clear_or_missing"}
backup = _backup_file(path, backup_dir, backed_up)
_atomic_write_json(path, payload)
return {"session_id": sid, "action": "clear_index_cli_flag", "applied": True, "backup": backup}
def _materialize_sidecar_from_state_db(session_dir: Path, state_db_path: Path | None, sid: str, backup_dir: Path, backed_up: dict[Path, str]) -> dict:
if state_db_path is None:
return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "error": "state_db_required"}
target = session_dir / f"{sid}.json"
if target.exists():
return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "skipped": "sidecar_exists"}
try:
from api.session_recovery import _read_state_db_missing_sidecar_rows, _state_db_row_to_sidecar
except Exception as exc:
return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "error": f"recovery_import_failed:{exc}"}
rows = {str(row.get("id") or ""): row for row in _read_state_db_missing_sidecar_rows(session_dir, state_db_path)}
row = rows.get(sid)
if not row:
return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "skipped": "state_row_not_repairable"}
payload = _state_db_row_to_sidecar(row)
_backup_file(state_db_path, backup_dir, backed_up)
session_dir.mkdir(parents=True, exist_ok=True)
tmp = target.with_suffix(target.suffix + f".tmp.{os.getpid()}")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
try:
os.link(str(tmp), str(target))
except FileExistsError:
return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "skipped": "sidecar_appeared_during_repair"}
finally:
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
index_updated = False
index_path = session_dir / "_index.json"
index_payload = _read_json(index_path)
if not isinstance(index_payload, list):
index_payload = []
if not any(isinstance(entry, dict) and str(entry.get("session_id") or "") == sid for entry in index_payload):
_backup_file(index_path, backup_dir, backed_up)
index_entry = {key: value for key, value in payload.items() if key not in {"messages", "tool_calls"}}
index_payload.append(index_entry)
_atomic_write_json(index_path, index_payload)
index_updated = True
return {
"session_id": sid,
"action": "materialize_sidecar_from_state_db",
"applied": True,
"messages": len(payload.get("messages") or []),
"index_updated": index_updated,
"backup": str((backup_dir / state_db_path.name)) if (backup_dir / state_db_path.name).exists() else None,
}
def repair_session_discoverability(
session_dir: Path,
state_db_path: Path | None = None,
*,
api_sessions: Iterable[dict] | None = None,
dry_run: bool = True,
backup_dir: Path | None = None,
) -> dict:
"""Plan or apply deterministic discoverability repairs.
Default mode is read-only. Applying mutations requires ``backup_dir`` and is
limited to stale persisted WebUI-as-CLI flags plus materializing WebUI
messageful sidecars from canonical state.db rows.
"""
before = audit_session_discoverability(session_dir, state_db_path=state_db_path, api_sessions=api_sessions)
planned = _plan_discoverability_repairs(before)
if dry_run:
return {"ok": True, "dry_run": True, "planned": planned, "applied": [], "before": before, "after": before}
if backup_dir is None:
return {"ok": False, "dry_run": False, "error": "backup_dir_required_for_apply", "planned": planned, "applied": [], "before": before}
session_dir = Path(session_dir)
backup_dir = Path(backup_dir)
backed_up: dict[Path, str] = {}
applied: list[dict] = []
for action in planned:
sid = str(action.get("session_id") or "")
name = action.get("action")
try:
if name == "clear_sidecar_cli_flag":
applied.append(_clear_sidecar_cli_flag(session_dir, sid, backup_dir, backed_up))
elif name == "clear_index_cli_flag":
applied.append(_clear_index_cli_flag(session_dir, sid, backup_dir, backed_up))
elif name == "materialize_sidecar_from_state_db":
applied.append(_materialize_sidecar_from_state_db(session_dir, state_db_path, sid, backup_dir, backed_up))
except Exception as exc:
applied.append({"session_id": sid, "action": name, "applied": False, "error": str(exc)})
after = audit_session_discoverability(session_dir, state_db_path=state_db_path, api_sessions=api_sessions)
errors = [item for item in applied if item.get("error")]
return {
"ok": not errors,
"dry_run": False,
"planned": planned,
"applied": applied,
"backups": sorted(set(backed_up.values())),
"before": before,
"after": after,
}
def render_discoverability_markdown(report: dict) -> str:
lines = [
"# WebUI Session Discoverability Audit",
"",
f"Status: `{report.get('status')}`",
"",
"## Summary",
"",
]
for key, value in (report.get("summary") or {}).items():
lines.append(f"- `{key}`: {value}")
lines.extend(["", "## Stores", ""])
for key, value in (report.get("stores") or {}).items():
lines.append(f"- `{key}`: {value}")
lines.extend(["", "## Findings", ""])
items = report.get("items") or []
if items:
lines.extend(["### By kind", ""])
for kind, count in sorted(Counter(str(item.get("kind")) for item in items).items()):
lines.append(f"- `{kind}`: {count}")
lines.extend(["", "### Details", ""])
if not items:
lines.append("No discoverability findings.")
else:
for item in items:
lines.append(
f"- `{item.get('kind')}` `{item.get('session_id')}` "
f"messages={item.get('message_count', 'n/a')} recommendation=`{item.get('recommendation')}`"
)
present = item.get("present_in")
if isinstance(present, dict):
lines.append(
" - present_in: " + ", ".join(f"{k}={v}" for k, v in sorted(present.items()))
)
if item.get("represented_by_api_lineage"):
lines.append(
f" - represented_by_api_lineage: true via `{item.get('api_representative_session_id')}`"
)
lines.append("")
return "\n".join(lines)
def _main() -> int:
parser = argparse.ArgumentParser(description="Read-only Hermes WebUI session discoverability audit")
parser.add_argument("--session-dir", type=Path, required=True)
parser.add_argument("--state-db", type=Path, default=None)
parser.add_argument("--format", choices=("json", "markdown"), default="json")
parser.add_argument("--repair-safe", action="store_true", help="Plan/apply deterministic discoverability repairs")
parser.add_argument("--apply", action="store_true", help="Apply --repair-safe changes; default is dry-run")
parser.add_argument("--backup-dir", type=Path, default=None, help="Required with --repair-safe --apply")
parser.add_argument("--out", type=Path, default=None)
args = parser.parse_args()
if args.repair_safe:
report = repair_session_discoverability(
args.session_dir,
state_db_path=args.state_db,
dry_run=not args.apply,
backup_dir=args.backup_dir,
)
text = json.dumps(report, sort_keys=True)
else:
report = audit_session_discoverability(args.session_dir, state_db_path=args.state_db)
text = render_discoverability_markdown(report) if args.format == "markdown" else json.dumps(report, sort_keys=True)
if args.out:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(text, encoding="utf-8")
else:
print(text)
return 0
if __name__ == "__main__":
raise SystemExit(_main())

107
api/session_events.py Normal file
View File

@@ -0,0 +1,107 @@
"""Lightweight in-process invalidation events for session sidebar state."""
import queue
import threading
_SESSION_EVENTS_LOCK = threading.Lock()
_SESSION_EVENTS_SUBSCRIBERS: set[queue.Queue] = set()
_SESSION_EVENTS_VERSION = 0
def _profile_is_root_alias(profile: str | None) -> bool:
name = str(profile or "").strip()
if not name:
return False
if name == "default":
return True
try:
from api.profiles import _is_root_profile
return bool(_is_root_profile(name))
except Exception:
return False
def _sessions_changed_payload(
*,
reason: str,
version: int,
profile: str | None = None,
) -> dict:
payload = {
"type": "sessions_changed",
"version": version,
"reason": reason,
}
normalized_profile = str(profile or "").strip()
# Root/default aliases must stay unscoped: browser tabs cannot infer every
# renamed-root alias, and an unscoped refresh preserves the old fail-safe.
if normalized_profile and not _profile_is_root_alias(normalized_profile):
payload["profile"] = normalized_profile
return payload
def _payload_profile(payload: dict | None) -> str | None:
value = payload.get("profile") if isinstance(payload, dict) else None
value = str(value or "").strip()
return value or None
def _coalesced_sessions_changed_payload(pending: dict | None, incoming: dict) -> dict:
"""Merge bounded-queue refresh events without dropping profile-relevant work.
A maxsize=1 queue is safe only while all events are interchangeable. Once
events can be profile-scoped, replacing profile A with profile B can make
an A tab ignore the queued event and miss the refresh entirely. On any
scope mismatch, fall back to an unscoped refresh-all event.
"""
if pending is None:
return incoming
pending_profile = _payload_profile(pending)
incoming_profile = _payload_profile(incoming)
if pending_profile == incoming_profile:
return incoming
merged = dict(incoming)
merged.pop("profile", None)
return merged
def publish_session_list_changed(
reason: str = "session_changed",
profile: str | None = None,
) -> None:
"""Notify connected browsers that the session sidebar may be stale."""
global _SESSION_EVENTS_VERSION
with _SESSION_EVENTS_LOCK:
_SESSION_EVENTS_VERSION += 1
payload = _sessions_changed_payload(
reason=reason,
version=_SESSION_EVENTS_VERSION,
profile=profile,
)
subscribers = list(_SESSION_EVENTS_SUBSCRIBERS)
for q in subscribers:
try:
q.put_nowait(payload)
except queue.Full:
pending = None
try:
pending = q.get_nowait()
except queue.Empty:
pass
try:
q.put_nowait(_coalesced_sessions_changed_payload(pending, payload))
except queue.Full:
pass
def subscribe_session_events() -> queue.Queue:
q: queue.Queue = queue.Queue(maxsize=1)
with _SESSION_EVENTS_LOCK:
_SESSION_EVENTS_SUBSCRIBERS.add(q)
return q
def unsubscribe_session_events(q: queue.Queue) -> None:
with _SESSION_EVENTS_LOCK:
_SESSION_EVENTS_SUBSCRIBERS.discard(q)

241
api/session_lifecycle.py Normal file
View File

@@ -0,0 +1,241 @@
"""
Hermes WebUI memory-provider session lifecycle.
Batch-extraction memory providers (OpenViking, Holographic) only extract memories
when AIAgent.commit_memory_session() invokes provider on_session_end(). WebUI
sessions can be reopened and continued many times, so the lifecycle must guarantee:
1. Only completed, non-ephemeral turns are committable.
2. A commit finishing late must not erase work completed while it was in flight.
3. A failed commit preserves the uncommitted generation and owning agent handle.
4. Replacement/reopened agents cannot steal older dirty generations.
5. Overlapping commits are serialised via a per-session in-flight guard.
CLI-parity semantics — post-turn marking, boundary extraction/commit:
- Completed turn: Hermes core still mirrors the exchange through
run_agent.py::_sync_external_memory_for_turn(), MemoryManager sync_all(), and
provider sync_turn() WITHOUT triggering extraction. WebUI then calls
mark_turn_completed() after the saved/completed-turn boundary so later drains
know the synced session has uncommitted work and which agent owns it.
- Session boundary: commit_session_memory() triggers
AIAgent.commit_memory_session(), which calls provider on_session_end(),
posting /api/v1/sessions/<sid>/commit and triggering extraction. This is
called only at boundaries — /api/session/new with prev_session_id, explicit
agent eviction, LRU cache eviction, and shutdown drain — matching the CLI's
AIAgent.commit_memory_session()/shutdown_memory_provider() boundary.
The design uses a monotonic generation counter per session plus per-generation
agent ownership segments. mark_turn_completed() records which agent owns the new
generation. commit_session_memory() commits the earliest uncommitted segment and
compare-and-clears only that captured segment after success.
"""
from __future__ import annotations
import logging
import threading
import time
logger = logging.getLogger(__name__)
_lock = threading.Lock()
_condition = threading.Condition(_lock)
_sessions: dict[str, dict] = {}
def _new_entry() -> dict:
return {
"generation": 0,
"committed_generation": 0,
"agent": None,
"in_flight": False,
"segments": [],
}
def _reset_for_tests() -> None:
with _condition:
_sessions.clear()
_condition.notify_all()
def register_agent(session_id: str, agent) -> None:
"""Register the current agent handle for future completed generations.
Existing dirty generations keep their original segment owner. This prevents
a rebuilt/reopened agent from overwriting the handle needed to retry older
failed memory-provider work.
"""
if not session_id:
return
with _condition:
entry = _sessions.setdefault(session_id, _new_entry())
entry["agent"] = agent
_condition.notify_all()
def unregister_agent(session_id: str) -> None:
"""Clear the current future-generation agent handle.
Dirty segment owners are intentionally preserved so failed work remains
retryable even if the cache drops the current agent reference.
"""
if not session_id:
return
with _condition:
entry = _sessions.get(session_id)
if entry is not None:
entry["agent"] = None
_condition.notify_all()
def discard_session(session_id: str) -> bool:
"""Permanently drop a session's lifecycle entry to bound memory growth.
The ``_sessions`` dict is process-global and historically only ever grew:
``register_agent`` / ``mark_turn_completed`` insert keys but no runtime path
ever removed them, so every unique ``session_id`` the WebUI touched leaked a
permanent entry (issue #3506). Over days of use on a large install this is a
monotonic, unbounded climb.
This removes the entry, but only when it is provably safe to do so: no commit
is in flight and there is no uncommitted memory work that still needs the
retained agent handle. If the entry is busy or dirty it is left untouched so
failed batch-extraction memory work stays retryable -- exactly the invariant
``unregister_agent`` and ``_evict_session_agent`` already preserve.
Returns True when the entry was removed (or was already absent), False when
it was retained because work is still pending.
"""
if not session_id:
return False
with _condition:
entry = _sessions.get(session_id)
if entry is None:
return True
if entry["in_flight"]:
return False
if entry["generation"] > entry["committed_generation"]:
return False
del _sessions[session_id]
_condition.notify_all()
return True
def mark_turn_completed(session_id: str, *, agent=None) -> int:
if not session_id:
return 0
with _condition:
entry = _sessions.setdefault(session_id, _new_entry())
if agent is not None:
entry["agent"] = agent
owner = agent if agent is not None else entry.get("agent")
entry["generation"] += 1
generation = entry["generation"]
segments = entry["segments"]
if segments and not entry["in_flight"] and segments[-1].get("agent") is owner:
segments[-1]["end"] = generation
else:
segments.append({"start": generation, "end": generation, "agent": owner})
_condition.notify_all()
return generation
def has_uncommitted_work(session_id: str) -> bool:
if not session_id:
return False
with _lock:
entry = _sessions.get(session_id)
if entry is None:
return False
return entry["generation"] > entry["committed_generation"]
def _first_uncommitted_segment(entry: dict) -> dict | None:
committed = entry["committed_generation"]
for segment in entry["segments"]:
if segment["end"] > committed:
return segment
return None
def commit_session_memory(session_id: str, agent=None, *, wait: bool = False, timeout: float | None = None) -> bool:
if not session_id:
return False
deadline = time.monotonic() + timeout if timeout is not None else None
with _condition:
entry = _sessions.get(session_id)
if entry is None:
return False
while entry["in_flight"]:
if not wait:
return False
if deadline is None:
_condition.wait()
else:
remaining = deadline - time.monotonic()
if remaining <= 0:
return False
_condition.wait(remaining)
entry = _sessions.get(session_id)
if entry is None:
return False
if entry["generation"] <= entry["committed_generation"]:
return False
segment = _first_uncommitted_segment(entry)
if segment is None:
return False
effective_agent = segment.get("agent")
if effective_agent is None:
effective_agent = agent if agent is not None else entry.get("agent")
if effective_agent is not None:
segment["agent"] = effective_agent
if effective_agent is None:
return False
captured_generation = segment["end"]
entry["in_flight"] = True
try:
effective_agent.commit_memory_session()
except Exception:
logger.exception("commit_memory_session() failed for session %s", session_id)
with _condition:
re_entry = _sessions.get(session_id)
if re_entry is not None:
re_entry["in_flight"] = False
_condition.notify_all()
return False
with _condition:
re_entry = _sessions.get(session_id)
if re_entry is not None:
re_entry["in_flight"] = False
if captured_generation > re_entry["committed_generation"]:
re_entry["committed_generation"] = captured_generation
committed = re_entry["committed_generation"]
segments = re_entry["segments"]
while segments and segments[0]["end"] <= committed:
segments.pop(0)
if segments and segments[0]["start"] <= committed:
segments[0]["start"] = committed + 1
_condition.notify_all()
return True
def drain_all_on_shutdown() -> None:
while True:
with _lock:
snapshot = [sid for sid, entry in _sessions.items() if entry["generation"] > entry["committed_generation"]]
if not snapshot:
return
made_progress = False
for sid in snapshot:
if commit_session_memory(sid, wait=True):
made_progress = True
if not made_progress:
logger.debug("drain_all_on_shutdown: stopped with uncommitted sessions: %s", sorted(snapshot))
return

View File

@@ -14,6 +14,36 @@ from api.models import get_session, SESSIONS
logger = logging.getLogger(__name__)
AUTO_TITLE_LABELS = {'untitled', 'new chat'}
def session_has_manual_title(session) -> bool:
"""Return whether adaptive title refresh should leave this title alone."""
return getattr(session, 'manual_title', False) is True
def apply_session_title_rename(session, raw_title) -> str:
"""Apply user-driven rename semantics to a Session object.
Non-empty custom titles are protected from adaptive refresh. Clearing the
title, or resetting it to an automatic label, removes that protection so the
normal auto-title path can run again.
"""
title = str(raw_title or '').strip()[:80]
if not title:
title = 'Untitled'
manual_title = title.strip().casefold() not in AUTO_TITLE_LABELS
session.title = title
session.manual_title = manual_title
session.llm_title_generated = False
return title
def mark_session_title_generated(session) -> None:
"""Mark a session title as generated by the title model."""
session.llm_title_generated = True
session.manual_title = False
def _truncate_at_last_user(messages):
history = messages or []
@@ -27,6 +57,16 @@ def _truncate_at_last_user(messages):
return history[:last_user_idx]
def _truncation_watermark_for(messages):
history = list(messages or [])
if not history:
return 0.0
try:
return float(history[-1].get('timestamp') or 0)
except (AttributeError, TypeError, ValueError):
return 0.0
def retry_last(session_id: str) -> dict[str, Any]:
"""Truncate the session to before the last user message, return its text.
@@ -75,6 +115,7 @@ def retry_last(session_id: str) -> dict[str, Any]:
last_user_text = _extract_text(history[last_user_idx].get('content', ''))
removed_count = len(history) - last_user_idx
s.messages = history[:last_user_idx]
s.truncation_watermark = _truncation_watermark_for(s.messages)
if isinstance(getattr(s, 'context_messages', None), list) and s.context_messages:
truncated_context = _truncate_at_last_user(s.context_messages)
if truncated_context is not None:
@@ -114,6 +155,7 @@ def undo_last(session_id: str) -> dict[str, Any]:
removed_text = _extract_text(history[last_user_idx].get('content', ''))
removed_count = len(history) - last_user_idx
s.messages = history[:last_user_idx]
s.truncation_watermark = _truncation_watermark_for(s.messages)
if isinstance(getattr(s, 'context_messages', None), list) and s.context_messages:
truncated_context = _truncate_at_last_user(s.context_messages)
if truncated_context is not None:

View File

@@ -5,13 +5,16 @@ data-loss bugs like #1558.
``Session.save()`` writes a ``<sid>.json.bak`` snapshot of the previous
state whenever an incoming save would shrink the messages array. This
module reads those snapshots back and restores any session whose live
file has fewer messages than its backup.
file has fewer messages than its backup, or whose live file is missing
while a valid backup remains.
Three integration points:
1. ``recover_all_sessions_on_startup()`` — called from server.py at boot,
scans the session dir, restores any session whose JSON has fewer
messages than its .bak. Idempotent: a clean run is a no-op.
messages than its .bak, and recreates a missing ``<sid>.json`` from an
orphaned ``<sid>.json.bak`` when the canonical state DB still has that
session. Idempotent: a clean run is a no-op.
2. ``recover_session(sid)`` — single-session helper backing the
``POST /api/session/recover`` endpoint, so users can re-run recovery
@@ -22,11 +25,22 @@ Three integration points:
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import shutil
import sqlite3
import threading
from pathlib import Path
from api.turn_journal import (
derive_turn_journal_states,
is_terminal_turn_event,
iter_turn_journal_session_ids,
read_turn_journal,
)
logger = logging.getLogger(__name__)
@@ -117,25 +131,477 @@ def recover_session(session_path: Path) -> dict:
return {**status, "restored": True}
def recover_all_sessions_on_startup(session_dir: Path) -> dict:
"""Scan session_dir for shrunken sessions, restore each from its .bak.
def _state_db_has_session(session_id: str, state_db_path: Path | None) -> bool:
"""Return whether state.db still knows this session.
Returns {"scanned": N, "restored": M, "details": [...]}.
The check is deliberately fail-open: recovery must not be prevented by a
locked, absent, or older-schema state DB. When a DB is readable and has no
row, treat the orphan backup as a tombstoned/deleted session and skip it.
"""
if state_db_path is None or not state_db_path.exists():
return True
try:
with sqlite3.connect(f"file:{state_db_path}?mode=ro", uri=True) as conn:
cur = conn.execute(
"select 1 from sqlite_master where type='table' and name='sessions'"
)
if cur.fetchone() is None:
return True
cur = conn.execute("select 1 from sessions where id = ? limit 1", (session_id,))
return cur.fetchone() is not None
except Exception as exc:
logger.debug("state_db session tombstone check failed for %s: %s", session_id, exc)
return True
def _orphaned_backup_live_paths(
session_dir: Path,
state_db_path: Path | None = None,
) -> list[Path]:
"""Return live ``<sid>.json`` paths whose ``<sid>.json.bak`` exists.
``Path.glob('*.json')`` does not see orphan backups because their suffix is
``.bak``. Existing startup recovery only handled shrunken live files; this
helper covers the crash shape where the live sidecar is gone but the rescue
copy remains.
"""
paths: list[Path] = []
for bak_path in sorted(session_dir.glob('*.json.bak')):
live_path = bak_path.with_suffix('')
if live_path.name.startswith('_') or live_path.exists():
continue
if _msg_count(bak_path) < 0:
continue
session_id = live_path.stem
if not _state_db_has_session(session_id, state_db_path):
logger.info(
"recover_all_sessions_on_startup: skipped orphan backup %s; "
"state.db has no live session row",
bak_path.name,
)
continue
paths.append(live_path)
return paths
def _read_state_db_missing_sidecar_rows(
session_dir: Path,
state_db_path: Path | None,
*,
include_empty: bool = False,
) -> list[dict]:
"""Return WebUI-origin state.db rows whose JSON sidecar is missing."""
if state_db_path is None or not state_db_path.exists():
return []
try:
with sqlite3.connect(f"file:{state_db_path}?mode=ro", uri=True) as conn:
conn.row_factory = sqlite3.Row
session_cols = {row[1] for row in conn.execute("PRAGMA table_info(sessions)").fetchall()}
message_cols = {row[1] for row in conn.execute("PRAGMA table_info(messages)").fetchall()}
if not {'id', 'source'}.issubset(session_cols):
return []
title_expr = _sql_optional_col('title', session_cols)
model_expr = _sql_optional_col('model', session_cols)
started_expr = _sql_optional_col('started_at', session_cols, '0')
parent_expr = _sql_optional_col('parent_session_id', session_cols)
msg_count_expr = _sql_optional_col('message_count', session_cols, '0')
workspace_expr = _sql_optional_col('workspace', session_cols)
worktree_path_expr = _sql_optional_col('worktree_path', session_cols)
worktree_branch_expr = _sql_optional_col('worktree_branch', session_cols)
worktree_repo_root_expr = _sql_optional_col('worktree_repo_root', session_cols)
worktree_created_at_expr = _sql_optional_col('worktree_created_at', session_cols)
rows = []
for row in conn.execute(
f"""
SELECT id, source, {title_expr}, {model_expr}, {started_expr},
{parent_expr}, {msg_count_expr}, {workspace_expr},
{worktree_path_expr}, {worktree_branch_expr},
{worktree_repo_root_expr}, {worktree_created_at_expr}
FROM sessions
WHERE source = 'webui'
ORDER BY COALESCE(started_at, 0) DESC
"""
).fetchall():
data = dict(row)
sid = str(data.get('id') or '').strip()
if not sid or (session_dir / f"{sid}.json").exists():
continue
message_rows: list[dict] = []
if {'session_id', 'role', 'content'}.issubset(message_cols):
order = "timestamp, id" if 'timestamp' in message_cols and 'id' in message_cols else "rowid"
ts_expr = 'timestamp' if 'timestamp' in message_cols else 'NULL AS timestamp'
for msg in conn.execute(
f"SELECT role, content, {ts_expr} FROM messages WHERE session_id = ? ORDER BY {order}",
(sid,),
).fetchall():
message = {
'role': msg['role'],
'content': msg['content'] or '',
}
if msg['timestamp'] is not None:
message['timestamp'] = msg['timestamp']
message_rows.append(message)
if not message_rows and not include_empty:
continue
data['messages'] = message_rows
data['_state_db_empty_messages'] = not message_rows
rows.append(data)
return rows
except Exception as exc:
logger.debug("state_db sidecar reconciliation scan failed for %s: %s", state_db_path, exc)
return []
def _sql_optional_col(name: str, columns: set[str], fallback: str = "NULL") -> str:
return name if name in columns else f"{fallback} AS {name}"
def _state_db_row_to_sidecar(row: dict) -> dict:
try:
from api.agent_sessions import normalize_agent_session_source
except Exception:
normalize_agent_session_source = None
source = str(row.get('source') or '').strip().lower()
source_meta = normalize_agent_session_source(source) if normalize_agent_session_source else {
'raw_source': source or None,
'session_source': source or None,
'source_label': source.title() if source else None,
}
started_at = row.get('started_at') or 0
messages = row.get('messages') if isinstance(row.get('messages'), list) else []
last_ts = messages[-1].get('timestamp') if messages and isinstance(messages[-1], dict) else started_at
workspace_value = row.get('workspace') or ''
return {
'session_id': row.get('id'),
'title': row.get('title') or 'Recovered WebUI Session',
'workspace': workspace_value if isinstance(workspace_value, str) else '',
'message_count': row.get('message_count') if isinstance(row.get('message_count'), int) else len(messages),
'worktree_path': row.get('worktree_path') or None,
'worktree_branch': row.get('worktree_branch') or None,
'worktree_repo_root': row.get('worktree_repo_root') or None,
'worktree_created_at': row.get('worktree_created_at') or None,
'model': row.get('model') or 'unknown',
'model_provider': None,
'created_at': started_at,
'updated_at': last_ts or started_at,
'pinned': False,
'archived': False,
'project_id': None,
'profile': None,
'input_tokens': 0,
'output_tokens': 0,
'estimated_cost': None,
'personality': None,
'active_stream_id': None,
'pending_user_message': None,
'pending_attachments': [],
'pending_started_at': None,
'compression_anchor_visible_idx': None,
'compression_anchor_message_key': None,
'compression_anchor_summary': None,
'context_length': None,
'threshold_tokens': None,
'last_prompt_tokens': None,
'gateway_routing': None,
'gateway_routing_history': [],
'llm_title_generated': False,
'parent_session_id': row.get('parent_session_id'),
'is_cli_session': False,
'source_tag': source or None,
**source_meta,
'enabled_toolsets': None,
'composer_draft': {},
'messages': messages,
'tool_calls': [],
'_recovered_from_state_db': True,
}
def recover_missing_sidecars_from_state_db(session_dir: Path, state_db_path: Path | None) -> dict:
"""Materialize missing WebUI JSON sidecars from canonical state.db rows."""
rows = _read_state_db_missing_sidecar_rows(session_dir, state_db_path)
materialized = 0
details: list[dict] = []
session_dir.mkdir(parents=True, exist_ok=True)
for row in rows:
sid = str(row.get('id') or '').strip()
if not sid:
continue
target = session_dir / f"{sid}.json"
if target.exists():
continue
payload = _state_db_row_to_sidecar(row)
# Per-process/per-thread tmp suffix to avoid corruption under
# concurrent reconciliation calls (matches api/models.py:484
# Session.save() convention).
tmp_suffix = f".json.reconcile.tmp.{os.getpid()}.{threading.current_thread().ident}"
tmp = target.with_suffix(tmp_suffix)
detail_recorded = False
try:
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8')
except OSError as exc:
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
details.append({'session_id': sid, 'materialized': False, 'error': str(exc)})
continue
# Atomic create-or-fail: os.link() refuses to overwrite an existing
# target. Closes the TOCTOU window between the target.exists() check
# above and the rename — a concurrent Session.save() for the same SID
# will win and we silently skip rather than overwrite a live sidecar.
materialized_now = False
try:
os.link(str(tmp), str(target))
materialized_now = True
except FileExistsError:
# Live sidecar appeared between the check and the link — keep it.
pass
except OSError as exc:
details.append({'session_id': sid, 'materialized': False, 'error': str(exc)})
detail_recorded = True
finally:
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
if materialized_now:
materialized += 1
details.append({'session_id': sid, 'materialized': True, 'messages': len(payload.get('messages') or [])})
elif not detail_recorded:
details.append({'session_id': sid, 'materialized': False, 'skipped': 'sidecar_appeared_during_reconcile'})
return {'scanned': len(rows), 'materialized': materialized, 'details': details}
def _new_audit_item(
session_id: str,
kind: str,
category: str,
recommendation: str,
live_messages: int = -1,
bak_messages: int = -1,
**extra,
) -> dict:
item = {
"session_id": session_id,
"kind": kind,
"category": category,
"recommendation": recommendation,
"live_messages": live_messages,
"bak_messages": bak_messages,
}
item.update(extra)
return item
def _read_index_session_ids(index_path: Path) -> set[str]:
try:
data = json.loads(index_path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError, ValueError):
return set()
if not isinstance(data, list):
return set()
ids: set[str] = set()
for entry in data:
if isinstance(entry, dict) and isinstance(entry.get('session_id'), str):
ids.add(entry['session_id'])
return ids
def audit_session_recovery(session_dir: Path, state_db_path: Path | None = None) -> dict:
"""Read-only audit of session recovery state.
The audit intentionally does not mutate files. It classifies only the safe
recovery primitives this module knows how to perform: backup restores and
derived index rebuilds. Call ``recover_all_sessions_on_startup`` separately
for safe repairs.
"""
if not session_dir.exists():
return {"scanned": 0, "restored": 0, "details": []}
scanned = 0
return {
"status": "ok",
"summary": {"ok": 0, "repairable": 0, "unsafe_to_repair": 0},
"items": [],
}
items: list[dict] = []
live_paths = sorted(p for p in session_dir.glob('*.json') if not p.name.startswith('_'))
live_ids = {p.stem for p in live_paths}
for live_path in live_paths:
status = inspect_session_recovery_status(live_path)
if status.get('recommend') == 'restore':
items.append(_new_audit_item(
status['session_id'],
"shrunken_live",
"repairable",
"restore_from_bak",
status.get('live_messages', -1),
status.get('bak_messages', -1),
))
for bak_path in sorted(session_dir.glob('*.json.bak')):
live_path = bak_path.with_suffix('')
if live_path.exists() or live_path.name.startswith('_'):
continue
bak_messages = _msg_count(bak_path)
session_id = live_path.stem
if bak_messages < 0:
items.append(_new_audit_item(
session_id, "malformed_orphan_backup", "unsafe_to_repair", "manual_review", -1, bak_messages
))
elif _state_db_has_session(session_id, state_db_path):
items.append(_new_audit_item(
session_id, "orphan_backup", "repairable", "restore_from_bak", -1, bak_messages
))
else:
items.append(_new_audit_item(
session_id,
"orphan_backup_without_state_row",
"unsafe_to_repair",
"manual_review",
-1,
bak_messages,
))
index_path = session_dir / '_index.json'
if index_path.exists():
index_ids = _read_index_session_ids(index_path)
for session_id in sorted(index_ids - live_ids):
items.append(_new_audit_item(
session_id, "index_missing_file", "repairable", "rebuild_index"
))
for session_id in sorted(live_ids - index_ids):
items.append(_new_audit_item(
session_id, "index_missing_entry", "repairable", "rebuild_index",
_msg_count(session_dir / f"{session_id}.json"), -1,
))
for row in _read_state_db_missing_sidecar_rows(session_dir, state_db_path, include_empty=True):
sid = str(row.get('id') or '')
if row.get('_state_db_empty_messages'):
items.append(_new_audit_item(
sid,
"state_db_orphan_webui_row",
"unsafe_to_repair",
"manual_review",
-1,
-1,
))
continue
items.append(_new_audit_item(
sid,
"state_db_missing_sidecar",
"repairable",
"materialize_from_state_db",
-1,
-1,
))
for session_id in iter_turn_journal_session_ids(session_dir):
journal = read_turn_journal(session_id, session_dir=session_dir)
states, _ = derive_turn_journal_states(journal.get('events') or [])
live_path = session_dir / f"{session_id}.json"
live_messages = _msg_count(live_path)
existing_user_messages: set[str] = set()
try:
payload = json.loads(live_path.read_text(encoding='utf-8'))
if isinstance(payload, dict):
for message in payload.get('messages') or []:
if isinstance(message, dict) and message.get('role') == 'user':
existing_user_messages.add(str(message.get('content') or '').strip())
except (OSError, json.JSONDecodeError, ValueError):
pass
for turn_id, event in sorted(states.items()):
if is_terminal_turn_event(event):
continue
content = str(event.get('content') or '').strip()
if not content or content in existing_user_messages:
continue
items.append(_new_audit_item(
session_id,
"turn_journal_pending_turn",
"repairable",
"audit_only_pending_turn_journal",
live_messages,
-1,
turn_id=turn_id,
event=str(event.get('event') or ''),
))
summary = {"ok": len(live_paths), "repairable": 0, "unsafe_to_repair": 0}
for item in items:
category = item.get('category')
if category in summary:
summary[category] += 1
if summary["unsafe_to_repair"]:
overall = "needs_manual_review"
elif summary["repairable"]:
overall = "warn"
else:
overall = "ok"
return {"status": overall, "summary": summary, "items": items}
def repair_safe_session_recovery(session_dir: Path, state_db_path: Path | None = None) -> dict:
"""Run safe, deterministic session recovery repairs.
This mutates only repairable classes already handled by startup recovery:
shrunken live sidecars and orphan backups that are not tombstoned by a
readable state.db. Unsafe audit findings remain for manual review.
"""
before = audit_session_recovery(session_dir, state_db_path=state_db_path)
backup_repair = recover_all_sessions_on_startup(
session_dir,
rebuild_index=True,
state_db_path=state_db_path,
)
sidecar_repair = recover_missing_sidecars_from_state_db(session_dir, state_db_path)
if sidecar_repair.get('materialized'):
try:
from api.models import _write_session_index
_write_session_index(updates=None)
except Exception as exc:
logger.warning("repair_safe_session_recovery: index rebuild after state.db reconciliation failed: %s", exc)
after = audit_session_recovery(session_dir, state_db_path=state_db_path)
unsafe_remaining = int((after.get("summary") or {}).get("unsafe_to_repair") or 0)
repairable_remaining = int((after.get("summary") or {}).get("repairable") or 0)
clean = unsafe_remaining == 0 and repairable_remaining == 0
return {
"clean": clean,
"ok": clean,
"repaired": int(backup_repair.get("restored") or 0) + int(sidecar_repair.get("materialized") or 0),
"before": before,
"backup_repair": backup_repair,
"sidecar_repair": sidecar_repair,
"after": after,
}
def recover_all_sessions_on_startup(
session_dir: Path,
rebuild_index: bool = False,
state_db_path: Path | None = None,
) -> dict:
"""Scan session_dir for shrunken/orphaned sessions and restore from .bak.
Returns {"scanned": N, "restored": M, "orphaned_backups": K, "details": [...]}.
"""
if not session_dir.exists():
return {"scanned": 0, "restored": 0, "orphaned_backups": 0, "details": []}
restored = 0
details: list[dict] = []
for path in session_dir.glob('*.json'):
# Skip non-session JSON files in the same dir:
# - ``_index.json`` is a top-level list of session metadata
# - any future non-session JSON marked with the ``_`` convention is
# skipped automatically (project convention for system files in
# directories that otherwise hold user data)
if path.name.startswith('_'):
continue
scanned += 1
live_paths = [path for path in sorted(session_dir.glob('*.json')) if not path.name.startswith('_')]
orphan_paths = _orphaned_backup_live_paths(session_dir, state_db_path=state_db_path)
# Only sessions with a backup can be restored through this startup path.
# Older code called recover_session() for every live sidecar, and
# inspect_session_recovery_status() read the complete JSON file before even
# checking whether <sid>.json.bak existed. Large WebUI installs therefore
# parsed the entire session corpus on every boot even when there was
# nothing to recover. Keep the public scanned count compatible, but limit
# expensive reads to actual recovery candidates.
recovery_paths = [path for path in live_paths if path.with_suffix('.json.bak').exists()]
scanned = len(live_paths) + len(orphan_paths)
for path in [*recovery_paths, *orphan_paths]:
try:
result = recover_session(path)
except Exception as exc:
@@ -155,4 +621,37 @@ def recover_all_sessions_on_startup(session_dir: Path) -> dict:
"If you weren't expecting this, check the session list for missing "
"messages — see #1558.", restored, scanned,
)
return {"scanned": scanned, "restored": restored, "details": details}
if rebuild_index:
try:
from api.models import SESSION_INDEX_FILE, _write_session_index
if restored or not SESSION_INDEX_FILE.exists():
_write_session_index(updates=None)
except Exception as exc:
logger.warning("recover_all_sessions_on_startup: index rebuild failed: %s", exc)
return {
"scanned": scanned,
"restored": restored,
"orphaned_backups": len(orphan_paths),
"details": details,
}
def _main() -> int:
parser = argparse.ArgumentParser(description="Audit Hermes WebUI session recovery state")
parser.add_argument("--audit", action="store_true", help="run a read-only recovery audit")
parser.add_argument("--session-dir", type=Path, required=True, help="path to WebUI sessions directory")
parser.add_argument("--state-db", type=Path, default=None, help="optional Hermes state.db path")
parser.add_argument("--repair-safe", action="store_true", help="run safe deterministic repairs after auditing")
args = parser.parse_args()
if args.repair_safe:
report = repair_safe_session_recovery(args.session_dir, state_db_path=args.state_db)
elif args.audit:
report = audit_session_recovery(args.session_dir, state_db_path=args.state_db)
else:
parser.error("choose --audit or --repair-safe")
print(json.dumps(report, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(_main())

32
api/skill_usage.py Normal file
View File

@@ -0,0 +1,32 @@
# ── Skill usage reader (read-only) ──
# Note: .usage.json is written by hermes-agent (tools/skill_usage.py).
# WebUI only reads to display usage stats in Insights page.
import json
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
_USAGE_FILE = ".usage.json"
def read_skill_usage(skills_dir: Path) -> dict:
"""Read the current .usage.json.
Returns the raw nested dict ``{skill_name: {use_count: N, view_count: N, ...}}``
or an empty dict when the file does not exist or is corrupt.
"""
usage_path = skills_dir / _USAGE_FILE
if not usage_path.exists():
return {}
try:
raw = usage_path.read_text(encoding="utf-8")
data = json.loads(raw)
if isinstance(data, dict):
return data
logger.debug("Unexpected .usage.json format, resetting: %s", raw[:200])
return {}
except (json.JSONDecodeError, OSError) as exc:
logger.debug("Failed to read .usage.json: %s", exc)
return {}

View File

@@ -16,26 +16,83 @@ any double-counting risk.
import logging
import os
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
def _get_state_db():
"""Get a SessionDB instance for the active profile's state.db.
Returns None if hermes_state is not importable or DB is unavailable.
Each caller is responsible for calling db.close() when done.
def _get_state_db(profile: Optional[str] = None):
"""Get a SessionDB instance for a profile's state.db.
When ``profile`` is provided the function resolves *that* profile's
home directory directly (via ``_resolve_profile_home_for_name``).
If resolution fails (unknown profile name, IO error, etc.) the
function returns ``None`` rather than silently falling back to
``HERMES_HOME`` — silently routing the write to the wrong DB
would defeat the point of the explicit-profile path (#2762).
When ``profile`` is None it falls back to the TLS-based
``get_active_hermes_home()`` lookup for backward compatibility,
with a final ``HERMES_HOME`` fallback only on that path. TLS may be
unset in background/worker threads, in which case the lookup falls
through to the process-global active profile and can write to the
wrong DB. Callers that know the session's profile (e.g.
``sync_session_usage`` after a stream completes on a background
thread) should pass it explicitly to avoid that race.
Returns None if hermes_state is not importable, the explicit
profile cannot be resolved, or the DB is unavailable. Each caller
is responsible for calling db.close() when done.
"""
try:
from hermes_state import SessionDB
except ImportError:
return None
try:
from api.profiles import get_active_hermes_home
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
except Exception:
logger.debug("Failed to resolve hermes home, using default")
hermes_home = Path(os.getenv('HERMES_HOME', str(Path.home() / '.hermes')))
if profile is not None:
# Explicit-profile path — a resolution failure here MUST NOT
# silently fall back to HERMES_HOME or the caller's "write to
# the named profile" contract is broken (the original #2762
# symptom: writes leaking into the wrong profile's state.db).
#
# Defense-in-depth (per #2827 maintainer review): validate the
# name shape BEFORE handing it to ``_resolve_profile_home_for_name``.
# The resolver itself rarely raises — for an invalid-but-non-
# malicious name (e.g. one that fails ``_PROFILE_ID_RE``) it
# quietly returns ``_DEFAULT_HERMES_HOME``, which is the exact
# leak we're trying to prevent on the explicit-profile path.
# Validating up-front turns that quiet leak into an explicit
# "refuse + log + return None" so the contract is "write to
# the EXACT named profile, or write nowhere."
try:
from api.profiles import (
_resolve_profile_home_for_name,
_PROFILE_ID_RE,
_is_root_profile,
)
if not (_is_root_profile(profile) or _PROFILE_ID_RE.fullmatch(profile)):
logger.warning(
"state_sync: refusing invalid profile name %r — skipping "
"write rather than leaking to the default state.db (#2762).",
profile,
)
return None
hermes_home = Path(_resolve_profile_home_for_name(profile)).expanduser().resolve()
except Exception:
logger.warning(
"state_sync: could not resolve profile %r — skipping write rather "
"than leaking to the active profile (#2762).", profile,
)
return None
else:
# Implicit / TLS-fallback path — preserves pre-#2762 behavior
# for any caller that doesn't pass profile= explicitly.
try:
from api.profiles import get_active_hermes_home
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
except Exception:
logger.debug("Failed to resolve hermes home, using default")
hermes_home = Path(os.getenv('HERMES_HOME', str(Path.home() / '.hermes')))
db_path = hermes_home / 'state.db'
if not db_path.exists():
@@ -48,11 +105,16 @@ def _get_state_db():
return None
def sync_session_start(session_id: str, model=None) -> None:
def sync_session_start(session_id: str, model=None, profile: Optional[str] = None) -> None:
"""Register a WebUI session in state.db (idempotent).
Called when a session's first message is sent.
``profile`` lets the caller name the target state.db explicitly,
avoiding the TLS-vs-background-thread mismatch in #2762. When
omitted, the active profile is resolved from TLS (then process
globals) as before.
"""
db = _get_state_db()
db = _get_state_db(profile=profile)
if not db:
return
try:
@@ -71,13 +133,21 @@ def sync_session_start(session_id: str, model=None) -> None:
def sync_session_usage(session_id: str, input_tokens: int=0, output_tokens: int=0,
estimated_cost=None, model=None, title: str=None,
message_count: int=None) -> None:
estimated_cost=None, model=None, title: Optional[str] = None,
message_count: Optional[int] = None, profile: Optional[str] = None) -> None:
"""Update token usage and title for a WebUI session in state.db.
Called after each turn completes. Uses absolute=True to set totals
(the WebUI Session already accumulates across turns).
``profile`` lets the caller name the target state.db explicitly,
which is what fixes #2762: this function is invoked from the
agent streaming worker thread, where the request-thread's TLS
profile context has not been propagated. Without an explicit
profile, the TLS lookup falls back to the process-global active
profile and writes the session's usage to the wrong state.db
(e.g. ``hiyuki``'s instead of the cookie-switched ``maiko``'s).
"""
db = _get_state_db()
db = _get_state_db(profile=profile)
if not db:
return
try:

File diff suppressed because it is too large Load Diff

View File

@@ -9,20 +9,31 @@ in the agent execution layer.
from __future__ import annotations
import errno
import atexit
import codecs
import fcntl
import os
import queue
import select
import shutil
import signal
import struct
import subprocess
import termios
import sys
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
_TERMINAL_SUPPORTED = sys.platform != "win32"
if _TERMINAL_SUPPORTED:
import fcntl
import select
import termios
else:
fcntl = None # type: ignore[assignment]
select = None # type: ignore[assignment]
termios = None # type: ignore[assignment]
def _set_nonblocking(fd: int) -> None:
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
@@ -35,6 +46,13 @@ def _winsize(rows: int, cols: int) -> bytes:
return struct.pack("HHHH", rows, cols, 0, 0)
def _safe_close_fd(fd: int) -> None:
try:
os.close(fd)
except OSError:
pass
@dataclass
class TerminalSession:
session_id: str
@@ -67,6 +85,148 @@ class TerminalSession:
_TERMINALS: dict[str, TerminalSession] = {}
_LOCK = threading.RLock()
_spawn_queue: queue.Queue = queue.Queue()
_spawn_supervisor_started = False
_spawn_supervisor_lock = threading.Lock()
_spawn_supervisor_thread: threading.Thread | None = None
_terminal_descendant_reaper_lock = threading.Lock()
_TERMINAL_DESCENDANT_REAPER_LIMIT = 64
@dataclass
class _SpawnRequest:
kwargs: dict
done: threading.Event = field(default_factory=threading.Event)
timed_out: threading.Event = field(default_factory=threading.Event)
lock: threading.Lock = field(default_factory=threading.Lock)
proc: subprocess.Popen | None = None
error: BaseException | None = None
def _reap_abandoned_spawn(proc: subprocess.Popen) -> bool:
if proc.poll() is not None:
return True
try:
os.killpg(proc.pid, signal.SIGHUP)
except (OSError, ProcessLookupError):
try:
proc.terminate()
except (OSError, ProcessLookupError):
pass
try:
proc.wait(timeout=1.0)
except subprocess.TimeoutExpired:
try:
os.killpg(proc.pid, signal.SIGKILL)
except (OSError, ProcessLookupError):
try:
proc.kill()
except (OSError, ProcessLookupError):
pass
try:
proc.wait(timeout=1.0)
except (subprocess.TimeoutExpired, ProcessLookupError):
pass
if proc.poll() is None:
print("terminal abandoned spawn cleanup failed", flush=True)
return False
return True
def _reap_terminal_descendants(
terminal_pgid: int,
limit: int = _TERMINAL_DESCENDANT_REAPER_LIMIT,
) -> int:
"""Reap exited descendants that still belong to a terminal-owned process group."""
if not _TERMINAL_SUPPORTED:
return 0
try:
terminal_pgid = abs(int(terminal_pgid))
except (TypeError, ValueError):
return 0
if terminal_pgid <= 0:
return 0
reaped = 0
with _terminal_descendant_reaper_lock:
for _ in range(max(0, int(limit))):
try:
pid, _status = os.waitpid(-terminal_pgid, os.WNOHANG)
except (ChildProcessError, OSError):
break
if pid == 0:
break
reaped += 1
return reaped
def _spawn_supervisor_loop() -> None:
while True:
request = None
try:
request = _spawn_queue.get()
try:
proc = subprocess.Popen(**request.kwargs)
with request.lock:
if request.timed_out.is_set():
_reap_abandoned_spawn(proc)
else:
request.proc = proc
request.done.set()
except BaseException as exc:
with request.lock:
try:
request.error = exc
except BaseException:
pass
request.done.set()
except BaseException as exc:
if request is not None:
try:
request.error = exc
except BaseException:
pass
try:
request.done.set()
except BaseException:
pass
time.sleep(0.01)
def _spawn_supervisor_entry() -> None:
while True:
try:
_spawn_supervisor_loop()
except BaseException:
time.sleep(0.01)
pass
def _ensure_spawn_supervisor() -> None:
global _spawn_supervisor_started, _spawn_supervisor_thread
with _spawn_supervisor_lock:
if _spawn_supervisor_started and _spawn_supervisor_thread and _spawn_supervisor_thread.is_alive():
return
thread = threading.Thread(target=_spawn_supervisor_entry, daemon=True)
thread.start()
_spawn_supervisor_thread = thread
_spawn_supervisor_started = True
if _TERMINAL_SUPPORTED:
_ensure_spawn_supervisor()
# NOTE on parent-death-signal: a previous version of this module set
# PR_SET_PDEATHSIG via a preexec_fn to terminate orphaned PTY shells when the
# WebUI process crashed. That broke every Linux user (#2853): WebUI runs a
# ThreadingHTTPServer, so the Popen call happens on a short-lived per-request
# thread, and PR_SET_PDEATHSIG is per-thread. The PTY shell registered the
# spawning thread as its "parent" and was killed with SIGTERM the instant that
# thread joined — within ~10 ms of opening the terminal — surfacing as the
# `[terminal closed]` banner. The graceful path is covered by
# `atexit.register(close_all_terminals)` and the explicit `close_terminal`
# call sites; hard kills of the WebUI process leak the shell, which is the
# tradeoff for working on Linux at all.
def _decode_terminal_output(decoder, data: bytes) -> str:
@@ -116,6 +276,7 @@ def _reader_loop(term: TerminalSession) -> None:
finally:
term.closed.set()
code = term.proc.poll()
_reap_terminal_descendants(term.proc.pid)
term.put_output("terminal_closed", {"exit_code": code})
@@ -135,6 +296,8 @@ def _set_size(term: TerminalSession, rows: int, cols: int) -> None:
def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int = 80, restart: bool = False) -> TerminalSession:
"""Start or return the embedded terminal for a WebUI session."""
if not _TERMINAL_SUPPORTED:
raise NotImplementedError("Embedded terminal is not supported on Windows")
sid = str(session_id or "").strip()
if not sid:
raise ValueError("session_id is required")
@@ -170,16 +333,41 @@ def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int =
}
)
shell = _shell_path()
proc = subprocess.Popen(
_shell_argv(shell),
cwd=cwd,
env=env,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True,
start_new_session=True,
# Keep the shell in its own process group for explicit cleanup via
# close_terminal()/close_all_terminals(); do not use PDEATHSIG here.
request = _SpawnRequest(
{
"args": _shell_argv(shell),
"cwd": cwd,
"env": env,
"stdin": slave_fd,
"stdout": slave_fd,
"stderr": slave_fd,
"close_fds": True,
# Required so cleanup can signal the whole interactive shell tree.
"start_new_session": True,
}
)
_ensure_spawn_supervisor()
_spawn_queue.put(request)
try:
if not request.done.wait(timeout=5.0):
timed_out = False
with request.lock:
if not request.done.is_set():
request.timed_out.set()
timed_out = True
if timed_out:
raise TimeoutError("terminal spawn timeout - supervisor unresponsive")
if request.error:
raise request.error
proc = request.proc
if proc is None:
raise RuntimeError("terminal spawn failed without process")
except BaseException:
_safe_close_fd(master_fd)
_safe_close_fd(slave_fd)
raise
os.close(slave_fd)
_set_nonblocking(master_fd)
@@ -199,6 +387,8 @@ def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int =
def get_terminal(session_id: str) -> TerminalSession | None:
if not _TERMINAL_SUPPORTED:
return None
with _LOCK:
term = _TERMINALS.get(str(session_id or ""))
if term and term.is_alive():
@@ -207,6 +397,8 @@ def get_terminal(session_id: str) -> TerminalSession | None:
def write_terminal(session_id: str, data: str) -> None:
if not _TERMINAL_SUPPORTED:
raise NotImplementedError("Embedded terminal is not supported on Windows")
term = get_terminal(session_id)
if not term or not term.is_alive():
raise KeyError("terminal not running")
@@ -214,6 +406,8 @@ def write_terminal(session_id: str, data: str) -> None:
def resize_terminal(session_id: str, rows: int, cols: int) -> None:
if not _TERMINAL_SUPPORTED:
raise NotImplementedError("Embedded terminal is not supported on Windows")
term = get_terminal(session_id)
if not term:
raise KeyError("terminal not running")
@@ -221,6 +415,8 @@ def resize_terminal(session_id: str, rows: int, cols: int) -> None:
def close_terminal(session_id: str) -> bool:
if not _TERMINAL_SUPPORTED:
return False
sid = str(session_id or "")
with _LOCK:
term = _TERMINALS.pop(sid, None)
@@ -240,9 +436,25 @@ def close_terminal(session_id: str) -> bool:
os.killpg(term.proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
try:
term.proc.wait(timeout=1.0)
except (subprocess.TimeoutExpired, ProcessLookupError):
pass
finally:
try:
os.close(term.master_fd)
except OSError:
pass
_reap_terminal_descendants(term.proc.pid)
return True
def close_all_terminals() -> None:
"""Best-effort reap of embedded shells during graceful WebUI shutdown."""
with _LOCK:
session_ids = list(_TERMINALS)
for session_id in session_ids:
close_terminal(session_id)
atexit.register(close_all_terminals)

320
api/todo_state.py Normal file
View File

@@ -0,0 +1,320 @@
"""Derive ``todo_state`` snapshots from tool results and settled session messages.
The ``todo`` tool's in-memory store lives on the per-session AIAgent. The
WebUI bridge needs to mirror that state to the browser in two situations:
1. **Live**: when the agent calls ``todo`` mid-stream, ``api.streaming``
emits a dedicated ``todo_state`` SSE event so the Todos panel updates
without waiting for the turn to finish. See :func:`emit_todo_state`.
2. **Cold-load**: when the browser opens a session (no live stream), the
session GET handler attaches ``todo_state`` derived from the most
recent ``role='tool'`` message whose JSON content carries a ``todos``
list. See :func:`attach_todo_state`.
Both paths normalize through :func:`_normalize_snapshot` so the frontend
has a single deserialization contract:
{
"todos": [{"id": ..., "content": ..., "status": ...}, ...],
"summary": {"total": N, "pending": N, "in_progress": N,
"completed": N, "cancelled": N},
"version": 1,
}
Live SSE payloads add ``session_id``, ``stream_id``, ``source`` and ``ts``
on top so the frontend can filter cross-session events and ignore
out-of-order replays.
**Detection symmetry with the agent.** The cold-load helper deliberately
uses the same loose detector as ``run_agent.AIAgent._hydrate_todo_store``
(``role='tool'`` + JSON content with ``todos: list``). If a future change
tightens or relaxes that detector, mirror it here so the WebUI panel
never disagrees with the agent's in-memory ``TodoStore``.
**Multimodal tool results.** Some tools return content as a list of
OpenAI/Anthropic content parts rather than a JSON string. The ``todo``
tool always returns a JSON string, so list-shaped content cannot be a
todo write — :func:`derive_todo_state` skips them by design.
This module is **side-effect free** by design — it only parses data and
calls a caller-supplied ``put`` callable for SSE. Routing/event-shape
decisions live here so the call sites stay one-liners.
"""
from __future__ import annotations
import json
import logging
import time
from typing import Any, Callable, Iterable, Optional, Sequence
logger = logging.getLogger(__name__)
# Bumped when the on-wire payload shape changes in a non-additive way.
# Additive fields (e.g. timestamps, tags) keep VERSION at 1.
VERSION = 1
# Single source of truth for the SSE event name and the session GET
# payload key. Any current or future caller must reuse these so a
# rename only happens in one place.
EVENT_NAME = "todo_state"
PAYLOAD_KEY = "todo_state"
def _normalize_snapshot(data: Any) -> Optional[dict]:
"""Return a normalized snapshot dict, or ``None`` if the payload is invalid.
Accepts the canonical ``{"todos": [...], "summary": {...}}`` shape
produced by ``tools.todo_tool.todo_tool``. Anything else returns
``None`` so callers can fall through to legacy paths or skip
emission.
The detector is intentionally loose so it stays symmetric with the
agent's hydration logic — see the module docstring.
**Empty list is a valid snapshot.** ``todos == []`` returns a normal
snapshot (not ``None``), so the latest write wins even when it cleared
the list. This is deliberately symmetric with the agent: its
``_hydrate_todo_store`` (run_agent.py) breaks at the most-recent todo
message and, because ``if last_todo_response:`` is falsy for ``[]``,
leaves its TodoStore empty — i.e. agent shows empty, panel shows empty.
Do NOT reintroduce a ``len(todos) > 0`` guard here or in the frontend
fallback (``_legacyTodosFromMessages``): that was the pre-Phase-2
behavior that kept scanning past an empty write to an older non-empty
list, diverging from the agent and showing a stale "cleared" list.
"""
if not isinstance(data, dict):
return None
todos = data.get("todos")
if not isinstance(todos, list):
return None
summary = data.get("summary")
if not isinstance(summary, dict):
summary = {}
return {
"todos": todos,
"summary": summary,
"version": VERSION,
}
def parse_todo_tool_result(function_result: Any) -> Optional[dict]:
"""Parse a fresh ``todo`` tool call result into a snapshot dict.
The agent's ``todo`` handler returns a JSON string; this helper
accepts either that string or an already-parsed dict (defensive —
future callers may deserialize earlier in the pipeline).
Returns ``None`` on any parse/shape failure so the caller can
swallow the error without breaking the tool delivery path.
"""
data: Any = function_result
if isinstance(function_result, str):
try:
data = json.loads(function_result)
except (ValueError, TypeError):
return None
return _normalize_snapshot(data)
def derive_todo_state(messages: Optional[Iterable[dict]]) -> Optional[dict]:
"""Derive the latest todo snapshot from settled conversation history.
Mirrors the agent-side ``_hydrate_todo_store`` logic: walk messages
in reverse, return the first ``role='tool'`` message whose JSON
content carries a ``todos`` list. Returns ``None`` when no such
message is found (fresh session, or a session that never invoked
``todo``).
Multimodal tool results — ``content`` as a list of content parts
rather than a JSON string — are skipped intentionally. The ``todo``
tool always returns a string, so list-shaped content cannot be a
todo write; non-string ``content`` is therefore correct to ignore.
The fast-path string check (``'"todos"' in content``) avoids parsing
JSON for every tool result — most sessions have many non-todo tool
calls but at most a handful of todo writes.
"""
if not messages:
return None
# ``reversed`` works on ``list`` and ``tuple`` natively; for any
# other iterable (e.g. a generator) we materialize once. Routes
# always pass a list, so this branch is normally a no-op.
if not isinstance(messages, (list, tuple)):
messages = list(messages)
for idx in range(len(messages) - 1, -1, -1):
msg = messages[idx]
if not isinstance(msg, dict) or msg.get("role") != "tool":
continue
content = msg.get("content", "")
if not isinstance(content, str) or '"todos"' not in content:
continue
try:
data = json.loads(content)
except (ValueError, TypeError):
continue
snapshot = _normalize_snapshot(data)
if snapshot is not None:
# Carry a timestamp so the frontend can reconcile cold-load
# vs. INFLIGHT snapshots by recency.
#
# Primary source: this message's own ``timestamp``. But a
# todo tool message can lose its timestamp during context
# compression/rebuild — the on-disk message ends up with
# ``timestamp=None``. If we emit a snapshot with no ``ts``,
# the frontend reads coldTs=0 and a STALE-but-timestamped
# INFLIGHT snapshot wins the recency comparison, so the panel
# renders a historical todo list. This is the latest-by-
# POSITION snapshot, so it must never lose recency to an
# earlier list. When this message has no usable timestamp,
# fall back to the max timestamp seen anywhere at or before
# this position — guaranteeing cold ts >= any earlier todo
# write's ts.
ts_val = _message_ts_float(msg.get("timestamp"))
if ts_val <= 0:
ts_val = _max_timestamp_through(messages, idx)
if ts_val > 0:
snapshot["ts"] = ts_val
return snapshot
return None
def _message_ts_float(ts_raw: Any) -> float:
"""Coerce a message ``timestamp`` field to a positive float, or 0.0."""
try:
return float(ts_raw) if ts_raw is not None else 0.0
except (TypeError, ValueError):
return 0.0
def _max_timestamp_through(messages: "Sequence[Any]", upto_idx: int) -> float:
"""Largest valid ``timestamp`` among messages[0:upto_idx+1].
Used as a recency floor when the latest todo message itself lost its
timestamp during compression/rebuild. Scanning only up to the todo's
position keeps the floor causally correct — it never borrows a
timestamp from a message that came after the todo write.
"""
best = 0.0
end = min(upto_idx, len(messages) - 1)
for i in range(end, -1, -1):
m = messages[i]
if not isinstance(m, dict):
continue
ts = _message_ts_float(m.get("timestamp"))
if ts > best:
best = ts
return best
def _redact_snapshot(snapshot: dict) -> dict:
"""Redact credential-shaped text from a todo snapshot before it leaves the process.
The live SSE path (:func:`emit_todo_state`) does NOT pass through
``redact_session_data`` (api/helpers.py) the way the cold-load session
GET response does, so emission must redact the same content that path
would — otherwise the live Todos panel (and the run-journal replay that
persists every SSE event) becomes a redaction bypass for any credential
an agent wrote into a todo item's ``content``. The live event also
carries the FULL untruncated todos, a wider exposure surface than the
truncated ``preview`` the sibling ``tool``/``tool_complete`` events send.
``_redact_value`` is imported lazily to keep the dependency direction
one-way (helpers must never import todo_state) and to avoid paying the
import cost on the cold-load path, which redacts via ``redact_session_data``.
The redaction setting is read once per SSE snapshot and threaded through the
recursive helper so nested strings do not reload settings.json individually.
Returns a new, redacted snapshot. Raises on failure so the caller fails
closed (no emission) rather than leaking an unredacted payload.
"""
from typing import cast
from api.config import load_settings
from api.helpers import _redact_value
_enabled = bool(load_settings().get("api_redact_enabled", True))
# ``_redact_value`` preserves container shape (dict in → dict out); the
# cast narrows its broad recursive union back to dict for the type checker.
return cast(dict, _redact_value(snapshot, _enabled=_enabled))
def emit_todo_state(
put: Callable[[str, dict], Any],
*,
name: Optional[str],
function_result: Any,
session_id: Optional[str],
stream_id: Optional[str],
source: str = "tool",
) -> bool:
"""Emit a ``todo_state`` SSE event when ``name == 'todo'``.
Returns ``True`` if an event was emitted, ``False`` otherwise.
Always swallows internal errors — emission must never break tool
delivery, which is the caller's primary contract.
Args:
put: streaming queue callback; signature ``put(event, data)``.
name: tool name from the callback. Skipped when not ``'todo'``.
function_result: raw tool result (JSON string or dict).
session_id: tag so the frontend can filter cross-session events.
stream_id: tag so SSE replay can dedupe by stream.
source: emission origin tag. ``'tool'`` for live tool calls;
future callers may use ``'compression-refresh'`` etc.
The full snapshot is always sent — idempotent re-application is safe
under SSE replay through the run journal. The snapshot is redacted
before emission (see :func:`_redact_snapshot`); if redaction fails the
event is dropped (fail-closed) rather than leaking an unredacted payload.
"""
if name != "todo":
return False
try:
snapshot = parse_todo_tool_result(function_result)
if snapshot is None:
return False
snapshot = _redact_snapshot(snapshot)
put(EVENT_NAME, {
"session_id": session_id,
"stream_id": stream_id,
"source": source,
"ts": time.time(),
**snapshot,
})
return True
except Exception:
# Per-call debug logging — a flood would mean the queue is
# broken, in which case the rest of the stream is already dead.
# Redaction failure also lands here and correctly drops the event.
logger.debug("todo_state emit failed (name=%s)", name, exc_info=True)
return False
def attach_todo_state(
payload: dict,
messages: Optional[Iterable[dict]],
) -> bool:
"""Attach a derived ``todo_state`` snapshot to a session GET response.
Mutates ``payload`` in place when a snapshot can be derived.
Returns ``True`` if attached, ``False`` otherwise. Always swallows
errors — a malformed sidecar must never break the session GET
response.
The caller is responsible for any higher-level gating
(e.g. ``load_messages``); this helper is a no-op on empty/``None``
``messages`` so callers can hand it whatever message list they have.
"""
if not messages:
return False
try:
snapshot = derive_todo_state(messages)
if snapshot is None:
return False
payload[PAYLOAD_KEY] = snapshot
return True
except Exception:
logger.debug("todo_state attach failed", exc_info=True)
return False

281
api/turn_journal.py Normal file
View File

@@ -0,0 +1,281 @@
"""Crash-safe WebUI turn journal helpers.
The journal is deliberately tiny: one JSONL file per session, append-only events,
and read helpers that tolerate malformed lines. Recovery and repair can then
reason about submitted turns without depending on in-memory stream state.
"""
from __future__ import annotations
import json
import os
import re
import time
import uuid
from contextlib import contextmanager
from pathlib import Path
from typing import Iterable
try: # pragma: no cover - fcntl is unavailable on Windows.
import fcntl as _fcntl
except ImportError: # pragma: no cover
_fcntl = None
TURN_JOURNAL_DIR_NAME = "_turn_journal"
_TERMINAL_EVENTS = {"completed", "interrupted"}
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
def _default_session_dir() -> Path:
from api.models import SESSION_DIR
return Path(SESSION_DIR)
def _journal_path(session_id: str, session_dir: Path | None = None) -> Path:
sid = str(session_id or "").strip()
if not sid or "/" in sid or "\\" in sid or not _SESSION_ID_RE.fullmatch(sid):
raise ValueError("invalid session_id")
root = Path(session_dir) if session_dir is not None else _default_session_dir()
return root / TURN_JOURNAL_DIR_NAME / f"{sid}~{os.getpid()}.jsonl"
def _make_turn_id() -> str:
return f"{time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())}-{uuid.uuid4().hex[:12]}"
@contextmanager
def _journal_file_lock(file_obj):
"""Serialize multi-process journal writes when advisory locks exist.
``O_APPEND`` keeps normal same-process appends simple, but a long JSONL event
can exceed POSIX's small atomic-write boundary. On Unix, take an advisory
lock around the single event write+fsync so two WebUI worker processes cannot
interleave large submitted-message payloads into corrupted JSONL. Platforms
without ``fcntl`` keep the previous best-effort append behavior.
"""
if _fcntl is None:
yield
return
_fcntl.flock(file_obj.fileno(), _fcntl.LOCK_EX)
try:
yield
finally:
_fcntl.flock(file_obj.fileno(), _fcntl.LOCK_UN)
def append_turn_journal_event(
session_id: str,
event: dict,
*,
session_dir: Path | None = None,
) -> dict:
"""Append one turn journal event and fsync it before returning.
The returned event is the exact payload written, with default ``version``,
``session_id``, ``turn_id``, and ``created_at`` fields filled in.
"""
if not isinstance(event, dict):
raise TypeError("event must be a dict")
event_name = str(event.get("event") or "").strip()
if not event_name:
raise ValueError("event is required")
payload = dict(event)
payload.setdefault("version", 1)
payload["session_id"] = str(session_id)
payload.setdefault("turn_id", _make_turn_id())
payload.setdefault("created_at", time.time())
if event_name in _TERMINAL_EVENTS:
payload.setdefault("terminal", True)
path = _journal_path(session_id, session_dir=session_dir)
path.parent.mkdir(parents=True, exist_ok=True)
line = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
fd = os.open(path, os.O_CREAT | os.O_APPEND | os.O_WRONLY, 0o600)
with os.fdopen(fd, "a", encoding="utf-8") as fh:
with _journal_file_lock(fh):
fh.write(line)
fh.flush()
os.fsync(fh.fileno())
o_directory = getattr(os, "O_DIRECTORY", None)
if o_directory is not None:
try:
dir_fd = os.open(path.parent, o_directory)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError:
pass
return payload
def read_turn_journal(session_id: str, *, session_dir: Path | None = None) -> dict:
"""Read a session journal, merging all pid-scoped shards and returning valid events plus malformed lines."""
sid = str(session_id or "").strip()
if not sid or "/" in sid or "\\" in sid or not _SESSION_ID_RE.fullmatch(sid):
raise ValueError("invalid session_id")
root = Path(session_dir) if session_dir is not None else _default_session_dir()
journal_dir = root / TURN_JOURNAL_DIR_NAME
events: list[dict] = []
malformed: list[dict] = []
# Collect pid-scoped shards ({sid}~{pid}.jsonl) plus legacy ({sid}.jsonl).
# The ~ separator cannot appear in session IDs (_SESSION_ID_RE allows only [A-Za-z0-9_.-]),
# so the glob is unambiguous even for dotted-numeric session IDs like "sess.123".
shards: list[Path] = list(journal_dir.glob(f"{sid}~*.jsonl")) if journal_dir.exists() else []
legacy = journal_dir / f"{sid}.jsonl"
if legacy.exists():
shards.append(legacy)
if not shards:
return {"session_id": str(session_id), "events": [], "malformed": []}
for shard in shards:
try:
lines = shard.read_text(encoding="utf-8").splitlines()
except FileNotFoundError:
continue
for line_no, raw in enumerate(lines, start=1):
if not raw.strip():
continue
try:
event = json.loads(raw)
except json.JSONDecodeError:
malformed.append({"line": line_no, "raw": raw, "shard": shard.name})
continue
if isinstance(event, dict):
events.append(event)
else:
malformed.append({"line": line_no, "raw": raw, "shard": shard.name})
def _safe_ts(e):
try:
return float(e.get("created_at") or 0)
except (ValueError, TypeError):
return 0.0
events.sort(key=_safe_ts)
return {"session_id": str(session_id), "events": events, "malformed": malformed}
def derive_turn_journal_states(events: Iterable[dict]) -> tuple[dict[str, dict], list[dict]]:
'''Return the latest event per ``turn_id`` and any terminal-collision entries.
The first element is the latest event per turn_id (same overwrite-by-timestamp
behaviour as before). The second element is a list of collision records, one
per turn_id that had more than one terminal event. Each collision record
contains ``turn_id`` and the ``events`` list (in ascending created_at order).
A collision means the same logical turn recorded both ``completed`` and
``interrupted`` terminal events -- the derived state still picks the latest
by timestamp, but callers can now detect and audit the double-terminal
situation explicitly rather than having it silently collapse.
'''
states: dict[str, dict] = {}
# Collect all terminal events per turn_id to detect collisions
terminal_events: dict[str, list[dict]] = {}
for event in events:
if not isinstance(event, dict):
continue
turn_id = str(event.get('turn_id') or '').strip()
if not turn_id:
continue
# Track terminal events for collision detection
if is_terminal_turn_event(event):
terminal_events.setdefault(turn_id, []).append(event)
# Existing latest-by-timestamp derivation
previous = states.get(turn_id)
if previous is None or float(event.get('created_at') or 0) >= float(previous.get('created_at') or 0):
states[turn_id] = event
# Build collision list: turn_ids with more than one terminal event
collisions = [
{'turn_id': tid, 'events': sorted(evts, key=lambda e: float(e.get('created_at') or 0))}
for tid, evts in terminal_events.items()
if len(evts) > 1
]
return states, collisions
def _latest_turn_id_for_stream(events: Iterable[dict], stream_id: str) -> str | None:
stream = str(stream_id or "").strip()
if not stream:
return None
latest: str | None = None
for event in events:
if not isinstance(event, dict):
continue
if str(event.get("stream_id") or "") != stream:
continue
turn_id = str(event.get("turn_id") or "").strip()
if turn_id:
latest = turn_id
return latest
def append_turn_journal_event_for_stream(
session_id: str,
stream_id: str,
event: dict,
*,
session_dir: Path | None = None,
) -> dict:
"""Append a lifecycle event for the turn associated with ``stream_id``."""
payload = dict(event)
payload["stream_id"] = str(stream_id)
if not payload.get("turn_id"):
journal = read_turn_journal(session_id, session_dir=session_dir)
turn_id = _latest_turn_id_for_stream(journal.get("events") or [], stream_id)
if turn_id:
payload["turn_id"] = turn_id
return append_turn_journal_event(session_id, payload, session_dir=session_dir)
def iter_turn_journal_session_ids(session_dir: Path) -> list[str]:
journal_dir = Path(session_dir) / TURN_JOURNAL_DIR_NAME
if not journal_dir.exists():
return []
session_ids: set[str] = set()
for path in journal_dir.glob("*.jsonl"):
if not path.is_file():
continue
stem = path.stem # e.g. "sid-1~12345" or "sid-1"
tilde = stem.find("~")
if tilde > 0:
session_ids.add(stem[:tilde])
else:
session_ids.add(stem)
return sorted(session_ids)
def delete_turn_journal(session_id: str, *, session_dir: Path | None = None) -> int:
"""Remove every turn-journal shard for ``session_id``.
Deletes both the pid-scoped shards (``{sid}~{pid}.jsonl``) written by
:func:`append_turn_journal_event` and the legacy single-file form
(``{sid}.jsonl``) that :func:`read_turn_journal` still merges. Returns the
number of files removed. Invalid/empty ids and a missing journal directory
are treated as a no-op so callers can invoke this unconditionally on delete.
"""
sid = str(session_id or "").strip()
# Reject "."/".." for parity with delete_run_journal — the regex permits
# dots, and a traversal id has no legitimate use here.
if sid in (".", "..") or not sid or "/" in sid or "\\" in sid or not _SESSION_ID_RE.fullmatch(sid):
return 0
root = Path(session_dir) if session_dir is not None else _default_session_dir()
journal_dir = root / TURN_JOURNAL_DIR_NAME
if not journal_dir.exists():
return 0
removed = 0
shards = list(journal_dir.glob(f"{sid}~*.jsonl"))
legacy = journal_dir / f"{sid}.jsonl"
if legacy.exists():
shards.append(legacy)
for shard in shards:
try:
shard.unlink()
removed += 1
except FileNotFoundError:
pass
except OSError:
# Best-effort cleanup; the caller logs the overall delete outcome.
pass
return removed
def is_terminal_turn_event(event: dict) -> bool:
return str((event or {}).get("event") or "") in _TERMINAL_EVENTS

File diff suppressed because it is too large Load Diff

View File

@@ -2,24 +2,75 @@
Hermes Web UI -- File upload: multipart parser and upload handler.
"""
import mimetypes
import os
import re as _re
import email.parser
import tempfile
from pathlib import Path
from api.config import MAX_UPLOAD_BYTES
from api.config import MAX_UPLOAD_BYTES, STATE_DIR
from api.helpers import j, bad
from api.models import get_session
from api.workspace import safe_resolve_ws
from api.workspace import (
safe_resolve_ws,
resolve_trusted_workspace,
open_anchored_create_fd,
make_anchored_dir,
rmtree_anchored,
unlink_anchored,
)
def _max_extracted_bytes() -> int:
"""Total-extracted-bytes cap for archive uploads (zip/tar-bomb guard).
Independently tunable from the upload size cap via
HERMES_WEBUI_MAX_EXTRACTED_MB; defaults to 10x the upload cap. Read at call
time (not import) so the value reflects the running process's environment
and is exercisable by tests against the out-of-process test server.
"""
raw = os.getenv("HERMES_WEBUI_MAX_EXTRACTED_MB", "").strip()
if raw:
try:
mb = float(raw)
if mb > 0:
return int(mb * 1024 * 1024)
except ValueError:
pass
return 10 * MAX_UPLOAD_BYTES
# Back-compat module constant (some call sites / tests reference it). The
# authoritative value is _max_extracted_bytes(), read at extraction time.
_MAX_EXTRACTED_BYTES = 10 * MAX_UPLOAD_BYTES
def parse_multipart(rfile, content_type, content_length) -> tuple:
import re as _re, email.parser as _ep
# Imported locally (not just module-level) so the function stays
# self-contained — some tests exec() this function's source in an isolated
# namespace, and a bare module global would NameError there.
try:
from api.config import MAX_UPLOAD_BYTES as _MAX_UPLOAD_BYTES
except Exception:
_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
m = _re.search(r'boundary=([^;\s]+)', content_type)
if not m:
raise ValueError('No boundary in Content-Type')
boundary = m.group(1).strip('"').encode()
raw = rfile.read(content_length)
# Centralized length guard for ALL upload callers: a missing/garbage or
# NEGATIVE Content-Length must never reach rfile.read(<0), which reads the
# stream unbounded (read(-1) == read-to-EOF) and bypasses the per-handler
# size cap. Reject anything not in [0, MAX_UPLOAD_BYTES].
try:
length = int(content_length)
except (TypeError, ValueError):
raise ValueError('Invalid Content-Length') from None
if length < 0:
raise ValueError('Invalid Content-Length (negative)')
if length > _MAX_UPLOAD_BYTES:
raise ValueError(f'Upload too large (max {_MAX_UPLOAD_BYTES} bytes)')
raw = rfile.read(length)
fields = {}
files = {}
delimiter = b'--' + boundary
@@ -59,6 +110,46 @@ def _sanitize_upload_name(filename: str) -> str:
return safe_name
def _attachment_root() -> Path:
"""Return the configured upload inbox root.
Plain chat attachments are transient context for the agent, not project
source files. Keep them out of the active workspace by default while still
allowing operators to move the inbox with HERMES_WEBUI_ATTACHMENT_DIR.
"""
override = os.getenv('HERMES_WEBUI_ATTACHMENT_DIR', '').strip()
if override:
return Path(override).expanduser().resolve()
return (STATE_DIR / 'attachments').resolve()
def _upload_destination(session_id: str, safe_name: str) -> Path:
dest_dir = _session_attachment_dir(session_id)
dest_dir.mkdir(parents=True, exist_ok=True)
dest = (dest_dir / safe_name).resolve()
if not dest.is_relative_to(dest_dir):
raise ValueError('Invalid upload destination')
if dest.exists():
stem = dest.stem
suffix = dest.suffix
for idx in range(1, 1000):
candidate = (dest_dir / f'{stem}-{idx}{suffix}').resolve()
if not candidate.is_relative_to(dest_dir):
raise ValueError('Invalid upload destination')
if not candidate.exists():
return candidate
raise ValueError('Too many uploads with the same filename')
return dest
def _session_attachment_dir(session_id: str, *, root: Path | None = None) -> Path:
root = (root or _attachment_root()).resolve()
dest_dir = (root / _re.sub(r'[^\w.\-]', '_', str(session_id or 'session'))[:120]).resolve()
if not dest_dir.is_relative_to(root):
raise ValueError('Invalid attachment directory')
return dest_dir
def handle_upload(handler):
import traceback as _tb
try:
@@ -77,13 +168,12 @@ def handle_upload(handler):
s = get_session(session_id)
except KeyError:
return j(handler, {'error': 'Session not found'}, status=404)
workspace = Path(s.workspace)
safe_name = _sanitize_upload_name(filename)
dest = safe_resolve_ws(workspace, safe_name)
dest = _upload_destination(session_id, safe_name)
dest.write_bytes(file_bytes)
mime = mimetypes.guess_type(safe_name)[0] or 'application/octet-stream'
return j(handler, {
'filename': safe_name,
'filename': dest.name,
'path': str(dest),
'size': dest.stat().st_size,
'mime': mime,
@@ -96,11 +186,6 @@ def handle_upload(handler):
return j(handler, {'error': 'Upload failed'}, status=500)
# Maximum total extracted bytes — guards against zip/tar bombs.
# Set to 10x the upload limit; a legitimate archive rarely exceeds 3-4x.
_MAX_EXTRACTED_BYTES = 10 * 20 * 1024 * 1024 # 200 MB
def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
"""Extract a zip or tar archive into the workspace.
@@ -109,6 +194,7 @@ def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
"""
import zipfile, tarfile, io, os, shutil
cap = _max_extracted_bytes()
name = Path(filename).name
stem = Path(filename).stem # strip .zip / .tar.gz etc.
@@ -121,13 +207,23 @@ def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
# Determine destination directory — use archive stem as folder name
dest_dir = safe_resolve_ws(workspace, stem)
# Avoid overwriting existing files by appending a suffix
# Avoid overwriting existing files by appending a suffix (bounded — astronomically
# unlikely to collide, but never spin forever).
if dest_dir.exists():
import string, random
while dest_dir.exists():
for _ in range(1000):
if not dest_dir.exists():
break
suffix = ''.join(random.choices(string.digits, k=3))
dest_dir = dest_dir.with_name(stem + '_' + suffix)
dest_dir.mkdir(parents=True, exist_ok=True)
dest_dir = safe_resolve_ws(workspace, stem).with_name(stem + '_' + suffix)
else:
raise ValueError('Could not allocate a unique extraction directory')
# #3398: create the extraction root race-safely under the true workspace root.
make_anchored_dir(workspace, dest_dir)
# Member-count cap: a tiny archive with millions of (possibly empty) members
# slips under the byte cap but can exhaust inodes / file descriptors. Bound it.
_MAX_ARCHIVE_MEMBERS = 10000
extracted_files = []
total_extracted = 0
@@ -139,29 +235,38 @@ def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
# Skip directories
if member.is_dir():
continue
if len(extracted_files) >= _MAX_ARCHIVE_MEMBERS:
raise ValueError(
f'Archive has too many files (> {_MAX_ARCHIVE_MEMBERS}). '
f'Possible archive bomb.'
)
# Zip-slip protection
member_path = (dest_dir / member.filename).resolve()
if not member_path.is_relative_to(dest_dir.resolve()):
raise ValueError(f'Zip-slip blocked: {member.filename}')
# Zip-bomb protection: track actual extracted bytes (not declared file_size)
if total_extracted > _MAX_EXTRACTED_BYTES:
if total_extracted > cap:
raise ValueError(
f'Extraction too large ({total_extracted // (1024*1024)} MB > '
f'{_MAX_EXTRACTED_BYTES // (1024*1024)} MB limit). '
f'{cap // (1024*1024)} MB limit). '
f'Possible zip bomb.'
)
member_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(member) as src, open(member_path, 'wb') as dst:
# #3398: open_anchored_create_fd creates intermediate dirs
# race-safely under the true workspace root (anchored mkdirat),
# so no pathname member_path.parent.mkdir() before it (which
# could be redirected outside by a raced symlink component).
_mfd = open_anchored_create_fd(workspace, member_path)
with zf.open(member) as src, os.fdopen(_mfd, 'wb', closefd=True) as dst:
_chunk_size = 65536
while True:
chunk = src.read(_chunk_size)
if not chunk:
break
total_extracted += len(chunk)
if total_extracted > _MAX_EXTRACTED_BYTES:
if total_extracted > cap:
raise ValueError(
f'Extraction too large (> '
f'{_MAX_EXTRACTED_BYTES // (1024*1024)} MB limit). '
f'{cap // (1024*1024)} MB limit). '
f'Possible zip bomb.'
)
dst.write(chunk)
@@ -172,31 +277,39 @@ def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
for member in tf.getmembers():
if not member.isfile():
continue
if len(extracted_files) >= _MAX_ARCHIVE_MEMBERS:
raise ValueError(
f'Archive has too many files (> {_MAX_ARCHIVE_MEMBERS}). '
f'Possible archive bomb.'
)
# Tar-slip protection
member_path = (dest_dir / member.name).resolve()
if not member_path.is_relative_to(dest_dir.resolve()):
raise ValueError(f'Tar-slip blocked: {member.name}')
# Tar-bomb protection: track actual extracted bytes (not declared size)
if total_extracted > _MAX_EXTRACTED_BYTES:
if total_extracted > cap:
raise ValueError(
f'Extraction too large ({total_extracted // (1024*1024)} MB > '
f'{_MAX_EXTRACTED_BYTES // (1024*1024)} MB limit). '
f'{cap // (1024*1024)} MB limit). '
f'Possible zip bomb.'
)
member_path.parent.mkdir(parents=True, exist_ok=True)
# #3398: anchored member create makes intermediate dirs
# race-safely; no pathname member_path.parent.mkdir() first.
src_obj = tf.extractfile(member)
if src_obj:
with src_obj as src, open(member_path, 'wb') as dst:
# #3398: fd-anchored member create under the TRUE workspace root.
_mfd = open_anchored_create_fd(workspace, member_path)
with src_obj as src, os.fdopen(_mfd, 'wb', closefd=True) as dst:
_chunk_size = 65536
while True:
chunk = src.read(_chunk_size)
if not chunk:
break
total_extracted += len(chunk)
if total_extracted > _MAX_EXTRACTED_BYTES:
if total_extracted > cap:
raise ValueError(
f'Extraction too large (> '
f'{_MAX_EXTRACTED_BYTES // (1024*1024)} MB limit). '
f'{cap // (1024*1024)} MB limit). '
f'Possible zip bomb.'
)
dst.write(chunk)
@@ -204,7 +317,7 @@ def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
except Exception:
# Clean up partially-extracted directory to avoid orphaned folders
try:
shutil.rmtree(dest_dir, ignore_errors=True)
rmtree_anchored(workspace, dest_dir)
except Exception:
pass
raise
@@ -231,8 +344,9 @@ def handle_upload_extract(handler):
s = get_session(session_id)
except KeyError:
return j(handler, {'error': 'Session not found'}, status=404)
workspace = Path(s.workspace)
result = extract_archive(file_bytes, filename, workspace)
session_dir = _session_attachment_dir(session_id)
session_dir.mkdir(parents=True, exist_ok=True)
result = extract_archive(file_bytes, filename, session_dir)
return j(handler, {'ok': True, **result})
except ValueError as e:
return j(handler, {'error': str(e)}, status=400)
@@ -282,3 +396,295 @@ def handle_transcribe(handler):
Path(temp_path).unlink(missing_ok=True)
except Exception:
pass
def _stt_provider_capability_from_module(stt):
"""Return (available, provider) for a loaded transcription_tools module."""
try:
load_cfg = getattr(stt, "_load_stt_config", None)
stt_config = load_cfg() if callable(load_cfg) else {}
cfg_dict = stt_config if isinstance(stt_config, dict) else {}
is_enabled = getattr(stt, "is_stt_enabled", None)
if callable(is_enabled) and not is_enabled(stt_config):
return False, "none"
# Some tests and future agent releases expose the provider decision as a
# single helper. Use it when the lower-level capability flags are not
# available. The current agent module exposes the flags below, so the
# normal path mirrors _get_provider() without triggering its lazy local
# STT install side effect during a passive web page probe.
has_internal_flags = any(
hasattr(stt, name)
for name in ("_HAS_FASTER_WHISPER", "_HAS_OPENAI", "_HAS_MISTRAL")
)
get_provider = getattr(stt, "_get_provider", None)
if callable(get_provider) and not has_internal_flags:
provider = str(get_provider(stt_config) or "none")
return provider not in ("", "none"), provider or "none"
def env(name):
getter = getattr(stt, "get_env_value", None)
try:
if callable(getter):
return str(getter(name) or "").strip()
except Exception:
return ""
return os.getenv(name, "").strip()
def has_local_command():
helper = getattr(stt, "_has_local_command", None)
try:
return bool(helper()) if callable(helper) else False
except Exception:
return False
def has_browser_audio_converter():
helper = getattr(stt, "_find_ffmpeg_binary", None)
try:
return bool(helper()) if callable(helper) else False
except Exception:
return False
def has_openai_audio():
helper = getattr(stt, "_has_openai_audio_backend", None)
try:
return bool(helper()) if callable(helper) else False
except Exception:
return False
def local_command_available():
# The browser sends WebM/Ogg blobs; the local-command path converts
# non-WAV input through ffmpeg before invoking the command.
return has_local_command() and has_browser_audio_converter()
def resolve_provider(provider):
if provider == "local":
if bool(getattr(stt, "_HAS_FASTER_WHISPER", False)):
return "local"
if local_command_available():
return "local_command"
return "none"
if provider == "local_command":
if local_command_available():
return "local_command"
if bool(getattr(stt, "_HAS_FASTER_WHISPER", False)):
return "local"
return "none"
if provider == "groq":
return "groq" if bool(getattr(stt, "_HAS_OPENAI", False)) and bool(env("GROQ_API_KEY")) else "none"
if provider == "openai":
return "openai" if bool(getattr(stt, "_HAS_OPENAI", False)) and has_openai_audio() else "none"
if provider == "mistral":
return "mistral" if bool(getattr(stt, "_HAS_MISTRAL", False)) and bool(env("MISTRAL_API_KEY")) else "none"
if provider == "xai":
try:
from tools.xai_http import resolve_xai_http_credentials
return "xai" if resolve_xai_http_credentials().get("api_key") else "none"
except Exception:
return "none"
if provider == "elevenlabs":
return "elevenlabs" if bool(env("ELEVENLABS_API_KEY")) else "none"
return "none"
explicit = "provider" in cfg_dict
if explicit:
configured = str(cfg_dict.get("provider") or "local")
provider = resolve_provider(configured)
return provider != "none", provider if provider != "none" else configured
for candidate in ("local", "local_command", "groq", "openai", "mistral", "xai", "elevenlabs"):
provider = resolve_provider(candidate)
if provider != "none":
return True, provider
return False, "none"
except Exception:
return False, "none"
def _stt_provider_capability():
"""Return (available, provider) for a cheap server-side STT capability probe."""
try:
import tools.transcription_tools as stt
except ImportError:
return False, "none"
return _stt_provider_capability_from_module(stt)
def handle_transcribe_capability(handler):
available, provider = _stt_provider_capability()
return j(handler, {"ok": True, "available": bool(available), "provider": provider})
def handle_workspace_upload(handler):
"""Upload a file into a session's workspace directory.
Form fields:
session_id target session
path subdirectory within the workspace (default: '')
File:
file the uploaded file(s)
"""
import traceback as _tb
try:
content_type = handler.headers.get('Content-Type', '')
content_length = int(handler.headers.get('Content-Length', 0) or 0)
if content_length > MAX_UPLOAD_BYTES:
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
fields, files = parse_multipart(handler.rfile, content_type, content_length)
session_id = fields.get('session_id', '')
subpath = fields.get('path', '')
if not session_id:
return j(handler, {'error': 'Missing session_id'}, status=400)
if not files:
return j(handler, {'error': 'No file field in request'}, status=400)
# Validate session
try:
session = get_session(session_id)
except KeyError:
return j(handler, {'error': 'Session not found'}, status=404)
# Resolve workspace root from session
workspace = resolve_trusted_workspace(session.workspace)
# Resolve target subdirectory within workspace
target_dir = safe_resolve_ws(workspace, subpath) if subpath else workspace
# safe_resolve_ws intentionally permits in-workspace symlinks pointing
# outside the root (read trust model). For an UPLOAD target that's not
# acceptable: a planted symlink subpath would let mkdir() + writes create
# files OUTSIDE the workspace. Require the resolved target to be inside
# the workspace before creating anything. (is_relative_to is True for the
# workspace==target equality case, so the normal subpath='' path passes.)
if not target_dir.resolve().is_relative_to(workspace.resolve()):
return j(handler, {'error': 'Upload target escapes workspace'}, status=403)
# #3398: create the upload target dir race-safely under the workspace root
# (anchored mkdirat) so a raced symlink subpath can't mkdir outside.
try:
make_anchored_dir(workspace, target_dir)
except (ValueError, OSError):
return j(handler, {'error': 'Upload target escapes workspace'}, status=403)
results = []
for _field_name, (filename, file_bytes) in files.items():
if not filename:
continue
safe_name = _sanitize_upload_name(filename)
dest = safe_resolve_ws(target_dir, safe_name)
# Path traversal guard (belt-and-suspenders: safe_resolve_ws above is
# the authoritative guard and raises ValueError on traversal; this
# check catches any edge case where the resolved path escapes).
if not dest.resolve().is_relative_to(workspace.resolve()):
return j(handler, {'error': f'Path traversal blocked: {safe_name}'}, status=403)
# Deduplicate: append -1, -2, etc. if file already exists
if dest.exists():
stem = dest.stem
suffix = dest.suffix
for idx in range(1, 1000):
candidate = safe_resolve_ws(target_dir, f'{stem}-{idx}{suffix}')
if not candidate.resolve().is_relative_to(workspace.resolve()):
return j(handler, {'error': 'Path traversal blocked'}, status=403)
if not candidate.exists():
dest = candidate
break
else:
return j(handler, {'error': 'Too many uploads with the same filename'}, status=400)
# #3398 TOCTOU hardening: create the destination via an anchored
# openat-walk from the true workspace root with O_CREAT|O_EXCL|
# O_NOFOLLOW, so a symlink raced into any path component after the
# containment checks above cannot redirect the write outside the
# workspace. The dedup loop guarantees `dest` does not exist.
try:
_wfd = open_anchored_create_fd(workspace, dest.resolve())
except FileExistsError:
return j(handler, {'error': f'Upload destination already exists: {safe_name}'}, status=409)
except (ValueError, OSError):
return j(handler, {'error': f'Path traversal blocked: {safe_name}'}, status=403)
with os.fdopen(_wfd, 'wb', closefd=True) as _wfh:
_wfh.write(file_bytes)
mime = mimetypes.guess_type(safe_name)[0] or 'application/octet-stream'
# For archives, optionally extract into the target directory.
# Suffix set MUST match extract_archive()'s supported formats, else
# accepted-but-unlisted archives (.tar/.tbz2/.txz) silently land as
# raw files instead of extracting.
is_archive = safe_name.lower().endswith(('.zip', '.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz'))
if is_archive:
import zipfile, tarfile, traceback as _extract_tb
try:
extraction = extract_archive(file_bytes, safe_name, target_dir)
# Remove the archive file after successful extraction
try:
unlink_anchored(workspace, dest.resolve())
except FileNotFoundError:
pass
results.append({
'filename': safe_name,
'path': str(extraction.get('dest', target_dir)),
'size': len(file_bytes),
'is_image': False,
'extracted': True,
'extracted_files': extraction.get('files', []),
'extracted_count': extraction.get('extracted', 0),
})
continue
except (zipfile.BadZipFile, tarfile.TarError, ValueError) as e:
# Extraction failed — remove the archive file (no partial
# content left behind) and surface the error to the user.
try:
unlink_anchored(workspace, dest.resolve())
except FileNotFoundError:
pass
print(f'[webui] workspace upload extract error: {e}', flush=True)
results.append({
'filename': safe_name,
'path': str(target_dir),
'size': len(file_bytes),
'mime': mime,
'is_image': False,
'extracted': False,
'extract_error': str(e) or 'Archive extraction failed',
})
continue
except Exception:
print('[webui] workspace upload extract error: ' + _extract_tb.format_exc(), flush=True)
try:
unlink_anchored(workspace, dest.resolve())
except FileNotFoundError:
pass
results.append({
'filename': safe_name,
'path': str(target_dir),
'size': len(file_bytes),
'mime': mime,
'is_image': False,
'extracted': False,
'extract_error': 'Archive extraction failed',
})
continue
results.append({
'filename': dest.name,
'path': str(dest),
'size': dest.stat().st_size,
'mime': mime,
'is_image': mime.startswith('image/'),
'extracted': False,
})
if len(results) == 1:
return j(handler, results[0])
return j(handler, {'files': results, 'count': len(results)})
except ValueError as e:
return j(handler, {'error': str(e)}, status=400)
except Exception:
print('[webui] workspace upload error: ' + _tb.format_exc(), flush=True)
return j(handler, {'error': 'Upload failed'}, status=500)

26
api/usage.py Normal file
View File

@@ -0,0 +1,26 @@
"""Usage metric helpers for WebUI display payloads.
Prompt-cache hit percentage is cached prompt reads over the full prompt total
(input + cache reads + cache writes). Keep this calculation in the backend so
browser display code cannot drift across context indicator and per-turn labels.
"""
def _to_int(value) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def prompt_cache_hit_percent(cache_read_tokens, prompt_tokens):
"""Return cached reads as a percent of full prompt-token total.
``prompt_tokens`` must include ordinary input, cache reads, and cache writes
(matching Agent's ``session_prompt_tokens`` value).
"""
cache_read = _to_int(cache_read_tokens)
prompt = _to_int(prompt_tokens)
if cache_read <= 0 or prompt <= 0:
return None
return min(100, round((cache_read / prompt) * 100))

250
api/webui_session_db.py Normal file
View File

@@ -0,0 +1,250 @@
"""Dormant JSON-backed SessionDB-shaped adapter for WebUI sessions.
This module intentionally does not replace existing WebUI runtime call sites.
It provides a small compatibility surface over the current JSON sidecars so the
unified SessionDB contract can be tested without changing persistence behavior.
"""
from __future__ import annotations
import copy
import json
import os
import threading
from pathlib import Path
from typing import Any
import api.models as models
_METADATA_FIELDS = frozenset(
{
"title",
"workspace",
"model",
"model_provider",
"created_at",
"updated_at",
"pinned",
"archived",
"project_id",
"profile",
"input_tokens",
"output_tokens",
"estimated_cost",
"cache_read_tokens",
"cache_write_tokens",
"personality",
"active_stream_id",
"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",
"truncation_watermark",
"gateway_routing",
"gateway_routing_history",
"llm_title_generated",
"manual_title",
"parent_session_id",
"worktree_path",
"worktree_branch",
"worktree_repo_root",
"worktree_created_at",
"is_cli_session",
"source_tag",
"raw_source",
"session_source",
"source_label",
"read_only",
"enabled_toolsets",
"composer_draft",
}
)
_UNSAFE_FIELDS = frozenset({"session_id", "messages", "tool_calls", "message_count"})
class WebUIJsonSessionDB:
"""Small SessionDB-like facade over existing WebUI session JSON files."""
def __init__(self, session_dir: Path | str | None = None):
self._session_dir = Path(session_dir).expanduser().resolve() if session_dir else None
@property
def session_dir(self) -> Path:
return self._session_dir or models.SESSION_DIR
def list_sessions(self) -> list[dict[str, Any]]:
"""Return compact metadata for persisted WebUI JSON sessions.
Reads are direct JSON loads and never call ``Session.load()``, because
that path may self-heal and write repaired transcripts.
"""
rows: list[dict[str, Any]] = []
if not self.session_dir.exists():
return rows
for path in self.session_dir.glob("*.json"):
if path.name.startswith("_"):
continue
data = self._read_path(path)
if not isinstance(data, dict):
continue
sid = str(data.get("session_id") or path.stem)
if not models.is_safe_session_id(sid):
continue
rows.append(self._metadata_row(sid, data))
rows.sort(key=lambda row: (bool(row.get("pinned")), self._sort_timestamp(row)), reverse=True)
return rows
def read_session(self, sid: str) -> dict[str, Any] | None:
"""Return the full JSON session payload for ``sid`` without mutation."""
path = self._path_for_sid(sid)
if path is None or not path.exists():
return None
data = self._read_path(path)
if not isinstance(data, dict):
return None
return copy.deepcopy(data)
def update_metadata(self, sid: str, fields: dict[str, Any]) -> dict[str, Any]:
"""Persist allowlisted metadata fields while preserving messages.
This dormant adapter method is for migration experiments and tests only.
Runtime wiring must add Session lock/cache/index parity before using it
from live WebUI routes.
"""
if not isinstance(fields, dict):
raise TypeError("fields must be a dict")
unsafe = sorted((set(fields) & _UNSAFE_FIELDS) | (set(fields) - _METADATA_FIELDS))
if unsafe:
raise ValueError(f"Unsafe session metadata fields: {', '.join(unsafe)}")
path = self._existing_path_for_sid(sid)
data = self._read_writable_session(path)
data.update(copy.deepcopy(fields))
data["message_count"] = len(data["messages"])
self._atomic_write(path, data)
return self._metadata_row(str(data.get("session_id") or sid), data)
def archive(self, sid: str, archived: bool = True) -> dict[str, Any]:
"""Set the archived metadata flag without touching transcript messages."""
return self.update_metadata(sid, {"archived": bool(archived)})
def write_session(self, session: dict[str, Any]) -> dict[str, Any]:
"""Write a full session payload for tests and migration experiments."""
if not isinstance(session, dict):
raise TypeError("session must be a dict")
sid = session.get("session_id")
path = self._path_for_sid(sid)
if path is None:
raise ValueError(f"Unsafe session_id {sid!r}")
messages = session.get("messages")
if not isinstance(messages, list):
raise ValueError("session payload must include a messages list")
payload = copy.deepcopy(session)
payload["message_count"] = len(messages)
path.parent.mkdir(parents=True, exist_ok=True)
self._atomic_write(path, payload)
return copy.deepcopy(payload)
def _path_for_sid(self, sid: str) -> Path | None:
if not models.is_safe_session_id(sid):
return None
return self.session_dir / f"{sid}.json"
def _existing_path_for_sid(self, sid: str) -> Path:
path = self._path_for_sid(sid)
if path is None:
raise ValueError(f"Unsafe session_id {sid!r}")
if not path.exists():
raise KeyError(sid)
return path
def _read_writable_session(self, path: Path) -> dict[str, Any]:
data = self._read_path(path)
if not isinstance(data, dict):
raise ValueError(f"Malformed session JSON: {path.name}")
sid = data.get("session_id")
if not models.is_safe_session_id(sid):
raise ValueError(f"Unsafe session_id {sid!r}")
if not isinstance(data.get("messages"), list):
raise ValueError(f"Refusing to write metadata-only session stub: {sid!r}")
return data
@staticmethod
def _read_path(path: Path) -> dict[str, Any] | None:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return None
@staticmethod
def _metadata_row(sid: str, data: dict[str, Any]) -> dict[str, Any]:
messages = data.get("messages")
message_count = data.get("message_count")
if not isinstance(message_count, int):
message_count = len(messages) if isinstance(messages, list) else 0
row = {field: copy.deepcopy(data.get(field)) for field in _METADATA_FIELDS if field in data}
row["session_id"] = sid
row["message_count"] = message_count
row["last_message_at"] = data.get("last_message_at") or data.get("updated_at") or data.get("created_at")
return row
@staticmethod
def _sort_timestamp(row: dict[str, Any]) -> float:
for key in ("last_message_at", "updated_at", "created_at"):
value = row.get(key)
if value is None or value == "":
continue
try:
return float(value)
except (TypeError, ValueError):
continue
return 0.0
@staticmethod
def _atomic_write(path: Path, data: dict[str, Any]) -> None:
payload = json.dumps(data, ensure_ascii=False, indent=2)
tmp = path.with_suffix(f".tmp.{os.getpid()}.{threading.current_thread().ident}")
try:
with open(tmp, "w", encoding="utf-8") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, path)
finally:
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
def list_sessions() -> list[dict[str, Any]]:
return WebUIJsonSessionDB().list_sessions()
def read_session(sid: str) -> dict[str, Any] | None:
return WebUIJsonSessionDB().read_session(sid)
def update_metadata(sid: str, fields: dict[str, Any]) -> dict[str, Any]:
return WebUIJsonSessionDB().update_metadata(sid, fields)
def archive(sid: str, archived: bool = True) -> dict[str, Any]:
return WebUIJsonSessionDB().archive(sid, archived)
def write_session(session: dict[str, Any]) -> dict[str, Any]:
return WebUIJsonSessionDB().write_session(session)

View File

@@ -7,9 +7,12 @@ profile has its own workspace configuration. State files live at
``{profile_home}/webui_state/last_workspace.txt``. The global STATE_DIR
paths are used as fallback when no profile module is available.
"""
import hashlib
import json
import logging
import os
import shutil
import stat
import subprocess
import concurrent.futures
from pathlib import Path
@@ -55,6 +58,48 @@ def _last_workspace_file() -> Path:
return _profile_state_dir() / 'last_workspace.txt'
def _is_remote_terminal_backend(terminal_cfg: dict | None) -> bool:
"""Return True when the active terminal backend runs outside this WebUI host."""
if not isinstance(terminal_cfg, dict):
return False
backend = str(terminal_cfg.get('backend') or '').strip().lower()
return backend not in ('', 'local')
def _remote_terminal_cwd() -> str | None:
"""Return target-side terminal cwd for remote profiles, without local stat()."""
try:
from api.config import get_config
terminal_cfg = get_config().get('terminal', {})
if not _is_remote_terminal_backend(terminal_cfg):
return None
cwd = str(terminal_cfg.get('cwd') or '').strip()
if not cwd or cwd == '.':
return None
return cwd
except Exception:
logger.debug("Failed to read remote terminal cwd", exc_info=True)
return None
def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None:
"""Return a non-stat'ed target-side Path when it is under terminal.cwd."""
cwd = _remote_terminal_cwd()
if not cwd:
return None
raw = _strip_surrounding_quotes(str(path)).strip()
if not raw:
return None
candidate = Path(raw).expanduser().resolve()
base = Path(cwd).expanduser().resolve()
if _is_blocked_workspace_path(candidate, raw) or _is_blocked_workspace_path(base, cwd):
return None
if candidate == base or _is_within(candidate, base):
return candidate
return None
def _profile_default_workspace() -> str:
"""Read the profile's default workspace from its config.yaml.
@@ -63,36 +108,48 @@ def _profile_default_workspace() -> str:
2. 'default_workspace' — alternate explicit key
3. 'terminal.cwd' — hermes-agent terminal working dir (most common)
Falls back to the boot-time DEFAULT_WORKSPACE constant.
For remote/SSH terminal profiles, ``terminal.cwd`` lives on the target
machine, not on the WebUI server. In that case return it without a
server-local existence check so WebUI can send the correct workspace hint
to the agent/tool backend.
Falls back to the live DEFAULT_WORKSPACE from api.config.
"""
try:
from api.config import get_config
cfg = get_config()
terminal_cfg = cfg.get('terminal', {})
remote_terminal = _is_remote_terminal_backend(terminal_cfg)
# Explicit webui workspace keys first
for key in ('workspace', 'default_workspace'):
ws = cfg.get(key)
if ws:
p = Path(str(ws)).expanduser().resolve()
if p.is_dir():
if remote_terminal or p.is_dir():
return str(p)
# Fall through to terminal.cwd — the agent's configured working directory
terminal_cfg = cfg.get('terminal', {})
if isinstance(terminal_cfg, dict):
cwd = terminal_cfg.get('cwd', '')
if cwd and str(cwd) not in ('.', ''):
p = Path(str(cwd)).expanduser().resolve()
if p.is_dir():
if remote_terminal or p.is_dir():
return str(p)
except (ImportError, Exception):
logger.debug("Failed to load profile default workspace config")
return str(_BOOT_DEFAULT_WORKSPACE)
try:
from api.config import DEFAULT_WORKSPACE as _LIVE_DEFAULT_WORKSPACE
return str(Path(_LIVE_DEFAULT_WORKSPACE).expanduser().resolve())
except Exception:
return str(Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve())
# ── Public API ──────────────────────────────────────────────────────────────
def _clean_workspace_list(workspaces: list) -> list:
"""Sanitize a workspace list:
- Remove entries whose paths no longer exist on disk.
- Preserve saved paths even when they are currently missing or inaccessible;
picker state must not be destroyed by a transient stat/permission failure.
- Remove entries whose paths live inside another profile's directory
(e.g. ~/.hermes/profiles/X/... should not appear on a different profile).
- Rename any entry whose name is literally 'default' to 'Home' (avoids
@@ -104,10 +161,9 @@ def _clean_workspace_list(workspaces: list) -> list:
for w in workspaces:
path = w.get('path', '')
name = w.get('name', '')
p = Path(path).resolve() if path else Path('/')
# Skip paths that no longer exist
if not p.is_dir():
if not path:
continue
p = _safe_resolve(Path(path).expanduser())
# Skip paths inside a DIFFERENT profile's directory (cross-profile leak).
# Allow paths inside the CURRENT profile's own directory (e.g. test workspaces
# created under ~/.hermes/profiles/webui/webui-mvp-test/).
@@ -130,6 +186,32 @@ def _clean_workspace_list(workspaces: list) -> list:
return result
def _workspace_access_error(candidate: Path, *, missing_label: str = "Path does not exist") -> str | None:
"""Return a user-facing validation error for an unusable workspace path.
``Path.exists()`` can collapse permission/stat failures into a generic falsey
result on some Python/OS combinations, which produced misleading "does not
exist" messages for macOS/TCC-denied directories. Probe with ``stat()`` so
missing paths, non-directories, and permission-denied paths can be reported
separately.
"""
try:
st = candidate.stat()
except FileNotFoundError:
return f"{missing_label}: {candidate}"
except PermissionError as exc:
return (
f"Cannot access path: {candidate}. The server process could not inspect "
f"this directory ({exc}). On macOS, grant Full Disk Access or Files and "
f"Folders permission to the Hermes/WebUI app or server process, then try again."
)
except OSError as exc:
return f"Cannot access path: {candidate}. The server process could not inspect this path ({exc})."
if not stat.S_ISDIR(st.st_mode):
return f"Path is not a directory: {candidate}"
return None
def _migrate_global_workspaces() -> list:
"""Read the legacy global workspaces.json, clean it, and return the result.
@@ -192,19 +274,35 @@ def save_workspaces(workspaces: list) -> None:
def get_last_workspace() -> str:
remote_cwd = _remote_terminal_cwd()
def valid_last_workspace(raw: str) -> str | None:
if not raw:
return None
if remote_cwd:
# For remote/SSH profiles, last_workspace is target-side state. Do
# not accept stale server-local paths merely because they exist on
# the WebUI host; require the value to stay under terminal.cwd.
if _remote_terminal_workspace_candidate(raw) is not None:
return raw
return None
if Path(raw).is_dir():
return raw
return None
lw_file = _last_workspace_file()
if lw_file.exists():
try:
p = lw_file.read_text(encoding='utf-8').strip()
if p and Path(p).is_dir():
p = valid_last_workspace(lw_file.read_text(encoding='utf-8').strip())
if p:
return p
except Exception:
logger.debug("Failed to read last workspace from %s", lw_file)
# Fallback: try global file
if _GLOBAL_LW_FILE.exists():
try:
p = _GLOBAL_LW_FILE.read_text(encoding='utf-8').strip()
if p and Path(p).is_dir():
p = valid_last_workspace(_GLOBAL_LW_FILE.read_text(encoding='utf-8').strip())
if p:
return p
except Exception:
logger.debug("Failed to read global last workspace")
@@ -433,12 +531,35 @@ def list_workspace_suggestions(prefix: str = "", limit: int = 12) -> list[str]:
else:
target = Path.home() / raw
normalized = str(target)
try:
match_target = target.expanduser().resolve()
except Exception:
match_target = target
normalized = str(match_target)
normalized_lower = normalized.lower()
preserve_tilde = raw.startswith("~")
home_root: Path | None = None
if preserve_tilde:
try:
home_root = Path.home().expanduser().resolve()
except Exception:
home_root = None
suggestions: list[str] = []
def format_suggestion(path: Path) -> str:
if preserve_tilde and home_root is not None:
try:
rel = path.resolve().relative_to(home_root)
if str(rel) == ".":
return "~"
return "~/" + rel.as_posix()
except (OSError, ValueError):
pass
return str(path)
def add(path: Path) -> None:
value = str(path)
value = format_suggestion(path)
if value not in suggestions:
suggestions.append(value)
@@ -517,10 +638,18 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
candidate = Path(path).expanduser().resolve()
if not candidate.exists():
raise ValueError(f"Path does not exist: {candidate}")
if not candidate.is_dir():
raise ValueError(f"Path is not a directory: {candidate}")
access_error = _workspace_access_error(candidate)
remote_candidate = _remote_terminal_workspace_candidate(path)
if access_error:
# For remote terminal profiles, workspace paths belong to the target
# machine. Allow paths under terminal.cwd so session switching can
# update the workspace hint even though this WebUI host cannot stat
# the target-side path.
if remote_candidate is None:
raise ValueError(access_error)
if remote_candidate is not None:
return remote_candidate
# (A) Trusted if under the user's home directory — cross-platform via Path.home()
# Must be checked before system roots to allow symlinks like /var/home.
@@ -602,10 +731,17 @@ def validate_workspace_to_add(path: str) -> Path:
path = _strip_surrounding_quotes(path)
candidate = Path(path).expanduser().resolve()
if not candidate.exists():
raise ValueError(f"Path does not exist: {candidate}")
if not candidate.is_dir():
raise ValueError(f"Path is not a directory: {candidate}")
access_error = _workspace_access_error(candidate)
remote_candidate = _remote_terminal_workspace_candidate(path)
if access_error:
# Remote terminal profiles validate workspace existence on the target
# machine, not on the WebUI server. Permit target-side paths under
# terminal.cwd.
if remote_candidate is None:
raise ValueError(access_error)
if remote_candidate is not None:
return remote_candidate
# Home directory is always trusted regardless of where it lives on disk
# (e.g. /var/home/... on systemd-homed Fedora/RHEL).
@@ -622,74 +758,348 @@ def validate_workspace_to_add(path: str) -> Path:
def safe_resolve_ws(root: Path, requested: str) -> Path:
"""Resolve a relative path inside a workspace root, raising ValueError on traversal.
Symlinks whose *unresolved* path is within the workspace root are allowed —
the user placed them there intentionally. Only raw ``..`` traversal outside
the root is blocked.
Both raw ``..`` traversal and symlink escapes are blocked. Workspace file
APIs can be reached by browser UI actions and agent/tool calls, so a symlink
inside the workspace must not expand the trusted workspace boundary to an
arbitrary host path.
"""
import os
unresolved = root / requested
resolved = unresolved.resolve()
# Fast path: resolved path is inside root (covers most cases)
root_resolved = root.resolve()
resolved = (root / requested).resolve()
try:
resolved.relative_to(root.resolve())
return resolved
except ValueError:
pass
# Symlink path: normalize '..' (without following symlinks) and check
# os.path.normpath collapses '..' but does NOT follow symlinks.
norm = Path(os.path.normpath(str(unresolved)))
try:
norm.relative_to(root)
resolved.relative_to(root_resolved)
except ValueError:
raise ValueError(f"Path traversal blocked: {requested}")
# Symlink points outside workspace root — additionally block system directories.
# Even if the user placed the symlink intentionally, prevent reads from
# /etc, /proc, /sys, /dev and other blocked roots (LLM agents can call
# read_file_content via tool calls, not just human users).
if _is_blocked_system_path(resolved):
raise ValueError(f"Path traversal blocked (system dir): {requested}")
return resolved
# ── Race-safe (TOCTOU) anchored open ─────────────────────────────────────────
# safe_resolve_ws() validates a path, but if callers then re-open by pathname a
# symlink swapped in AFTER the check could still escape the workspace. To close
# that window we open the (already symlink-resolved) target component-by-component
# from the workspace root using openat (dir_fd) + O_NOFOLLOW: every component must
# be a real, non-symlink entry, so a component swapped to a symlink mid-flight is
# refused. Legit in-workspace symlinks still work because safe_resolve_ws() has
# already collapsed them to their real in-workspace target, and we walk that real
# (symlink-free) path. Portable: uses os.supports_dir_fd where available (Linux,
# macOS); on platforms without dir_fd support (Windows — where creating symlinks
# also requires admin) we fall back to a plain pathname open, matching the prior
# behaviour with no regression.
_DIR_FD_OK = os.open in getattr(os, "supports_dir_fd", set())
_O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
_O_DIRECTORY = getattr(os, "O_DIRECTORY", 0)
def open_anchored_fd(workspace: Path, target: Path, *, want_dir: bool) -> int:
"""Open ``target`` race-safely and return an owned file descriptor.
``target`` must be the symlink-resolved path returned by safe_resolve_ws()
(i.e. already verified to live under the workspace). Raises FileNotFoundError
if a component is missing / wrong-type, or ValueError if a component was
swapped to a symlink (escape attempt). Caller owns and must close the fd.
"""
root_resolved = workspace.resolve()
# Relative, symlink-free component list (resolve() already collapsed any links).
try:
rel_parts = target.relative_to(root_resolved).parts
except ValueError:
raise ValueError(f"Path traversal blocked: {target}") from None
if not _DIR_FD_OK:
# Windows / no openat: fall back to a plain pathname open. No new race
# protection, but no regression vs the prior path-based behaviour, and
# symlink creation needs admin on Windows anyway.
flags = os.O_RDONLY | (_O_DIRECTORY if want_dir else 0) | _O_NOFOLLOW
try:
return os.open(str(target), flags)
except OSError:
raise FileNotFoundError(f"Not found: {target}") from None
# Open the (trusted) workspace root. root_resolved is canonical (resolve()
# collapsed any symlinks to REACH it, e.g. macOS /tmp -> /private/tmp), so its
# final component is legitimately a real directory — O_NOFOLLOW here only fires
# if the root itself was raced into a symlink after resolve() (escape attempt).
fd = os.open(str(root_resolved), os.O_RDONLY | _O_DIRECTORY | _O_NOFOLLOW)
try:
for i, part in enumerate(rel_parts):
is_last = i == len(rel_parts) - 1
want_directory = (not is_last) or want_dir
flags = os.O_RDONLY | _O_NOFOLLOW | (_O_DIRECTORY if want_directory else 0)
try:
nfd = os.open(part, flags, dir_fd=fd)
except OSError:
# ELOOP (component is a symlink — swapped in) or missing/wrong type.
raise FileNotFoundError(f"Not found: {target}") from None
os.close(fd)
fd = nfd
return fd
except BaseException:
try:
os.close(fd)
except OSError:
pass
raise
def open_anchored_create_fd(root: Path, dest: Path) -> int:
"""Create ``dest`` for exclusive writing race-safely, anchored under ``root``.
Walks from ``root`` via openat + O_NOFOLLOW (creating missing intermediate
directories with mkdir(dir_fd=...)), then creates the leaf with
O_CREAT|O_EXCL|O_NOFOLLOW so a symlink raced into any component cannot
redirect the write outside ``root``. ``dest`` must be the resolved path and
must not already exist (callers dedup first). Raises ValueError if ``dest``
is not under ``root``, FileExistsError if it exists, FileNotFoundError if a
component was swapped to a symlink. Caller owns and must close the returned
write fd. On platforms without dir_fd support (Windows) falls back to a plain
exclusive create — no new race protection but no regression.
"""
root_resolved = root.resolve()
try:
rel_parts = dest.relative_to(root_resolved).parts
except ValueError:
raise ValueError(f"Path traversal blocked: {dest}") from None
if not rel_parts:
raise ValueError(f"Invalid destination: {dest}")
if not _DIR_FD_OK:
# Windows / no openat: create parent dirs then exclusively create the leaf.
dest.parent.mkdir(parents=True, exist_ok=True)
return os.open(str(dest), os.O_WRONLY | os.O_CREAT | os.O_EXCL | _O_NOFOLLOW, 0o644)
fd = os.open(str(root_resolved), os.O_RDONLY | _O_DIRECTORY | _O_NOFOLLOW)
try:
for part in rel_parts[:-1]:
try:
nfd = os.open(part, os.O_RDONLY | _O_DIRECTORY | _O_NOFOLLOW, dir_fd=fd)
except FileNotFoundError:
os.mkdir(part, 0o755, dir_fd=fd)
nfd = os.open(part, os.O_RDONLY | _O_DIRECTORY | _O_NOFOLLOW, dir_fd=fd)
except OSError:
# ELOOP — component swapped to a symlink (escape attempt).
raise FileNotFoundError(f"Not found: {dest}") from None
os.close(fd)
fd = nfd
return os.open(
rel_parts[-1],
os.O_WRONLY | os.O_CREAT | os.O_EXCL | _O_NOFOLLOW,
0o644,
dir_fd=fd,
)
finally:
try:
os.close(fd)
except OSError:
pass
def make_anchored_dir(root: Path, dest: Path) -> None:
"""Create directory ``dest`` (and any missing parents) race-safely under ``root``.
Walks from ``root`` via openat + O_NOFOLLOW, creating each missing component
with mkdir(dir_fd=...), so a symlink raced into any component cannot make the
server create directories outside ``root``. Idempotent (existing dirs are
fine). Raises ValueError if ``dest`` is not under ``root``, FileNotFoundError
if a component was swapped to a symlink. On platforms without dir_fd support
(Windows) falls back to a plain Path.mkdir — no regression.
"""
root_resolved = root.resolve()
dest_resolved = dest.resolve()
if dest_resolved == root_resolved:
return
try:
rel_parts = dest_resolved.relative_to(root_resolved).parts
except ValueError:
raise ValueError(f"Path traversal blocked: {dest}") from None
if not _DIR_FD_OK:
dest.mkdir(parents=True, exist_ok=True)
return
fd = os.open(str(root_resolved), os.O_RDONLY | _O_DIRECTORY | _O_NOFOLLOW)
try:
for part in rel_parts:
try:
nfd = os.open(part, os.O_RDONLY | _O_DIRECTORY | _O_NOFOLLOW, dir_fd=fd)
except FileNotFoundError:
os.mkdir(part, 0o755, dir_fd=fd)
nfd = os.open(part, os.O_RDONLY | _O_DIRECTORY | _O_NOFOLLOW, dir_fd=fd)
except OSError:
# ELOOP — component swapped to a symlink (escape attempt).
raise FileNotFoundError(f"Not found: {dest}") from None
os.close(fd)
fd = nfd
finally:
try:
os.close(fd)
except OSError:
pass
def open_anchored_write_fd(root: Path, target: Path) -> int:
"""Open existing ``target`` for truncating writes anchored under ``root``."""
root_resolved = root.resolve()
target_resolved = target.resolve()
try:
rel_parts = target_resolved.relative_to(root_resolved).parts
except ValueError:
raise ValueError(f"Path traversal blocked: {target}") from None
if not rel_parts:
raise ValueError(f"Invalid target: {target}")
flags = os.O_WRONLY | os.O_TRUNC | _O_NOFOLLOW
if not _DIR_FD_OK:
return os.open(str(target_resolved), flags)
parent_fd = open_anchored_fd(root_resolved, target_resolved.parent, want_dir=True)
try:
return os.open(rel_parts[-1], flags, dir_fd=parent_fd)
finally:
os.close(parent_fd)
def unlink_anchored(root: Path, target: Path) -> None:
"""Unlink an existing file anchored under ``root``."""
root_resolved = root.resolve()
target_resolved = target.resolve()
try:
rel_parts = target_resolved.relative_to(root_resolved).parts
except ValueError:
raise ValueError(f"Path traversal blocked: {target}") from None
if not rel_parts:
raise ValueError(f"Invalid target: {target}")
if not _DIR_FD_OK:
target_resolved.unlink()
return
parent_fd = open_anchored_fd(root_resolved, target_resolved.parent, want_dir=True)
try:
os.unlink(rel_parts[-1], dir_fd=parent_fd)
finally:
os.close(parent_fd)
def rmtree_anchored(root: Path, target: Path) -> None:
"""Remove a directory tree anchored under ``root`` without following symlink swaps."""
root_resolved = root.resolve()
target_resolved = target.resolve()
try:
rel_parts = target_resolved.relative_to(root_resolved).parts
except ValueError:
raise ValueError(f"Path traversal blocked: {target}") from None
if not rel_parts:
raise ValueError(f"Invalid target: {target}")
if not _DIR_FD_OK:
shutil.rmtree(target_resolved)
return
parent_fd = open_anchored_fd(root_resolved, target_resolved.parent, want_dir=True)
try:
shutil.rmtree(rel_parts[-1], dir_fd=parent_fd)
finally:
os.close(parent_fd)
def rename_anchored(root: Path, source: Path, dest: Path) -> None:
"""Rename ``source`` to ``dest`` using anchored parent directory fds."""
root_resolved = root.resolve()
source_resolved = source.resolve()
dest_parent_resolved = dest.parent.resolve()
try:
source_parts = source_resolved.relative_to(root_resolved).parts
except ValueError:
raise ValueError(f"Path traversal blocked: {source}") from None
try:
dest_parent_resolved.relative_to(root_resolved)
except ValueError:
raise ValueError(f"Path traversal blocked: {dest}") from None
if not source_parts:
raise ValueError(f"Invalid source: {source}")
dest_leaf = dest.name
if not dest_leaf:
raise ValueError(f"Invalid destination: {dest}")
if not _DIR_FD_OK:
source_resolved.rename(dest)
return
src_parent_fd = open_anchored_fd(root_resolved, source_resolved.parent, want_dir=True)
try:
dst_parent_fd = open_anchored_fd(root_resolved, dest_parent_resolved, want_dir=True)
try:
try:
os.stat(dest_leaf, dir_fd=dst_parent_fd, follow_symlinks=False)
raise FileExistsError(dest_leaf)
except FileNotFoundError:
pass
os.rename(
source_parts[-1],
dest_leaf,
src_dir_fd=src_parent_fd,
dst_dir_fd=dst_parent_fd,
)
finally:
os.close(dst_parent_fd)
finally:
os.close(src_parent_fd)
def list_dir(workspace: Path, rel: str='.'):
target = safe_resolve_ws(workspace, rel)
if not target.is_dir():
raise FileNotFoundError(f"Not a directory: {rel}")
ws_resolved = workspace.resolve()
target_resolved = target.resolve()
entries = []
for item in sorted(target.iterdir(), key=lambda p: (not p.is_symlink(), p.is_file(), p.name.lower())):
if item.is_symlink():
# Resolve the symlink target and check if it stays within workspace
def _process(name, is_symlink, raw_link, lstat_result, reachable):
"""Append one directory entry. ``raw_link`` is the os.readlink() result
for symlinks (else None); ``lstat_result`` is an os.stat_result obtained
with follow_symlinks=False (else None); ``reachable`` is False when a
follow_symlinks=True stat raised (broken target or symlink loop)."""
if is_symlink:
if raw_link is None:
return
# A symlink whose follow-stat raised (ELOOP / broken target) can never
# be opened — filter it. This catches mutual/self loops portably across
# Python versions where Path.resolve() loop handling differs (3.11
# raises RuntimeError, 3.13 can return a path), so do not rely on
# resolve() raising for cycle detection.
if not reachable:
return
try:
link_target = item.resolve()
except OSError:
continue
# Cycle detection: skip if symlink points back to current dir,
# workspace root, or any ancestor of current dir.
# This must run REGARDLESS of whether target is inside workspace.
if (link_target == target.resolve() or link_target == target
or link_target == ws_resolved):
continue
link_target = (target_resolved / raw_link).resolve()
except (OSError, RuntimeError):
return
# Cycle detection: skip if symlink points back to current dir or root.
if link_target == target_resolved or link_target == ws_resolved:
return
try:
target.resolve().relative_to(link_target)
# target is under link_target — link_target is an ancestor → cycle
continue
target_resolved.relative_to(link_target)
return # target is under link_target — ancestor → cycle
except ValueError:
pass
# Block symlinks that resolve to system directories.
# Hide symlinks that resolve outside the workspace (can never be opened).
try:
link_target.relative_to(ws_resolved)
except ValueError:
return
if _is_blocked_system_path(link_target):
continue
return
is_dir = link_target.is_dir()
# Keep the display path relative to workspace (don't follow the link)
display_path = str(Path(item.name))
display_path = name
if rel and rel != '.':
display_path = rel + '/' + display_path
mtime_ns = lstat_result.st_mtime_ns if lstat_result is not None else None
entry = {
'name': item.name,
'name': name,
'path': display_path,
'type': 'symlink',
'target': str(link_target),
'is_dir': is_dir,
'mtime_ns': mtime_ns,
}
if not is_dir:
try:
@@ -698,31 +1108,163 @@ def list_dir(workspace: Path, rel: str='.'):
entry['size'] = None
entries.append(entry)
else:
# Use rel-based path so entries under symlink targets (outside
# the workspace root) still get a valid workspace-relative path.
entry_path = item.name
entry_path = name
if rel and rel != '.':
entry_path = rel + '/' + item.name
entry_path = rel + '/' + name
if lstat_result is not None:
is_file = stat.S_ISREG(lstat_result.st_mode)
size = lstat_result.st_size if is_file else None
mtime_ns = lstat_result.st_mtime_ns
is_dir_entry = stat.S_ISDIR(lstat_result.st_mode)
else:
size = None
mtime_ns = None
is_dir_entry = False
entries.append({
'name': item.name,
'name': name,
'path': entry_path,
'type': 'dir' if item.is_dir() else 'file',
'size': item.stat().st_size if item.is_file() else None,
'type': 'dir' if is_dir_entry else 'file',
'size': size,
'mtime_ns': mtime_ns,
})
if len(entries) >= 200:
break
if _DIR_FD_OK:
# #3398 TOCTOU hardening (Linux/macOS): open the directory via an anchored
# openat-walk (O_NOFOLLOW on every component) and enumerate via the verified
# fd (os.scandir(fd) + fd-relative fstatat/readlinkat), so a path component
# swapped to an escaping symlink after safe_resolve_ws() cannot redirect the
# listing.
def _sort_key_de(de):
try:
is_link = de.is_symlink()
except OSError:
is_link = False
is_file = False
if not is_link:
try:
is_file = de.is_file()
except OSError:
pass
return (not is_link, is_file, de.name.lower())
dir_fd = open_anchored_fd(workspace, target, want_dir=True)
try:
st = os.fstat(dir_fd)
if not stat.S_ISDIR(st.st_mode):
raise FileNotFoundError(f"Not a directory: {rel}")
with os.scandir(dir_fd) as scan:
scandir_entries = sorted(scan, key=_sort_key_de)
for de in scandir_entries:
name = de.name
is_symlink = de.is_symlink()
raw_link = None
if is_symlink:
try:
raw_link = os.readlink(name, dir_fd=dir_fd)
except OSError:
raw_link = None
try:
lst = os.stat(name, dir_fd=dir_fd, follow_symlinks=False)
except OSError:
lst = None
# reachable: follow-stat succeeds (filters ELOOP/broken symlinks).
reachable = True
if is_symlink:
try:
os.stat(name, dir_fd=dir_fd, follow_symlinks=True)
except OSError:
reachable = False
_process(name, is_symlink, raw_link, lst, reachable)
if len(entries) >= 200:
break
finally:
try:
os.close(dir_fd)
except OSError:
pass
else:
# Portability fallback (Windows / no dir_fd): path-based enumeration after
# safe_resolve_ws(). No anchored-fd race protection on these platforms, but
# no regression vs the prior behaviour (creating symlinks on Windows needs
# admin anyway), and safe_resolve_ws() still blocks the static escape.
def _sort_key_p(p: Path):
is_link = p.is_symlink()
is_file = False
if not is_link:
try:
is_file = p.is_file()
except OSError:
pass
return (not is_link, is_file, p.name.lower())
for item in sorted(target.iterdir(), key=_sort_key_p):
name = item.name
is_symlink = item.is_symlink()
raw_link = None
if is_symlink:
try:
raw_link = os.readlink(str(item))
except OSError:
raw_link = None
try:
lst = item.lstat()
except OSError:
lst = None
# reachable: follow-stat succeeds (filters ELOOP/broken symlinks).
reachable = True
if is_symlink:
try:
os.stat(str(item), follow_symlinks=True)
except OSError:
reachable = False
_process(name, is_symlink, raw_link, lst, reachable)
if len(entries) >= 200:
break
return entries
def dir_signature(workspace: Path, rel: str = '.', entries: list[dict] | None = None) -> str:
"""Return a cheap, stable signature for a listed workspace directory.
The signature is based only on bounded directory-entry metadata already used
by the workspace tree: names, displayed paths, entry type, file sizes,
mtimes, and symlink targets. It intentionally does not read file contents.
"""
if entries is None:
entries = list_dir(workspace, rel)
payload = []
for entry in entries:
payload.append({
'name': entry.get('name'),
'path': entry.get('path'),
'type': entry.get('type'),
'is_dir': entry.get('is_dir'),
'size': entry.get('size'),
'mtime_ns': entry.get('mtime_ns'),
'target': entry.get('target'),
})
raw = json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
def read_file_content(workspace: Path, rel: str) -> dict:
target = safe_resolve_ws(workspace, rel)
if not target.is_file():
raise FileNotFoundError(f"Not a file: {rel}")
size = target.stat().st_size
if size > MAX_FILE_BYTES:
raise ValueError(f"File too large ({size} bytes, max {MAX_FILE_BYTES})")
content = target.read_text(encoding='utf-8', errors='replace')
return {'path': rel, 'content': content, 'size': size, 'lines': content.count('\n') + 1}
# #3398 TOCTOU hardening: open the resolved file via an anchored openat-walk
# (O_NOFOLLOW on every component) so a path swapped to an escaping symlink
# after safe_resolve_ws() cannot be followed, then read from the fd (not the
# pathname) so the bytes returned are guaranteed to be the verified file.
fd = open_anchored_fd(workspace, target, want_dir=False)
with os.fdopen(fd, 'rb', closefd=True) as fh:
st = os.fstat(fh.fileno())
if not stat.S_ISREG(st.st_mode):
raise FileNotFoundError(f"Not a file: {rel}")
if st.st_size > MAX_FILE_BYTES:
raise ValueError(f"File too large ({st.st_size} bytes, max {MAX_FILE_BYTES})")
raw = fh.read(MAX_FILE_BYTES + 1)
content = raw.decode('utf-8', errors='replace')
return {'path': rel, 'content': content, 'size': len(raw), 'lines': content.count('\n') + 1}
# ── Git detection ──────────────────────────────────────────────────────────

1296
api/workspace_git.py Normal file

File diff suppressed because it is too large Load Diff

357
api/worktrees.py Normal file
View File

@@ -0,0 +1,357 @@
"""Helpers for WebUI-managed Hermes Agent git worktrees."""
from __future__ import annotations
import subprocess
import time
from contextlib import redirect_stderr, redirect_stdout
from io import StringIO
from pathlib import Path
import logging
logger = logging.getLogger(__name__)
def _run_git(args: list[str], cwd: str | Path, timeout: float = 2) -> subprocess.CompletedProcess:
return subprocess.run(
["git", *args],
cwd=str(cwd),
text=True,
capture_output=True,
timeout=timeout,
check=False,
)
def _resolve_path(path: str | Path | None) -> Path | None:
if not path:
return None
try:
return Path(path).expanduser().resolve(strict=False)
except (OSError, RuntimeError):
return Path(path).expanduser()
def _worktree_list_cwd(worktree_path: Path, repo_root: str | Path | None) -> Path | None:
repo = _resolve_path(repo_root)
if repo and repo.is_dir():
return repo
if worktree_path.is_dir():
return worktree_path
return None
def _parse_worktree_list_porcelain(output: str) -> set[str]:
paths: set[str] = set()
for line in str(output or "").splitlines():
if not line.startswith("worktree "):
continue
path = line[len("worktree "):].strip()
if not path:
continue
resolved = _resolve_path(path)
paths.add(str(resolved or Path(path).expanduser()))
return paths
def _worktree_listed(worktree_path: Path, repo_root: str | Path | None) -> bool:
"""Return whether git currently lists the worktree.
False is a safe fallback for probe failures, not definitive orphan proof.
Future cleanup UI must combine this with the rest of the status payload.
"""
cwd = _worktree_list_cwd(worktree_path, repo_root)
if cwd is None:
return False
try:
result = _run_git(["worktree", "list", "--porcelain"], cwd)
except (OSError, subprocess.TimeoutExpired):
return False
if result.returncode != 0:
return False
return str(worktree_path) in _parse_worktree_list_porcelain(result.stdout)
def _status_porcelain(worktree_path: Path) -> tuple[bool, int]:
try:
result = _run_git(
["status", "--porcelain", "--untracked-files=normal"],
worktree_path,
)
except (OSError, subprocess.TimeoutExpired):
return False, 0
if result.returncode != 0:
return False, 0
lines = [line for line in result.stdout.splitlines() if line]
return bool(lines), sum(1 for line in lines if line.startswith("??"))
def _ahead_behind(worktree_path: Path) -> dict:
payload = {
"ahead": 0,
"behind": 0,
"available": False,
"upstream": None,
}
try:
upstream = _run_git(
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
worktree_path,
)
except (OSError, subprocess.TimeoutExpired):
return payload
if upstream.returncode != 0:
return payload
upstream_ref = upstream.stdout.strip()
if not upstream_ref:
return payload
payload["upstream"] = upstream_ref
try:
counts = _run_git(
["rev-list", "--left-right", "--count", "HEAD...@{u}"],
worktree_path,
)
except (OSError, subprocess.TimeoutExpired):
return payload
if counts.returncode != 0:
return payload
parts = counts.stdout.strip().split()
if len(parts) != 2:
return payload
try:
payload["ahead"] = max(0, int(parts[0]))
payload["behind"] = max(0, int(parts[1]))
payload["available"] = True
except ValueError:
pass
return payload
def _locked_by_stream(session) -> bool:
stream_id = getattr(session, "active_stream_id", None)
if not stream_id:
return False
try:
from api.config import STREAMS, STREAMS_LOCK
with STREAMS_LOCK:
return stream_id in STREAMS
except Exception:
return False
def _locked_by_terminal(session_id: str, worktree_path: Path) -> bool:
try:
from api.terminal import get_terminal
term = get_terminal(session_id)
except Exception:
return False
if not term:
return False
try:
if not term.is_alive():
return False
terminal_workspace = _resolve_path(getattr(term, "workspace", None))
return terminal_workspace == worktree_path
except Exception:
return False
def worktree_status_for_session(session) -> dict:
"""Return a read-only worktree status snapshot for a WebUI session."""
raw_path = getattr(session, "worktree_path", None)
if not raw_path:
raise ValueError("Session is not worktree-backed")
worktree_path = _resolve_path(raw_path)
if worktree_path is None:
raise ValueError("Session is not worktree-backed")
exists = worktree_path.is_dir()
status = {
"path": str(worktree_path),
"exists": bool(exists),
"dirty": False,
"untracked_count": 0,
"ahead_behind": {
"ahead": 0,
"behind": 0,
"available": False,
"upstream": None,
},
"locked_by_stream": _locked_by_stream(session),
"locked_by_terminal": _locked_by_terminal(
getattr(session, "session_id", ""),
worktree_path,
),
"listed": _worktree_listed(
worktree_path,
getattr(session, "worktree_repo_root", None),
),
}
if not exists:
return status
dirty, untracked_count = _status_porcelain(worktree_path)
status["dirty"] = dirty
status["untracked_count"] = untracked_count
status["ahead_behind"] = _ahead_behind(worktree_path)
return status
def remove_worktree_for_session(session, *, force: bool = False) -> dict:
"""Remove a session's git worktree from disk.
Returns status dict with keys: ok, removed_path, warnings.
Raises ValueError for terminal blockers (locked by stream/terminal,
dirty with force=False).
"""
raw_path = getattr(session, "worktree_path", None)
if not raw_path:
raise ValueError("Session is not worktree-backed")
worktree_path = _resolve_path(raw_path)
if worktree_path is None:
raise ValueError("Session is not worktree-backed")
# Read current status before removal
status = worktree_status_for_session(session)
if not status["exists"]:
return {
"ok": True,
"removed_path": str(worktree_path),
"warnings": ["Worktree directory no longer exists on disk."],
}
warnings = []
# Guard: locked by stream
if status["locked_by_stream"]:
raise ValueError("Worktree is locked by an active streaming session")
# Guard: locked by terminal
if status["locked_by_terminal"]:
raise ValueError("Worktree is locked by an active terminal session")
# Guard: local changes and unpushed commits without explicit force.
if status["dirty"] and not force:
raise ValueError(
"Worktree has uncommitted changes. Use force=true to override."
)
if status["untracked_count"] > 0:
if force:
warnings.append(
f"{status['untracked_count']} untracked file(s) will be removed."
)
else:
raise ValueError(
f"Worktree has {status['untracked_count']} untracked file(s). "
"Use force=true to override."
)
ahead = int((status.get("ahead_behind") or {}).get("ahead") or 0)
if ahead > 0:
if force:
warnings.append(f"{ahead} unpushed commit(s) will be removed.")
else:
raise ValueError(
f"Worktree has {ahead} unpushed commit(s). "
"Use force=true to override."
)
# Remove the worktree — must run from the repo root, not the worktree dir
repo_root = getattr(session, "worktree_repo_root", None)
if not repo_root:
raise ValueError("Session missing worktree_repo_root")
try:
remove_args = ["worktree", "remove"]
if force:
remove_args.append("--force")
remove_args.append(str(worktree_path))
result = _run_git(remove_args, str(repo_root), timeout=10)
except (OSError, subprocess.TimeoutExpired) as exc:
raise ValueError(f"Failed to remove worktree: {exc}") from exc
if result.returncode != 0:
stderr = (result.stderr or "").strip().split("\n")[-1]
raise ValueError(
f"git worktree remove failed: {stderr or result.stdout.strip()}"
)
# Prune in case the worktree dir was already gone
try:
_run_git(
["worktree", "prune"],
str(repo_root),
timeout=5,
)
except Exception:
pass
return {
"ok": True,
"removed_path": str(worktree_path),
"warnings": warnings or None,
}
def find_git_repo_root(workspace: str | Path) -> Path:
"""Return the enclosing git repo root for *workspace*.
Use git itself instead of checking ``workspace/.git`` so nested workspaces
and linked git worktrees are both handled correctly.
"""
ws = Path(workspace).expanduser().resolve()
if not ws.is_dir():
raise ValueError("Workspace path does not exist or is not a directory")
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
cwd=ws,
text=True,
capture_output=True,
timeout=5,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise ValueError("Workspace is not inside a git repository") from exc
if result.returncode != 0:
raise ValueError("Workspace is not inside a git repository")
root = result.stdout.strip()
if not root:
raise ValueError("Workspace is not inside a git repository")
return Path(root).expanduser().resolve()
def _setup_agent_worktree(repo_root: str) -> dict:
try:
import api.config # noqa: F401 # ensure Hermes Agent dir is on sys.path
from cli import _setup_worktree
except Exception as exc:
raise RuntimeError("Hermes Agent worktree helper is unavailable") from exc
output = StringIO()
with redirect_stdout(output), redirect_stderr(output):
info = _setup_worktree(repo_root)
emitted = output.getvalue().strip()
if emitted:
logger.debug("Hermes Agent worktree helper output: %s", emitted)
if not info:
raise RuntimeError("Hermes Agent failed to create a git worktree")
return info
def create_worktree_for_workspace(workspace: str | Path) -> dict:
repo_root = find_git_repo_root(workspace)
info = _setup_agent_worktree(str(repo_root))
path = info.get("path")
branch = info.get("branch")
if not path or not branch:
raise RuntimeError("Hermes Agent returned incomplete worktree metadata")
return {
"path": str(Path(path).expanduser().resolve()),
"branch": str(branch),
"repo_root": str(Path(info.get("repo_root") or repo_root).expanduser().resolve()),
"created_at": time.time(),
}

98
bootstrap.py Normal file → Executable file
View File

@@ -28,8 +28,8 @@ def _load_repo_dotenv() -> None:
``python3 bootstrap.py`` directly behaves identically to ``./start.sh``.
Variables are set unconditionally (matching shell source semantics), so a
value in .env overrides one already present in the shell environment.
To keep a CLI-supplied value, unset it from .env or launch via start.sh
and override there.
``ctl.sh`` sets HERMES_WEBUI_PRESERVE_ENV=1 when it has already resolved
launcher-specific values such as HERMES_HOME or HERMES_WEBUI_STATE_DIR.
Only loads the webui repo .env — not ~/.hermes/.env, which the server
loads independently at startup for provider credentials.
@@ -41,6 +41,12 @@ def _load_repo_dotenv() -> None:
if not env_path.exists():
return
try:
preserve_existing = os.getenv("HERMES_WEBUI_PRESERVE_ENV", "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
@@ -52,6 +58,8 @@ def _load_repo_dotenv() -> None:
k = k[7:].strip()
v = v.strip().strip('"').strip("'")
if k:
if preserve_existing and k in os.environ:
continue
os.environ[k] = v
except Exception as exc:
import sys as _sys
@@ -84,12 +92,53 @@ def is_wsl() -> bool:
def ensure_supported_platform() -> None:
if platform.system() == "Windows" and not is_wsl():
raise RuntimeError(
"Native Windows is not supported for this bootstrap yet. "
"Please run it from Linux, macOS, or inside WSL2."
info(
"Warning: Native Windows bootstrap is experimental. "
"Embedded terminal and auto-install are not supported."
)
def _agent_dir_from_hermes_cli() -> Path | None:
"""Resolve the agent install root by inspecting the `hermes` CLI shebang.
The Hermes Agent installer drops a `hermes` console-script in the user's
PATH whose shebang points at the agent's bundled venv:
#!/path/to/hermes-agent/venv/bin/python3
Walking up the parents until we find a directory that contains
`run_agent.py` recovers the install root regardless of where the user
chose to clone the agent (e.g. ~/Projects/GitHub/hermes-agent), which
the hard-coded candidate list in :func:`discover_agent_dir` cannot.
Last-resort only: this is invoked after every explicit candidate
(`HERMES_WEBUI_AGENT_DIR`, `$HERMES_HOME/hermes-agent`, etc.) has missed.
A stale clone in a known location still wins over the live `hermes` CLI
— that's intentional, since the candidate list is treated as
authoritative when present, and matches existing behavior.
"""
hermes_path = shutil.which("hermes")
if not hermes_path:
return None
try:
with open(hermes_path, "r", encoding="utf-8", errors="replace") as f:
first_line = f.readline().strip()
except OSError:
return None
if not first_line.startswith("#!"):
return None
interp_field = first_line[2:].strip().split(None, 1)
if not interp_field:
return None
interp = Path(interp_field[0])
if not interp.is_absolute():
return None
for parent in interp.parents:
if (parent / "run_agent.py").exists():
return parent.resolve()
return None
def discover_agent_dir() -> Path | None:
home = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser()
candidates = [
@@ -105,7 +154,7 @@ def discover_agent_dir() -> Path | None:
candidate = Path(raw).expanduser().resolve()
if candidate.exists() and (candidate / "run_agent.py").exists():
return candidate
return None
return _agent_dir_from_hermes_cli()
def discover_launcher_python(agent_dir: Path | None) -> str:
@@ -179,7 +228,16 @@ def ensure_python_has_webui_deps(python_exe: str, agent_dir: Path | None = None)
)
if not venv_python.exists():
info(f"Creating local virtualenv at {venv_dir}")
venv.EnvBuilder(with_pip=True).create(venv_dir)
# symlinks=True: some Python builds (notably mise/asdf shared-library
# installs on macOS) default venv to copy mode. The copied binary still
# uses @executable_path/../lib/libpython3.X.dylib for its load command,
# so the venv binary aborts with SIGABRT on first import because the
# dylib never gets copied into .venv/lib. Symlinking the interpreter
# keeps @executable_path resolving back to the original install.
# CPython's venv falls back to copy mode automatically when symlink
# creation fails (e.g. older Windows without SeCreateSymbolicLinkPrivilege),
# so this is safe to set unconditionally.
venv.EnvBuilder(with_pip=True, symlinks=True).create(venv_dir)
info("Installing WebUI dependencies into local virtualenv")
subprocess.run(
@@ -212,6 +270,11 @@ def hermes_command_exists() -> bool:
def install_hermes_agent() -> None:
if platform.system() == "Windows" and not is_wsl():
raise RuntimeError(
"Auto-install is not supported on native Windows. "
"Install hermes-agent manually first."
)
info(f"Hermes Agent not found. Attempting install via {INSTALLER_URL}")
subprocess.run(
["/bin/bash", "-lc", f"curl -fsSL {INSTALLER_URL} | bash"], check=True
@@ -258,8 +321,10 @@ def parse_args() -> argparse.Namespace:
"--foreground",
action="store_true",
help=(
"Run server.py in this process (via os.execv) instead of spawning a "
"child. Use this under launchd / systemd / supervisord so the "
"Run server.py in this process (via os.execv on POSIX; via a "
"Popen child + exit on Windows, where execv can't replace the "
"process image) instead of spawning a detached child. Use this "
"under launchd / systemd / supervisord so the "
"supervisor sees the long-lived server as the original child. "
"Implies --no-browser. Skips the post-launch health probe — the "
"supervisor's own KeepAlive / Restart=on-failure handles liveness."
@@ -386,8 +451,19 @@ def main() -> int:
f"Set HERMES_WEBUI_PYTHON to a working interpreter or fix "
f"the agent venv at {agent_dir}."
)
# os.execv replaces the current process image. Anything after this line
# only runs if execv itself fails (it raises OSError on failure).
# os.execv replaces the current process image. On Windows, execv
# spawns a new process instead of replacing (Python calls CreateProcess),
# orphaning it from any supervisor. Use Popen + exit there instead.
if sys.platform == "win32":
# CREATE_NEW_PROCESS_GROUP only exists in the subprocess module on
# Windows; resolve it defensively (0 = no extra flags) so this line
# can't AttributeError if reached on a non-Windows interpreter
# (e.g. a win32-simulating test) — mirrors the getattr() guard used
# for SO_EXCLUSIVEADDRUSE.
_CREATE_NEW_PROCESS_GROUP = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
subprocess.Popen([python_exe, server_path],
creationflags=_CREATE_NEW_PROCESS_GROUP)
sys.exit(0)
os.execv(python_exe, [python_exe, server_path])
# Unreachable — execv either replaces the process or raises.
raise RuntimeError("os.execv returned unexpectedly")

162
ctl.sh
View File

@@ -7,6 +7,7 @@ PID_FILE="${HERMES_WEBUI_PID_FILE:-${HERMES_HOME}/webui.pid}"
LOG_FILE="${HERMES_WEBUI_LOG_FILE:-${HERMES_HOME}/webui.log}"
STATE_FILE="${HERMES_WEBUI_CTL_STATE_FILE:-${HERMES_HOME}/webui.ctl.env}"
DEFAULT_STATE_DIR="${HERMES_WEBUI_STATE_DIR:-${HERMES_HOME}/webui}"
DEFAULT_LAUNCHD_LABEL="${HERMES_WEBUI_LAUNCHD_LABEL:-com.parantoux.hermes-webui}"
usage() {
cat <<'EOF'
@@ -27,6 +28,7 @@ ensure_home() {
}
_load_repo_dotenv_preserving_env() {
[[ "${HERMES_WEBUI_NO_DOTENV:-0}" == "1" ]] && return 0
local env_file="${REPO_ROOT}/.env"
[[ -f "${env_file}" ]] || return 0
@@ -39,7 +41,7 @@ _load_repo_dotenv_preserving_env() {
key="${key#export }"
key="${key//[[:space:]]/}"
[[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
if [[ -v ${key} ]]; then
if [[ -n "${!key+x}" ]]; then
value="${!key}"
preserved+=("${key}=${value}")
fi
@@ -51,9 +53,11 @@ _load_repo_dotenv_preserving_env() {
set +a
local assignment
for assignment in "${preserved[@]}"; do
export "${assignment}"
done
if [[ ${#preserved[@]} -gt 0 ]]; then
for assignment in "${preserved[@]}"; do
export "${assignment}"
done
fi
}
_find_python() {
@@ -127,11 +131,12 @@ _build_bootstrap_args() {
}
_write_state() {
local pid="$1" host="$2" port="$3"
local pid="$1" host="$2" port="$3" python_exe="${4:-}"
local state_dir="${HERMES_WEBUI_STATE_DIR:-${DEFAULT_STATE_DIR}}"
{
printf 'PID=%q\n' "${pid}"
printf 'REPO_ROOT=%q\n' "${REPO_ROOT}"
printf 'PYTHON_EXE=%q\n' "${python_exe}"
printf 'HOST=%q\n' "${host}"
printf 'PORT=%q\n' "${port}"
printf 'LOG_FILE=%q\n' "${LOG_FILE}"
@@ -160,20 +165,96 @@ _is_alive() {
kill -0 "${pid}" >/dev/null 2>&1
}
_proc_args() {
_is_windows_bash() {
[[ "${OS:-}" == "Windows_NT" ]] && return 0
case "$(uname -s 2>/dev/null || true)" in
MINGW*|MSYS*|CYGWIN*) return 0 ;;
*) return 1 ;;
esac
}
_windows_bash_path() {
local path="${1//\\//}" drive rest
if [[ "${path}" =~ ^([A-Za-z]):(.*)$ ]]; then
drive="${BASH_REMATCH[1],,}"
rest="${BASH_REMATCH[2]}"
printf '/%s%s\n' "${drive}" "${rest}"
return
fi
printf '%s\n' "${path}"
}
_windows_pid_for_bash_pid() {
local pid="$1"
ps -p "${pid}" -o args= 2>/dev/null || true
ps -p "${pid}" -l 2>/dev/null | awk 'NR == 2 { print $4 }'
}
_stop_webui_pid() {
local pid="$1" signal="${2:-TERM}"
if _is_windows_bash && command -v taskkill >/dev/null 2>&1; then
local winpid
winpid="$(_windows_pid_for_bash_pid "${pid}")"
if [[ "${winpid}" =~ ^[0-9]+$ ]]; then
taskkill //F //T //PID "${winpid}" >/dev/null 2>&1 || true
return
fi
fi
if [[ "${signal}" == "KILL" ]]; then
kill -KILL "${pid}" >/dev/null 2>&1 || true
else
kill "${pid}" >/dev/null 2>&1 || true
fi
}
_proc_args() {
local pid="$1" args
args="$(ps -p "${pid}" -o args= 2>/dev/null || true)"
if [[ -n "${args}" ]]; then
printf '%s\n' "${args}"
return
fi
if _is_windows_bash; then
local winpid
winpid="$(_windows_pid_for_bash_pid "${pid}")"
if [[ "${winpid}" =~ ^[0-9]+$ ]] && command -v wmic >/dev/null 2>&1; then
args="$(wmic process where "ProcessId=${winpid}" get CommandLine //value 2>/dev/null | sed -n 's/^CommandLine=//p' | tr -d '\r')"
if [[ -n "${args}" ]]; then
printf '%s\n' "${args}"
return
fi
fi
ps -p "${pid}" -f 2>/dev/null | awk 'NR == 2 { for (i = 8; i <= NF; i++) printf "%s%s", (i == 8 ? "" : " "), $i; print "" }'
fi
}
_is_owned_webui_pid() {
local pid="$1" args state_repo=""
local pid="$1" args args_slash state_repo="" state_repo_slash="" state_repo_win="" state_repo_win_slash="" state_python="" state_python_slash="" state_python_bash=""
[[ -f "${STATE_FILE}" ]] || return 1
_load_state_if_present
state_repo="${REPO_ROOT:-}"
state_python="${PYTHON_EXE:-}"
state_repo_slash="${state_repo//\\//}"
state_python_slash="${state_python//\\//}"
if _is_windows_bash; then
state_repo_win="$(cygpath -w "${state_repo}" 2>/dev/null || true)"
state_repo_win_slash="${state_repo_win//\\//}"
fi
if [[ -n "${state_python}" ]] && _is_windows_bash; then
state_python_bash="$(_windows_bash_path "${state_python}")"
fi
[[ "${state_repo}" == "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ]] || return 1
args="$(_proc_args "${pid}")"
[[ -n "${args}" ]] || return 1
[[ "${args}" == *"${state_repo}/bootstrap.py"* || "${args}" == *"${state_repo}/server.py"* || "${args}" == *"${state_repo}/start.sh"* ]]
args_slash="${args//\\//}"
[[ "${args_slash}" == *"${state_repo_slash}/bootstrap.py"* ||
"${args_slash}" == *"${state_repo_slash}/server.py"* ||
"${args_slash}" == *"${state_repo_slash}/start.sh"* ||
( -n "${state_repo_win_slash}" && "${args_slash}" == *"${state_repo_win_slash}/bootstrap.py"* ) ||
( -n "${state_repo_win_slash}" && "${args_slash}" == *"${state_repo_win_slash}/server.py"* ) ||
( -n "${state_repo_win_slash}" && "${args_slash}" == *"${state_repo_win_slash}/start.sh"* ) ||
( -n "${state_python}" && "${args}" == *"${state_python}"* ) ||
( -n "${state_python_slash}" && "${args_slash}" == *"${state_python_slash}"* ) ||
( -n "${state_python_bash}" && "${args_slash}" == *"${state_python_bash}"* ) ]]
}
_current_pid() {
@@ -193,6 +274,53 @@ _clear_stale_pid() {
fi
}
_pid_listens_on_port() {
# Best-effort check that PID $1 has a listening socket on TCP port $2.
# macOS (where launchd exists) ships lsof; if we can't determine ownership we
# return 2 ("unknown") so the caller can fall back conservatively rather than
# guess. Never blocks on a hard failure.
local pid="$1" port="$2"
[[ "${pid}" =~ ^[0-9]+$ && "${port}" =~ ^[0-9]+$ ]] || return 2
if command -v lsof >/dev/null 2>&1; then
if lsof -nP -p "${pid}" -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then
return 0 # PID is listening on that port → real conflict
fi
return 1 # PID is alive but NOT listening on that port → no conflict
fi
return 2 # can't determine
}
_launchd_webui_pid() {
[[ "${HERMES_WEBUI_CTL_ALLOW_LAUNCHD_CONFLICT:-0}" == "1" ]] && return 1
command -v launchctl >/dev/null 2>&1 || return 1
local label="${HERMES_WEBUI_LAUNCHD_LABEL:-${DEFAULT_LAUNCHD_LABEL}}"
[[ -n "${label}" ]] || return 1
local uid launchd_out pid
uid="$(id -u)"
launchd_out="$(launchctl print "gui/${uid}/${label}" 2>/dev/null)" || return 1
pid="$(printf '%s\n' "${launchd_out}" | awk '/^[[:space:]]*pid = / {print $3; exit}')"
[[ "${pid}" =~ ^[0-9]+$ ]] || return 1
(( pid > 0 )) || return 1
_is_alive "${pid}" || return 1
# Only treat the launchd job as a conflict for the port we are about to bind.
# A second instance on a DIFFERENT port (e.g. HERMES_WEBUI_PORT=8788 for a
# test build) does not collide with the launchd-managed default and must be
# allowed to start (#3291 over-block fix). When port ownership can't be
# determined (no lsof), fall back to the conservative previous behavior of
# only guarding the default port so non-default ports are never wrongly blocked.
local want_port="${CTL_PORT:-${HERMES_WEBUI_PORT:-8787}}"
_pid_listens_on_port "${pid}" "${want_port}"
case "$?" in
0) printf '%s\n' "${pid}"; return 0 ;; # launchd job listens on our port → block
1) return 1 ;; # launchd job on a different port → allow
*) # unknown: only guard the default port
if [[ "${want_port}" == "8787" ]]; then
printf '%s\n' "${pid}"; return 0
fi
return 1 ;;
esac
}
start_cmd() {
ensure_home
_load_repo_dotenv_preserving_env
@@ -208,6 +336,12 @@ start_cmd() {
echo "[ctl] Hermes WebUI is already running (PID ${existing_pid})"
return 0
fi
local launchd_pid
if launchd_pid="$(_launchd_webui_pid 2>/dev/null)"; then
echo "[ctl] Refusing to start a second Hermes WebUI while launchd job ${HERMES_WEBUI_LAUNCHD_LABEL:-${DEFAULT_LAUNCHD_LABEL}} is running (PID ${launchd_pid})." >&2
echo "[ctl] Use launchctl kickstart -k gui/$(id -u)/${HERMES_WEBUI_LAUNCHD_LABEL:-${DEFAULT_LAUNCHD_LABEL}} or disable the launchd job before using ctl.sh start." >&2
return 2
fi
_clear_stale_pid >/dev/null 2>&1 || true
local python_exe pid
@@ -215,12 +349,14 @@ start_cmd() {
: >> "${LOG_FILE}"
(
cd "${REPO_ROOT}"
exec "${python_exe}" "${REPO_ROOT}/bootstrap.py" --no-browser --foreground --host "${CTL_HOST}" "${CTL_PORT}" "${CTL_BOOTSTRAP_ARGS[@]}"
trap '' HUP
export HERMES_WEBUI_PRESERVE_ENV=1
exec nohup "${python_exe}" "${REPO_ROOT}/bootstrap.py" --no-browser --foreground --host "${CTL_HOST}" "${CTL_PORT}" ${CTL_BOOTSTRAP_ARGS[@]+"${CTL_BOOTSTRAP_ARGS[@]}"}
) >> "${LOG_FILE}" 2>&1 &
pid=$!
printf '%s\n' "${pid}" > "${PID_FILE}"
_write_state "${pid}" "${CTL_HOST}" "${CTL_PORT}"
_write_state "${pid}" "${CTL_HOST}" "${CTL_PORT}" "${python_exe}"
sleep 0.15
if ! _is_alive "${pid}"; then
echo "[ctl] Hermes WebUI failed to stay running. Log: ${LOG_FILE}" >&2
@@ -247,7 +383,7 @@ stop_cmd() {
fi
echo "[ctl] Stopping Hermes WebUI (PID ${pid})"
kill "${pid}" >/dev/null 2>&1 || true
_stop_webui_pid "${pid}" TERM
local i
for i in {1..50}; do
if ! _is_alive "${pid}"; then
@@ -259,7 +395,7 @@ stop_cmd() {
done
echo "[ctl] Process did not exit after SIGTERM; sending SIGKILL" >&2
kill -KILL "${pid}" >/dev/null 2>&1 || true
_stop_webui_pid "${pid}" KILL
rm -f "${PID_FILE}" "${STATE_FILE}"
}

View File

@@ -109,13 +109,15 @@ services:
# Same hermes home as the agent — shares config, sessions, state
- hermes-home:/home/hermeswebui/.hermes
# Agent source mounted where docker_init.bash expects it.
# At startup the init script runs:
# uv pip install /home/hermeswebui/.hermes/hermes-agent
# which installs the agent and all its Python dependencies.
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent
# Mounted read-only — the WebUI only reads this volume to install
# the agent's Python dependencies at startup (`uv pip install`).
# Read-only enforces that defence-in-depth at the kernel layer.
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro
# Workspace directory — browse and edit files from the WebUI.
# Adapt the host path to your project directory.
- ${HERMES_WORKSPACE:-~/workspace}:/workspace
# ${HOME} is used rather than `~` so the default resolves the same way
# across Linux, macOS, WSL2, and Docker Desktop on Windows.
- ${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace
environment:
- HERMES_WEBUI_HOST=0.0.0.0
- HERMES_WEBUI_PORT=8787
@@ -150,5 +152,17 @@ networks:
driver: bridge
volumes:
# IMPORTANT — upgrading the agent image:
# The `hermes-agent-src` volume is initialised from the agent image's
# `/opt/hermes` on first `up`, and Docker reuses the volume verbatim on
# later runs — even after `docker pull` of a newer agent image. After
# upgrading the agent image, run:
#
# docker compose -f docker-compose.three-container.yml down
# docker volume rm <project>_hermes-agent-src
# docker compose -f docker-compose.three-container.yml pull
# docker compose -f docker-compose.three-container.yml up -d
#
# The full procedure (and why) is documented in docs/docker.md.
hermes-home:
hermes-agent-src:

View File

@@ -91,14 +91,16 @@ services:
# Same hermes home as the agent — shares config, sessions, state
- hermes-home:/home/hermeswebui/.hermes
# Agent source mounted where docker_init.bash expects it.
# At startup the init script runs:
# uv pip install /home/hermeswebui/.hermes/hermes-agent
# which installs the agent and all its Python dependencies.
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent
# Mounted read-only — the WebUI only reads this volume to install
# the agent's Python dependencies at startup (`uv pip install`).
# Read-only enforces that defence-in-depth at the kernel layer.
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro
# Workspace directory — browse and edit files from the WebUI.
# Adapt the host path to your project directory.
# Override with: HERMES_WORKSPACE=/your/path docker compose up
- ${HERMES_WORKSPACE:-~/workspace}:/workspace
# ${HOME} is used rather than `~` so the default resolves the same way
# across Linux, macOS, WSL2, and Docker Desktop on Windows.
- ${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace
environment:
- HERMES_WEBUI_HOST=0.0.0.0
- HERMES_WEBUI_PORT=8787
@@ -129,5 +131,17 @@ networks:
driver: bridge
volumes:
# IMPORTANT — upgrading the agent image:
# The `hermes-agent-src` volume is initialised from the agent image's
# `/opt/hermes` on first `up`, and Docker reuses the volume verbatim on
# later runs — even after `docker pull` of a newer agent image. After
# upgrading the agent image, run:
#
# docker compose down
# docker volume rm <project>_hermes-agent-src
# docker compose pull
# docker compose up -d
#
# The full procedure (and why) is documented in docs/docker.md.
hermes-home:
hermes-agent-src:

View File

@@ -19,13 +19,14 @@ ok_exit() {
# Ignore list: variables to ignore when loading environment variables from user to user
export ENV_IGNORELIST="HOME PWD USER SHLVL TERM OLDPWD SHELL _ SUDO_COMMAND HOSTNAME LOGNAME MAIL SUDO_GID SUDO_UID SUDO_USER CHECK_NV_CUDNN_VERSION VIRTUAL_ENV VIRTUAL_ENV_PROMPT ENV_IGNORELIST ENV_OBFUSCATE_PART"
# Obfuscate part: part of the key to obfuscate when loading environment variables from user to user, ex: HF_TOKEN, ...
export ENV_OBFUSCATE_PART="TOKEN API KEY"
export ENV_OBFUSCATE_PART="TOKEN API KEY PASSWORD SECRET CREDENTIAL COOKIE SESSION"
# Check for ENV_IGNORELIST and ENV_OBFUSCATE_PART
if [ -z "${ENV_IGNORELIST+x}" ]; then error_exit "ENV_IGNORELIST not set"; fi
if [ -z "${ENV_OBFUSCATE_PART+x}" ]; then error_exit "ENV_OBFUSCATE_PART not set"; fi
whoami=`whoami`
# whoami fails under set -e if the UID has no /etc/passwd entry (k8s runAsUser).
whoami=$(whoami 2>/dev/null || echo "uid-$(id -u)")
script_dir=$(dirname $0)
script_name=$(basename $0)
echo ""; echo ""
@@ -36,25 +37,25 @@ script_fullname=$0
echo " - script_fullname: ${script_fullname}"
ignore_value="VALUE_TO_IGNORE"
# everyone can read our files by default
umask 0022
# Keep init scratch files private to the container user that owns them.
umask 0077
# Write a world-writeable file (preferably inside /tmp -- ie within the container)
write_worldtmpfile() {
write_privtmpfile() {
tmpfile=$1
if [ -z "${tmpfile}" ]; then error_exit "write_worldfile: missing argument"; fi
if [ -f $tmpfile ]; then rm -f $tmpfile; fi
echo -n $2 > ${tmpfile}
chmod 777 ${tmpfile}
if [ -z "${tmpfile}" ]; then error_exit "write_privtmpfile: missing argument"; fi
if [ -f "$tmpfile" ]; then rm -f "$tmpfile"; fi
printf '%s' "$2" > "$tmpfile"
chmod 600 "$tmpfile"
}
itdir=/tmp/hermeswebui_init
if [ ! -d $itdir ]; then mkdir $itdir; chmod 777 $itdir; fi
if [ ! -d $itdir ]; then error_exit "Failed to create $itdir"; fi
if [ ! -d "$itdir" ]; then mkdir -p "$itdir"; fi
chmod 700 "$itdir" || error_exit "Failed to secure $itdir"
if [ ! -d "$itdir" ]; then error_exit "Failed to create $itdir"; fi
# Set user and group id
# logic: if not set and file exists, use file value, else use default. Create file for persistence when the container is re-run
# reasoning: needed when using docker compose as the file will exist in the stopped container, and changing the value from environment variables or configuration file must be propagated from hermeswebuitoo to hermeswebuitoo transition (those values are the only ones loaded before the environment variables dump file are loaded)
# reasoning: needed when using docker compose as the file will exist in the stopped container, and changing the value from environment variables or configuration file must be propagated from the root init phase to the hermeswebui runtime phase
it=$itdir/hermeswebui_user_uid
if [ -z "${WANTED_UID+x}" ]; then
if [ -f $it ]; then WANTED_UID=$(cat $it); fi
@@ -88,7 +89,7 @@ if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then
fi
fi
WANTED_UID=${WANTED_UID:-1024}
write_worldtmpfile $it "$WANTED_UID"
write_privtmpfile $it "$WANTED_UID"
echo "-- WANTED_UID: \"${WANTED_UID}\""
it=$itdir/hermeswebui_user_gid
@@ -120,7 +121,7 @@ if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then
fi
fi
WANTED_GID=${WANTED_GID:-1024}
write_worldtmpfile $it "$WANTED_GID"
write_privtmpfile $it "$WANTED_GID"
echo "-- WANTED_GID: \"${WANTED_GID}\""
echo "== Most Environment variables set"
@@ -180,22 +181,39 @@ load_env() {
fi
}
# hermeswebuitoo is a specfiic user not existing by default on ubuntu, we can check its whomai
if [ "A${whoami}" == "Ahermeswebuitoo" ]; then
echo "-- Running as hermeswebuitoo, will switch hermeswebui to the desired UID/GID"
# The script is started as hermeswebuitoo -- UID/GID 1025/1025
chown_home_hermeswebui() {
# macOS Docker bind mounts can expose hermes-agent git object packs as
# read-only host files. The runtime only needs to read those existing objects;
# requiring chown on them makes startup fail before WebUI can run (#2237).
#
# Multi-container compose (#2470) additionally mounts the entire
# hermes-agent-src volume read-only on the WebUI side because the WebUI only
# reads it for `uv pip install`. On a :ro mount, chown returns EROFS for any
# file inside the subtree, which would propagate to `set -e` and kill startup
# before the WebUI can run. Either way, the WebUI never writes to the agent
# source — prune the entire hermes-agent path from the chown walk so a
# read-only or partially-read-only mount doesn't break the rest of the home
# ownership alignment.
find /home/hermeswebui \
-path "/home/hermeswebui/.hermes/hermes-agent" -prune \
-o -name ".git" -prune \
-o -exec chown -h "${WANTED_UID}:${WANTED_GID}" {} +
}
# The production image does not ship sudo. The entrypoint starts as root only
# long enough to align the hermeswebui UID/GID with mounted volumes, prepare
# root-owned paths, and then drop privileges for the server process.
if [ "A${whoami}" == "Aroot" ]; then
echo "-- Running as root for one-time container init; will switch to hermeswebui"
# We are altering the UID/GID of the hermeswebui user to the desired ones and restarting as that user
# using usermod for the already create hermeswebui user, knowing it is not already in use
# using usermod for the already created hermeswebui user, knowing it is not already in use
# per usermod manual: "You must make certain that the named user is not executing any processes when this command is being executed"
# Guard for read-only root filesystem (podman with read_only=true, issue #1470).
# The script runs as hermeswebuitoo (non-root), but groupmod/usermod use sudo.
# So we must check writability via sudo — a non-root user cannot write /etc/group
# even on a normal writable rootfs, which caused a false positive (issue #1658).
_readonly_root=false
if ! sudo sh -c 'test -w /etc/group && test -w /etc/passwd' 2>/dev/null; then
if ! sh -c 'test -w /etc/group && test -w /etc/passwd' 2>/dev/null; then
_readonly_root=true
echo " !! Detected read-only root filesystem — /etc/group or /etc/passwd is not writable (even via sudo)"
echo " !! Detected read-only root filesystem — /etc/group or /etc/passwd is not writable"
fi
if [ "A${_readonly_root}" == "Atrue" ]; then
_current_hermeswebui_gid=$(id -g hermeswebui 2>/dev/null || echo "")
@@ -206,20 +224,77 @@ if [ "A${whoami}" == "Ahermeswebuitoo" ]; then
error_exit "Cannot modify /etc/group or /etc/passwd (read-only root fs). Set UID=${_current_hermeswebui_uid} and GID=${_current_hermeswebui_gid} to match, or run without read_only=true. See issue #1470."
fi
else
sudo groupmod -o -g ${WANTED_GID} hermeswebui || error_exit "Failed to set GID of hermeswebui user"
sudo usermod -o -u ${WANTED_UID} hermeswebui || error_exit "Failed to set UID of hermeswebui user"
groupmod -o -g "${WANTED_GID}" hermeswebui || error_exit "Failed to set GID of hermeswebui user"
usermod -o -u "${WANTED_UID}" hermeswebui || error_exit "Failed to set UID of hermeswebui user"
fi
sudo chown -R ${WANTED_UID}:${WANTED_GID} /home/hermeswebui || error_exit "Failed to set owner of /home/hermeswebui"
save_env /tmp/hermeswebuitoo_env.txt
chown_home_hermeswebui || error_exit "Failed to set owner of /home/hermeswebui"
echo ""; echo "-- Preparing /app for the hermeswebui runtime user"
mkdir -p /app || error_exit "Failed to create /app directory"
chown hermeswebui:hermeswebui /app || error_exit "Failed to set owner of /app to hermeswebui user"
rsync -av --chown=hermeswebui:hermeswebui /apptoo/ /app/ || error_exit "Failed to sync /apptoo to /app with correct ownership"
if [ -z "${HERMES_WEBUI_DEFAULT_WORKSPACE+x}" ]; then export HERMES_WEBUI_DEFAULT_WORKSPACE="/workspace"; fi
if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then
mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit "Failed to create default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE"
fi
if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then error_exit "HERMES_WEBUI_DEFAULT_WORKSPACE directory does not exist at $HERMES_WEBUI_DEFAULT_WORKSPACE"; fi
chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE" 2>/dev/null || echo "!! WARNING: Could not chown $HERMES_WEBUI_DEFAULT_WORKSPACE (continuing)"
export UV_CACHE_DIR=${UV_CACHE_DIR:-/uv_cache}
mkdir -p "${UV_CACHE_DIR}" || error_exit "Failed to create ${UV_CACHE_DIR} directory"
chown hermeswebui:hermeswebui "${UV_CACHE_DIR}" || error_exit "Failed to set owner of ${UV_CACHE_DIR} to hermeswebui user"
chown -R "${WANTED_UID}:${WANTED_GID}" "$itdir" || error_exit "Failed to set owner of $itdir"
# Issue #2010 — Railway / user-namespaced runtimes: in-container UID 0 may map
# to a host UID outside the writable subuid range, so /tmp writes fail despite
# id -u == 0. Probe writability and fall back through $itdir → /app.
ENV_FILE="/tmp/hermeswebui_root_env.txt"
if ! ( : > "$ENV_FILE" ) 2>/dev/null; then
ENV_FILE="${itdir:-/tmp/hermeswebui_init}/hermeswebui_root_env.txt"
mkdir -p "$(dirname "$ENV_FILE")" 2>/dev/null
if ! ( : > "$ENV_FILE" ) 2>/dev/null; then
ENV_FILE="/app/.hermeswebui_root_env"
fi
echo " !! /tmp not writable by root — falling back to $ENV_FILE (user-namespaced runtime?)"
fi
save_env "$ENV_FILE"
chown "${WANTED_UID}:${WANTED_GID}" "$ENV_FILE" || error_exit "Failed to set owner of $ENV_FILE"
chmod 600 "$ENV_FILE" || error_exit "Failed to secure $ENV_FILE"
export _HW_ROOT_ENV_PATH="$ENV_FILE"
# Preserve Docker --group-add supplemental groups (for example render/video
# for /dev/dri GPU access) when dropping privileges. `su` rebuilds the target
# user's groups from /etc/group, so host-passed numeric groups must be made
# visible to hermeswebui before re-entering as the runtime user.
for gid in $(id -G); do
if [ "$gid" = "0" ] || [ "$gid" = "$WANTED_GID" ]; then
continue
fi
group_name="$(getent group "$gid" | cut -d: -f1 || true)"
if [ -z "$group_name" ]; then
group_name="hostgpu${gid}"
groupadd -g "$gid" "$group_name" 2>/dev/null || true
group_name="$(getent group "$gid" | cut -d: -f1 || true)"
fi
if [ -z "$group_name" ]; then
echo "!! WARNING: Could not create supplemental group for GID $gid; GPU device access may be unavailable"
continue
fi
if [ -n "$group_name" ]; then
usermod -a -G "$group_name" hermeswebui 2>/dev/null || echo "!! WARNING: Could not add hermeswebui to supplemental group $group_name ($gid)"
fi
done
# restart the script as hermeswebui set with the correct UID/GID this time
echo "-- Restarting as hermeswebui user with UID ${WANTED_UID} GID ${WANTED_GID}"
sudo su hermeswebui $script_fullname || error_exit "subscript failed"
ok_exit "Clean exit"
exec su -s /bin/bash -c "exec \"${script_fullname}\"" hermeswebui || error_exit "subscript failed"
fi
# If we are here, the script is started as another user than hermeswebuitoo
# because the whoami value for the hermeswebui user can be any existing user, we can not check against it
# instead we check if the UID/GID are the expected ones
# If we are here, the script is started as an unprivileged runtime user.
# Because the whoami value for the hermeswebui user can be any existing user, we cannot check against it;
# instead we check if the UID/GID are the expected ones.
if [ "$WANTED_GID" != "$new_gid" ]; then error_exit "hermeswebui MUST be running as UID ${WANTED_UID} GID ${WANTED_GID}, current UID ${new_uid} GID ${new_gid}"; fi
if [ "$WANTED_UID" != "$new_uid" ]; then error_exit "hermeswebui MUST be running as UID ${WANTED_UID} GID ${WANTED_GID}, current UID ${new_uid} GID ${new_gid}"; fi
@@ -228,18 +303,21 @@ if [ "$WANTED_UID" != "$new_uid" ]; then error_exit "hermeswebui MUST be running
# We are therefore running as hermeswebui
echo ""; echo "== Running as hermeswebui"
# Load environment variables one by one if they do not exist from /tmp/hermeswebuitoo_env.txt
it=/tmp/hermeswebuitoo_env.txt
if [ -f $it ]; then
echo "-- Loading not already set environment variables from $it"
load_env $it true
# Load environment variables one by one if they do not exist from the root init phase
tmp_root_env="${_HW_ROOT_ENV_PATH:-/tmp/hermeswebui_root_env.txt}"
if [ -f $tmp_root_env ]; then
echo "-- Loading not already set environment variables from $tmp_root_env"
load_env $tmp_root_env true
fi
##
echo ""; echo "-- Making sure /app is owned by the hermeswebui user to avoid permission issues when running the server "
sudo mkdir -p /app || error_exit "Failed to create /app directory"
sudo chown hermeswebui:hermeswebui /app || error_exit "Failed to set owner of /app to hermeswebui user"
sudo rsync -av --chown=hermeswebui:hermeswebui /apptoo/ /app/ || error_exit "Failed to sync /apptoo to /app with correct ownership"
if [ ! -f /app/server.py ] && [ -d /apptoo ]; then
echo ""; echo "-- Seeding /app from /apptoo (rootless startup)"
cp -a /apptoo/. /app/ || error_exit "Failed to seed /app from /apptoo (is /app writable by the runtime user?)"
fi
echo ""; echo "-- Verifying /app is writable by the hermeswebui runtime user"
if [ ! -d /app ]; then error_exit "/app directory does not exist"; fi
it=/app/.testfile; touch $it || error_exit "Failed to verify /app directory"
rm -f $it || error_exit "Failed to delete test file in /app"
@@ -247,7 +325,7 @@ rm -f $it || error_exit "Failed to delete test file in /app"
echo ""; echo "== Checking required environment variables for hermes-webui"
echo ""; echo "-- HERMES_WEBUI_VERSION: Where to store sessions, workspaces, and other state (default: ~/.hermes/webui-mvp)"
echo ""; echo "-- HERMES_WEBUI_STATE_DIR: Where to store sessions, workspaces, and other state (default: ~/.hermes/webui)"
if [ -z "${HERMES_WEBUI_STATE_DIR+x}" ]; then error_exit "HERMES_WEBUI_STATE_DIR not set"; fi;
echo "-- HERMES_WEBUI_STATE_DIR: $HERMES_WEBUI_STATE_DIR"
if [ ! -d "$HERMES_WEBUI_STATE_DIR" ]; then mkdir -p $HERMES_WEBUI_STATE_DIR || error_exit "Failed to create state directory at $HERMES_WEBUI_STATE_DIR"; fi
@@ -258,19 +336,18 @@ rm -f $it || error_exit "Failed to delete test file in $HERMES_WEBUI_STATE_DIR"
echo ""; echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE: Default workspace directory shown on first launch"
if [ -z "${HERMES_WEBUI_DEFAULT_WORKSPACE+x}" ]; then echo "HERMES_WEBUI_DEFAULT_WORKSPACE not set, setting to /workspace"; export HERMES_WEBUI_DEFAULT_WORKSPACE="/workspace"; fi;
echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE: $HERMES_WEBUI_DEFAULT_WORKSPACE"
# Use sudo for mkdir — Docker may auto-create bind-mount directories as root (#357).
# Skip mkdir if the directory already exists (e.g. a read-only mount — #670).
# The root init phase creates/chowns missing bind-mount directories before
# dropping privileges. After that, the runtime user only verifies access.
if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then
sudo mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit "Failed to create default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE"
mkdir -p "$HERMES_WEBUI_DEFAULT_WORKSPACE" || error_exit "Failed to create default workspace at $HERMES_WEBUI_DEFAULT_WORKSPACE"
fi
if [ ! -d "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then error_exit "HERMES_WEBUI_DEFAULT_WORKSPACE directory does not exist at $HERMES_WEBUI_DEFAULT_WORKSPACE"; fi
# Only chown and write-test if the workspace is writable. Read-only bind-mounts
# (:ro) are valid — the workspace is used for browsing, not writing by the server.
# Only write-test if the workspace is writable. Read-only bind-mounts (:ro)
# are valid — the workspace is used for browsing, not writing by the server.
if [ -w "$HERMES_WEBUI_DEFAULT_WORKSPACE" ]; then
sudo chown hermeswebui:hermeswebui "$HERMES_WEBUI_DEFAULT_WORKSPACE" || echo "!! WARNING: Could not chown $HERMES_WEBUI_DEFAULT_WORKSPACE (continuing)"
it="$HERMES_WEBUI_DEFAULT_WORKSPACE/.testfile"; touch $it && rm -f $it || echo "!! WARNING: Could not write to $HERMES_WEBUI_DEFAULT_WORKSPACE (continuing)"
else
echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE is read-only — skipping chown/write check (read-only workspace is supported)"
echo "-- HERMES_WEBUI_DEFAULT_WORKSPACE is read-only — skipping write check (read-only workspace is supported)"
fi
echo ""; echo "==================="
@@ -285,9 +362,9 @@ else
fi
export UV_PROJECT_ENVIRONMENT=venv
export UV_CACHE_DIR=/uv_cache
sudo mkdir -p ${UV_CACHE_DIR} || error_exit "Failed to create /uv_cache directory"
sudo chown hermeswebui:hermeswebui ${UV_CACHE_DIR} || error_exit "Failed to set owner of ${UV_CACHE_DIR} to hermeswebui user"
export UV_CACHE_DIR=${UV_CACHE_DIR:-/uv_cache}
mkdir -p "${UV_CACHE_DIR}" || error_exit "Failed to create ${UV_CACHE_DIR} directory"
test -w "${UV_CACHE_DIR}" || error_exit "${UV_CACHE_DIR} is not writable by hermeswebui"
cd /app
if [ -f /app/venv/bin/python3 ]; then
@@ -338,7 +415,49 @@ else
fi
done
if [ -n "$_agent_src" ]; then
uv pip install "$_agent_src[all]" --trusted-host pypi.org --trusted-host files.pythonhosted.org || error_exit "Failed to install hermes-agent's requirements"
if [ -w "$_agent_src" ]; then
echo ""
echo "!! WARNING: hermes-agent source mount is writable from the WebUI container."
echo "!! Path: $_agent_src"
echo "!! The multi-container compose defaults use a read-only mount for defence-in-depth."
echo "!! If this is not an intentional local development checkout, switch the WebUI"
echo "!! agent source volume/bind mount to read-only. See docs/rfcs/agent-source-boundary.md."
echo ""
fi
# The agent source can be mounted read-only (see docker-compose.two-container.yml
# / docker-compose.three-container.yml — the WebUI only reads this volume to
# install the agent's Python dependencies and never writes to it). setuptools'
# `egg_info` build step, however, touches `hermes_agent.egg-info/` inside the
# source tree even under PEP 517 build isolation, which `EROFS`-fails on a
# `:ro` mount and (under `set -e`) kills startup of every multi-container
# deploy. Stage the source into a writable tmpfs copy so the build can write
# its metadata side-by-side without touching the underlying mount.
#
# The copy excludes any pre-baked `*.egg-info` / `build` / `dist` artifacts
# to avoid the timestamp-update path setuptools takes when one is present,
# and `--reflink=auto` makes the copy near-free on overlay2/btrfs where
# supported. We rebuild on every container start (the agent source can
# change across volume re-init); cost is one rsync of ~10MB of Python source.
_stage_src="/tmp/hermes-agent-build"
rm -rf "$_stage_src"
mkdir -p "$_stage_src"
if command -v rsync >/dev/null 2>&1; then
rsync -a \
--exclude='*.egg-info' --exclude='build' --exclude='dist' \
--exclude='__pycache__' --exclude='.git' \
"$_agent_src"/ "$_stage_src"/ \
|| error_exit "Failed to stage hermes-agent source to writable build dir"
else
# Fallback when rsync isn't in the image — straight cp -a, then drop
# the build artifacts that would trip setuptools.
cp -a "$_agent_src"/. "$_stage_src"/ \
|| error_exit "Failed to copy hermes-agent source to writable build dir"
rm -rf "$_stage_src"/*.egg-info "$_stage_src"/build "$_stage_src"/dist 2>/dev/null || true
find "$_stage_src" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
fi
uv pip install "$_stage_src[all]" --trusted-host pypi.org --trusted-host files.pythonhosted.org \
|| error_exit "Failed to install hermes-agent's requirements"
rm -rf "$_stage_src"
else
echo ""
echo "!! WARNING: hermes-agent source not found."

225
docs/CONTRACTS.md Normal file
View File

@@ -0,0 +1,225 @@
# Project Contracts
This document is a contributor-facing index for existing Hermes WebUI contracts,
RFCs, design constraints, and review expectations. It does not replace the
source documents and it does not mark proposals as implemented. Follow each
linked document's status and scope.
Use this file when starting a change so the relevant public contract is visible
before code is edited. This first version focuses on documentation routing; it
does not change runtime behavior, maintainer policy, bot behavior, or CI gates.
## Start here
- [`AGENTS.md`](../AGENTS.md): repository entry point for AI assistants,
public-safety rules, and the short redline checklist.
- [`CONTRIBUTING.md`](../CONTRIBUTING.md): contribution style, verification,
PR description expectations, UI evidence, and project-specific constraints.
- [`README.md`](../README.md): product overview, quick start, architecture map,
feature inventory, and docs index.
- [`CHANGELOG.md`](../CHANGELOG.md): release-note-ready history. Update it when
maintainers should carry the change into release notes.
## Runtime, durability, and state contracts
- [`docs/rfcs/webui-run-state-consistency-contract.md`](rfcs/webui-run-state-consistency-contract.md):
proposed consistency rules for current WebUI streaming, recovery, replay,
model-context reconstruction, compression, UI scene/cache, and sidebar metadata
repairs. Start here for narrow fixes that keep the existing WebUI execution
path.
- [`docs/rfcs/live-to-final-assistant-replies.md`](rfcs/live-to-final-assistant-replies.md):
proposed product model for long-running assistant replies, live process text,
tool activity, recovery, terminal outcomes, and final-answer boundaries. Start
here for UI/UX changes to running-session assistant reply rendering.
- [`docs/architecture/stable-assistant-turn-anchor-phase0.md`](architecture/stable-assistant-turn-anchor-phase0.md):
current Phase 0 inventory for the Stable Assistant Turn Anchors work under
#3926. Use this before wiring anchor helpers into live SSE, replay,
settlement, `INFLIGHT`, or `renderMessages()` paths.
- [`docs/rfcs/canonical-session-resolution.md`](rfcs/canonical-session-resolution.md):
proposed contract for resolving URL routes, query parameters, localStorage,
sidebar rows, and compression-lineage IDs to one canonical visible session
target. Start here for session routing, boot restore, stale parent, or
compression-tip selection changes.
- [`docs/rfcs/hermes-run-adapter-contract.md`](rfcs/hermes-run-adapter-contract.md):
proposed event/control contract, runtime-state ownership matrix,
acceptance-test catalog, and reversible migration gates for moving WebUI
execution behind an adapter boundary. Use this for adapter-seam, control-plane,
runner, sidecar, or execution-ownership work; do not treat it as authorization
to implement those slices.
- [`docs/architecture/agent-api-contract.md`](architecture/agent-api-contract.md):
current audit of WebUI dependencies on the hermes-agent source checkout and
the replacement API/client surfaces needed before source mounts can be removed.
Start here for issue #2491 and Docker/source-boundary migration slices.
- [`docs/rfcs/turn-journal.md`](rfcs/turn-journal.md): proposed crash-safe
write-ahead journal for browser-originated chat turns.
- [`docs/rfcs/webui-pending-intent-controls.md`](rfcs/webui-pending-intent-controls.md):
proposed control-surface companion to the long-running-session reply model for
Queue, Steer, Stop-and-send, Interrupt, and leftover-steer inputs submitted
while an agent run is active. Start here for busy-composer behavior, pending
queued messages, interrupt replacement, steer visibility, or leftover-steer
recovery changes.
- [`docs/rfcs/README.md`](rfcs/README.md): RFC conventions and current RFC index.
When a change touches streaming, recovery, replay, compression, context
reconstruction, cancellation, approval/clarify, session metadata, or run state,
read the relevant RFC before editing. In the PR description, name the state layer
or event/control surface affected and include a regression test or manual
verification for the relevant invariant.
Proposed RFCs are review guardrails, not implementation authorization. Do not
implement RFC fragments unless the task or tracking issue explicitly asks for
that slice.
## UI, UX, and theme contracts
- [`DESIGN.md`](../DESIGN.md): design tokens and the current calm-console
direction: conversation first, quiet metadata, restrained accents, and
progressive disclosure for debugging detail.
- [`docs/UIUX-GUIDE.md`](UIUX-GUIDE.md): contributor-facing synthesis of the
repository's UI/UX principles, sourced from existing project docs and code
comments.
- [`docs/ui-ux/index.html`](ui-ux/index.html): message-area inventory wired to
the real app stylesheet.
- [`docs/ui-ux/two-stage-proposal.html`](ui-ux/two-stage-proposal.html):
existing two-stage chat UX proposal for issue #536.
- [`THEMES.md`](../THEMES.md): theme and skin guidance; the core palette
variable contract lives in `static/style.css`.
Current appearance has a theme axis (`light`, `dark`, `system`) and a separate
skin axis (`default`, `ares`, `mono`, `slate`, `poseidon`, `sisyphus`,
`charizard`, `sienna`, `catppuccin`, `nous`, `geist-contrast`) in
`static/boot.js` and `static/style.css`. Do not follow stale `data-theme`-only theme guidance unless
the current code and tests prove that model still applies.
For UI or UX work, include before/after evidence, verify relevant responsive
states, and prefer stable class/data hooks over one-off visual behavior.
## Choosing the relevant contract
Before editing, identify which contract family the task exercises. This is a
routing check, not a request to read every document in the repository. Read the
documents that match the touched subsystem.
Use this lightweight note in an issue comment, draft PR, task note, or AI-agent
handoff when it helps clarify scope:
```markdown
## Contract Routing
Task type:
Touched areas:
Relevant public docs:
- `AGENTS.md`
- `CONTRIBUTING.md`
- `docs/CONTRACTS.md`
- <subsystem-specific documents>
Scope boundaries:
Evidence needed before claiming done:
```
For small, obvious fixes, keep this short. The goal is to avoid routing mistakes,
not to create process overhead.
## Contract changes
Changing contract documents, RFC guidance, or contract tests changes review
expectations for future contributors. A PR that intentionally changes an
existing contract should include a `Contract Change` section in its PR body with:
- the previous contract,
- the new contract,
- the affected docs and tests,
- the compatibility or migration reason.
Contract tests and corresponding docs must move together. Tests that encode
product semantics must not silently redefine the contract by asserting the
opposite behavior without updating the public docs and naming the change in the
PR body.
The static tests for this guidance are advisory coverage. They pin contributor
wording so the rule stays visible. This advisory coverage is not an automated
policy gate; static coverage is not an automated policy gate and does not enforce
PR-body content on GitHub. A future release-time or CI check could
surface contract-affecting diffs whose PR body lacks `Contract Routing`, but this
document only defines the review expectation.
Release batches should list included contract-affecting PRs explicitly so
reviewers can distinguish ordinary green-CI fixes from changes that update the
project's product or runtime guardrails.
## PR preparation checklist
Before opening or updating a PR, verify `CONTRIBUTING.md` against the actual PR
body. This checklist applies even when code and tests are already done.
Required checks:
- The PR solves one logical problem.
- The PR body contains all required sections from `CONTRIBUTING.md`:
`Thinking Path`, `What Changed`, `Why It Matters`, `Verification`,
`Risks / Follow-ups`, and `Model Used`.
- `Model Used` discloses provider/model and notable agent/tool use, or says
`None -- human-authored`.
- UI/UX changes include before/after evidence and responsive-state coverage.
- Runtime/streaming changes name the state layer or invariant being changed and
list the regression or manual invariant check.
- Contract-affecting PRs include `Contract Routing`; intentional contract
changes also include `Contract Change`.
- Onboarding/setup validation used isolated `HERMES_HOME` and
`HERMES_WEBUI_STATE_DIR`, unless the human operator explicitly requested real
state.
- Docs and `CHANGELOG.md` updates are either included or explicitly not needed.
- After the GitHub write, read the PR back and verify the headings rendered as
intended.
Green CI plus a focused diff is not sufficient if the PR description or evidence
does not match the touched subsystem.
## Setup, onboarding, and operational references
- [`TESTING.md`](../TESTING.md): automated test command and manual browser test
plan.
- [`ARCHITECTURE.md`](../ARCHITECTURE.md): API, module layout, and design
constraints.
- [`docs/onboarding.md`](onboarding.md): first-run wizard and provider setup.
- [`docs/onboarding-agent-checklist.md`](onboarding-agent-checklist.md): safety
rules for assistant-led install, reinstall, bootstrap, provider setup, local
model setup, Docker onboarding, and WSL onboarding.
- [`docs/docker.md`](docker.md): Docker compose setup, common failures, and
bind-mount migration.
- [`docs/troubleshooting.md`](troubleshooting.md): diagnostic flows for common
failures.
- [`docs/EXTENSIONS.md`](EXTENSIONS.md): administrator-controlled WebUI
extension injection.
## Quick redline checklist
Before opening a change for review, confirm:
- The change solves one logical problem; unrelated refactors are split out.
- `AGENTS.md`, this index, and any linked contract for the touched subsystem were
read before editing.
- Behavior, setup, architecture, testing, or workflow changes update the relevant
docs; release-note-ready changes update `CHANGELOG.md`.
- UI/UX changes include before/after evidence and cover relevant desktop,
narrow, and mobile states.
- Runtime, streaming, recovery, replay, compression, or sidebar changes state
which layer they mutate and include a regression for the invariant.
- New dependencies, build tools, frameworks, or long-lived processes are avoided
unless the benefit and rollback story are explicit.
- Onboarding/setup validation uses isolated `HERMES_HOME` and
`HERMES_WEBUI_STATE_DIR` unless the human operator explicitly asks to use real
state.
- Secrets, private paths, local-only workflows, and personal notes stay out of
tracked docs and examples.
## Future evolution
This index is not intended to make the first contract set final. Future PRs may
add, revise, split, or retire contracts when real issues, implementation changes,
RFC decisions, contributor feedback, or review experience show that guidance is
incomplete or stale.
Potential follow-up areas include session import/export, cron, extensions,
security boundaries, Docker/runtime isolation, and lightweight checks that keep
key contract links from drifting.

205
docs/UIUX-GUIDE.md Normal file
View File

@@ -0,0 +1,205 @@
# UI/UX Guide
This document summarizes UI/UX principles that are already visible in the
repository. It is a contributor guide, not a new design proposal. Source
documents include [`DESIGN.md`](../DESIGN.md), [`README.md`](../README.md),
[`THEMES.md`](../THEMES.md), [`docs/ui-ux/index.html`](ui-ux/index.html),
[`docs/ui-ux/two-stage-proposal.html`](ui-ux/two-stage-proposal.html), and
design comments in `static/style.css`.
Use this guide when a change touches layout, chat rendering, composer chrome,
navigation, theme/skin behavior, responsive behavior, or visual hierarchy. For
purely backend changes, use the runtime/state contracts instead.
## Product shape
Hermes WebUI is a browser workbench for Hermes Agent with near-CLI parity and a
simple implementation shape: Python on the server, vanilla JavaScript in the
browser, no build step, no bundler, and no frontend framework.
The primary layout is three-panel:
- left sidebar for sessions and navigation,
- center panel for chat,
- right panel for workspace file browsing and previews.
Model, profile, workspace, attachments, voice input, context usage, Stop, and
Send controls live in the composer footer. Settings and session-level tools live
in the Hermes Control Center. Preserve this shape unless the change explicitly
justifies a different interaction model.
## Core feeling: calm developer console
The main artifact is the conversation. Tool calls, thinking traces, context
compaction records, token usage, runtime status, and other internals are useful,
but they are transcript metadata. They should sit below user and assistant prose
in visual priority.
Prefer:
- quiet surfaces,
- clear spacing,
- restrained accent use,
- progressive disclosure for debugging detail,
- legible text over decorative chrome.
Avoid turning the interface into a demo page of colorful cards. Errors,
approvals, and other action-required states may be prominent because the user
must notice and respond to them.
## Conversation hierarchy
A chat turn should read as one coherent story:
1. User message: right-aligned, compact bubble.
2. Assistant content: left-aligned, prose-first, not a heavy bubble.
3. Tool, thinking, progress, and context traces: quiet disclosure rows inside or
adjacent to the assistant turn.
4. Raw logs and verbose details: hidden until explicitly expanded.
Do not render every internal event as a first-class chat card. A turn that used
many tools should summarize the work as inspectable activity, not make the user
read a stack of unrelated-looking cards.
## Tool, thinking, and activity traces
Tool cards are debug event rows, not chat messages. Show the icon, name, short
target or preview, and status first. Arguments, result snippets, and long logs
belong behind expansion, with result snippets truncated and full output behind a
show-more affordance where needed.
Thinking and context cards should share the quiet metadata visual family. They
should not overpower assistant prose. Collapsed activity summaries should be
terse, for example `Activity: 4 tools`, and should not duplicate the thinking
area, list every tool name in the summary, or add redundant trailing count
badges.
Visible interim assistant progress is part of the live conversation timeline,
not raw debug detail. Compact Activity may collapse tool arguments, long tool
results, and low-level reasoning detail, but it must not make concise
user-visible progress text available only inside a collapsed disclosure.
Automatic compression is a live-only context barrier, not a special branded
tool card. Render it as a centered, non-interactive divider with quiet horizontal
rules: `Compressing context` while the compression barrier is active and
`Context auto-compressed` when the agent has continued or the compression
completion event arrives. Do not give it a caret, click target, leading status
dot, or standalone running badge. In settled final history, remove live-only
automatic compression rows unless they explain a visible recovery or error
state.
The existing two-stage proposal in `docs/ui-ux/two-stage-proposal.html` records a
compatible direction for long turns: live work can be grouped as a worklog, then
settled history can collapse while the final answer reads as the calm
conclusion. Treat that page as an existing proposal, not as shipped behavior
unless the code and tests prove it is implemented.
## Typography and content
Use split typography intentionally:
- assistant prose uses the same system sans stack as the rest of the UI by
default (`--font-ui` in `static/style.css`),
- editorial serif assistant prose is historical/proposal or skin-scoped only;
do not reintroduce a global assistant serif without explicit design approval
plus code and test evidence,
- user bubbles and functional UI also use the system sans stack unless a scoped
skin intentionally overrides typography,
- monospace is for code, file paths, commands, tool names, and compact metadata.
Keep scale tight. Avoid introducing near-duplicate one-off font sizes, colors,
radius values, or spacing values when an existing token works.
## Color, depth, and shape
Use one accent at a time. Semantic colors are for semantic state: success,
warning, error, and info. Do not mix many bright colors decoratively in the same
viewport.
Use almost no shadows in the transcript. Reserve shadows for popovers,
dropdowns, modals, and floating controls. Chat cards should usually use either a
subtle border or a subtle tint, not aggressive combinations of both.
Avoid stacks of nested rounded rectangles. Rows and list items should feel
compact; panels and cards may be slightly rounder; true pills are reserved for
chips and badges.
## Composer and controls
The composer is the command surface. Keep it legible, stable, and focused:
- no theatrical hover scaling for routine controls,
- no ambient chrome that crowds the model/workspace/profile controls,
- no new footer buttons on tight layouts without a clear value tradeoff,
- keep Stop/Send and context feedback easy to find while composing.
When adding a control, consider where users will find it on both wide desktop and
mobile. If a setting or quota/control surface does not fit in the composer, route
it through the appropriate Control Center panel instead of squeezing the footer.
## Responsive behavior
Mobile is not an afterthought. The repository documents a responsive layout with
a hamburger sidebar, mobile-accessible top tabs, a right-edge file slide-over,
full-height chat/composer behavior on phones, and touch-friendly controls.
For UI changes, verify the relevant states:
- wide desktop,
- ordinary laptop width,
- narrow/mobile width,
- open and closed side panels when relevant,
- long chat content and live streaming when relevant.
Controls should remain usable at touch sizes, and mobile navigation should not
steal chat height unnecessarily.
## Themes and skins
Theme and skin work should use the existing variable system. `THEMES.md` points
to the core palette variables in `static/style.css`; skin comments in the CSS
show the expected pattern for full palette rewrites and accent-only changes.
Current implementation has two appearance axes, sourced from `static/boot.js`:
`theme` is only `light`, `dark`, or `system` and resolves to the `.dark` class
for dark mode; `skin` is a separate axis applied with `data-skin` and currently
includes `default`, `ares`, `mono`, `slate`, `poseidon`, `sisyphus`,
`charizard`, `sienna`, `catppuccin`, `nous`, and `geist-contrast` / Geist Contrast. `slate` is both an active skin
and a legacy theme-name migration target; `solarized`, `monokai`, `nord`, and
`oled` are legacy theme names mapped to current theme/skin pairs. Do not follow
stale `data-theme`-only guidance without first proving the current
`static/boot.js`, `static/index.html`, and `static/style.css` contracts still
support it.
Do not hardcode new colors, radii, shadows, or typography values into isolated
components when a token or existing variable can carry the intent. If a token is
missing, explain why a new one is needed.
## Evidence expected for UI changes
For any interface or interaction change:
- include before/after images or a short video,
- mention the tested viewport sizes and responsive states,
- reference the affected visual inventory or design source when applicable,
- add or update tests for behavior, state persistence, or regression-prone DOM
structure where practical,
- keep stable class or data hooks when they help future visual regression tests.
## Do / don't summary
Do:
- keep the conversation primary,
- collapse noisy internals by default when settled,
- make debugging details accessible without making them visually dominant,
- use existing tokens, variables, and component patterns,
- protect action-required states such as errors and approvals.
Don't:
- make every tool call look like a separate chat message,
- add decorative color or motion without a user-facing reason,
- introduce a frontend framework, bundler, or build step for ordinary UI work,
- hide important recovery, error, or approval state,
- treat proposal mockups as shipped behavior without code/test evidence.

View File

@@ -0,0 +1,83 @@
# Advanced chat setup
Two optional features for self-hosted Hermes WebUI deployments. **Most users need neither** — the defaults (in-process chat, no prefill) work out of the box.
## Session recall prefill
WebUI can attach ephemeral prefill messages to new browser-originated
agent turns. This is useful when a deployment already has a local recall or
router script for Joplin, Obsidian, Notion, llm-wiki, or another third-party
notes source and wants browser chat to know where durable context lives.
Prefer a compact router-style prefill (for example, "Joplin has the durable
project context; use the available notes/search tools before answering
detail-dependent questions") instead of dumping the full note corpus into every
new browser session. The prefill should point the agent toward retrieval; the
notes/search tools should provide the specific facts on demand.
Static JSON remains supported through `prefill_messages_file` or
`HERMES_PREFILL_MESSAGES_FILE`. For dynamic recall, opt in explicitly with a
WebUI-specific script hook:
```yaml
webui_prefill_messages_script:
- python3
- /path/to/notes_recall.py
webui_prefill_messages_script_timeout: 5
```
or:
```bash
HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT="python3 /path/to/notes_recall.py" \
HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT=5 \
./ctl.sh restart
```
The script may print either an OpenAI-style JSON message list, a JSON object with
a `messages` list, or plain text; plain text is wrapped as one `user` prefill
message so dynamic recall text becomes ordinary context instead of an extra
system instruction. If the hook must provide system-level guidance, emit JSON
messages with an explicit `role: "system"` entry instead. Script output is capped
at 256 KiB before parsing. Parsed prefill context is then bounded by
`webui_prefill_context_max_chars` or `HERMES_WEBUI_PREFILL_CONTEXT_MAX_CHARS`
(default: 12,000 characters; set to `0` to disable). When a dynamic script
exceeds the budget and a compact static prefill file is configured, WebUI falls
back to that file. If no compact fallback is available, WebUI injects a short
retrieval instruction instead of sending the oversized note/body payload with
every new browser turn. The browser only receives a compact status event
(`source`, `label`, message count, compaction metadata, and redacted errors),
never the prefill message bodies.
## Gateway-backed browser chat
By default, browser chat runs through WebUI's in-process legacy runtime. Advanced
self-hosted deployments can opt into routing new browser turns through a running
Hermes Gateway API server while preserving the existing WebUI `/api/chat/start`
and `/api/chat/stream` browser contract:
```bash
HERMES_WEBUI_CHAT_BACKEND=gateway \
HERMES_WEBUI_GATEWAY_BASE_URL=http://127.0.0.1:8642 \
HERMES_WEBUI_GATEWAY_API_KEY=... \
./ctl.sh restart
```
`HERMES_WEBUI_CHAT_BACKEND` is intentionally strict: only `gateway`,
`api_server`, or `api-server` enable the bridge. Generic truthy values such as
`1` or `true` are ignored so existing deployments do not change execution
ownership accidentally. If `HERMES_WEBUI_GATEWAY_API_KEY` is omitted, WebUI falls
back to `API_SERVER_KEY` when present. When Gateway returns HTTP 401, WebUI
reports a `gateway_auth_error` that points at this WebUI↔Gateway key mismatch
rather than showing the Gateway's generic provider-style "Invalid API key" body.
`/api/health/agent` also includes a redacted `gateway_chat` block so operators can
see whether gateway mode, base URL, and API-key presence are configured without
exposing the key value. That `gateway_chat` field is an operator diagnostic
payload only; it is not currently rendered as a user-facing health banner in the
browser UI.
The bridge is best used by operators who already run Hermes Gateway/API Server
locally and want browser-originated chat to use the same runtime/tool path as
messaging surfaces. Attachments, cancellation, approvals, and clarify prompts
still follow WebUI's current compatibility path and may not match every messaging
surface until the runtime-adapter migration is complete.

View File

@@ -0,0 +1,111 @@
# WebUI to hermes-agent source dependency contract
This document records the current WebUI dependency on the hermes-agent source
tree for issue #2491. It is an audit and replacement contract only; it does not
change runtime behavior or Docker wiring.
Run the deterministic audit with:
```powershell
python scripts/audit_agent_source_dependencies.py
python scripts/audit_agent_source_dependencies.py --format markdown
```
The JSON output is repo-relative and grouped by stable dependency class IDs so
follow-up PRs can remove one class at a time without relying on exact line
fixtures.
## Current boundary
The multi-container setup still shares the agent source tree with the WebUI:
- `docker-compose.two-container.yml` mounts `hermes-agent-src` at `/opt/hermes`
in the agent service and read-only at
`/home/hermeswebui/.hermes/hermes-agent` in the WebUI service.
- `docker-compose.three-container.yml` uses the same source volume pattern.
- `docker_init.bash` documents that the WebUI-side mount is read-only and uses
a staged copy when installing from the source checkout.
- `docs/docker.md` explicitly documents that this is not a filesystem trust
boundary: the WebUI cannot write the mount, but it still installs and imports
code from it.
The durable target is that multi-container WebUI should not need a direct
`hermes-agent-src` mount. The WebUI should communicate with hermes-agent through
HTTP endpoints and a small versioned client/shared-schema package where pure
helpers are genuinely shared code.
## Dependency classes
| Audit class | Current surface | Replacement classification |
| --- | --- | --- |
| `docker_agent_source_volume` | Compose files and Docker docs expose `hermes-agent-src` and `/opt/hermes` to make the agent checkout visible to WebUI. | Remove the WebUI source mount only after startup install and runtime imports have migrated. This needs Docker/compose follow-up work, not a runtime behavior change in this audit PR. |
| `startup_dependency_install` | `api/startup.py` discovers `HERMES_WEBUI_AGENT_DIR` or `$HERMES_HOME/hermes-agent`; `server.py` calls `auto_install_agent_deps()` after import verification fails; `docker_init.bash` installs from the staged agent source. | Replace source-tree pip installs with a packaged hermes-agent WebUI client plus an agent health/version capability contract. Keep `HERMES_WEBUI_AGENT_DIR` during migration as an override/debug path, but it should stop being required in normal multi-container startup. |
| `runtime_auxiliary_model_metadata` | `api/streaming.py`, `api/routes.py`, `api/config.py`, and `api/providers.py` import `agent.auxiliary_client`, `agent.model_metadata`, `agent.models_dev`, `hermes_cli.models`, and `agent.account_usage`. | Existing provider/model WebUI endpoints can keep serving UI data where they already wrap agent helpers. Missing surfaces need hermes-agent endpoints or a client package for auxiliary task config, text auxiliary calls, context length, token estimate, provider catalog, and account usage. |
| `runtime_session_state` | `api/streaming.py`, `api/goals.py`, and `api/state_sync.py` import `hermes_state.SessionDB` directly. | Move cross-container state reads and writes behind hermes-agent session/state endpoints. WebUI-only presentation state can remain local, but agent session storage should not be opened from the WebUI container. |
| `runtime_gateway_provider` | `api/streaming.py` and `api/routes.py` import `hermes_cli.runtime_provider`; adapter helpers such as `agent.anthropic_adapter` are also imported for gateway normalization. | Provider resolution, runtime routing, and gateway invocation should be hermes-agent API calls. WebUI can keep request validation and display formatting, but it should not import runtime provider internals from the agent checkout. |
| `webui_local_or_client_package` | WebUI imports `hermes_cli.auth`, `hermes_cli.config`, `hermes_cli.plugins`, `hermes_cli.profiles`, `hermes_cli.goals`, `agent.skill_utils`, `agent.credential_pool`, and `hermes_constants`. | Pure schemas, constants, and parsing helpers can move into a small versioned client/shared package. Privileged data such as credential pools, auth status, profile mutation, plugin discovery, and goal persistence need hermes-agent endpoints. UI-only formatting can remain in WebUI. |
## Replacement contract
### Existing endpoint candidates
The WebUI already exposes provider, model, profile, route, and streaming
handlers that callers use today. Those handlers can remain as WebUI HTTP routes
when they only format UI responses, but their implementations should stop
loading agent modules directly. Good candidates for reusing the current WebUI
route shape while changing its backend dependency are:
- Provider/model catalog routes currently backed by `hermes_cli.models`.
- Auxiliary title/compression paths currently backed by `agent.auxiliary_client`.
- Context-window and token-estimate paths currently backed by
`agent.model_metadata`.
- Runtime-provider choices currently backed by `hermes_cli.runtime_provider`.
### New hermes-agent endpoints needed
These surfaces require an agent-owned endpoint because they read agent state,
perform provider/runtime decisions, or expose privileged data:
- SessionDB/session state read and write operations now using
`hermes_state.SessionDB`.
- Runtime provider resolution and gateway normalization now using
`hermes_cli.runtime_provider` and `agent.anthropic_adapter`.
- Auxiliary task execution and configuration now using `agent.auxiliary_client`.
- Credential/auth/account usage access now using `agent.credential_pool`,
`hermes_cli.auth`, and `agent.account_usage`.
- Profile, plugin, goal, and skill operations that mutate or discover
agent-owned resources.
### Client/shared package candidates
These items can be kept out of the live agent API if they are pure, versioned,
and safe to import without the agent source tree:
- Shared constants currently imported from `hermes_constants`.
- Provider/model schema names and non-privileged catalog shape definitions.
- Pure skill/profile parsing helpers that do not touch agent-owned state.
- Typed response/request models for the new hermes-agent endpoints.
### WebUI-local items
The WebUI can keep code that is only presentation, validation, or routing glue:
- User-facing diagnostics that display whether `HERMES_WEBUI_AGENT_DIR` is set.
- Route-level request validation and response formatting.
- WebUI-only caches and client-facing state that do not open agent SessionDB.
- Docker documentation describing the transition while both paths are supported.
## Audit expectations
`tests/test_agent_source_dependency_audit.py` pins the contract shape:
- Docker/compose source sharing is reported.
- Startup dependency installation and `HERMES_WEBUI_AGENT_DIR` are reported.
- Runtime auxiliary/model metadata imports are reported.
- Runtime SessionDB/state imports are reported.
- Runtime provider/gateway imports are reported.
- The catch-all class for local/client-package candidates remains populated.
The tests intentionally check stable class IDs and representative anchors, not
exact full fixtures. Follow-up migration PRs should update this document and the
audit expectations when a dependency class is intentionally reduced or removed.

View File

@@ -0,0 +1,191 @@
# Stable Assistant Turn Anchors Phase 0 Inventory
This inventory implements the first non-visual slice of
[`stable-assistant-turn-anchors.md`](../rfcs/stable-assistant-turn-anchors.md).
It documents the current per-turn state layers and the event-shape contract that
future anchor phases must consume. It does not claim that anchors are wired into
streaming or rendering yet.
## RFC Phase Progress
- The #3962 Phase 0 scaffold shipped through #3977 / v0.51.359: inventory the
current state layers, encode the owner seed, and pin the source classification
contract.
- PR #3980 / v0.51.366 delivered the first RFC Phase 2 foundation: normalize
current live, replay, and settled source events into anchor-shaped events while
staying unwired from rendering.
- This slice advances RFC Phase 1 and Phase 2 together: it adds a local registry
owner plus a shadow source-feed harness that can combine live, replay,
settled, and in-flight observations into one anchor snapshot.
- It also covers the RFC Phase 2.5 contract-hardening boundary: the semantic
anchor seed excludes renderer presentation state, terminal states are exposed
as constants with alias normalization, and replay + settlement ordering is
pinned by tests before visible wiring begins.
- Slice 4 starts RFC Phase 3 by routing settled assistant final prose through the
anchor owner before `renderMessages()` renders the final assistant body.
- Slice 5 starts RFC Phase 5 by projecting anchor-owned activity events into a
renderer-neutral activity scene that Compact Worklog and Transparent Stream
can later consume from the same ordered rows.
- The next independently reviewable boundary is wiring one current renderer to
the activity scene. `S.messages`, `INFLIGHT`, stream-local state, and DOM nodes
remain projection/cache layers outside the settled final-prose path and the
inert activity-scene projection.
## State Layers
| Layer | Current surface | Phase 0 anchor policy |
| --- | --- | --- |
| RuntimeAdapter / run-journal Event Envelope | `event_id`, `run_id`, `seq`, `Last-Event-ID` / `after_seq` | Preferred identity and replay dedupe source. |
| Run journal replay events | `read_run_events()`, `_replay_run_journal`, `runtime_journal_snapshot` | Durable replay hydration source before browser caches. |
| Server settled transcript | `/api/session` messages and metadata | Settlement updates final answer and terminal state on an existing turn. |
| `S.messages` | Browser transcript projection consumed by `renderMessages()` | Projection/cache, not a second semantic owner. |
| `INFLIGHT` | Browser recovery cache and persisted localStorage state | Recovery fallback only; does not outrank journal or settled transcript. |
| Stream closure state | `attachLiveStream()` local assistant text, reasoning text, parser target, tool state | Hot-path write buffer; future phases normalize this into anchor events. |
| Live DOM | `#liveAssistantTurn`, Worklog rows, tool cards, Thinking cards | Renderer output only; DOM survival is not semantic truth. |
The same inventory is encoded in `static/assistant_turn_anchors.js` as
`HermesAssistantTurnAnchors.stateLayers` so tests can pin the current authority
order.
## Slice 2 Normalizer Helper
`HermesAssistantTurnAnchors.normalizeAssistantTurnAnchorSourceEvent()` converts a
single current source event into a normalized anchor event envelope without
registering it, rendering it, or mutating browser state. It accepts live SSE-like
events (`type`, `data`, `lastEventId`), replay/journal-like events (`event`,
`payload`, `event_id`, `seq`), and settled/session payload events such as
`settled_message`.
`HermesAssistantTurnAnchors.normalizeAssistantTurnAnchorSourceEvents()` applies
the same helper to a list and dedupes repeated live + replay observations by the
same event-envelope key. This is still inert: `send()`, `attachLiveStream()`,
`renderMessages()`, settlement restore, `S.messages`, `INFLIGHT`, and the DOM do
not consume the helper yet.
## Slice 3 Registry / Owner Skeleton
`HermesAssistantTurnAnchors.createAssistantTurnAnchorRegistry()` creates a local
owner object for one assistant turn. The registry contains the anchor seed, a
dedupe index, and application stats. It is not a global store and is not wired
into current runtime, session, or renderer code.
`HermesAssistantTurnAnchors.applyAssistantTurnAnchorSourceEvent()` and
`applyAssistantTurnAnchorSourceEvents()` normalize incoming source events, apply
the same event-envelope dedupe rule, and route events into one owner:
- `activity_events` for visible assistant activity such as prose, reasoning,
tools, control boundaries, and terminal status
- `artifacts` for workspace/file references
- `side_effects` for persisted state side effects
- `metadata_events` for settlement/session metadata such as `settled_message`
- `transport_events` for transport-only signals such as `stream_end`
The registry may fill missing `run_id` / `stream_id` identity from the first
matching normalized event, update lifecycle on terminal status, and copy the
settled assistant message into `content.final_answer` as a derived render
snapshot while keeping `content.final_message_ref` as the settled transcript
reference. It rejects mismatched session or turn identity and skips duplicate
live + replay observations by the same dedupe key.
This slice deliberately keeps the ownership boundary inert: `send()`,
`attachLiveStream()`, replay hydration, `renderMessages()`, `S.messages`,
`INFLIGHT`, and DOM continuity still do not consume the registry. Later slices
can replace local renderer-owned state with this owner instead of adding another
parallel source of truth.
`HermesAssistantTurnAnchors.createAssistantTurnAnchorShadowSnapshot()` is the
shadow wiring harness for this slice. It accepts grouped `live_events`,
`replay_events` / `run_journal_events`, `settled_events`, and `inflight_events`,
feeds them through one local registry, and returns the resulting snapshot plus
per-source apply results. This gives later slices an invariant target without
making the current UI consume the owner yet.
Renderer-only UI state such as Compact Worklog expansion, Transparent Stream
expansion, copy-button visibility, and scroll-follow preference is intentionally
not stored in the anchor seed. Those choices belong in renderer state or a
separate per-session UI preference store so replay and settlement do not carry
historic display preferences as semantic facts.
`HermesAssistantTurnAnchors.terminalStates` exposes the RFC terminal-state enum:
`completed`, `cancelled`, `interrupted`, `no_response`,
`tool_limit_reached`, `compression_exhausted`, `connection_lost`, `degraded`,
and `error`. `normalizeAssistantTurnAnchorTerminalState()` maps current source
aliases such as `done`, `cancel`, `apperror`, `interrupted-by-user`,
`max_iterations`, and `lost_worker_bookkeeping` into that enum.
During the later `INFLIGHT` migration, the registry is the semantic owner for
event identity, lifecycle, final answer reference, and activity events.
`INFLIGHT.lastRunJournalSeq`, `activityBurstAnchors`, `currentLiveSegmentSeq`,
`streamId`, and cached live text/tool state remain recovery or renderer caches
until the matching field is explicitly moved. The fallback order is journal
replay first, settled transcript second, `INFLIGHT` only for gaps.
## Slice 4 Settled Final Projection
`HermesAssistantTurnAnchors.projectAssistantTurnAnchorSettledMessageFinalAnswer()`
projects one settled assistant transcript message through a local anchor
registry. The settled transcript message reference remains the semantic
authority (`content.final_message_ref`); `content.final_answer` is a derived
render snapshot for the existing markdown pipeline.
`renderMessages()` uses that projection only for settled assistant messages
(`!isUser && !m._live`) and only after preserving the current content-array
flattening behavior. It then continues through the existing inline-thinking and
markdown rendering pipeline. If the anchor helper is unavailable or cannot
produce a final answer, `renderMessages()` falls back to the existing message
content path.
This is intentionally narrower than render-scene ownership: live stream tokens,
replay hydration, worklog rows, transparent-stream rows, tool cards, `INFLIGHT`,
and DOM continuity are still not consumed by the anchor registry in this slice.
## Slice 5 Activity Scene Projection
`HermesAssistantTurnAnchors.projectAssistantTurnAnchorActivityScene()` projects
an anchor or registry into `activity_scene_v1`: identity, lifecycle,
`final_answer`, `final_message_ref`, terminal state, and an ordered
`activity_rows` list.
The rows are renderer-neutral. Compact Worklog receives display hints such as
`main_prose`, `collapsed_thinking`, `tool_row`, and `terminal_status_row`.
Transparent Stream receives the same row IDs, order, kinds, roles, text, tool
IDs, and sanitized payloads with a chronological display hint. This pins the
shared input shape before either renderer is rewired.
This slice is still inert. No current UI module consumes the activity scene.
`renderMessages()` and the live streaming hot path are unchanged by this slice.
## Source Event Classification
Phase 0 classifies current sources before changing render behavior:
- activity: `token`, `interim_assistant`, `reasoning`, `tool`,
`tool_complete`, `tool_update`, `compressing`, `compressed`, `approval`,
`clarify`, `pending_steer_leftover`, `goal_continue`, `done`, `cancel`,
`error`, `apperror`
- artifact: `artifact_reference`
- side effect: `state_saved`
- metadata: `usage`, `title`, `settled_message`, `runtime_journal_snapshot`,
`inflight_snapshot`
- transport: `stream_end`
Future phases may add sources, but every source must choose one of these classes
or explicitly mark itself `excluded`.
## Dedupe Invariant
Anchor event dedupe is intentionally independent of visible text and timestamps.
The Phase 0 helper uses this order:
1. `event_id`
2. `run_id + seq`
3. `session_id + source_event_type + local_id + seq` as a browser fallback only
when a concrete local `seq` is present
This mirrors the RuntimeAdapter Event Envelope and keeps the browser aligned
with run-journal replay while the anchor registry is still unwired.
The registry tests also pin the reconnect/settlement race shape: if one run is
observed live, replayed, and settled in either order, duplicate event envelopes
are skipped and the resulting anchor has the same activity list, terminal state,
final message reference, final-answer snapshot, and usage metadata.

View File

@@ -0,0 +1,98 @@
# Unified SessionDB Adapter Spike
WebUI currently persists conversations as JSON files under the WebUI session
directory, while the CLI uses its own session database. The first safe slice of
unification is a dormant adapter that presents a small SessionDB-shaped API over
the existing WebUI JSON files without changing runtime call sites or file
format.
## Adapter Contract
`api.webui_session_db.WebUIJsonSessionDB` exposes:
- `list_sessions()` returns compact metadata rows for persisted WebUI JSON
sessions.
- `read_session(sid)` returns a full session JSON payload or `None`.
- `update_metadata(sid, fields)` writes only allowlisted metadata fields and
rejects unsafe keys such as `session_id`, `messages`, `tool_calls`, and
`message_count`.
- `archive(sid, archived=True)` is a convenience metadata update for the
archived flag.
- `write_session(session)` exists for tests and migration experiments that need
to materialize a complete JSON payload.
Read operations must not call `Session.load()` or `all_sessions()`, because
those paths can repair indexes or transcripts. Metadata writes must load the
complete JSON payload, verify that a real `messages` list is present, update only
safe fields, recompute `message_count`, and atomically replace the file. The
adapter must never write a metadata-only stub that could drop transcript
messages.
## Why JSON-Backed And Dormant
The selected first slice is infrastructure only. Keeping the adapter backed by
the current JSON sidecars validates the API shape while preserving all current
WebUI behavior, backups, and import paths. The feature flag defaults to:
```yaml
experimental:
unified_session_db: false
```
No UI exposes this flag, and no runtime session route switches to the adapter in
this slice.
## Runtime Wiring Preconditions
Before any route uses this adapter for live metadata changes, a follow-up PR must
prove parity with the existing `Session.save()` path:
- take the same per-session mutation locks used by streaming and session routes,
so metadata writes cannot replace a newer transcript with a stale copy;
- refresh or invalidate the in-memory `Session` cache and `_index.json`, so
sidebar rows and later `Session.save()` calls cannot overwrite adapter changes;
- match `Session.compact()` sidebar semantics for pending first turns,
`has_pending_user_message`, `pending_started_at`, and real non-tool
`last_message_at` ordering.
Until those invariants are implemented, `update_metadata()` and `archive()` are
test/migration helpers, not runtime persistence replacements.
## Planned Migration Sequence
1. Land the dormant JSON adapter and contract tests.
2. Add parity tests that compare adapter reads with existing WebUI sidebar and
session payloads.
3. Introduce an opt-in dual-read or shadow-read mode for development builds.
4. Add a migration path that can write unified SessionDB records without
deleting or rewriting JSON sidecars.
5. Switch selected call sites behind the flag only after parity and rollback
behavior are proven.
6. Make the unified store authoritative in a later release after import,
archive, pin, profile, project, and recovery semantics match WebUI JSON.
## Authoritative Fields And Open Questions
The JSON sidecar remains authoritative for `messages`, `tool_calls`, metadata
display fields, profile/project ownership, archive and pin state, token/cost
totals, pending stream recovery fields, worktree metadata, and composer draft
state during this spike.
Open questions for later slices:
- Whether `updated_at` should reflect metadata-only changes such as archive and
pin operations or only transcript changes.
- How to resolve conflicts when CLI and WebUI update titles, archive state, or
project/profile ownership concurrently.
- Whether imported CLI sessions remain read-only projections or become editable
unified records.
- How unified records should preserve WebUI recovery safeguards such as backup
creation before transcript shrinkage.
- Which store owns sidebar ordering once JSON and SessionDB records coexist.
## Out Of Scope
This spike does not switch runtime WebUI call sites, migrate existing session
files, expose a UI setting, alter CLI storage, change session import behavior, or
remove any JSON sidecars. It is a contract and safety test bed for future
migration work.

View File

@@ -11,8 +11,34 @@ This is the comprehensive Docker reference. For a 5-minute quickstart, see the [
| **Three-container** | Two-container PLUS the dashboard for monitoring. | `docker-compose.three-container.yml` |
| **All-in-one image** (community fork — third-party, not maintained by us) | Podman 3.4 / multi-arch / supervisord-style preference. | [sunnysktsang/hermes-suite](https://github.com/sunnysktsang/hermes-suite) — see [#1399](https://github.com/nesquena/hermes-webui/issues/1399) for the original discussion |
> **Note (v0.14+):** If you use `docker-compose.three-container.yml`, both
> `hermes-agent` and `hermes-dashboard` initialise from the same image and write
> to the same `hermes-home` volume simultaneously. This can cause overlapping lock
> files and stale `gateway_state.json` entries. The unified pattern described in
> [Three-service unified setup (v0.14+)](#three-service-unified-setup-v014) below
> avoids this by running a single `hermes-agent` process that serves both the
> gateway and the dashboard.
If something stops working, **start with the single-container setup** — it's the simplest path and fixes most permission/UID/path-mismatch issues by construction.
## Production image security model
The production Docker image is hardened for the normal single-tenant container threat model:
Hermes WebUI assumes one operator controls the container, mounted Hermes home, and workspace.
The image does **not** install `sudo`, does not add runtime users to a sudo group, and does not
grant `NOPASSWD` escalation. If an agent/tool process gains a shell as `hermeswebui`, it should
not be able to become root with a passwordless sudo command.
The entrypoint still starts as `root` for a narrow init phase because Docker bind mounts often need
UID/GID alignment and ownership preparation before the app can read `~/.hermes`, `/workspace`,
`/app`, and `/uv_cache`. After that setup, `docker_init.bash` re-execs itself as the unprivileged
`hermeswebui` user and starts the server there. Init scratch files under `/tmp/hermeswebui_init`
are owner-only (`0700` directory, `0600` files), not world-writable.
For multi-tenant or hostile-container environments, rebuild with your own runtime user, mount policy,
and supervisor assumptions. Development images that need package-manager convenience should add
those tools in a dev-only Dockerfile instead of reintroducing passwordless sudo to production.
## 5-minute quickstart (single container)
```bash
@@ -24,10 +50,235 @@ docker compose up -d
open http://localhost:8787
```
That's it. Your existing `~/.hermes` directory is mounted, your `~/workspace` is browsable, and the WebUI auto-detects your UID/GID from the mounted volume.
That's it for a real personal Docker install. Your existing `~/.hermes`
directory is mounted, your `~/workspace` is browsable, and the WebUI
auto-detects your UID/GID from the mounted volume.
The single-container setup runs the WebUI only. It can create cron jobs and run
them manually from the Tasks panel. In Docker, scheduled jobs require the Hermes gateway daemon
to tick while you are away. If System Settings shows `Gateway not configured`,
use `docker-compose.two-container.yml`,
`docker-compose.three-container.yml`, or run `hermes gateway` separately before
relying on offline scheduled runs. See [Scheduled jobs and the gateway daemon](#scheduled-jobs-and-the-gateway-daemon) below for the full background and verification steps.
For troubleshooting, reinstall, or onboarding reproduction trials, do not mount
your real `~/.hermes` unless you intentionally want to test real state. Use an
isolated Hermes home and follow
[`docs/onboarding-agent-checklist.md`](onboarding-agent-checklist.md) instead.
> **Linux note**: run Compose as the user who owns the Hermes home. The command
> `sudo docker compose up -d` can make Compose expand `${HOME}` as `/root`, so
> the default `${HOME}/.hermes` bind mount becomes `/root/.hermes` instead of
> your user's real Hermes directory. Prefer adding your user to the `docker group`
> and running `docker compose up -d`; if you must preserve the caller environment
> for a one-off root run, use `sudo -E docker compose up -d` and verify the
> rendered mount with `docker compose config` first.
## Optional GPU runtime image
The default Hermes WebUI Docker image stays CPU-only. GPU user-space packages
are installed only when you build a custom image with the opt-in build arg:
```bash
docker build --build-arg INSTALL_GPU_LIBS=1 -t hermes-webui:gpu .
```
That build path installs VA-API basics (`libva2`, `vainfo`), AMD Mesa VA-API
drivers (`mesa-va-drivers`), and the Intel non-free media driver when that
package is available from the configured Debian repositories. NVIDIA host
runtime tooling is not installed into the app image; use the NVIDIA Container
Toolkit on the host and pass GPUs through at runtime.
GPU passthrough still depends on host drivers, Docker runtime support, and
device mappings. The commands below are configuration guidance for a suitable
Linux Docker host; they are not a claim that native GPU passthrough was verified
in this workspace.
### Intel and AMD VA-API
Expose the host render devices and add the runtime user to the common video and
render groups:
```bash
docker run --rm \
--device /dev/dri:/dev/dri \
--group-add video \
--group-add render \
hermes-webui:gpu vainfo
```
For Compose, add the same mapping to a custom service definition:
```yaml
services:
hermes-webui:
image: hermes-webui:gpu
devices:
- /dev/dri:/dev/dri
group_add:
- video
- render
```
`vainfo` should list the VA-API driver and supported profiles when the host
driver stack and container permissions are correct. The container entrypoint
preserves Docker-provided supplemental groups before it drops privileges to the
`hermeswebui` runtime user, so the WebUI process keeps access to `/dev/dri`.
### NVIDIA
Install and configure the NVIDIA Container Toolkit on the host first, then use
Docker's GPU runtime flag:
```bash
docker run --rm --gpus all hermes-webui:gpu nvidia-smi
```
For Compose, use a custom service with GPU access enabled:
```yaml
services:
hermes-webui:
image: hermes-webui:gpu
gpus: all
```
If `nvidia-smi` is unavailable or reports no devices, fix the host NVIDIA driver
and container toolkit setup before debugging Hermes WebUI. The container image
only supplies the WebUI plus optional user-space media libraries; it cannot
provide host kernel drivers or the NVIDIA runtime.
## Scheduled jobs and the gateway daemon
**Symptom**: Cron jobs created in the Tasks panel never fire. System Settings or Tasks shows:
- Orange "Gateway not configured", or
- Red "Gateway metadata stale" when runtime metadata is stale, or
- Red "Gateway endpoint not reachable" when WebUI has a gateway URL configured but cannot reach its health endpoint.
**Cause**: Scheduled cron ticks are not driven by the WebUI itself. The gateway daemon ticks the scheduler every 60 seconds; without one running, scheduled jobs sit idle. "Run now" / "Trigger" buttons still work because the WebUI handles those in-process.
In older gateway builds, or when the daemon runs in a separate container, `gateway_state.json` can become stale and WebUI may lose confidence even if the daemon is up. This is especially visible if only base URLs are configured (e.g. `HERMES_WEBUI_GATEWAY_BASE_URL`) and local daemon state files are not being refreshed.
**Fix**: Run a gateway container alongside the WebUI. The two-container compose file is the recommended path:
```bash
cp .env.docker.example .env
docker compose -f docker-compose.two-container.yml up -d
```
The three-container layout adds the dashboard but is otherwise the same shape. If you must stay single-container, you can run `hermes gateway` inside the container as a long-lived background process, but the compose split is sturdier.
**Verify**: Once the gateway is up, the System Settings pill should turn green and the Tasks banner disappear. From the host:
```bash
export GATEWAY_BASE_URL="${HERMES_API_URL:-${HERMES_WEBUI_GATEWAY_BASE_URL:-http://hermes:8642}}"
docker compose -f docker-compose.two-container.yml exec hermes-agent hermes gateway status
curl -sS "${GATEWAY_BASE_URL%/}/health/detailed" | jq '.gateway_state, .state'
```
If the service name differs in your compose file, `docker compose -f docker-compose.two-container.yml ps` lists the running services.
For container-to-container diagnostics, set one of `HERMES_API_URL` or `HERMES_WEBUI_GATEWAY_BASE_URL` in the WebUI environment when using gateway chat mode (`HERMES_WEBUI_CHAT_BACKEND=gateway`), then restart WebUI.
Refs #2785.
## Three-service unified setup (v0.14+)
Since v0.14, `hermes-agent` can serve the gateway API and the built-in dashboard
from the same process by setting `HERMES_DASHBOARD_HOST` and
`HERMES_DASHBOARD_PORT`. Running agent and dashboard in one container means a
single writer to `hermes-home`, eliminating the concurrent-init write conflicts
that occur when `hermes-agent` and `hermes-dashboard` both start from the same
image against the same volume.
The three-service pattern uses two containers:
| Service | Image | Ports |
|---|---|---|
| `hermes-agent` | `nousresearch/hermes-agent:latest` | 8642 (gateway), 9119 (dashboard) |
| `hermes-webui` | `ghcr.io/nesquena/hermes-webui:latest` | 8787 (chat UI) |
Example compose snippet (save as `docker-compose.three-service.yml` or inline into your own file):
```yaml
services:
hermes-agent:
image: nousresearch/hermes-agent:latest
container_name: hermes-agent
command: gateway run
ports:
- "127.0.0.1:8642:8642"
- "127.0.0.1:9119:9119"
volumes:
- hermes-home:/home/hermes/.hermes
- hermes-agent-src:/opt/hermes
environment:
- HERMES_HOME=/home/hermes/.hermes
- HERMES_UID=${UID:-1000}
- HERMES_GID=${GID:-1000}
- HERMES_DASHBOARD_HOST=0.0.0.0
- HERMES_DASHBOARD_PORT=9119
restart: unless-stopped
networks:
- hermes-net
hermes-webui:
image: ghcr.io/nesquena/hermes-webui:latest
container_name: hermes-webui
depends_on:
- hermes-agent
ports:
- "127.0.0.1:8787:8787"
volumes:
- hermes-home:/home/hermeswebui/.hermes
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro
- ${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace
environment:
- HERMES_WEBUI_HOST=0.0.0.0
- HERMES_WEBUI_PORT=8787
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui
- WANTED_UID=${UID:-1000}
- WANTED_GID=${GID:-1000}
restart: unless-stopped
networks:
- hermes-net
networks:
hermes-net:
driver: bridge
volumes:
hermes-home:
hermes-agent-src:
```
Open http://localhost:8787 for chat and http://localhost:9119 for the dashboard.
Check `hermes gateway run --help` for the exact flag names for your agent release —
the env-var equivalents shown above (`HERMES_DASHBOARD_HOST`, `HERMES_DASHBOARD_PORT`)
are available in recent releases alongside the CLI flags.
If you need the separate dashboard container (e.g. resource limits per service),
`docker-compose.three-container.yml` still works. Add a `depends_on` from
`hermes-dashboard` to `hermes-agent` with a `condition: service_healthy` healthcheck
so the dashboard waits for the gateway to finish initialising agent-home before it
starts its own init pass.
## What goes wrong (and how to fix it)
### Compatibility policy and version pinning
WebUI shows the version it is currently running, but that display does not in itself guarantee tested compatibility with your agent release.
Until the compatibility boundary work in [#1925](https://github.com/nesquena/hermes-webui/issues/1925) and [#2491](https://github.com/nesquena/hermes-webui/issues/2491) land, the WebUI and Hermes Agent deployment should be treated as a release pair: the WebUI release is tested against its matching agent release and should be upgraded/pinned together.
If you use `latest`, use it consistently on both sides and avoid mixing a fixed tag with `latest`:
- fixed WebUI tag + `hermes-agent:latest`
- `hermes-webui:latest` + fixed `hermes-agent` tag
In multi-container setups, if you must run a pinned pair, prefer the matching tag in `docker-compose.two-container.yml`/`docker-compose.three-container.yml` and perform the agent-volume refresh workflow in [Upgrading the agent container](#upgrading-the-agent-container) whenever you upgrade the agent image.
If you see behavior issues after a mixed-version upgrade, capture both WebUI and hermes-agent versions and the compose layout in the issue.
### 1. "Permission denied" at startup
**Symptom**: Container starts but immediately crashes, logs show:
@@ -123,6 +374,27 @@ If you must use a bind mount: pick a host path, then mount it to `/opt/hermes` i
**Fix**: Either upgrade to Podman 4+ (which fixes this), or use the [single-container setup](#5-minute-quickstart-single-container), or use the [community all-in-one image](https://github.com/sunnysktsang/hermes-suite).
### 8. "API base URL set to localhost fails from Docker" (#3012)
**Symptom**: A provider, local model server, webhook, or custom API works on the host at `http://localhost:<port>`, but fails when the same URL is configured in Hermes WebUI running in Docker.
**Cause**: Inside a container, `localhost` means *that container*, not your laptop/host. The WebUI process cannot reach host services through `127.0.0.1` unless the service is running inside the same container.
**Fix**: Point Docker-hosted WebUI at the host gateway name instead:
- Docker Desktop on macOS/Windows: `http://host.docker.internal:<port>`
- Podman: `http://host.containers.internal:<port>`
- Linux Docker Engine: either publish the host service on the Docker bridge address, or add a host-gateway alias to your compose service:
```yaml
services:
hermes-webui:
extra_hosts:
- "host.docker.internal:host-gateway"
```
Then configure the URL as `http://host.docker.internal:<port>`. Also ensure the host service binds to an address reachable from containers (not only a loopback interface the Docker bridge cannot reach) and that your host firewall allows the connection.
## Multi-container architecture
The two- and three-container setups use **named Docker volumes** (not bind mounts) by default for a reason: named volumes solve the UID/GID problem by construction. Docker creates the volume's root directory with the correct ownership, all containers reading/writing to it see the same files, no host-side permission setup required.
@@ -148,7 +420,52 @@ The two- and three-container setups use **named Docker volumes** (not bind mount
└─────────────────────────┘
```
The WebUI container doesn't ship with the agent's Python deps — at startup it runs `uv pip install /home/hermeswebui/.hermes/hermes-agent` to install them from the shared volume.
The WebUI container doesn't ship with the agent's Python deps — at startup it runs `uv pip install /home/hermeswebui/.hermes/hermes-agent` to install them from the shared volume. The WebUI mount is read-only; the agent container is the only writer.
## Upgrading the agent container
The `hermes-agent-src` named volume is initialised from the agent image's `/opt/hermes` on first `up`. Docker reuses the volume verbatim on every subsequent `up`**even after `docker pull` of a newer agent image**. The cached volume content masks the new image's source tree, so a fresh `docker pull` of `nousresearch/hermes-agent:latest` does not by itself give you the new agent code, dependencies, or entrypoint.
This is the root cause of [#1416](https://github.com/nesquena/hermes-webui/issues/1416): the symptom looked like a missing entrypoint, but the entrypoint was actually present in the new image and hidden behind the stale named volume.
To upgrade the agent image cleanly, drop the source volume before recreating:
```bash
# Two-container setup
docker compose -f docker-compose.two-container.yml down
docker volume rm <project>_hermes-agent-src
docker compose -f docker-compose.two-container.yml pull
docker compose -f docker-compose.two-container.yml up -d
# Three-container setup
docker compose -f docker-compose.three-container.yml down
docker volume rm <project>_hermes-agent-src
docker compose -f docker-compose.three-container.yml pull
docker compose -f docker-compose.three-container.yml up -d
```
Replace `<project>` with your Compose project name (the parent directory by default; check with `docker volume ls`). The `hermes-home` volume (config, sessions, state) is left untouched — only `hermes-agent-src` (the agent's installed Python source) is recreated.
> The single-container setup (`docker-compose.yml`) does not use `hermes-agent-src` and is not affected by this upgrade pattern — pulling a newer WebUI image and `docker compose up -d --force-recreate` is sufficient.
## What the multi-container setup isolates (and what it doesn't)
The two- and three-container setups give you **process, network, and resource isolation** between the gateway and the chat UI:
- Each service has its own PID namespace and lifecycle — the agent process can crash without taking down the chat UI and vice versa.
- The gateway API (port 8642) is bound by the agent service only; the WebUI cannot bind it. Other containers reach the gateway via the `hermes-net` Docker network.
- Resource limits (`deploy.resources.limits` in `docker-compose.three-container.yml`) apply per service, so you can cap the agent independently of the dashboard.
- Restart policies, log streams, and container health checks are scoped per service.
What multi-container does **not** isolate:
- **Filesystem boundary.** Both services share `hermes-home` (config, sessions, state), and the WebUI mounts the agent's installed source from `hermes-agent-src`. The WebUI mount is read-only (since v0.51.84), but the agent service still has write access, and both services share the home volume.
- **UID/GID boundary.** Both services default to `${UID:-1000}` so files written by one are readable by the other. If you align them to different UIDs you'll get permission errors on the shared volume.
- **Trust boundary on the agent source.** The WebUI installs Python dependencies from the shared `hermes-agent-src` volume at startup. The read-only mount means a compromised WebUI cannot rewrite the agent source, but it does run code from that volume.
If you need **filesystem isolation** between the chat UI and the agent (e.g. you don't trust the WebUI to read agent state), the multi-container setup is not enough — run the agent on a separate host and connect the WebUI to it via the gateway HTTP API. If you don't need any boundary, the single-container setup is simpler.
The direct source mount is a compatibility bridge, not the long-term API contract. The current source/API boundary inventory and decoupling task list live in [`docs/rfcs/agent-source-boundary.md`](rfcs/agent-source-boundary.md) for [#2453](https://github.com/nesquena/hermes-webui/issues/2453). If you customize the compose files with bind mounts, keep the WebUI-side agent source mount read-only unless you are intentionally doing local development; `docker_init.bash` warns at startup when that path is writable.
## Bind-mount migration (advanced)
@@ -174,7 +491,8 @@ volumes:
1. The host directory MUST be readable by your container UID. Run `id -u` on the host and ensure `~/.hermes` is owned by that UID (or readable via group bits).
2. ALL containers sharing the volume must run as the SAME UID/GID. Set `UID=$(id -u)` and `GID=$(id -g)` in `.env`.
3. If your host `.env` is mode 0640, set `HERMES_SKIP_CHMOD=1` or `HERMES_HOME_MODE=0640` so the startup hook doesn't try to enforce 0600.
3. If you run Compose with sudo, do not rely on `${HOME}` defaults: `sudo` often changes `$HOME` to `/root`, so `${HERMES_HOME:-${HOME}/.hermes}` becomes `/root/.hermes`. Prefer running Docker as your user; otherwise pass absolute paths with `sudo -E`, for example `HERMES_HOME=/home/youruser/.hermes HERMES_WORKSPACE=/home/youruser/workspace sudo -E docker compose up -d`, and confirm the rendered bind mount with `docker compose config`.
4. If your host `.env` is mode 0640, set `HERMES_SKIP_CHMOD=1` or `HERMES_HOME_MODE=0640` so the startup hook doesn't try to enforce 0600.
## Reference
@@ -187,8 +505,12 @@ volumes:
## Related issues
- #1416 — agent-image upgrade requires removing `hermes-agent-src` named volume (see [Upgrading the agent container](#upgrading-the-agent-container))
- #1389`HERMES_HOME_MODE` override (fixed in v0.50.254 — agent honors `HERMES_SKIP_CHMOD` and `HERMES_HOME_MODE`)
- #1399 — UID alignment in compose files (fixed in v0.50.260 via PR #1428 + this guide)
- #3012 — host `localhost` API URLs fail from Docker containers (use `host.docker.internal` / `host.containers.internal`)
- #3006`sudo docker compose` can mount `/root/.hermes` instead of the user's Hermes home
- #3243 — optional GPU runtime image/docs for containerized acceleration workloads
- #858 — two-container `/opt/hermes` path confusion
- #681 — tools running in WebUI container, not agent container (architectural)
- #668 — auto-detect UID/GID from mounted volume

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

View File

@@ -0,0 +1,207 @@
# Agent-assisted onboarding checklist
This checklist is for an AI assistant helping a human install, reinstall, or
debug Hermes WebUI onboarding. It does not replace the human first-run wizard.
Use it before running bootstrap commands, inspecting logs, or recommending a
cleanup path.
If you are an AI assistant, read this file before assisting with onboarding,
bootstrap, provider setup, reinstall, or first-run support.
## Role split
The human operator owns:
- choosing the install path
- choosing the provider and model
- entering API keys, OAuth codes, and passwords
- approving any cleanup of a real Hermes home
- approving any external exposure outside localhost
The assistant owns:
- using isolated trial directories unless the human explicitly says otherwise
- checking non-secret status endpoints and logs
- explaining which step passed or failed
- collecting redacted evidence for Discord or GitHub support
- stopping before destructive cleanup, credential handling, or public exposure
## Hard safety rules
- Do not delete, move, or overwrite the real `~/.hermes` directory unless the
human explicitly asks for that exact action.
- Do not print API keys, OAuth tokens, cookies, full `.env` files, full
`auth.json` files, or password hashes.
- Do not modify real cron jobs, real sessions, real profiles, or real memory
files during an onboarding trial.
- Do not expose WebUI on a public interface without password protection and
explicit human approval.
- Do not proxy or tunnel local service checks such as `localhost`,
`127.0.0.1`, private LAN addresses, or Docker container loopback paths.
## Pre-flight
Confirm the basic context:
```bash
pwd
git branch --show-current
git rev-parse --short HEAD
python3 --version
```
Check whether repo-local environment overrides will affect bootstrap:
```bash
test -f .env && grep -n 'HERMES_HOME\|HERMES_WEBUI_STATE_DIR\|HERMES_WEBUI_PORT\|HERMES_WEBUI_HOST' .env
```
If `.env` exists, do not print the full file. Inspect only the specific
non-secret keys needed to understand the active Hermes home, WebUI state
directory, port, or host.
## Isolated local trial
Use an isolated Hermes home and WebUI state directory for a reinstall or support
trial. This keeps the test away from the operator's real memory, sessions,
profiles, credentials, and cron state.
```bash
mkdir -p ~/hermes-onboarding-test
HERMES_HOME=~/hermes-onboarding-test/.hermes \
HERMES_WEBUI_STATE_DIR=~/hermes-onboarding-test/webui \
HERMES_WEBUI_PORT=8789 \
python3 bootstrap.py
```
Open:
```text
http://127.0.0.1:8789
```
The bootstrap writes a port-specific log under the selected WebUI state
directory:
```text
~/hermes-onboarding-test/webui/bootstrap-8789.log
```
For daemon-style installs, `ctl.sh` writes the daemon log to the active
`HERMES_HOME` by default:
```text
~/.hermes/webui.log
```
When using the isolated trial environment, prefer the bootstrap command above
unless the human specifically wants to validate `ctl.sh`.
## Non-secret evidence commands
After the server starts, collect status without secrets:
```bash
curl -sS http://127.0.0.1:8789/health
curl -sS http://127.0.0.1:8789/api/onboarding/status
find ~/hermes-onboarding-test -maxdepth 3 -type f | sort
tail -n 120 ~/hermes-onboarding-test/webui/bootstrap-8789.log
```
When summarizing `/api/onboarding/status`, focus on:
- `completed`
- `system.hermes_found`
- `system.imports_ok`
- `system.config_path`
- `system.config_exists`
- `system.setup_state`
- `system.provider_configured`
- `system.provider_ready`
- `system.chat_ready`
- `system.current_provider`
- `system.current_model`
- `system.current_base_url`
- `system.env_path`
Do not paste the full payload if it contains unexpected sensitive local paths
or values. Redact paths and provider details when the human asks for a public
GitHub or Discord support report.
## Pass criteria
A local onboarding trial passes when:
- `/health` returns successfully.
- `/api/onboarding/status` returns JSON.
- The wizard appears when `completed` is false.
- The wizard stays out of the way when `completed` is true or
`HERMES_WEBUI_SKIP_ONBOARDING=1` is intentionally set.
- `system.hermes_found` and `system.imports_ok` match the expected bootstrap
state.
- `system.provider_ready` and `system.chat_ready` become true after the human
completes a provider path that should support chat.
- `system.config_path` and `system.env_path` point inside the intended isolated
`HERMES_HOME` during a trial.
- WebUI files are written under the intended `HERMES_WEBUI_STATE_DIR`.
If the human chooses a provider that must be completed in the CLI, passing can
mean the wizard correctly points them to `hermes model` or `hermes auth` rather
than trying to collect unsupported credentials in the browser.
## Failure triage
If the server does not start:
- check the bootstrap log
- check for a port conflict on `8789`
- confirm Python can run `bootstrap.py`
- confirm `.env` is not overriding the isolated directories or port
If onboarding reports `agent_unavailable`:
- confirm the bootstrap found or installed Hermes Agent
- check whether the running Python can import `run_agent.AIAgent`
- use `docs/troubleshooting.md`, especially the `AIAgent not available` flow
If onboarding reports `provider_incomplete`:
- confirm whether the provider is API-key based, OAuth based, or local
- let the human enter credentials or run the CLI auth flow
- do not ask the human to paste secrets into chat
If a local model server does not probe successfully:
- from native macOS/Linux, use `http://127.0.0.1:<port>/v1` when the server is
on the same host
- from Docker Desktop, use `http://host.docker.internal:<port>/v1`
- from another LAN machine, use the server's LAN IP and `/v1`
- remember that `localhost` inside a container is the container itself
If password or reverse-proxy behavior is confusing:
- keep the first pass on `127.0.0.1`
- require password protection before exposing WebUI beyond localhost
- include the reverse proxy shape in the support report without pasting tokens
or cookies
## Final support report
Use this shape when reporting results to the human, Discord, or GitHub:
```text
Install path:
OS / Python:
Repo commit:
Command used:
WebUI URL:
State isolation:
Health result:
Onboarding status summary:
Files created or changed:
Log excerpt:
Pass/fail:
Next recommended action:
```
Redact secrets and private paths before posting publicly.

211
docs/onboarding.md Normal file
View File

@@ -0,0 +1,211 @@
# First-run onboarding guide
This guide explains what happens the first time Hermes WebUI starts, which
setup path to choose, and how to recover when the wizard cannot finish.
If an AI assistant is helping with install, reinstall, bootstrap, provider
setup, or first-run support, read
[`docs/onboarding-agent-checklist.md`](onboarding-agent-checklist.md) before
running commands or inspecting logs.
The short version: run the bootstrap, open the WebUI, choose a provider, choose
a workspace, optionally set a password, then start a chat. If you are using a
local model server from Docker, pay special attention to the Base URL section
below.
## Before you start
Hermes WebUI is only the browser interface. The actual agent runtime, memory,
skills, config, cron jobs, and provider credentials belong to Hermes Agent.
The bootstrap supports Linux, macOS, and WSL2. Native Windows is not supported
by the bootstrap yet. A community native Windows setup is being tracked in
[#1952](https://github.com/nesquena/hermes-webui/issues/1952), including:
- [Native Windows guide](https://github.com/markwang2658/hermes-windows-native-guide)
- [Native Windows setup scripts](https://github.com/markwang2658/hermes-windows-native)
For Windows users who want the supported path today, use WSL2 and see
[Windows / WSL auto-start](wsl-autostart.md).
## Install path choices
| Path | Use it when | Notes |
|---|---|---|
| Local bootstrap | You run WebUI directly on Linux, macOS, or WSL2 | Best for a personal server, Mac mini, VPS, or homelab host. |
| Docker single-container | You want the simplest container setup | Recommended first Docker path. WebUI runs the agent in-process. |
| Docker two-container | You already run the agent gateway separately | More isolated, but tools launched from WebUI run in the WebUI container. |
| Docker three-container | You want agent gateway plus dashboard plus WebUI | Same caveats as two-container, plus the dashboard service. |
| Native Windows community path | You are intentionally testing unsupported native Windows | Community-maintained for now, not the official bootstrap path. |
If a Docker install gets confusing, start again with the single-container setup.
It avoids most UID/GID, source-volume, and tool-location surprises. See
[Docker setup guide](docker.md) for the full container reference.
## Re-running onboarding safely
Do not delete `~/.hermes` just to see the wizard again. That directory can hold
your real Hermes config, credentials, memory, skills, profiles, sessions, and
cron state.
For a clean local trial, use an isolated Hermes home and WebUI state directory:
```bash
mkdir -p ~/hermes-onboarding-test
HERMES_HOME=~/hermes-onboarding-test/.hermes \
HERMES_WEBUI_STATE_DIR=~/hermes-onboarding-test/webui \
HERMES_WEBUI_PORT=8789 \
python3 bootstrap.py
```
Then open `http://127.0.0.1:8789`.
For an assistant-led trial run, follow the safety rules, evidence commands, and
pass/fail criteria in
[`docs/onboarding-agent-checklist.md`](onboarding-agent-checklist.md).
If your repo has a `.env` file, remember that the bootstrap loads it. Remove or
adjust any `HERMES_HOME`, `HERMES_WEBUI_STATE_DIR`, or `HERMES_WEBUI_PORT`
entries there before using the isolated command above.
For managed hosting or fully preconfigured images, set
`HERMES_WEBUI_SKIP_ONBOARDING=1` to bypass the wizard.
## What the wizard checks
The first screen reports the runtime state WebUI can see:
- Hermes Agent importability: whether WebUI can import and run `AIAgent`.
- Provider status: whether `config.yaml` and credential state are enough for a
chat request.
- Password status: whether WebUI password protection is enabled.
- Config paths: the active `config.yaml` and `.env` locations for this profile.
If the agent check fails, use [Troubleshooting](troubleshooting.md), especially
the `AIAgent not available` section. If provider setup is incomplete, continue
through the wizard or run `hermes model` in the same machine environment that
will run WebUI.
## Choosing a provider
The setup step groups providers by how much information they usually need.
| Group | Examples | What you usually enter |
|---|---|---|
| Easy start | OpenRouter, Anthropic, OpenAI | API key and model. |
| Open / self-hosted | Ollama, LM Studio, custom OpenAI-compatible, AIML API | Base URL, model, optional API key. |
| Specialized | Gemini, DeepSeek, Xiaomi MiMo, Z.AI / GLM, NVIDIA NIM, Mistral, xAI | Provider API key and default model. |
For API-key providers, the wizard writes the key to the active Hermes `.env`
file and writes the default model/provider to `config.yaml`.
For local providers, the API key field can be blank when the server is keyless.
Most LM Studio, Ollama, vLLM, llama-server, and TabbyAPI installs run this way.
Use **Test connection** to verify the Base URL and populate the model list
before continuing.
AIML API uses the existing custom OpenAI-compatible setup path, not a
first-class built-in Hermes provider id. Configure it under the
custom-provider flow with Base URL `https://api.aimlapi.com/v1`, then use
either the normal custom-provider API key field or a config entry that points
at `AIMLAPI_API_KEY` if you want the custom provider to read its key from the
environment. Create or manage keys at `https://aimlapi.com/app/keys`. Model
discovery comes from the live `/v1/models` response for that endpoint, not from
a static WebUI-maintained model list.
Advanced provider flows such as Nous Portal and GitHub Copilot are still
terminal-first. OpenAI Codex and Anthropic Claude Code OAuth can be started in
the onboarding flow when your Hermes config selects the corresponding provider.
If the wizard points you back to `hermes model`, use that CLI flow first, then
refresh WebUI.
## Base URL rules for local model servers
For self-hosted providers, the Base URL should point to the OpenAI-compatible
API root. Common examples:
| Server | Typical Base URL |
|---|---|
| LM Studio on the same non-Docker host | `http://127.0.0.1:1234/v1` |
| Ollama on the same non-Docker host | `http://127.0.0.1:11434/v1` |
| LM Studio from Docker Desktop | `http://host.docker.internal:1234/v1` |
| Ollama from Docker Desktop | `http://host.docker.internal:11434/v1` |
| Local server from Linux Docker Engine | `http://api.local:<port>/v1` with `api.local:host-gateway` in Compose `extra_hosts` |
| Local server on another LAN machine | `http://<lan-ip>:<port>/v1` |
Inside Docker, `localhost` means the WebUI container itself, not your Mac,
Windows host, Linux host, or another machine on your LAN. If LM Studio or Ollama
is running outside the container, use `host.docker.internal` on Docker Desktop,
use the server's LAN IP address, or add a Linux Docker host alias:
```yaml
services:
hermes-webui:
extra_hosts:
- "api.local:host-gateway"
```
Then use `http://api.local:<port>/v1` as the Base URL. The alias avoids writing
`localhost` in WebUI config where it would resolve to the container loopback
instead of the host service.
The wizard probes `<base-url>/models` before saving. A successful probe fills
the model dropdown. A failed probe blocks the setup step and shows an inline
error such as DNS failure, connection refused, timeout, HTTP error, or
unexpected response shape.
## Workspace step
The workspace is the filesystem location Hermes should use for new sessions.
It can be a source checkout, a project directory, or a general workspace folder.
In Docker, the default browsable path is `/workspace`, which maps to the host
directory mounted by the compose file. If the workspace appears empty, check the
Docker UID/GID and mount guidance in [Docker setup guide](docker.md).
## Password step
Password protection is optional for localhost-only installs. Enable it if you
expose WebUI outside `127.0.0.1`, behind a reverse proxy, or on a LAN.
The password is stored through the normal WebUI settings path and hashed
server-side. You can change it later from Settings.
## What gets written
The wizard uses the same files and APIs as the normal app:
- Active Hermes `config.yaml`: provider, default model, and Base URL when
relevant.
- Active Hermes `.env`: provider API keys when you entered one.
- WebUI `settings.json`: onboarding completion, workspace, password state, and
other WebUI preferences.
State normally lives outside the repository. By default:
- Hermes Agent state: Windows `%LOCALAPPDATA%\hermes`; POSIX `~/.hermes`
- WebUI state: `$HERMES_HOME/webui` (Windows default `%LOCALAPPDATA%\hermes\webui`, POSIX default `~/.hermes/webui`)
Override these with `HERMES_HOME` and `HERMES_WEBUI_STATE_DIR` when you need an
isolated test install.
## When to file an issue
File an issue when the diagnostics point to WebUI rather than local
configuration. Include:
1. Install path: local bootstrap, Docker single-container, Docker
two-container, Docker three-container, WSL2, or community native Windows.
2. Output from `/health`, or the startup banner if the server never starts.
3. The provider selected in onboarding and the Base URL shape, with secrets
redacted.
4. For Docker provider problems, the result of probing from inside the
container, for example:
```bash
docker exec hermes-webui sh -c 'curl -sS -w "\nHTTP %{http_code}\n" http://host.docker.internal:1234/v1/models | head -50'
```
5. Any inline wizard error text and relevant logs.
Never paste API keys, OAuth tokens, or full `.env` contents into an issue.

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -0,0 +1,25 @@
{
"issue": 1772,
"check": "api.models.get_cli_session_messages preserves CLI tool metadata for WebUI rendering",
"session_id": "cli_issue_1772_demo",
"message_count": 2,
"assistant_tool_calls": [
{
"id": "call_1772_demo",
"type": "function",
"function": {
"name": "terminal",
"arguments": "{\"command\": \"printf ok\"}"
}
}
],
"tool_result": {
"role": "tool",
"tool_call_id": "call_1772_demo",
"tool_name": "terminal",
"name": "terminal",
"content": {
"output": "ok"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -0,0 +1,25 @@
{
"issue": 1784,
"commit_under_test": "9875967",
"fixture": "Synthetic 180-row session sidebar with active sid_0 streaming and long chat pane content.",
"pre_fix_observation": {
"steps": [
"Set _scrollPinned=true with #messages at scrollTop 0 in a long chat fixture.",
"Dispatch a wheel gesture on the active sidebar session row.",
"Call scrollIfPinned() to mimic the next streaming token render."
],
"result": "#messages jumped from scrollTop 0 to 3073 immediately after the sidebar wheel gesture, showing the chat auto-scroll path fought non-chat scroll intent."
},
"post_fix_observation": {
"steps": [
"Repeat the same fixture and sidebar wheel gesture after the fix.",
"Call scrollIfPinned() immediately, then again after the 350ms non-chat intent guard expires."
],
"result": {
"afterSidebarWheel": 0,
"afterIntentExpires": 2992,
"sessionListCss": "overscroll-behavior-y: contain; touch-action: pan-y"
},
"meaning": "A sidebar wheel/touch scroll intent now suppresses only the immediate chat-pane auto-scroll write, leaving the sidebar gesture free while streaming continues."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Some files were not shown because too many files have changed in this diff Show More