Core's 2026-09-09 catalog sync (hermes_cli/models_catalog_static.py, unreleased
as of v2026.9.7) dropped ox-alpha-free — the Go relay delisted it (GET
/zen/go/v1/models omits it, POST returns 401) — and added glm-5.3-flash and
muse-spark-1.3-contributor. Mirror core main (29 ids, verified exact match).
Keeping the delisted id would offer a model that always 401s — the original
#5311 failure class.
The installed-core equality test now skips while released cores still carry
the delisted id (transition window documented in the test); verified 7/7
green with PYTHONPATH pointed at core main where equality binds.
The WebUI fallback list mirrors Hermes core's curated opencode-go list
(hermes_cli/models.py, synced 2026-08-20/21 against the live
/zen/go/v1/models endpoint and the public Go docs). Core owns the sync
duty; WebUI only mirrors. Adds the nine ids core gained since the
2026-07-07 snapshot (kimi-k3, gpt-5.6-luna, glm-5.3, grok-4.5,
qwen3.8-max, hy3, hy3-preview, muse-spark-1.2-contributor,
ox-alpha-free) and reorders to core's order so future diffs are
mechanical. Snapshot test updated accordingly.
Remove the #5611 stopgap that skipped the live /v1/models probe for
OpenCode Go. Its premise — the probe returning public-catalog models
not enabled on the Go tier — no longer holds: Hermes core v0.20.5
(fcbd1076) ships a Go-specific provider profile probing
https://opencode.ai/zen/go/v1/models directly and merges results
live-first with its curated Go list (#49129).
WebUI again delegates like every other provider (#1240): live catalog
first, curated static _PROVIDER_MODELS as offline fallback only.
Runtime tests pin the behavior (sentinel live id must reach the Go
group; probe failure falls back to static; providers.opencode-go.models
allowlist still wins #644).
Settle removed approval producers before fallible projection work, and fail-close every producer owned by a terminal gateway run across completion, failure, cancellation, and teardown while preserving unrelated runs.
Two repository-guide violations raised in review on #7570.
Selection is what this fix changes, but the regression test seeded only the
producer being retired. It now seeds a sibling approval on the same run and a
producer belonging to an unrelated run, then asserts the target is consumed,
both survivors remain queued, and reconciliation does not resurrect the denied
approval's mirror. Mutation-checked so the new assertions have teeth: an
over-aggressive filter that retires every same-run entry fails on the sibling
assertion, and the unfixed implementation fails on the target assertion.
Runtime behaviour also changed without tracked documentation of the invariant.
The api/route_approvals.py module docstring now records the producer/mirror
ownership direction and the retirement rules, including why an unattributable
same-run producer is retired with its run.
Additive commit: the previous commit is published and under review, so the
reviewed history is preserved rather than rewritten.
Stop the permanent ~7% core burn from re-fingerprinting state.db every 5s
and spinning on 0.1s stop-checks while nobody is listening. Block on
Event.wait for the poll interval, and sleep on _idle_wake until the first
subscriber arrives (maint #3035).
Co-authored-by: Cursor <cursoragent@cursor.com>
Metering telemetry is journaled at ~10 Hz during active turns, so marathon
reasoning runs balloon the durable run journal (12.4 MB of a single 18 MB run
was metering). On a mid-run refresh the reconnect path replays the entire
journal as one unpaced SSE burst, OOM-crashing the browser tab in a
crash-refresh loop while the server stays healthy.
- Write time: RunJournalWriter.append_sse_event() skips metering rows via
REPLAY_SKIPPED_SSE_EVENTS, returning None so caller journal-id plumbing is
untouched and remaining journaled seqs stay contiguous.
- Replay time: _replay_run_journal() and emit_replay() filter metering rows
through journal_replay_visible(), so legacy journals never stream the
backlog either.
- Readers (read_run_events / read_session_run_events) deliberately keep every
row: cursor math and the offline-gap coverage check count seqs.
Refs #7272
The cache-only Steer path only special-cased backend == "gateway" and sent
every other value — including a missing, empty, or unknown backend — to the
cached local agent. Another runtime could own the run while guidance was
enqueued on a stale local agent.
Fail closed instead: the in-process worker now registers its active run with
the explicit local backend tag (WEBUI_LOCAL_CHAT_BACKEND, the same "legacy"
value webui_chat_backend_mode() returns), and cache-only Steer only enqueues
agent.steer() on that tag. Gateway keeps its gateway_steer_queued outcome; a
missing, empty, or foreign backend returns stream_dead without steering.
Extends the positive-ownership regression with backend cases and adds a
worker-side check that the local backend tag is actually registered.
The cache-only Steer branch (no AGENT_INSTANCES worker) only rejected
*conflicting* ownership, so a missing stream owner or a missing
active-run session_id was treated as non-conflicting and let
agent.steer() run and report accepted without proving the requesting
session owns the run.
Fail closed instead: both the stream owner and the active-run session
must be present and strictly equal to the requesting session (backend
and phase are still revalidated) under the same STREAMS_LOCK ->
ACTIVE_RUNS_LOCK edge; any absent identity returns stream_dead without
calling agent.steer(). No cache eviction, no agent closing, HTTP write
still outside the locks.
Regressions: missing owner, missing/empty run session, both missing,
missing run entry -> stream_dead + steer never called; both present and
equal -> accepted, cache untouched. Legacy happy-path fixtures now
register the ownership they rely on.
The cache-only compatibility path selected the matching cached agent,
checked stream liveness under STREAMS_LOCK, released the lock, and only
then called agent.steer(). A Stop running in that gap published
phase='cancelling' and detached STREAMS/AGENT_INSTANCES while the
earlier Steer still enqueued into the cached worker, so the HTTP
response reported accepted guidance that cancellation already owned.
Revalidate stream membership, owner, and active-run
session/backend/phase and call agent.steer() under the same
STREAMS_LOCK -> ACTIVE_RUNS_LOCK edge Stop uses; interrupt,
persistence, and HTTP writes stay outside the lock. Cache eviction and
agent closing remain absent.
Extend tests/test_steer_cancel_ordering.py with a cache-only reverse-
order test (AGENT_INSTANCES absent, matching cached worker, decoy cache
entry) gating both unlocked edges around the stream-lock reacquire.
Stop-first must return stream_dead without calling agent.steer(); the
selected cache object is asserted to be the intended worker. Proven
red on the pre-fix code (order ['cancel','steer']) and green after.
## Thinking Path
The Agent's OpenAI Codex Responses adapter persists user-visible
assistant progress in `codex_message_items` with `phase:
"commentary"` while the outer `content` field is empty. The Agent
already stamps these items with `role: assistant` and a normalized
phase (`agent/codex_responses_adapter.py::_message_item` and
`_OutputScan._message`), and the rendering lane is owned by #6828.
The WebUI's two consumers of those persisted rows, however, only
read the outer `content` field:
- `api/routes.py::_session_media_token_allows_path` flattens
`message.get("content")` through `_message_content_text()` and
scans it for `MEDIA:` tokens.
- `api/media_snapshots.py::annotate_media_snapshots` does the
same when annotating the settled transcript so previews can
survive an in-place overwrite of the source file.
For a safe image emitted only inside commentary, the browser
request hits the real `_handle_media` route, owns the correct
session_id and exact path, but is denied 403 by the auth predicate
because it cannot see the assistant's persisted `MEDIA:` token.
Symmetrically, the snapshot annotator never captures commentary
artifacts, so a subsequent in-place overwrite rewrites history
without a recoverable snapshot.
The fix is the maintainer's narrow recommendation: one shared
fail-closed public-assistant-text iterator in
`api/media_snapshots.py` (`codex_commentary_text`), consumed by
both the auth predicate and the snapshot annotator. Keeping the
extraction in one neutral helper means the auth and capture paths
cannot drift apart on a future change. The fail-closed predicate
is a strict chain — outer role is `assistant`; item type is
`message`; item role is `assistant`; item phase normalizes to
`commentary`; content is a list; the part is textual `output_text`
with a string `text` value. Every step that fails returns the safe
empty string, so the helper is safe to concatenate with the
existing top-level extraction. User rows, user sidecars, reasoning
items, analysis/final phases, malformed parts, non-string
`text` values, and arbitrary nested fields are all skipped.
## What Changed
- **`api/media_snapshots.py::codex_commentary_text`** — new
module-level helper that returns the concatenation of all
public-text `output_text` parts inside assistant-owned
`codex_message_items` with `phase: "commentary"`. The outer
row role, item type, item role, item phase, content shape, and
part type are all gated, so the helper is fail-closed and
safe to concatenate with the existing top-level text extraction.
- **`api/routes.py::_session_media_token_allows_path`** — inside
the existing per-message loop, the per-row `text` is now the
concatenation of `_message_content_text(message.get("content"))`
and the new `codex_commentary_text(message)`. The exact-path,
owning-session, safe-MIME, URL-rejection, hard-denied, and
symlink guards are unchanged. The commentary helper is imported
lazily inside the loop so this file does not pick up a new
top-level dependency.
- **`api/media_snapshots.py::annotate_media_snapshots`** — the
per-row `text` is now the concatenation of the existing
top-level string `content` and the new commentary helper
output. The existing `media_re.findall()` and resolve/allow
paths are unchanged, so otherwise-capture-eligible commentary
previews are now snapshotted at settle time. The string
pre-filter on `"MEDIA:" in text` is kept so the helper
short-circuits before regex extraction when neither source
contains a token.
- **`tests/test_issue7565_codex_commentary_media.py`** — new
regression module with eleven tests: a positive
valid-shape test, six fail-closed tests (user outer role,
wrong item type, wrong item role, wrong phase, non-output_text
parts, non-string output_text text), a case-insensitive
commentary-phase test, a malformed-input test, and two
integration static tests pinning that the auth predicate and
the snapshot annotator both consume the new helper.
## Why
- A user-visible artifact that the agent emitted only inside
commentary was returning 403 at the real `_handle_media` route
boundary even with the correct owning session_id, and the
artifact was never snapshotted at settle time. The fix restores
parity with top-level public assistant content for the
commentary phase specifically.
- Symmetry between auth and capture matters: a token that auth
admits must be one capture can stamp, otherwise the served
bytes can desynchronize from the snapshot pointer and the
in-place-overwrite survivor feature (#6922) silently breaks.
Sharing the helper makes drift impossible.
## Verification
```
$ .venv/bin/pytest \
tests/test_issue7565_codex_commentary_media.py \
tests/test_media_inline.py \
tests/test_media_message_snapshots.py \
-q --no-header
…
============================= 115 passed in 6.16s ==============================
```
Eleven new tests cover the helper contract and the integration
points. The 104 surrounding media_inline / media_message_snapshots
tests stay green.
## Contract
| Outer role | Item shape | Helper result | Auth result | Snapshot result |
| --- | --- | --- | --- | --- |
| `assistant` | `{type:"message", role:"assistant", phase:"commentary", content:[{type:"output_text", text:"…MEDIA:…"}]}` | text surfaced | `MEDIA:` token found; existing exact-path / safe-MIME / owning-session guards apply | `MEDIA:` token snapshotted at settle time |
| `assistant` | `{type:"message", role:"assistant", phase:"analysis", content:[{type:"output_text", text:"…"}]}` | empty | unchanged from existing top-level-only behavior | unchanged |
| `assistant` | `{type:"message", role:"assistant", phase:"final", content:[{type:"output_text", text:"…"}]}` | empty | unchanged | unchanged |
| `assistant` | `{type:"reasoning", …}` | empty | unchanged | unchanged |
| `assistant` | item role `user` | empty | unchanged | unchanged |
| `assistant` | item content not a list | empty | unchanged | unchanged |
| `assistant` | part type `tool_call` / `image` / `input_text` (even with text containing `MEDIA:…`) | empty | unchanged | unchanged |
| `assistant` | `output_text` with non-string `text` (dict / list / number) | empty | unchanged | unchanged |
| `user` (outer role) | any sidecar | empty | unchanged (user rows already skipped) | unchanged |
| `assistant` | no `codex_message_items` | empty | unchanged | unchanged |
| malformed (`None`, `""`, list, string, missing role) | n/a | empty | unchanged | unchanged |
The fix only widens the inspection surface for the one
public-text sidecar shape; everything else stays exactly as it
was, so existing safe-MIME, hard-denied, exact-path-equality,
owning-session, URL-rejection, and symlink guards remain intact.
## Risks
- **Commentary widening the inspection surface**: yes, by design.
The widening is narrowly scoped to the outer-assistant
/ item-message / item-role-assistant / phase-commentary /
part-output_text / string-text chain. The new tests pin every
step in that chain and every out-of-chain shape that must
remain rejected.
- **Existing safe-MIME / hard-denied / exact-path / symlink
guards weakened?** No. The `media_capture_allowed` predicate,
the `MIME_MAP` lookup, the `Path(ref).expanduser().resolve() ==
target_resolved` equality check, the `if "://" in ref` URL
rejection, the hard-denied state/secret paths, and the symlink
protections are all unchanged. The only change is what text
the regex sees.
- **Agent producer change**: the helper reads the same shape
the agent already stamps (`role: assistant`, `phase:
"commentary"`, `content: [{type: "output_text", text: "..."}]`).
A future producer change that adds a new phase or part type
requires an explicit follow-up PR; the helper's comments
document the precise chain.
- **Helper duplication drift risk avoided**: by placing the
helper in `api/media_snapshots.py` (the storage half) and
having `api/routes.py` import it lazily inside the loop, both
consumers go through one definition. A follow-up change to
the helper's contract automatically applies to both call sites.
## Model Used
- Generated with `MiniMax-M3` via `happy5318/hermes-webui` worktree
`/tmp/webui-worktrees/pr7565-codex-media`, branch
`fix/codex-commentary-media` (off `origin/master` via `ghfast`
mirror — `origin` was still TLS recv-failing at PR creation
time; the mirror was used for `git fetch` and `git worktree add`
only; push goes through the `fork` remote as usual).
## Related
- Closes#7565
- Surface code:
- `api/media_snapshots.py` (new) `codex_commentary_text` helper
- `api/routes.py::_session_media_token_allows_path` (modified
to concatenate the helper output)
- `api/media_snapshots.py::annotate_media_snapshots` (modified
to concatenate the helper output)
- Adjacent lanes (intentionally not folded in):
- #7294 / #7297 (safe-MIME, session_id, snapshot query
parameters) — independent contract
- #6828 (persisted-commentary rendering / recovery) — frontend
UI; the WebUI auth predicate does not assume or expand its
rendering scope
- Open PRs #7367 and #6923 — overlap the token-extraction sites
but neither implements this commentary ownership rule, per
the maintainer's open-PR search
- Test surface: new `tests/test_issue7565_codex_commentary_media.py`
## Thinking Path
The WebUI populates the editable schedule field from `schedule_display`,
which is the human-readable label `once at 2026-08-28 16:00` for one-shot
jobs. `saveCronForm()` then submits that exact string back to
`POST /api/crons/update`. The Agent's `cron.jobs.parse_schedule()`
rejects the display form with `ValueError` (it only accepts the canonical
ISO timestamp). The route's earlier `try/except ValueError` block
covered input normalization but not the `update_job()` call, so the
parser exception escaped as HTTP 500.
There are two independent failures to fix:
1. **Frontend** ships the unsafe display label as editable input. Both
`openCronEdit()` (`static/panels.js:1593`) and `duplicateCurrentCron()`
(`static/panels.js:1532`) use the same `schedule_display ||
schedule.expression` fallback. The cleanest fix is a shared helper
that returns the canonical Agent-parseable value by schedule kind,
matching the Agent's own schema:
- `once` → `schedule.run_at`
- recurring cron → `schedule.expr` (current schema; `expression`
accepted for legacy payloads)
- interval / legacy → the stored value, with `schedule_display`
kept as a final fallback only when it isn't the `once at …` label.
2. **Backend** converts `ValueError` from `update_job()` into HTTP 400
with the parser message, so the WebUI can show a real validation
toast instead of an opaque Internal Server Error. The 404 path
(job not found) is preserved.
## What Changed
- **`static/panels.js`** — add `_cronScheduleForEdit(job)` helper and
use it in both `openCronEdit()` and `duplicateCurrentCron()`. The
helper returns `schedule.run_at` for one-shot jobs,
`schedule.expr` (or legacy `schedule.expression`) for recurring
cron, and falls back to `schedule_display` only when it is not the
`once at …` presentation form. `saveCronForm()` is unchanged — it
still submits `schEl.value`, which is now the canonical field
instead of the display label.
- **`api/routes.py::_handle_cron_update`** — wrap the `update_job()`
call in `try/except ValueError` and return HTTP 400 with the parser
message. The earlier normalization try block stays untouched; the
404 check (`if not job`) sits outside the new try block so a missing
job still returns 404.
- **`tests/test_cron_edit_round_trip.py`** — new regression module
with seven tests covering the helper contract (helper exists, both
call sites use it, `saveCronForm` still submits the field by name),
the route's 400-on-`ValueError` behavior, the preserved 404 path,
and the happy-path 200.
## Why
- Editing a one-shot cron job's scheduled time has been returning
HTTP 500 since the WebUI's initial public release (per #7352
historical note), because the editable field round-trips the
`once at …` display label straight to the Agent parser.
- The backend regression is independent: even with a correct
frontend, any user-typed invalid schedule text (or a programmatic
caller) used to produce a 500 instead of a validation response.
Both halves are required to deliver a "no more 500" user
experience.
## Verification
```
$ .venv/bin/pytest \
tests/test_cron_edit_round_trip.py \
tests/test_cron_no_agent_edit.py \
tests/test_cron_model_override.py \
tests/test_cron_model_provider_picker.py \
tests/test_issue617_cron_profile_selector.py \
-v --no-header
…
============================== 27 passed in 4.84s ==============================
```
Seven new tests cover both layers; twenty surrounding cron tests
(no-agent edit, model override, model/provider picker, profile
selector) stay green.
## Contract
| Scenario | Before | After |
| --- | --- | --- |
| Open one-shot job for edit, change time, save | 500 (display label round-trips to parser) | 200, persisted with new `run_at` |
| Open recurring cron job for edit | 200 (legacy `schedule.expression` chain) | 200, unchanged canonical field (`schedule.expr` first, `expression` as legacy fallback) |
| Duplicate a one-shot job | 500 on first save | 200, populated with `schedule.run_at` from the source job |
| Submit a `once at …` schedule value (programmatic / pre-fix frontend) | 500 | 400 with the parser message |
| Submit any other unparseable schedule | 500 | 400 with the parser message |
| Update a job that does not exist | 404 | 404 (preserved) |
| Update a job with a valid canonical schedule | 200 | 200 (preserved) |
## Risks
- **Frontend helper change**: I switched the editable field source
for both edit and duplicate in one commit. `saveCronForm()` is
untouched, so anything already rendered with the legacy chain
(rare in practice — the helper is only called from these two
functions) keeps working. The regression `test_save_cron_form_still_submits_schedule_value`
pins the submit field name.
- **New `try/except ValueError` in `_handle_cron_update`**: the
earlier normalization try block (lines 24986–24998) is left
exactly as-is. The new try block sits directly around
`update_job()` only, so a `ValueError` from input parsing still
surfaces before any persistence side effect. The 404 check is
outside the new try, so a missing-job path cannot be misreported
as 400. Both `test_cron_update_preserves_404_when_job_missing` and
`test_cron_update_valid_schedule_still_succeeds` pin those
contracts.
- **Legacy `schedule.expression` payloads**: accepted as a
back-compat fallback in `_cronScheduleForEdit` and in the route
(the Agent schema is `expr`, but the parser still handles
`expression` per the existing contract). If you prefer to drop
the legacy fallback, the helper can be trimmed to `sched.expr`
only.
## Model Used
- Generated with `MiniMax-M3` via `happy5318/hermes-webui` worktree
`/tmp/webui-worktrees/pr7352-cron-edit`, branch
`fix/cron-edit-round-trip` (off `origin/master`).
## Related
- Closes#7352
- Surface code:
- `static/panels.js:1510` `duplicateCurrentCron` (uses helper)
- `static/panels.js:1585` `openCronEdit` (uses helper)
- `static/panels.js:1905` `saveCronForm` (submit path, unchanged)
- `api/routes.py:24979` `_handle_cron_update` (new try/except)
- `cron/jobs.py:785` `parse_schedule` (rejects the display form;
Agent-side, not modified in this PR)
- `cron/jobs.py:2199` `update_job` (the call that raises)
- Test surface: new `tests/test_cron_edit_round_trip.py`
Own a private stdout descriptor before agent imports and serialize request log writes. Keep logging failures isolated from HTTP responses. Add successful chat/start subprocess delivery regressions and document header-latency semantics and cron ownership.
The webhook second-pass loop in _load_cli_sessions_uncached called
get_last_workspace(profile=_cli_profile) directly per row instead of the
_cli_workspace() closure the main/cron/kanban passes already use, so it
paid its own redundant file reads/is_dir() probe on every webhook row even
though the loop already sits inside the profile-home resolve cache scope.
Flagged in PR #7636 review (greptile): this is the one concrete gap in an
otherwise-correct fix.
Also adds an integration-level regression test proving the actual N+1 this
PR targets -- Session.__init__ constructed via
_state_projection_sidecar_metadata's Session.load_metadata_only() fallback
on a cold sidecar-metadata cache -- is deduped by
@profile_home_resolve_cache_scope() on _load_cli_sessions_uncached, with a
companion test showing the same fixture scales linearly (one filesystem
resolve per row) when the scope is bypassed. This directly answers the
review claim that the cache scope doesn't reach any Session construction:
it does, verified by counting real _safe_resolve() filesystem calls rather
than call counts on the memoizing wrapper alone.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The process-lifetime cache from the previous commit was flagged in PR
review (nesquena-hermes): Path.resolve() is a filesystem call, not a
pure function of process-startup constants. A profile-home symlink can
be retargeted while the long-running webui process stays up, and
_safe_resolve() deliberately falls back to the unresolved input after a
transient OSError/RuntimeError/ValueError -- a process-lifetime cache
would serve either a stale symlink target or a stale fallback forever.
Redesign: replace the module-level dict with a contextvars.ContextVar
that defaults to None (no active scope -> resolve fresh, exactly like
pre-PR behavior). profile_home_resolve_cache_scope() sets it to a fresh
dict for the duration of one call and resets it in a finally block, so
no cache entry survives past that call. It now wraps only
_load_cli_sessions_uncached as a decorator -- the actual N+1 hot loop
(hundreds of Session.__init__ calls per sidebar build, each resolving
the same profile argument). All other call sites of
_resolve_profile_home_param (confirmed via repo-wide grep: all internal
to api/workspace.py, none elsewhere) get fresh resolution on every call,
same as before this PR existed.
Rewrote tests/test_profile_home_resolve_caching.py to prove: (a)
repeated resolves within one scope still hit the cache once, (b) two
separate scopes (simulating two requests) do not share state -- a
symlink change between them is observed on the second scope, and (c)
calls outside any scope never cache at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_resolve_profile_home_param() called Path.resolve() on the profile's home
directory with no caching. The webui session list builds one Session per
CLI session row, and Session.__init__ calls this function once per row, so
with ~484 sessions on the same profile it redundantly re-resolved the
identical path hundreds of times per request. Individually cheap (~1ms),
but under host load this compounded into multi-second sidebar hangs,
confirmed via journalctl showing repeated 'Slow WebUI request still
running' warnings (5-17s) pinned to this exact call site.
The mapping from a profile argument to its home path is a pure function of
process-startup constants (_DEFAULT_HERMES_HOME / _INITIAL_HERMES_HOME)
that are resolved once at import time and never mutated afterwards, so
memoizing per profile argument for the life of the process is safe. Profile
creation only creates the directory at the already-deterministic path; it
does not change what path a profile argument resolves to.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The all-profiles sidebar scan walks every profile's state.db in one
request. When it lazily minted a "Webhooks" / "Cron Jobs" project for a
profile that had webhook/cron rows, ensure_webhook_project() and
ensure_cron_project() tagged the new entry with get_active_profile_name()
-- the UI-selected profile -- instead of the profile whose rows triggered
it. A webhook-only profile therefore leaked a "Webhooks" project onto
whichever profile was selected; deleting it did nothing because the next
sidebar poll re-minted it, and switching profiles minted one per profile.
ensure_webhook_project / ensure_cron_project / _profile_has_user_projects
now take an explicit profile (default: active profile), and the scan
passes _cli_profile through, so the opt-in gate and the tag both key on
the scanned profile. Existing stubs in tests updated to accept the kwargs.
Gate found two blocking defects in the new caches:
1. The large-string LRU was keyed on input text only. The installed
agent.redact registry can extend the secret matcher at runtime via
register_redaction_patterns() and exposes no policy generation or
invalidation hook, so a >16k string primed before a registration was
served with the new secret intact (reproduced against the installed
registry). Drop the large-string cache: strings above 16,384 chars run
the redactor uncached again. Regression primes a large string, registers
a runtime pattern on the real registry and proves the next
redact_session_data() response is redacted.
2. The prefilter LRU was count-bounded (8192) but its per-entry gate was a
character cap, which is not a byte bound: 16k four-byte code points is
~64 KiB per key, ~512 MiB at capacity, and the workload died with
MemoryError under 96 MiB of headroom. Gate each entry on
sys.getsizeof(text) <= 2048 so the retained-key ceiling is a hard
16 MiB. Regressions cover max-width Unicode, sensitive verdict pairs,
eviction past the cap, and a low-memory RLIMIT_AS subprocess.
Once large strings stopped being re-redacted on every response, cProfile
showed the cheap prefilter had become the dominant cost of the redaction
pass: 2.98s of 3.7s, spent testing 71 literal markers against each of the
~74k strings (~19MB) of a large session, i.e. ~3.1M interpreted iterations
per request — repeated by every tab and every poll for a constant verdict.
Memoize the verdict behind a bounded LRU, mirroring the existing redaction
caches: entry count and per-entry size are both capped, so retained bytes
stay bounded and oversized blobs simply run the scan uncached. Non-strings
never reach the cache, so an unhashable value cannot raise.
Measured on a real 22MB session, warm: full redaction 2.07s -> 1.07s (-49%),
output byte-identical. Combined with the large-string cache, the recurring
redaction cost drops from ~8.4s to ~1.1s.
Rejected alternative, kept in a comment: collapsing the 71 `in` scans into
one compiled alternation is the obvious-looking fix but measured 38% SLOWER
(2.60s vs 1.88s) — CPython's substring search is already a tuned C routine.
Security: the prefilter gates the entire redaction pass, so equivalence is
pinned by a differential test suite against a copy of the previous
implementation — every marker individually, case variants, embeddings,
near-misses, unicode lowercase traps, 4000 fuzz cases, plus oversized and
unhashable inputs. Verdicts are identical on all 73,658 strings of a real
session (1935 flagged before and after).
Broad selection: 22 failures before and after, identical set — all
pre-existing.
`_redact_fn_cached` skipped memoization for any string over 16KB, so those
strings re-ran the full ~15-pass redactor on every single request. On a real
22MB session that is 59 large strings (29 unique, 0.76MB) costing 1.68s per
request — 99.9% of the recurring redaction cost once the small-string cache is
warm — repaid by every tab and every poll for a byte-identical result.
Large strings now get their own bounded cache, separate from the 4096-entry
small-string one so a large blob can never evict the thousands of small
recurring strings. Entry count and per-entry size are both capped; anything
above the ceiling stays uncached, keeping retained bytes bounded.
`_redact_value` additionally stops rebuilding containers whose descendants
were unchanged. Most of a transcript carries no credential marker, so the
unconditional dict/list comprehension deep-copied the whole payload to
reproduce an identical structure — pure CPU under the GIL, which serializes
concurrent tab loads on a threaded server. Callers treat redacted output as
read-only (`_public_message_projection` and `redact_session_data` both build
fresh containers), so sharing an unmodified subtree cannot leak a later
mutation back into session state. As soon as one string is masked, every
container on the path to it is rebuilt and the caller's original is untouched.
Measured on the live 22.6MB session, steady state: redact_session_data 3.78s
-> 1.96s single tab (-48%), 7.93s -> 4.06s with two concurrent tabs (-49%),
byte-identical output.
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>
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.