The Agent's September 2026 decomposition moved several names the WebUI uses into
sibling modules. The old import paths resolved only through PEP 562 __getattr__
compat pointers whose removal date (2026-09-14) has already passed. Every affected
WebUI call site is wrapped in a broad except, so once those pointers go away nothing
crashes -- features silently degrade: MCP tools vanish from turns, the per-turn
approval session key never binds, Claude Code credential linking stops, and the
kanban bridge raises AttributeError.
Adds api/agent_compat.py::agent_attr(owner, name, home, default), which resolves a
moved name via the original module's own namespace (pre-split Agents and test stubs),
then the new home module (split Agents, with or without the pointers), then plain
attribute access -- and routes the 8 affected call sites through it.
Gate: Codex SAFE TO SHIP. All 12 mappings verified against the installed Agent's
authoritative compat_manifest.json; owner-wins precedence is correct because PEP 562
pointers do not populate vars(owner), so current pointers are bypassed without
warnings; the only explicit defaults are the pre-existing optional kanban fallbacks
and remain semantically equivalent; turn-approval identity, MCP discovery/reload/status,
OAuth credential reading, LM Studio probing, and all three kanban paths preserve their
previous values and control flow; cached import_module costs ~1.36us and the hottest
site is a 0.3s kanban poll, not per-token streaming (caching the callable would break
supported module replacement/monkeypatching). Tests are non-vacuous across pre-split,
pointer-present, and pointer-removed shapes. Full suite 15,597 pass with only the known
box-baseline singleton. Maintainer proof: agent_attr resolves the moved name with zero
HermesPluginCompatWarning while a clean-master control still resolves via the pointer
and warns.
Co-authored-by: mikemchargue <mikemchargue@users.noreply.github.com>
Archives resolved to application/octet-stream (.zip absent from MIME_MAP) and were
therefore rejected by the session MEDIA: token allow-list, so a MEDIA:/path/file.zip
link always failed to open. Adds .zip -> application/zip and admits archives to the
session-token type set. Archives are download-only by construction: _ARCHIVE_TYPES is
never added to the inline-preview sets, so they always get Content-Disposition:
attachment.
Gate: Codex SAFE TO SHIP (disposition verified incl. inline=1 and uppercase .ZIP;
confinement requires exact canonical path equality vs a non-user MEDIA: token; the
#3234 state/secret deny guard runs before the allow decision and reproduced a 403 on a
token-authorized backups/state-backup.zip; traversal and workspace-symlink both 403;
.docx/.xlsx mappings intact). Full suite 15,580 pass with only the known box-baseline
singleton. Maintainer added a live behavioral proof (the PR's own tests are source-string
assertions only); it failed on clean master and passed on this head.
Co-authored-by: carlotestor <carlotestor@users.noreply.github.com>
Review follow-up for the Hermes Agent moved-name fix.
Tests: replace the AST source guard with behavior tests that run each
affected WebUI entry point against two fake Agent shapes, pre_split (name on
the original module) and pointer_removed (name only in its new home):
approval session identity bind/reset, MCP runtime status, /reload-mcp,
Claude Code credential read, LM Studio reasoning options, and kanban
connections (closed after use), board counts, SSE event feed, and dispatch.
All 8 pointer_removed cases fail against the pre-fix call sites; the
pre_split cases pass on both.
Docs: ARCHITECTURE.md gains the api/agent_compat.py inventory entry and
section 4.9 covering ownership (Agent's compat_manifest.json), resolution
order, call-site rules, and the removal policy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hermes Agent moved several names the WebUI uses into sibling modules
(tools.approval_context, tools.mcp_tool_discovery/_lifecycle,
hermes_cli.kanban_db_connect/_dispatch, hermes_cli.models_local,
agent.anthropic_credentials). The old paths only resolved through
PLUGIN-COMPAT __getattr__ pointers that warn on every use and are removed
on schedule by reverting one commit.
With the pointers removed, every affected WebUI call site is wrapped in a
broad except, so nothing crashes; features silently degrade instead:
per-turn approval session-key binding is skipped, MCP discovery/status
return nothing, /reload-mcp reports "MCP runtime unavailable", Claude Code
credential linking returns None, every kanban connection raises
AttributeError, and the dispatcher reports "unavailable".
Add api.agent_compat.agent_attr, which resolves a moved name from the
original module's own namespace first (pre-split Agents and existing test
stubs), then from its new home (split Agents, with or without pointers),
then plain attribute access (non-module doubles). Importing only the new
modules would have broken pre-split Agents and bypassed the sys.modules
stubs the test suite installs on the original modules.
Names still native to their original module (_servers, _lock,
has_blocking_approval, is_session_yolo_enabled, register/unregister
gateway notify, the rest of kanban_db) are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MEDIA:/path/to/file.zip links always failed to open in chat. .zip was
absent from MIME_MAP, so it resolved to application/octet-stream, which
_SESSION_MEDIA_TOKEN_TYPES rejects — the browser rendered a download
link that the server then refused.
Map .zip to application/zip and add an _ARCHIVE_TYPES set to the session
MEDIA: token allow-list. Archives are deliberately kept out of
_INLINE_PREVIEW_TYPES and _INLINE_IMAGE_TYPES so they are always sent as
Content-Disposition: attachment and never rendered inline.
The Extensions settings pane (Gallery / Installed / Diagnostics) was the
last large user-facing surface still rendering hardcoded English:
- static/index.html carried the section meta, both loading placeholders,
the Copy diagnostics button, and the whole Trust-model explainer block
as plain HTML with no data-i18n attributes.
- static/panels.js built the runtime / diagnostics cards from template
literals with 14 untranslated row labels, a separate installed card,
asset and warning card titles, sidecar card fields (Origin / Health
path / Health URL / Proxy / Proxy path), the Enabled/Disabled and
true/false badges, and every empty-state string (None, No warnings,
No extension directory is configured., No manifest extensions are
installed in the configured bundle., No loopback sidecars declared.)
inline in JS.
Every one of those call sites now routes through t() and the plain
strings live in the locale bundles. data-i18n is used for the static
HTML (the same mechanism the other selects already use, per #7582
triage), and esc(t(...)) for the template-literal DOM built in
panels.js so no injected text can introduce markup.
Locale keys are added to all 15 shipped locale bundles (en, ru, it, ja,
es, de, zh, zh-Hant, pt, ko, fr, cs, tr, pl, vi), following the same
key-parity contract that the #7602 triage enforced for the
sessions_source keys: inserted right after each locale's identity
(_label:_speech_) line so the brace-bounded locale extraction still
finds the whole block, matching the same pattern upstream used to fix
the sessions_source parity. No locale falls back to English at runtime
because t() resolves every bundle.
tests/test_extensions_settings_panel.py: assertions that used to match
the inline English strings now match the corresponding locale keys, and
the state_unknown_ids hint asserts both the key is used and the raw
English is gone.
Verified:
- python3 -m pytest tests/ -k "i18n or extension or parity or japanese"
-q -> 496 passed
- node --check on the three touched static assets -> all valid
Fixes#7581. Same surgical slice as #7602 (linked from #7579).
The structural test_autolink_regex_in_rendermd slices renderMd's body with a
hardcoded 15000-char window. This PR's <br>-in-table protection adds ~315 chars
near the top of renderMd, pushing the autolink pattern from offset 14785 (master)
to 15100 — just past the window. The autolink regex is unchanged (12 occurrences
remain in ui.js); the test window was simply too tight. Widen to 30000.
Co-authored-by: JayaMegelar <JayaMegelar@users.noreply.github.com>
The settings UI writes localStorage immediately but persists the server
engine through a debounced save, so a first-party Edge selection could
fall through the new server default to a stale ElevenLabs/OpenAI
engine. Both WebUI Edge callers now pass engine explicitly; regressions
cover both call sites.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNmJZVV9u6pBhcwVhwkPp6
* perf(composer): make the single-row autoResize fast path reachable
autoResize() skips the height:'auto' -> scrollHeight -> restore round trip only
when the composer is already at its one-row height, but the guard compared
el.offsetHeight against the CSS min-height (44px) while the composer's natural
one-row border box is line-height + vertical padding + borders (~48px at the
stock font). 48 <= ceil(44)+1 is false, so the skip was unreachable in a real
browser: every append keystroke wrote style.height twice, and the scrollHeight
read that follows forces a synchronous layout of the whole document, transcript
included. The cost therefore grows with the rendered transcript, which is the
"typing gets laggy in long sessions" report.
Accept the natural one-row height as the skip ceiling, computed from the same
already-read getComputedStyle. The strict finite-pixel gate is kept for every
value involved, so a percentage / calc / normal line-height, padding or
min-height still fails closed to the full resize (the #6349 re-gate property is
preserved, and test_issue5514_composer_grow_scroll_pin.py still pins it).
Measured on a 758-message / 5 MB session (31k DOM nodes rendered) with the
browser Event Timing API (input -> next paint) at 6x CPU throttle: a keystroke
went from 168ms to ~136ms median echo latency (72ms with the transcript
detached), 96ms to ~72ms in the windowed state, and the style writes per 8
keystrokes from 16 to 2.
The #5514 invariant holds: an oversized composer still takes the full resize
because its offsetHeight is far above the one-row ceiling.
Tests:
- new tests/test_long_session_composer_typing_latency.py runs the real
autoResize() body from messages.js in a node sandbox whose getComputedStyle
returns the composer's computed styles; it is red before this change
(2 failed, 27 passed) and green after (29 passed).
- tests/test_issue5514_composer_grow_scroll_pin.py source pins updated to the
new guard shape.
* test(docs): address #7604 review - behavior-only tests + composer sizing docs
Review feedback on the PR flagged two repo-rule items: tests that pin source
strings, and an undocumented runtime behavior change. Both are addressed here.
- tests/test_long_session_composer_typing_latency.py: delete
test_fix_is_pinned_in_source. It asserted exact JS declarations, which the
repo testing guidance rejects and which the harness in this module already
covers by executing the REAL autoResize() body. In its place add
test_natural_row_skip_survives_every_append_of_a_typed_word, which drives the
actual typing loop ('' -> 'h' -> ... -> 'hello') and asserts zero height
writes on every keystroke. Red on the pre-fix tree, green with the fix.
- tests/test_issue5514_composer_grow_scroll_pin.py: convert
test_single_line_growth_skips_the_height_round_trip from source-string pins to
a behavior fixture whose getComputedStyle returns the composer's real computed
styles. It asserts the observable outcome: a one-row append at the natural
48px row takes no height write, while an oversized 176px composer still
remeasures back to one row. Red before the fix (2 writes), green after.
- docs/UIUX-GUIDE.md: document the composer sizing invariants in the composer
section - the single-row skip is a hot path and must stay reachable, its
ceiling is the natural one-row height or the CSS min-height (whichever is
larger), everything else still remeasures, and non-px computed values fail
closed.
./scripts/test.sh on the two touched modules: 29 passed (3 failed on the
pre-fix tree). Neighbour sweep over the 51 composer/scroll/typing modules:
386 passed. ruff check clean.
* fix(tests,docs): derive composer test dimensions from style.css (#7604 review)
Greptile's re-review is right: the fixtures hardcoded the 18px
`data-font-size=large` composer (line-height 29.7px -> a 47.7px one-row box) as
if it were the stylesheet's 16px default (44.4px). At the default the pre-fix
ceiling (ceil(44)+1 = 45px) DID clear the one-row box, so the regression is
font-size dependent - the 16px default reaches the skip, the 18px and 20px
appearance settings do not - and not a stock-configuration bug. The runtime
change is unaffected and unchanged.
- new `tests/_composer_metrics.py`: parses `static/style.css` (the base
`textarea#msg` rule, the `data-font-size` overrides, the skin overrides) and
exposes each configuration's box metrics - natural one-row height, the integer
offsetHeight a browser reports, and both the pre-fix and post-fix ceilings -
so a fixture cannot encode the wrong font size again.
- `tests/test_long_session_composer_typing_latency.py`: parameterized over every
configuration the stylesheet can produce, with
`test_the_regression_was_font_size_dependent_not_stock` pinning the grounding.
- `tests/test_issue5514_composer_grow_scroll_pin.py`: the converted single-row
test uses the same derived metrics.
- `docs/UIUX-GUIDE.md`: the sizing section now states that the natural row
tracks the appearance font size (44 / 48 / 51px) while `min-height` stays
44px, which is why a min-height-only ceiling silently disables the skip.
Pre-fix tree: `4 failed, 31 passed` - exactly the large-18px and xlarge-20px
parameterizations plus the two tests that assert the reported configuration.
Post-fix tree: `35 passed`. Neighbour sweep over the 51 composer/scroll/typing
modules: `392 passed`. ruff clean.
* fix(i18n): route sidebar source labels through t() (#7580)
_sessionSourceLabel() hardcoded the two visible tab labels as English
template literals and assigned the result straight to textContent, so
non-English locales rendered untranslated sidebar tabs. Route both
filters through t() with the established {0}-interpolation pattern
(same shape as kanban_comments_count) and add explicit en + ru keys
so English remains the fallback for locales without a translation.
- static/sessions.js: return t("sessions_source_cli", n) /
t("sessions_source_webui", n) instead of template literals
- static/i18n.js: add the two keys to the en and ru bundles
- tests/test_issue2351_cli_session_source_filter.py: replace the
hardcoded-English regressions with locale-key assertions and a new
interpolation test; verify the template-literal fallback is gone
so a regression can no longer ro-introduce it silently
* i18n(sessions-source): add source-tab labels to the remaining 13 locales
Codex gate: the runtime fallback is non-regressive, but the repo enforces a
locale key-parity contract — 9 parity tests failed because the two new keys
existed only in EN+RU. Add sessions_source_webui / sessions_source_cli to it,
ja, es, de, zh, zh-Hant, pt, ko, fr, cs, tr, pl, vi, matching each locale's
established translation of 'session' and preserving the {0} count placeholder.
Placed after each locale's _label line so the {0} literal does not fall inside
test_japanese_locale.py's brace-bounded _label regex.
Co-authored-by: Yularzhi <Yularzhi@users.noreply.github.com>
---------
Co-authored-by: nesquena-hermes <agent@hermes.local>
Co-authored-by: Yularzhi <Yularzhi@users.noreply.github.com>
Serve the SPA shell for /sessions so the post-login redirect (when password auth is enabled) no longer 404s.
Co-authored-by: Skawsk59 <Skawsk59@users.noreply.github.com>
Fail closed when the Agent Git identity disappears or is unreadable, including when the revision read itself raises — a missing revision is indistinguishable from changed source and must never be treated as proof the loaded code is current.
Co-authored-by: LeonardoLGDS <LeonardoLGDS@users.noreply.github.com>
Read the metadata prefix in staged geometric reads so one oversized metadata field no longer forces a full re-parse of the whole sidecar.
Co-authored-by: rodrigogs <rodrigogs@users.noreply.github.com>
Route @custom:<slug> models to their owning custom provider's base_url so a model picked from a second custom provider group resolves correctly instead of 400ing.
Co-authored-by: b3nw <b3nw@users.noreply.github.com>
Decode the state.db content sentinel in the WebUI message projection so structured/multimodal content renders instead of a placeholder, with an out-of-band tuple identity that no scalar can forge and a per-call ContextVar memo for the canonical serialisation.
Co-authored-by: totalitarian <totalitarian@users.noreply.github.com>
Use native CSS field-sizing for composer auto-grow where supported, keeping the JS path as fallback. Includes a fallback-parity fix so an empty composer holds its resting height instead of measuring the placeholder.
Co-authored-by: starship-s <starship-s@users.noreply.github.com>
Nest delegated subagent sessions under the parent conversation that spawned them instead of forcing them to the sidebar top level.
Co-authored-by: lowlandsheperd <lowlandsheperd@users.noreply.github.com>
Classify Signal gateway sessions as messaging so they group, label, and list like other gateway platforms.
Co-authored-by: webtecnica <webtecnica@gmail.com>
Closes the remote POSIX workspace path preservation gap across Session and streaming. Profile-aware runtime path resolution.
Co-authored-by: alfred-rootson <alfred.wojtasordzik@gmail.com>
The /api/models cache identity shipped in #7556 (release #7558) changed from a
stat identity to content fingerprints with volatile-key deny-lists, and the #7556
review flagged that this runtime contract had no project-level documentation.
Record the three source-fingerprint axes and their rationale, the one-directional
deny-list invariant, the stat-identity fallbacks (including the deep-tree
RecursionError path), the independent schema/version stamps, and the change
protocol; index the document from docs/CONTRACTS.md. Docs only.
* fix(config): semantic fingerprint for the Codex catalog dependency (#7540)
* fix(#7556): guard the recursive codex-cache strip against RecursionError
Codex found a valid-but-deeply-nested models_cache.json crashes
_codex_models_cache_fingerprint with RecursionError (the recursive strip ran
outside the fallback try), 500ing /api/models. Move _strip_volatile_codex_cache_fields
inside the existing encode try/except so a transform failure degrades to the
stat-based fingerprint. +1 mutation-proven regression (forces the strip to raise;
fails with RecursionError if the guard is reverted).
* docs(changelog): stamp #7556 (semantic fingerprint for Codex catalog dependency)
---------
Co-authored-by: webtecnica <webtecnica@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: n <a@n>
Follow-up to #7551: the merged test file carried an unused `route_approvals as ra`
import and two unused `entry =` assignments (advisory lint gate was red, though not
a required check). Drop them; the two `entry` bindings that are asserted on are kept.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* fix(approvals): mint actionable ids for raw id-less local pending entries
A raw id-less approval entry in _pending (agent-side _pending_result drops
{command, pattern_key, pattern_keys, description} verbatim when no gateway
notifier is registered for the session) is unactionable by contract: the
frontend owner-capture requires an approval_id, so the approval card can
neither be approved nor dismissed - the 1.5s poll re-serves it forever.
reconcile_gateway_pending_mirror_locked already mints gwlocal:<token> ids
for id-less _gateway_queues producers; extend the same contract to raw
non-mirror _pending entries with a stable gwlocal-mirrorless:<uuid> id
minted once onto the stored entry dict, so the id persists across polls
and frontend dismiss markers stick.
The exact-id respond path needs no further change: once the entry carries
an id, _resolve_approval_legacy pops it (also clearing the stale
stream-pointer 409 guard), stale explicit ids still fail closed (#527),
and the yolo Skip-all release drains it.
* docs(changelog): stamp #7510 (actionable ids for raw id-less pending approvals)
---------
Co-authored-by: CharlesMcquade <6466275+CharlesMcquade@users.noreply.github.com>
Co-authored-by: n <a@n>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* fix(api): scan to first complete exchange for title regeneration (#7543)
_first_exchange_snippets() aborted at a second consecutive user message
before collecting assistant text, so transcripts opening with queued user
rows (channel-backed imports) hit the missing_exchange rejection and the
deterministic fallback got persisted with HTTP 200 — invisible in the UI
and unrecoverable by retrying.
Keep scanning until the first complete user+assistant pair instead, and
fall back to the last complete exchange when the opening user message
yields no user text (privacy scrubbing) instead of failing with
empty_user_message.
Tests: tests/test_issue7543_title_first_exchange.py (8 cases, incl. the
issue-pinned transcript shape and walker-mocked discriminator tests for
both fixes).
* test(api): drop local-machine notes and sys.path hack from 7543 test module
- Docstring described the change as a local-only uncommitted hotfix with
machine-specific paths — wrong for an upstream PR and unsafe to publish.
- Drop the defensive REPO_ROOT sys.path insert; the repo conftest already
puts the package root on sys.path (matches the neighboring test modules).
* fix(api): drop unreachable latest-exchange fallback from title regeneration
Greptile flagged the mocked-walker fallback test (P2) as fabricating
internal state, and an exhaustive enumeration of realistic transcript
shapes (5460 combinations, lengths 1-6) confirms it: both walkers share
the same text extraction and the forward walker scans every message, so
'first walker empty while latest walker yields a pair' is unreachable
from real transcripts. The fallback branch was dead code.
Remove it together with its two walker-mocked tests; the fixed first-
exchange walker alone resolves the reported issue shape. Replace the
mocked tests with an observable E2E error-path test through the real
walkers, and make the aux mock guard-faithful (empty assistant_text ->
missing_exchange per the generate_title_raw_via_aux contract) so the
fail-before proof actually exercises the endpoint contract.
* fix(#7543): scope first-exchange scan-past to the manual-regen path only
The full suite caught that broadening the SHARED _first_exchange_snippets also
activated the automatic in-stream background-title path for consecutive-user-row
transcripts, moving stream teardown from the synchronous stream_end branch to the
background-title-thread branch (test_issue3929 emits_done regressed: stream_end
was no longer in the synchronous queue). #7543 is specifically about the MANUAL
'Regenerate title' endpoint, so gate the scan-past behavior behind an opt-in
scan_past_consecutive_users flag (default False = master behavior) and pass True
only from generate_session_title_for_session. Auto in-stream path is now
byte-identical to master; added a default-behavior regression guard test.
* docs(changelog): stamp #7545 (manual title regen for queued-user transcripts)
---------
Co-authored-by: Raylands <Raylands@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: n <a@n>
Two tests pin opposite constraints on server.py: test_sprint10 requires <750 lines,
and test_issue5543 AST-asserts a signal.signal() call whose first arg literal is
signal.SIGTERM. The loop refactor satisfied the size budget but the loop var _sig
failed the AST literal check. Register both signals explicitly in one shared
try/except: keeps the SIGTERM literal, adds SIGINT (#7078), stays at 749 lines.
The contributor's SIGINT addition (#7315) pushed server.py to 754 lines, tripping
test_sprint10::test_server_py_under_750_lines (master was 749). Fold the two
signal.signal() try/except blocks into a single loop over (SIGTERM, SIGINT) so
SIGINT graceful shutdown lands (#7078) without growing the file past the enforced
split budget. Same _request_shutdown handler, same fail-safe on non-main-thread.
ctl.sh start spawns the server from a non-interactive bash background job,
which causes the child to inherit SIGINT=SIG_IGN. The Stop server buttonsends SIGINT (os.kill(pid, signal.SIGINT)), which is silently ignored.
Register _request_shutdown for SIGINT in addition to SIGTERM so the
shutdown button works for ctl.sh-managed daemons.