* feat(desktop): inherit project color on session rows
Sessions that belong to a colored project now pick up that color as the
sidebar row's idle lead dot, so work/personal/project buckets are legible
at a glance (Layer 1 of #66565). Derived from the same project membership
the sidebar already groups by; active states (working / needs-input /
background / unread) still own the dot so the tint never fights an
attention cue.
* feat(desktop): share session color across sidebar rows and pane tabs
Route session color through one computed store ($sessionColorById) that
both the sidebar rows and the pane tabs read, so a session and its tab can
never show different colors. Recomputed only when the session list or
projects change (cold atoms — the streaming pulse lives elsewhere) and read
as an O(1) lookup, never re-derived per render.
Tabs previously had no color at all: the strip renders only a title string.
Add a generic `accent` to the pane contribution that the tab strip paints as
a lead dot; the session tiles (via paneMirror) and the main workspace tab
(syncWorkspaceTitle) feed it from the same shared map. Precedence now lives
in one place, ready for per-session override / agent-set color (#66565).
* fix(desktop): resolve session color for repo-root-only sessions
liveSessionProjectId bailed the instant a session had no cwd, so an
older/imported session carrying only a git_repo_root — which the backend
still groups under its project — got no project and rendered a grey idle
dot instead of the project color ("grouped but grey"). Anchor on the repo
root when cwd is absent, matching how the sidebar grouped the row, and keep
the sibling-worktree guard for the cwd-present case.
Auto-detected git repos ("inherited" projects) have no projects.db row, so
their menu hid appearance/rename/etc. entirely and they could never be
themed. Add appearance to the auto-project menu: the first color/icon choice
adopts the repo as a real project (folder = repo root, name = its label)
carrying that look, after which it themes in place like any explicit
project. Routes both explicit and auto edits through one setProjectAppearance
helper; the picker closes on adopt so a stale second write can't double-create.
Replaces the dozen ad-hoc measure-*/profile-* scripts (each reinventing the
CDP client — 4 different copies — plus its own arg parsing, stats, output
path, and none with a baseline) with one framework under scripts/perf/:
- lib/cdp.mjs one CDP client + target discovery + typing + CPU-profile wrapper + DOM selectors
- lib/stats.mjs percentiles, histograms, CPU-profile self-time ranking
- lib/baseline.mjs load/compare/update baseline + regression gate (new capability)
- lib/launch.mjs attach, OR spawn a fully ISOLATED instance
- scenarios/* one module per measurement, registered in scenarios/index.mjs
- run.mjs / serve.mjs, baseline.json, README.md
Isolation solves the long-standing measurement blocker: a running `hgui` held
the Electron single-instance lock, so a second instance quit. `--spawn` /
`perf:serve` launch with their own --user-data-dir (separate lock scope), their
own HERMES_HOME (separate backend/sessions, config seeded from ~/.hermes so it
reaches a chat view without onboarding), and their own --remote-debugging-port.
Synthetic scenarios drive $messages via window.__PERF_DRIVE__, so no LLM credits.
Scenario -> sunset script mapping:
stream <- measure-synthetic-stream, profile-synth-stream, profile-long-stream
stream --real <- measure-real-stream, profile-real-stream
keystroke <- measure-latency, profile-typing, leak-typing
transcript <- (new: long-transcript mount cost)
submit <- measure-submit, measure-jump
session-switch <- profile-session-switch
profile-switch <- measure-profile-switch
CPU profiling is now a cross-cutting --cpuprofile flag, not 5 separate scripts.
CI-tier scenarios (stream, keystroke, transcript) need no backend/credits and
are gated against baseline.json (seed values; re-capture with --update-baseline
on a reference device). Backend-tier scenarios are report-only.
perf-probe.tsx gains loadTranscript() for the transcript scenario. No core
files touched; isolation is via CLI args, not env-gated app changes.
Verified: node --check all modules, tsc, eslint, and a unit smoke of the
stats + regression-gate logic. The end-to-end GUI run (which opens a window)
is left to run interactively via `npm run perf -- --spawn`.
Auto-detected git repos ("inherited" projects) have no projects.db row, so
their menu hid appearance/rename/etc. entirely and they could never be
themed. Add appearance to the auto-project menu: the first color/icon choice
adopts the repo as a real project (folder = repo root, name = its label)
carrying that look, after which it themes in place like any explicit
project. Routes both explicit and auto edits through one setProjectAppearance
helper; the picker closes on adopt so a stale second write can't double-create.
liveSessionProjectId bailed the instant a session had no cwd, so an
older/imported session carrying only a git_repo_root — which the backend
still groups under its project — got no project and rendered a grey idle
dot instead of the project color ("grouped but grey"). Anchor on the repo
root when cwd is absent, matching how the sidebar grouped the row, and keep
the sibling-worktree guard for the cwd-present case.
* fix(gateway): serialize concurrent turns per resolved session_id with a turn lease
Closes the serialization half of #64934. The busy guards are keyed by
routing key, but the durable transcript is owned by session_id — and
switch_session() makes the key→id mapping many-to-one (/resume from a
second chat/topic, CLI-continuity rebinding, async-delegation pinning,
topic-binding tip-walks). Two routing keys mapped to one session_id ran
concurrent turns on two different agent objects, invisible to every
per-key guard: flushes persisted in completion order, the identity-marker
dedup swallowed rows, and the second turn ran on a stale history base —
leaving a permanent user;user alternation wedge.
The fix: an asyncio lease keyed by RESOLVED session_id (gateway/turn_lease.py),
acquired in _handle_message_with_agent after session resolution is final
(post switch_session/tip-walk), immediately before the transcript load, and
released in _handle_message's finally on every exit path. Tokens are granted
per (routing key, run generation) so a stale unwind can never release a newer
turn's lease (#28686 ownership lesson). Same-key messages never reach the
acquisition point mid-turn (both routing-key guards hold them), so the lock
is uncontended outside the alias-key route — where the second turn now waits
for the first turn's flush and logs one WARNING naming the session and both
routing keys (pairs with the #67371 tripwire).
Fail-open: a stuck holder degrades to today's unserialized behavior with a
loud ERROR after agent.gateway_timeout — never a wedged session; a degraded
token holds nothing and can't steal the lease. Registry is size-capped and
never evicts a live lease. Persist-disabled review forks never dispatch
through _handle_message, so they cannot contend.
Known limits (tracked on #64934): CLI-continuity cross-process pairs need a
DB-level lease; mid-turn compression rotation leaves a small alias window
for a follow-up at the binding-sync sites.
Validation: 8 behavior tests (alias-key wait + flush order, no cross-session
contention, generation-scoped idempotent release, timeout fail-open without
lease theft, bounded registry, bare-runner-safe release wiring) + E2E against
a real SessionStore reproducing the issue's switch_session alias route —
strict alternation and arrival order preserved.
* refactor(gateway): conversation-scope funnel + mid-turn lease rebind
Completes the #64934 system beyond the point fix. Two structural changes,
both eliminating whole bug classes rather than instances:
1. _clear_conversation_scope — THE single conversation-boundary funnel.
/new, /resume, auto-reset, expiry finalization, and the
compression-exhausted reset each carried a hand-copied pop-list of the
per-session dicts, and the lists drifted every time a new dict was
added (#48031, #58403, #10702, #35809 were all 'boundary X forgot
dict Y' bugs). All five sites now make one funnel call driven by the
_CONVERSATION_SCOPED_STATE registry; adding a new conversation-scoped
dict means adding one name to the registry, and every boundary picks
it up automatically. Scope rules documented at the registry: turn-scoped
state, the monotonic generation counter, and the agent cache are
deliberately excluded (different lifecycles).
2. SessionTurnLeaseRegistry.rebind — the held turn lease now FOLLOWS
mid-turn compression rotation. Both rotation sites (session-hygiene
pre-compression, agent-result session_id swap) alias the same
_SessionLease object under the new id, so an alias routing key
resolving the fresh child (topic tip-walk) still serializes against
the in-flight turn. Closes the rotation-alias window flagged as a
known limit on #64934. Ownership-checked like release; when the
target id already has a live lease the rebind fails open with a loud
WARNING (never a mid-turn deadlock).
Tests: 3 new rebind behavior tests + 5 funnel behavior tests (including
a real-setter drift guard); the two AST change-detector pins in
test_10710/test_48031 were re-pointed at the funnel and the #58403 pin
converted to a behavioral test. E2E: rotation-alias scenario against a
real SessionStore + SessionDB — turn B on the fresh child waits behind
the rotated holder, sees its rows, alternation intact.
Follow-up to the salvaged #56724: the runtime's _generate_xai_tts reads
voice_id, language, speed, auto_speech_tags, optimize_streaming_latency,
sample_rate, and bit_rate — but never text_normalization, and the xAI
/v1/tts payload builder has no such field. Surfacing it in the desktop
GUI would be a dead knob, so remove it from DEFAULT_CONFIG, constants.ts
(labels/descriptions/SECTIONS), and the ja/zh/zh-hant locale catalogs.
The other six xAI keys are all verified against tools/tts_tool.py.
Consistent naming across the xAI TTS settings section. Speed and
sampleRate are shown only when xAI is the selected provider, so they
get the prefix too.
The Settings > Voice provider dropdowns (tts.provider / stt.provider) only offer
the built-in providers plus whatever value is currently set. Custom `type: command`
providers declared in config.yaml aren't selectable — and once you switch away from
one it drops off the list, so you can only return to it by hand-editing config.
enumOptionsFor now merges in the names of any `type: command` entries under the
tts/stt config sections, so local command-backed engines appear alongside the
built-ins and can be switched freely from the UI.
Enumeration mirrors the runtime's own resolution so the dropdown can only offer a
name the runtime would actually honour: the canonical `<section>.providers.<name>`
location plus the back-compat top-level `<section>.<name>` block, the optional
`type:` discriminator, and the built-in-name guard. The guard compares against the
runtime's built-in sets rather than the ENUM_OPTIONS display list, which is not a
substitute — it already omits `deepinfra` (TTS) and `deepinfra`/`local_command`
(STT), so a `providers.deepinfra` command block would otherwise be offered as
selectable while the runtime dispatches to the native backend instead.
- helpers.ts: add commandProviderNames() + the built-in guard; merge for
tts.provider + stt.provider
- helpers.test.ts: cover both sections, incl. that non-command config blocks
aren't offered and that built-ins absent from the display list are never
offered as command providers
Sibling-site fix for the flaw addressed in the global 'Configure all
platforms' flow: the per-platform checklist also returned to the menu
without opening provider setup when a selected toolset was already
enabled but lacked provider configuration. Adds a matching regression
test.
* fix(env): recognize export-prefixed .env lines in save/remove (#40041)
load_env() parses bash-compatible 'export KEY=value' lines (#6659), so a
hand-added 'export GITHUB_TOKEN=ghp_...' shows as set (green light) in the
desktop Tools & Keys page. But save_env_value/remove_env_value only matched
plain 'KEY=' lines:
- DELETE /api/env 404'd ('not found in .env') — the token could not be
removed through the UI
- PUT /api/env appended a SECOND line; a later delete removed the new line
while the export line silently resurrected the old value
Both writers now match assignments through a shared _env_line_defines_key()
helper that understands the export prefix. Commented-out lines are still
ignored.
Regression tests drive the real dashboard endpoint handlers against a temp
HERMES_HOME with runtime-constructed classic-PAT-shaped fixtures, covering
save-does-not-500, export-line remove, export-line replace-without-duplicate,
and the plain-line path staying intact.
Fixes#40041
* fix(credentials): unify provider key delete/update across .env, auth.json, config.yaml (#51071#59761#62269)
A provider API key can live in three stores at once: ~/.hermes/.env,
auth.json credential_pool (env-seeded 'env:<VAR>' entries persisted by the
pool loader), and config.yaml mirrors (model.api_key, auxiliary.*.api_key,
custom_providers[*].api_key). The desktop/dashboard endpoints and the TUI
gateway RPCs only ever mutated .env, so the stores diverged:
- #51071/#59761: DELETE /api/env removed the key from .env but left the
credential_pool entry (the loader is additive-only and never prunes),
so the provider kept appearing in the model picker — surviving restart
via the stale pool entry + provider_models_cache.json row.
- #62269: PUT /api/env rewrote .env but left the OLD key in config.yaml
(model.api_key wins over env at client construction), producing 401s
with a key the UI no longer showed.
New hermes_cli/credential_lifecycle.py is the single choke point:
- remove_provider_env_credential(): clears the .env entry, prunes
env:<VAR> pool entries across ALL providers (a shared var like
GITHUB_TOKEN can seed several), suppresses the env source so a lingering
shell export can't re-seed it (matching 'hermes auth remove' semantics),
drops the affected providers' model-cache rows, and scrubs value-matched
config.yaml api_key mirrors. Returns 'found' spanning every store so a
stale pool-only entry is cleanable through the same delete button.
- save_provider_env_credential(): writes .env, rotates any config.yaml
mirror that held the PREVIOUS value (value-matched — an unrelated inline
key is untouched), and lifts a prior env-source suppression so re-adding
behaves like 'hermes auth add'.
OAuth preservation: only entries with source == 'env:<VAR>' are pruned.
OAuth/device-code/manual/borrowed pool entries and providers.<id> OAuth
token blocks are never touched by a key-only delete. (model.disconnect in
the TUI gateway still clears OAuth via clear_provider_auth — that surface
is a full provider disconnect, which is the documented intent there.)
Rerouted call sites: PUT/DELETE /api/env (dashboard + desktop),
tui_gateway model.save_key / model.disconnect, save_env_value_secure
(TUI/gateway secret capture), and hermes config set/unset for env-shaped
keys.
E2E tests drive the real endpoint handlers against temp-HERMES_HOME
fixtures (.env + auth.json + config.yaml with runtime-constructed fake
keys) and assert cross-store consistency after delete/update, pool-reload
survival ('restart'), OAuth preservation, models-cache invalidation, and
the suppress/unsuppress round-trip.
Fixes#51071Fixes#59761Fixes#62269
* fix(desktop): compute truthful per-provider readiness for Capabilities tool config
Backend: GET /api/tools/toolsets/{name}/config now sends a per-provider
'status' field ('ready' | 'needs_keys' | 'needs_auth' | 'needs_setup')
computed by the new provider_readiness_status() in tools_config:
- env vars declared: all set -> ready, else needs_keys
- Nous-managed rows: Portal login + per-category tool-gateway entitlement
(MANAGED_FEATURE_COVERAGE_CATEGORY) -> ready, else needs_auth
- post_setup 'xai_grok' rows: Grok OAuth or XAI_API_KEY -> ready, else
needs_auth
- other keyless post_setup rows: installed-state predicate
(_POST_SETUP_READY: kittentts/piper/ddgs/langfuse via find_spec,
agent_browser via _has_agent_browser, cua_driver via PATH probe);
unknown hooks fall back to is_active as the setup-completed signal
- genuinely-free keyless rows (Edge TTS) stay ready
Existing fields are untouched; 'status' is additive so older desktops
keep working.
* fix(desktop): render server readiness status in Capabilities provider pills
The panel's providerConfigured() heuristic pilled every zero-env-var
provider 'Ready' — including logged-out Nous Subscription rows, xAI TTS
without Grok OAuth, and never-installed KittenTTS/Piper. Render the
backend's per-provider 'status' instead:
- ready -> existing 'Ready' pill
- needs_auth -> warn pill 'Needs sign-in'
- needs_setup -> warn pill 'Needs setup'
- needs_keys -> no pill (env-var fields are the signal)
Keyed rows keep deriving ready/needs_keys from local envState so saving
or clearing a key updates the pill without a refetch. Older backends
without 'status' fall back to the legacy env-var heuristic (narrow
compat path, desktop/runtime update on separate clocks).
Adds the warn tone to the settings Pill primitive and needsSignIn /
needsSetup strings to all locale catalogs (en, zh, zh-hant, ja).
The per-message ephemeral context prompt re-renders every turn, and
any byte change (Discord auto-thread rename, reset notes, voice
channel state) both breaks the provider prompt-cache prefix at the
head of every request and changes the gateway agent-cache signature,
forcing a full agent rebuild per message. Pin the rendered block per
session keyed by a hash of exactly the fields it renders, so only a
real input change (rename, topic edit, /sethome, redact_pii flip)
re-renders; deliver one-shot per-turn facts (auto-reset note,
first-contact intro, voice-channel changes) on the current user
message via the api_content sidecar instead of the system prompt; sort
get_connected_platforms for byte-stable ordering.
_manage_thinking_signatures treated every Kimi-family endpoint with the
#13848-era contract: strip signed Anthropic thinking blocks from replayed
history, assuming the upstream cannot validate Anthropic signatures.
Live probing shows that contract is outdated for the whole Kimi family:
- Kimi For Coding (api.kimi.com/coding) issues AND validates its own
thinking signatures (K3+): both verbatim and content-mutated signed
blocks replay with HTTP 200;
- Moonshot's Anthropic surface (api.moonshot.cn/anthropic) accepts signed
blocks the same way (200 on both verbatim and mutated);
- every other harness that replays signed blocks to KFC (Claude Code, pi,
Kilo Code) round-trips fine.
Stripping signed blocks there silently discarded the model's prior
chain-of-thought in multi-turn conversations — e.g. a two-turn recall
probe loses the reasoning between turns while the text answer survives
(agent.log: turn-2 input ≈ turn-1 input + a few dozen tokens instead of
+thinking). With this change, the same probe recalls the exact hidden
values from turn-1 thinking (+230 tokens on turn 2).
So: on _is_kimi_family_endpoint, keep signed and unsigned thinking blocks
unchanged on replay — one uniform rule for the whole Kimi family, no
/coding-vs-Moonshot split. DeepSeek keeps the #16748 contract (strip
signed, preserve unsigned). Third-party and direct-Anthropic behavior is
untouched.
Add tests/agent/test_anthropic_kimi_signed_thinking_replay.py pinning the
unified behavior (Kimi /coding + Moonshot keep signed and unsigned) and
the unchanged neighbors (DeepSeek strips, direct Anthropic keeps).
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).
Route session color through one computed store ($sessionColorById) that
both the sidebar rows and the pane tabs read, so a session and its tab can
never show different colors. Recomputed only when the session list or
projects change (cold atoms — the streaming pulse lives elsewhere) and read
as an O(1) lookup, never re-derived per render.
Tabs previously had no color at all: the strip renders only a title string.
Add a generic `accent` to the pane contribution that the tab strip paints as
a lead dot; the session tiles (via paneMirror) and the main workspace tab
(syncWorkspaceTitle) feed it from the same shared map. Precedence now lives
in one place, ready for per-session override / agent-set color (#66565).
Sessions that belong to a colored project now pick up that color as the
sidebar row's idle lead dot, so work/personal/project buckets are legible
at a glance (Layer 1 of #66565). Derived from the same project membership
the sidebar already groups by; active states (working / needs-input /
background / unread) still own the dot so the tint never fights an
attention cue.
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`.