Two residual gaps in the #67369 salvage of #67214, found during review:
1. No timeout on the download client. Since #67369, mutable branch pins
hit the network on EVERY run, and the stale-cache fallback only fires
when download() returns Err. A black-holed connection (captive portal,
hung proxy, dropped packets) never errors, so the whole bootstrap hung
at resolve() instead of falling back to the cached script. Verified
live: a request to a non-routable address now errors at the 10s connect
timeout instead of hanging indefinitely.
2. The UTF-8 BOM was only written inside download(). Immutable commit-pin
caches take CachePlan::Reuse and are served untouched forever, and the
stale-fallback path also re-serves the old file - so a BOM-less .ps1
cached by a pre-fix installer kept reproducing the #67193 ANSI-codepage
parse failure on every retry (production builds pin BUILD_PIN_COMMIT,
so this is exactly the retry population). upgrade_cached_script() now
BOM-upgrades legacy .ps1 caches in place (atomic tmp+rename,
best-effort, idempotent) on both reuse paths; .sh untouched.
Follow-up to the cherry-picked fix: the same UnicodeEncodeError bind
failure was live at sibling sites the PR didn't cover —
- api_content sidecar (append_message, _insert_message_rows,
set_latest_user_api_content): bound raw; a surrogate in the composed
api_content aborted the whole row/UPDATE. Scrubbing is wire-accurate:
the conversation loop already scrubs every outgoing payload, so the
scrubbed form IS what was sent.
- tool_name (both INSERT sites): raw bind.
- sanitize_title: session titles from LLM title generation or /title
could carry surrogates; scrub before validation.
E2E-verified each site raised UnicodeEncodeError on main and persists
after this commit. Tests added for all five paths.
sqlite3 encodes bound str parameters as UTF-8 and raises
UnicodeEncodeError on lone surrogates (U+D800..U+DFFF), but
SessionDB._encode_content returned str content untouched. One such code
point anywhere in a message therefore aborted the entire message write.
The path is reachable with ordinary input — the same scraped web/social
text that crashed the guardrail hasher in fb0217c65:
1. a tool result carrying a lone surrogate is appended to the canonical
`messages` history unsanitized;
2. the proactive sanitizer only cleans the `api_messages` *copy*
(conversation_loop.py), so the API call succeeds;
3. because the API never raises, the UnicodeEncodeError recovery
sanitizer (guarded by `isinstance(api_error, UnicodeEncodeError)`)
never runs and the history keeps the surrogate;
4. the DB flush hits it and run_agent swallows the failure with
`logger.warning("Session DB append_message failed")`.
Because replace_messages re-sends the whole history every turn, the
poisoned row stays and every later save raises too: the session freezes
at its last good state while the live conversation grows, and everything
after that point is gone on resume. Observed: persisted rows stuck at 2
while history reached 12, with only a warning in the log.
Scrub at the DB write boundary with the canonical _sanitize_surrogates
(surrogate -> U+FFFD): in _encode_content, which both INSERT sites share,
and on the raw-bound reasoning / reasoning_content columns. The JSON
branch already defaults to ensure_ascii=True and was safe. Well-formed
text — accents, CJK, emoji — round-trips byte-identically, matching
_encode_content's stated intent that persistence never fails.
Adds regression tests for content, reasoning, the multi-turn freeze, and
benign-Unicode passthrough.
The salvaged helper cleared its line buffer on entry. Inside run_script's
tokio::select! loop, a stdout line arrival cancels the in-flight stderr
read (and vice versa); read_until had already consumed bytes into the
buffer, and the next call's clear() silently dropped that partial line.
Keep partially-read bytes across cancellation (clear only after a full
line is decoded) and emit an unterminated final line at EOF instead of
swallowing it. Adds a cancellation regression test (fails against the
clear-on-entry version) and an EOF-tail test.
Locks the #67193 invariants: localized PowerShell error bytes survive
decode_console_bytes / read_decoded_line (including CP1252-only 0x91/0x92
punctuation), cached .ps1 files get a single UTF-8 BOM for Windows
PowerShell 5.1 -File, .sh stays BOM-less, and mutable branch caches plan
a refresh with stale-cache fallback.
Windows PowerShell 5.1 emits ParserError text in the console ANSI code page,
but the GUI bootstrap aborted BufReader::lines() on the first non-UTF-8 byte
and Retry kept reusing a poisoned install-main.ps1 for branch pins. Decode
child output with a real Windows-1252 fallback, write a UTF-8 BOM on cached
.ps1 files for -File, and refresh mutable branch/tag caches on each run
(immutable SHAs stay cached).
Fixes#67193
Background-review forks share the live parent's session_id for prompt-cache
warmth but are _persist_disabled — they can never write to the transcript.
Without this, every review fork on an active session would (a) trip a false
cross-agent overlap warning against the parent's real turn, sending the
#64934 route investigation the wrong way, and (b) pop the parent's in-flight
slot at its own persist, making a real overlap right after a review go
unreported. Both legs now skip persist-disabled agents symmetrically.
+2 tests.
note_turn_start kept its in-flight marker on the agent object, but the
gateway caches agents per routing key (_agent_cache) while transcripts
are owned by session_id — and switch_session (/resume from a second
surface, CLI-continuity rebinding, async-delegation pinning,
topic-binding tip-walks) maps multiple routing keys onto one session_id
without any cross-key check. Two keys mapped to one session run
concurrent turns on two different agent objects, so the per-agent
tripwire could never fire for exactly the dispatch route #64934 is
waiting to identify.
Add a module-level session_id-keyed in-flight registry alongside the
per-agent marker. Same philosophy as the original tripwire: log-only,
takes ownership on overlap, under-reports rather than double-reports
(a same-agent overlap warns once, not twice). The persist-time clear
pops the session id stamped at turn start, so a mid-turn compression
rotation of agent.session_id cannot strand the slot.
Follow-ups on the salvaged unknown-root-key warning from PR #67345:
- Add image_gen, video_gen, plugins, smart_model_routing, platform_toolsets,
session_reset, multiplex_profiles, profile_routes, platforms,
require_mention, unauthorized_dm_behavior, and signal to
_EXTRA_KNOWN_ROOT_KEYS — all are read from the raw user YAML (gateway,
registries, plugin CLI) or written by our own setup wizard, but absent
from DEFAULT_CONFIG. Without this, doctor would warn on configs Hermes
itself wrote.
- Convert tests/hermes_cli/test_config_validation.py back to LF line
endings (the PR's rewrite introduced CRLF).
Extracted from the config-validation portion of PR #67345 (the token-cost
half was not salvaged). Unknown top-level config keys now warn (naming the
key) instead of being silently ignored; known roots derive from
DEFAULT_CONFIG.keys() plus a small extras set for valid-on-disk roots
absent from defaults.
Two config-hygiene improvements (warning-only, non-blocking):
1) Unknown top-level config keys now surface a warning naming the key (known roots derived from DEFAULT_CONFIG.keys() as single source of truth) so typos like 'skillz:'/'secrity:' are no longer silently ignored. Provider.* unknown-key behavior preserved.
2) hermes doctor reports deprecated/legacy config keys (display.tool_progress_overrides, delegation.max_async_children, compression.summary_*) and legacy env vars (HERMES_TOOL_PROGRESS*, TERMINAL_CWD, QQ_HOME_CHANNEL*) with their modern replacements, as non-failing warnings. No auto-delete/migrate.
Tests: config_validation + doctor suites green (100 passed).
The ineffective-compression counter is not durable; when it is the sole
reason the gate is blocked there is nothing in the DB that could unblock
it, so re-reading the guard rows on every gate check for the rest of the
session is pure waste. Guard test pins the no-DB-touch behavior.
Follow-up on the salvaged #64511 commit:
- _automatic_compression_blocked() now refreshes durable guard state
(cooldown + fallback streak) when — and only when — the in-memory
snapshot says blocked, then re-evaluates. The should_compress()
pre-gates (preflight/turn paths) consult this before ever reaching
compress_context, and a stale fallback streak has no expiry timer, so
without a gate-level refresh a cleared durable row could never unblock
a prebound agent. The unblocked hot path pays no DB reads.
- A refresh that finds no durable cooldown row no longer clears a live
local cooldown whose DB persist FAILED (_cooldown_persist_failed):
an empty row is not evidence another agent cleared it, and honouring
it would reopen the #11529 thrash window. A successful durable
round-trip (record or read) makes the DB authoritative again.
- Guard tests for both directions (red on the pre-fix code), including
a hot-path test asserting the unblocked gate never touches the DB.
A final response generated but not confirmed-delivered was the one
artifact the gateway could lose without a trace: crash or planned
restart between finalize and platform ACK dropped it silently, and the
resume path re-ran the whole turn at full cost (#58818 P1, #41696,
#63695's gateway half).
gateway/delivery_ledger.py records each outbound final response in
state.db (same conventions as the async-delegation ledger: WAL, owner
pid + process-start-time liveness, bounded retention):
pending -> attempting -> delivered | failed
startup sweep on dead-owner rows -> redeliver | abandoned
Contract (the lessons from the closed delivery-outbox attempt #61790):
- obligation recorded BEFORE the first send attempt; cleared only on
SendResult.success (destination acceptance, #51184)
- ambiguity is labeled, never silently retried: rows that were mid-send
when the process died redeliver with a visible '♻️ Recovered reply —
may be a duplicate' prefix (honest at-least-once)
- stable ids from session_key + inbound message id + content, so
distinct threads/topics can never collide
- poison rows bounded: 3 attempts / 24h freshness -> abandoned; claim
atomically re-stamps ownership so racing sweeps can't double-claim
- redelivery clears resume_pending for the session so the resume path
never re-runs a turn whose answer the ledger already holds
- best-effort everywhere: ledger failure can never block or delay a send
- slash-command/ephemeral/empty responses are not recorded; cron and
proactive delivery stay on DeliveryRouter (separate subsystem)
Config: gateway.delivery_ledger (default on; no version bump needed).
Validation: 30 ledger+producer tests; 352 blast-radius gateway tests
green; cross-process E2E (record in process A, kill it mid-send, claim
+ marker + redeliver in a fresh process B against the same state.db).
Wire the scoped translator into the plugin context alongside storage/rest/
socket, and export usePluginI18n + the bundle types from @hermes/plugin-sdk.
The runtime shim re-exports the barrel automatically, so disk/third-party
plugins get the same door as bundled ones.
A plugin ships its own locale bundles and registers them under its id — never
editing core en.ts — exactly like ctx.storage namespaces persistence. The
registry merges repeated registrations and drops a plugin's bundles on
dispose (tracked by the loader like register/socket), resolving through the
shared translator: active locale -> the plugin's own en -> the raw key.
Two consumers, symmetric with core: usePluginI18n(id) for React UI
(re-renders on a locale switch or a late registration) and a module-level t
for handlers/stores.
Collapse the fallback walk out of `translateNow` into a single `translateFrom`
that takes a per-locale message source, so any translator (core catalog or a
plugin's bundles) reuses the exact dot-path walk, interpolation, and
active-locale/English/raw-key chain. Adds `getRuntimeI18nLocale`.
Add an end-to-end developer guide for extending the native Hermes Desktop
app introduced in #60638: the HermesPlugin contract, PluginContext, every
contribution area (panes, routes, sidebar nav, status/title bar, palette,
keybinds, themes, composer, mount-scoped Contribute), the host API, the
React Query + nanostores data layer, the UI kit + theme variables, the
scoped ctx.rest/ctx.socket backend (plugin_api.py under /api/plugins/<id>)
and its separate enable gate, Settings/defaultEnabled/storage, bundled
plugins, the security model, pitfalls, and a full reference.
- Register the page in the sidebar under Extending -> Plugins.
- Disambiguate from the unrelated web-dashboard plugin SDK from both
directions, and add a map-table row + a desktop user-guide pointer.
- Fill the gaps in the agent-facing hermes-desktop-plugins skill
(ctx.rest/socket + backend, React Query, defaultEnabled, Contribute)
and point it at the new reference so agents know the SDK when writing
addons.
In the Focus layout `files` shares one tab group with `workspace`, so when the
first reply adopts a cwd the reactive files unhide fronted files and yanked the
active tab off the new session (~1s after the reply). #65375 fixed the sibling
"reactive unhide reopens a collapsed side" bug but frontPaneInGroup still stole
the active slot unconditionally.
Only take the active slot when the group's current active pane isn't itself
showable, so a reactive unhide can't steal focus from a pane the user is
viewing while still fronting a valid tab when nothing is shown.
Carries forward linfeng961's diagnosis + fix from #66870, reworked onto the
frontPaneInGroup path introduced by #65375.
Co-authored-by: linfeng961 <133505766+linfeng961@users.noreply.github.com>
* fix(desktop): don't auto-expand user-collapsed side on reactive unhide
When `setTreePaneHidden(paneId, false)` was called for a workspace-gated
pane like `files` (bound to $hasWorkspace), it auto-called
`revealTreePane(paneId)` — which expanded the parent column even when
the user had explicitly collapsed it via Cmd+J.
That meant every session create / resume — anything that flipped
$currentCwd from empty to non-empty — silently re-opened the right
sidebar and persisted that open state to localStorage, so the original
session also showed the sidebar as open on return.
`setTreePaneHidden` is a state primitive; user-intent semantics (open
the side, front the tab) belong to `revealTreePane`. Drop the auto-call
and replace it with a narrower `frontPaneInGroup` helper that only
makes the pane the active tab in its group — visible the next time the
column is opened, without forcing it open now.
* fix(desktop): preserve explicit review reveal
---------
Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
A manual composer pick stays sticky across new chats (intended), but if that
model later disappears from the provider/config, every new chat kept trying the
dead model and 404'd. Fall back to the profile default in that one case only.
refreshCurrentModel now consults the model-options cache the composer already
populates (no extra fetch): a manual pick is preserved unless manualPickRemoved()
proves it's gone — provider present, non-empty catalog, model absent. An
unknown/absent provider, an empty (re-auth/unconfigured) list, or a not-yet-loaded
catalog all preserve the pick, so a still-valid selection is never clobbered.
Condense the header rationale to the load-bearing invariants and inline
the single-line math-fence guards. No behavior change; scanner logic and
public surface (createScanState / advanceScan / findStableBoundary)
unchanged.
_is_real_user_message no longer accepts a user-role compaction summary as
the human anchor, so _compress_context restores the original user turn
after the summary; update the lifecycle-status test's expectation.
Follow-up hardening on top of the salvaged #66637 commits:
- _insert_real_user_anchor could place the restored human turn directly
next to user-role scaffolding (index-0 insert before a leading synthetic
user turn, or a scaffolding-only transcript), breaking the strict
alternation contract (#55677). Restoration now merges into trailing
scaffolding (anchor text leads, synthetic flags cleared) and appends
after a user-role compaction summary instead of inserting adjacent.
- _is_real_user_message now also rejects user-role compaction summaries
(the compressor pins the summary to role=user when the tail opens with
an assistant turn), so a summary can no longer satisfy the human-anchor
check and skip restoration.
- _latest_user_task_snapshot reuses the same real-user predicate, so the
deterministic task snapshot can no longer anchor on todo snapshots,
truncation notices, or background-process reports.
- The Historical Task Snapshot rewrite keeps the section terminated with
a blank line; the previous replacement consumed the boundary newlines,
gluing the next '## ' heading mid-line and deleting all later sections
on the next iterative compaction.
- Drop _length_continuation_synthetic (no producer anywhere).
- AUTHOR_MAP entry for enzo-adami.
Dusk1e's fix wired _restore_session_cwd into _handle_resume_command, but that
handler was extracted from cli.py into hermes_cli/cli_commands_mixin.py
(094aa85c3) after the PR's base, so the original cli.py hunk no longer applied
(a naive cherry-pick fuzzily misplaced it inside new_session(), where
session_meta is undefined). Transplanted the call to the end of the handler in
its current home; /sessions <id> delegates here so both command forms are
covered. Dusk1e's regression tests carry over unchanged.
Co-authored-by: Dusk1e <yusufalweshdemir@gmail.com>
Rebased onto current main, where _ensure_session_db grew a per-profile
cache (get_hermes_home()-keyed) for /p/<profile>/ multiplex — after this
PR's base. The PR's async rewrite assumed the old single-self._session_db
model, so on current main `session_db=self._session_db` in _create_agent
would pass None in production (the real DB lives in the per-home cache).
Split the concern: keep a SYNC _ensure_session_db (per-profile, used by
_create_agent + the many sync-patching create_agent tests) and add an
async _ensure_session_db_async that captures the profile home on the loop
thread then offloads only the SQLite open via to_thread (single-flight).
Both share _open_and_cache_session_db. Request handlers use the async
variant; _create_agent reverts to the sync call. Updated the first-request
test's FakeDB to accept db_path to match main's SessionDB(db_path=...).
Co-authored-by: necoweb3 <sswdarius@gmail.com>
Surface the session's first-class Project in both chat surfaces: the
Desktop status bar (project name as the workspace label, full cwd in the
tooltip) and the TUI status label + /status output.
One source of truth. The per-profile projects.db is the authority, read
in tui_gateway via _project_info_for_cwd (backed by
projects_db.project_for_path) and threaded through every session.info
emission path the TUI consumes. The Desktop already caches that truth in
$projectTree, so it DERIVES the label from it (projectNameForCwd) instead
of carrying a second per-session $currentProject atom fed from
session.info.
That drops the parallel state #64721 introduced and the entire
reset/reconciliation surface it required (resume, agentless cwd.set,
gateway-switch, fresh-draft): the label is purely derived, so it stays
correct whenever the cwd or the project tree changes. Only explicit,
named projects resolve on both surfaces, so an auto-discovered repo root
keeps the cwd-leaf label everywhere.
Excludes the unrelated markdown shell-fence change bundled in #64721.