Commit Graph
5545 Commits
Author SHA1 Message Date
Stacey2911 b39c147b31 fix(workspace): live-sync active Workspace Todos tab 2026-06-28 07:28:30 +00:00
nesquena-hermes 37f4af5229 Merge pull request #5109 from nesquena/codex/changelog-guidance
docs: clarify changelog ownership for contributor PRs [reopened]
2026-06-28 00:17:47 -07:00
Paladin173 96766081d8 docs: clarify changelog release ownership 2026-06-28 07:10:21 +00:00
nesquena-hermes 2798b54011 Merge pull request #5107 from nesquena/codex/fix-5064-archived-session-restore
fix(boot): skip archived saved session on refresh [reopened]
2026-06-27 23:58:37 -07:00
Paladin173 53af91c5f2 Update boot restore static guard test 2026-06-28 06:51:36 +00:00
Paladin173 aceb5411ee Clear archived saved session on boot restore 2026-06-28 06:51:36 +00:00
Paladin173 a51e58efc0 fix: skip archived saved session on boot 2026-06-28 06:51:36 +00:00
nesquena-hermes 8a26269d72 Merge pull request #5105 from nesquena/pr/4731-mobile-message-actions
fix(#4731): enlarge mobile message action tap targets [reopened]
2026-06-27 23:49:58 -07:00
Rod Boev 861c29b35b fix(#4731): enlarge mobile message action tap targets 2026-06-28 06:41:16 +00:00
nesquena-hermes b1523416be Merge pull request #5101 from nesquena/feat/extension-tts-engine-registration
feat(extensions): TTS-engine registration capability (registerHermesTtsEngine)
2026-06-27 23:18:43 -07:00
nesquena-hermes 031b5b342a fix(extensions): define TTS registry at module scope + route auto-read through registered engines
Codex gate found 2 functional bugs:
1. registerHermesTtsEngine was inside the voice-mode IIFE, which returns early
   on browsers without SpeechRecognition/speechSynthesis — so the documented API
   didn't exist there. Moved the registry (registerHermesTtsEngine / _hermesTtsSynth
   / option helpers) to module scope so it runs unconditionally.
2. autoReadLastAssistant() ignored a selected registered extension engine (only
   handled elevenlabs/edge, fell through to browser TTS). Added the registered-engine
   branch mirroring speakMessage(), with graceful fallback when the saved engine
   is no longer registered.
2026-06-28 06:03:08 +00:00
nesquena-hermes cd71403be9 feat(extensions): TTS-engine registration capability (registerHermesTtsEngine)
Lets a trusted local extension contribute a TTS engine that appears in the
Settings -> TTS Engine dropdown and is used by BOTH playback paths (voice-mode
auto-read in boot.js _speakResponse + the per-message Listen button in ui.js
speakMessage). Mirrors window.registerHermesSkin.

  window.registerHermesTtsEngine({ id, label, synthesize(text, opts) })
    -> Promise<ArrayBuffer|Blob> of audio; core coerces + plays through the same
       <audio> lifecycle as the Edge engine (stop/rearm in voice mode).

Core owns selection, the dropdown <option> (label via textContent, never HTML),
and playback. Built-in ids (browser/edge/elevenlabs) are reserved; invalid
descriptors are rejected; empty/failed synth degrades gracefully. The settings
panel re-adds registered options on render (the markup hardcodes only built-ins).

Unblocks TTS-engine extensions (e.g. a local VOICEVOX engine). Docs in
docs/EXTENSIONS.md; CHANGELOG [Unreleased]. New regression test drives the real
registry via a Node harness (register/validation/reserved-guard/synth coercion).

Full suite: 10893 passed, 0 failed. Verified end-to-end in a live browser with a
companion VOICEVOX extension: engine registers, appears in the dropdown
(browser/edge/elevenlabs/voicevox), and the Listen button routes through the
registered synthesize (both VOICEVOX steps called).
2026-06-28 05:43:02 +00:00
nesquena-hermes d4a0687bdc Merge pull request #5100 from nesquena/feat/extension-theme-registration
feat(extensions): theme-registration capability (registerHermesSkin) [reopened]
2026-06-27 22:26:32 -07:00
nesquena-hermes 0c89b49cf8 chore: nudge PR head re-sync (no-op) 2026-06-28 05:22:27 +00:00
nesquena-hermes e4152c0398 fix(extensions): render skin picker label via textContent, not innerHTML (XSS hardening)
Codex flagged that _buildSkinPicker injected the extension-controlled skin
label/name via btn.innerHTML, so a malicious registerHermesSkin descriptor
(label='<img src=x onerror=...>') could execute when Settings -> Appearance
builds the picker. Rebuild the button with DOM nodes + textContent for the
label and set swatch backgrounds via element.style.background. Adds regression
tests asserting the innerHTML sink is gone.
2026-06-28 05:22:26 +00:00
nesquena-hermes dfe64313dc theme-registration: preserve pending ext skin across reload + expand accent token allowlist
Found via live browser testing of the first consumer (e-ink skin):
- An extension-registered skin selected by the user was lost on reload: both
  the pre-paint inline script in index.html AND the boot-time appearance sync
  normalized the persisted skin against the (extension-less) known-skins list
  and clobbered localStorage back to 'default' before the extension's
  registerHermesSkin() could run. Both sites now PRESERVE an unknown non-default
  persisted skin (a likely pending extension skin), so it survives the reload
  and the extension re-applies it on load. The server push is skipped for a
  still-pending ext skin.
- Expanded the token allowlist to the full accent family
  (--accent-hover/-text/-bg/-bg-strong/-rgb + --surface-subtle) so a skin can
  fully recolor accent-driven chrome (active rail, chips, profile labels), not
  just --accent. Value regex now also accepts a bare RGB triple for --accent-rgb.

Regression tests added for both preservation sites. Full suite: 10876 passed.
2026-06-28 05:22:26 +00:00
nesquena-hermes 6d8135df3a feat(extensions): theme-registration capability — registerHermesSkin()
Lets a trusted local extension contribute a custom skin to the NATIVE
Settings -> Appearance picker (instead of a parallel theme switcher).
window.registerHermesSkin(descriptor) validates + sanitizes the skin in
core, once, so every theme extension inherits the guard:
- token names restricted to a documented allowlist
- values sanitized against a strict color/dimension regex (rejects url(),
  expression(), semicolons, braces, and other CSS-injection vectors)
- reserved built-in skin keys cannot be overwritten
- descriptor with no valid tokens is refused
- idempotent re-register (updates in place) for a live theme editor

Injects a managed <style> for the :root[data-skin] tokens, appends to
_SKINS + _VALID_SKINS, refreshes the picker, and re-applies a persisted
selection that was waiting on a late-registered skin.

Unblocks theme-pack and theme-creator extensions. Documented in
docs/EXTENSIONS.md. Regression test drives the real registration +
sanitization logic via a Node harness (adversarial input).

Full suite: 10874 passed, 0 failed.
2026-06-28 05:22:26 +00:00
nesquena-hermes 63d818f8a4 Merge pull request #5098 from franksong2702/franksong2702/extensions-gallery-source-permissions
Improve extension gallery source and permissions display
2026-06-27 22:11:33 -07:00
Frank SongandPaperclip ddb8ddd811 Merge current master into extension gallery source permissions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-28 13:05:12 +08:00
nesquena-hermes 19097ec96e Merge pull request #5091 from nesquena/feat/extension-csp-frame-src
feat(extensions): opt-in CSP frame-src allowlist (HERMES_WEBUI_CSP_FRAME_EXTRA)
2026-06-27 21:55:20 -07:00
Frank Song 06465bbd91 Improve extension gallery source and permissions display 2026-06-28 12:44:57 +08:00
nesquena-hermes 58ba991c0d feat(extensions): opt-in CSP frame-src allowlist (HERMES_WEBUI_CSP_FRAME_EXTRA)
Adds a configurable frame-src directive so an extension can embed a
self-hosted web app in an iframe (e.g. an 'external app tab' pinning
Grafana / Vaultwarden / a dashboard). Mirrors the existing
HERMES_WEBUI_CSP_CONNECT_EXTRA knob: space-separated http(s) origins with
optional *.subdomain wildcard + port, validated, ignored if malformed
(directive-injection / paths / ws scheme / bad ports all rejected).

- Default frame-src is 'self' only, so existing same-origin iframes are
  unchanged; default-off when the env var is unset.
- frame-ancestors stays 'none' -- this only governs what THIS page may embed,
  never who may embed the WebUI.
- Threaded through both the enforced and report-only CSP builders + the
  server.py Handler wrapper, so both headers stay identical.

Docs in docs/EXTENSIONS.md. 8 new regression tests
(tests/test_csp_frame_src_extra.py) cover default, valid widening, enforced
parity, directive-injection rejection, path/ws/port rejection, and
connect/frame independence. Existing CSP test suite still green.

Note: full suite shows 2 pre-existing order-dependent flakes
(test_issue1574 spawn-context, test_issue3283 import-order) that pass in
isolation and are untouched by this CSP-only diff.
2026-06-28 04:08:04 +00:00
nesquena-hermes 4cb9dbcf80 Merge pull request #5076 from nesquena/stage/5070-moa-route
Release: /moa in the WebUI via server-resolved MoA config (#5070)
2026-06-27 17:29:02 -07:00
nesquena-hermesandrodboev dbfa33a999 feat(moa): route /moa through WebUI via server-resolved MoA config (#5070, #5057)
Adds a dedicated GET /api/commands/moa/resolve endpoint + resolve_moa_config()
that reads the MoA preset/usage server-side. The /moa send-path passes only a
moa_config:true boolean to /api/chat/start; the server re-resolves the real
config itself (never trusts a browser-supplied dict) and threads it per-turn into
the live agent run. Fails closed (409) on gateway-backed sessions.

Replaces the originally-proposed broad 43-command subprocess approach (bounced)
with this narrow, security-reviewed /moa route. Maintainer fixes on top of the
contributor's converged design: moa_config is added to run_conversation only
when not None (older-agent TypeError safety) at the main call AND both auth
self-heal retry sites, and forwarded through the legacy-journal runtime adapter.

Gate: Codex SAFE (after the maintainer fixes) + full suite 10784 pass + the
activity-stream regression gate clean.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-28 00:23:51 +00:00
nesquena-hermes 9b80e9b06f Merge pull request #5074 from nesquena/stage/skins-batch
Release: 3 new opt-in skins (GitHub, Codex, Terracotta)
2026-06-27 17:01:43 -07:00
nesquena-hermesandgottipx 3c3c9047bb skins: add GitHub, Codex, and Terracotta opt-in appearance skins (#4634, #4636, #4635)
GitHub (#4634): graphite + Primer-blue. Codex (#4636): minimal editor, sage accent.
Terracotta (#4635): warm clay accent, renamed from the originally-proposed name to a
descriptive material name. All CSS-only, namespaced under [data-skin], opt-in via
Settings > Appearance. Gate: Codex SAFE + full suite 10778 pass + 3 accents verified live.

Co-authored-by: gottipx <gottipx@users.noreply.github.com>
2026-06-27 23:57:37 +00:00
nesquena-hermes e768053e4a Merge pull request #5073 from nesquena/stage/4958-anchor-mixed-order
Release: preserve settled mixed content order (#4958)
2026-06-27 16:21:24 -07:00
nesquena-hermesandfranksong2702 e2ade27bfe fix(anchor): preserve settled mixed content order (#4958)
When a settled assistant turn carries _anchor_activity_scene and has mixed
content[] (text interleaved with tool_use), promote that ordering into anchor
scene rows (prose / tool / thinking rows in sequence) instead of only the raw
content[] fallback, and mirror the same scene model in the backend hydration
path so a cold reload reconstructs an identical transcript.

Also fixes: post-tool_use text/thinking in a NON-FINAL assistant message was
silently dropped — only the turn-final assistant's post-last-tool text is the
"final answer" tail; earlier assistant messages keep post-tool content as
activity rows. Tail thinking is excluded from the final-answer text but still
emitted as a thinking row (verified on both JS and Python paths).

Tool-row de-duplication is conservative — ID-equal always merges; name/
invocation matches are cardinality-gated and same-message; body-prefix is only
a secondary confirmation inside the id-flexible branch (also requires matching
started_at + compatible name + invocation). It biases toward an extra visible
tool card over ever silently merging two distinct calls.

Gate: Codex SAFE (tail-thinking edge verified already-handled both sides),
Opus SHIP (dedup fails toward duplicate-not-loss; non-final preservation, XSS,
non-mixed regression all clean), full suite 10778 passed (only the 4 known
pre-existing flakes). One non-blocking follow-up noted: bring the prose/thinking
peer-dedup into JS↔Python parity (cosmetic cold-reload extra-narration row).

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-27 22:54:29 +00:00
nesquena-hermes 5cd070ae76 Merge pull request #5072 from nesquena/stage/4986-compress-durability
Release: make manual /compress durable across reload + restart (#4986)
2026-06-27 14:53:26 -07:00
nesquena-hermes 919901265f test(#4986): drop unused imports (ruff F401) 2026-06-27 21:49:23 +00:00
nesquena-hermesandhyl-ailab f7d727d061 fix(compress): make manual /compress durable across reload + restart (#4986)
Manual /compress shrinks the model-facing context_messages but keeps the
visible messages[] transcript. The old context returned via two paths (#4836):
(1) append-only state.db reconciliation re-appending pre-compression rows, and
(2) startup .bak recovery treating the intentional shrink as data loss.

Half A (routes.py _handle_session_compress): persist truncation_watermark +
truncation_boundary (= watermark of the compressed context), set
compression_anchor_mode="manual", refresh last_prompt_tokens, stamp missing
timestamps on the compressed context, and delete the now-stale .bak. The
boundary==watermark stamp drives the #4772 reconciliation logic onto its
conservative path (block replay of pre-compression rows) while post-compression
turns still merge once sidecar timestamps advance past the watermark.

Half B (session_recovery.py): the .bak recovery guard. MAINTAINER FIX over the
original PR — the contributor suppressed recovery whenever
compression_anchor_mode=="manual", a flag set once at compress and never
cleared, which PERMANENTLY disabled #1558 crash-recovery for any compressed
session (real data loss). Recovery is now suppressed only when the session was
intentionally compressed AND the .bak is genuinely the pre-compression backup,
discriminated by the compaction marker (the same _context_messages_include_
compression_marker signal reconciliation uses): a marked .bak post-dates the
compression -> recover; an unmarked .bak with a larger context is the
shrink-undoing pre-compression one -> suppress. Fail-open on any error.

Resolves an Opus-gate edge in the first cut (length-only heuristic wrongly
suppressed a loss that shrank BOTH messages and context). Two new non-vacuous
regression tests: post-compression real loss recovers, and the both-shrunk
marked-backup case recovers.

Co-authored-by: hyl-ailab <hyl-ailab@users.noreply.github.com>
2026-06-27 21:43:27 +00:00
nesquena-hermes 6ecb231451 Merge pull request #5071 from nesquena/stage/5069-boot-composer
Release: preserve typed composer input during boot restore (#5069)
2026-06-27 13:56:01 -07:00
nesquena-hermesandrodboev 519c394dfe fix(boot): preserve typed composer input during saved-session boot restore (#5069)
On a hard refresh, boot-restore auto-opens the last session from localStorage,
but that load can finish after the user has started typing into the fresh
composer — and the saved-session draft restore clobbered the typed input.
_restoreComposerDraft already had a preserveActiveInput guard, but the boot
path never set it.

boot.js now calls loadSession(saved, {preserveActiveInput:true}); loadSession
threads it into the draft restore as
preserveActiveInput:!!opts.preserveActiveInput || (currentSid===sid&&forceReload),
preserving the prior same-session-force-reload behavior. A blank composer still
restores the saved draft (the guard only skips when active input is non-empty
and differs). opts is read via arguments[1] so the call is signature-safe.

The 4 one-line test changes refresh exact-string boot snapshots to the new call
signature; test_issue5060_boot_composer_preserve.py adds behavioral coverage
(2 assertions proven RED on master).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-27 20:51:46 +00:00
nesquena-hermes 3fa1c0a324 Merge pull request #5068 from nesquena/stage/4936-dashboard-chip
Release: sync a Hermes Dashboard chip with Appearance dropdown (#4936)
2026-06-27 13:37:50 -07:00
nesquena-hermesandrodboev 5bb03a6322 feat(settings): sync a Hermes Dashboard chip with Appearance dropdown (#4936)
Adds a "Hermes Dashboard" chip to the Settings > Appearance "Sidebar tabs"
grid, bidirectionally synced with the existing dashboard-mode dropdown
(never/always/auto). Chip off -> dropdown "never"; chip on -> restore last
non-never mode; dropdown change -> chip re-render.

The chip is a proxy for the dashboard config via saveDashboardSettings ->
POST /api/dashboard/config; it NEVER mutates hidden_tabs/tab_order (separate
toggle fn from _toggleTabVisibilityChip). loadDashboardSettings guards a stale
config GET from clobbering a newer save (_dashboardSettingsLoadSeq /
_dashboardSettingsWriteSeq), and a failed chip save rolls the dropdown/chip
back. Chip label via textContent (XSS-safe).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-27 20:33:51 +00:00
nesquena-hermes aa6eb35de7 Merge pull request #5067 from nesquena/stage/5010-max-tokens
Release: expose max_tokens override in Settings (#5010)
2026-06-27 13:32:40 -07:00
nesquena-hermesandrodboev 20d0313d0c feat(settings): expose max_tokens override in Settings (#5010)
The streaming path already honored a max_tokens cap from the active profile's
config.yaml, but there was no UI to manage it. Settings > Preferences now has a
"Max output tokens" field that writes a root-level max_tokens override through
the profile config (not settings.json, which the streaming worker never reads
for this), preserving unrelated YAML keys under _cfg_lock. Blank clears the
override so agent.max_tokens fallback resumes; get_max_tokens_status() surfaces
root / effective / fallback so the field shows what a new turn would use.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-27 20:27:18 +00:00
nesquena-hermes 93a7b96fbc Merge pull request #5066 from nesquena/stage/5065-gateway-health
Release: fix cross-container gateway health (#5065)
2026-06-27 12:59:13 -07:00
nesquena-hermesandrodboev 3ad07ba363 fix(agent_health): read cross-container gateway runtime status from gateway_state.json (#5065)
In cross-container Docker setups the agent-health check resolves the gateway's
runtime status from a shared gateway_state.json. After the keyword-form
read_runtime_status(pid_path=...) call fails against an older agent signature,
_read_gateway_runtime_status fell back to read_runtime_status(pid_path) passing
the sibling gateway.pid path positionally — so the reader parsed PID metadata as
runtime metadata and gateway_state/updated_at never resolved, breaking the
freshness check (issue #5030, #1879).

The positional fallback now passes the correct gateway_state.json sibling path
(pid_path.with_name(runtime_status_file), where runtime_status_file comes from
the agent's _RUNTIME_STATUS_FILE attr, defaulting to gateway_state.json). The
path computation is hoisted so both fallback branches use the correct file. The
keyword happy-path is unchanged for agents that accept pid_path=..., and normal
PID-based health is unaffected.

Verified: the new test test_cross_container_runtime_status_reads_sibling_runtime_file
fails on master without the fix and passes with it; #716 PID-health tests still pass.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-27 19:54:50 +00:00
nesquena-hermes 16a6827f73 Merge pull request #5063 from nesquena/stage/4978-oob-strip
Release: strip consumed OOB steer blocks from model-facing history (#4978)
2026-06-27 12:17:24 -07:00
nesquena-hermesandStacey2911 97a25a98fb fix(streaming): strip consumed OOB steer blocks from model-facing history (#4978)
When a turn is steered, the agent embeds an
[OUT-OF-BAND USER MESSAGE]...[/OUT-OF-BAND USER MESSAGE] control block, and that
block gets persisted into the session's messages/context_messages (appended onto
a tool-result entry — verified present in real prod session JSON, in both
messages[] and context_messages[]). WebUI then replays context_messages into the
provider-facing conversation history every turn, so the model kept re-seeing the
internal control marker: wasted tokens + stale control data.

Adds a non-mutating, recursive _strip_oob_blocks() (regex, DOTALL+IGNORECASE,
non-greedy) wired into every model-facing chokepoint:
_sanitize_messages_for_api, _api_safe_message_positions,
_restore_reasoning_metadata projection, and the gateway runs-API
conversation_history (context_messages + prefill). The compression path
(/session/compress) inherits the strip via _sanitize_messages_for_api.

The marker stays intact in the persisted transcript (display/reload/audit
fidelity); it's removed only on the way to the provider. Idempotent, only strips
a complete open+close pair, and does not mutate persisted session objects (the
content key is reassigned to a freshly-built value).

Maintainer hardening (Opus gate): relaxed the opening pattern from a mandatory
`]\r?\n` to `]\s*?` so a single-line OOB block is also stripped (DOTALL .*?
already spans newlines) — robustness against a format that omits the newline.

Co-authored-by: Stacey2911 <Stacey2911@users.noreply.github.com>
2026-06-27 19:13:36 +00:00
nesquena-hermes e848acc236 Merge pull request #5062 from nesquena/stage/5061-metadata-refresh
Release: reload active transcript only on message-count change (#5061)
2026-06-27 11:53:28 -07:00
nesquena-hermesandallenliang2022 4f64443f63 fix(sessions): reload active transcript only on message-count change (#5061)
The active-session external-refresh check (refreshActiveSessionIfExternallyUpdated)
force-reloaded the whole transcript whenever last_message_at/updated_at advanced.
The post-turn background skill/memory review bumps those timestamps without adding
any chat messages, so the destructive loadSession(force) (clears S.messages, awaits
a round-trip) made the open conversation visibly disappear and reappear with no new
content.

The refresh now reloads only when the message count actually changed, and treats a
timestamp-only bump as metadata (advance the local last-seen marker + refresh the
lightweight sidebar list, no transcript teardown).

Maintainer fix on top of the contributor's PR (Codex gate): the count check fires
in BOTH directions — remoteCount !== localCount, not just >. A LOWER remote count
means another tab/client truncated/undid/retried/regenerated the transcript
(/api/session/truncate, /retry, /undo all shrink s.messages and persist a lower
message_count); the original >-only gate would have treated that as metadata-only
and silently kept the stale longer transcript forever. Added a regression test for
the count-decrease case. The pending-user-message count-inflation path is excluded
by the existing busy/active-stream guards.

Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
2026-06-27 18:49:08 +00:00
nesquena-hermes 6ba4194d18 Merge pull request #5059 from nesquena/stage/4647-cancel-preserve
Release: preserve cancelled partial turn payloads (#4647)
2026-06-27 09:53:36 -07:00
nesquena-hermesandfranksong2702 3c493a9e37 fix(cancel): preserve partial turn payload via embedded cancel snapshot (#4647)
Cancelling a running turn briefly collapsed the already-streamed reasoning,
tool cards, and text to a bare "Task cancelled" marker before a follow-up
GET /api/session repopulated them — a visible race between the terminal
cancel SSE event and the refetch (#4076, #1361).

The terminal cancel event now carries a canonical, redacted session snapshot
captured under the per-session agent lock right after the cancelled turn is
persisted (including the recovered partial-assistant message). The frontend
applies that embedded snapshot immediately via _applyCancelSessionPayload and
skips the GET round-trip. The snapshot is a fully detached deep copy (redaction
rebuilds every container) so it cannot alias or be staled by concurrent writes;
it is only applied when its session_id matches the active view; and a
missing/failed snapshot degrades cleanly to the existing GET fallback.

The seven other cancel call sites are standardized via _cancel_event_payload()
(message + type + status), backward-compatible (no session key when none).

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-27 16:43:37 +00:00
nesquena-hermes d968c741f9 Merge pull request #5056 from nesquena/release/stage-5053-selfheal
Release: read-side sidecar self-heal from newer state.db, lock-safe (#5053)
2026-06-27 08:38:08 -07:00
nesquena-hermes 3abd17f51d Release: read-side sidecar self-heal from newer state.db, lock-safe (#5053) 2026-06-27 15:34:35 +00:00
allenliang2022 363097adad fix(webui): hold per-session lock for state.db sidecar self-heal (TOCTOU + clobber) 2026-06-27 15:24:15 +00:00
allenliang2022 dbe470aa6f test(webui): add registration-window race regression + grace guard 2026-06-27 15:24:15 +00:00
allenliang2022 f3da26dc07 fix(webui): address greptile P1 - snapshot-first save, safer recovery gate 2026-06-27 15:24:15 +00:00