Run a duplicate of CI on the new ARC (Actions Runner Controller) runners in
GKE, beside the existing CI. The duplicate does not change production CI.
Every workflow in .github/workflows/ that does not start with newci- is
byte-identical to main. Watch the shadow runs for a few days, then migrate.
The shadow set is 16 files: newci-ci.yml plus the 15 reusable workflows that
ci.yml calls on a pull request. Only pull-request workflows are copied.
js-autofix, deploy-site, and skills-index run on push or on a schedule. A
copy of those would push branches and deploy the site a second time.
Safety properties of the shadow:
- Concurrency groups are newci-prefixed. This is the important one. The
production groups use cancel-in-progress, so a shared group would let a
shadow run cancel the production run.
- Cache keys are newci-prefixed. The shadow cannot poison or evict a
production cache entry.
- Reusable-workflow calls point only at other newci-* files. No shadow job
calls a production workflow.
- The PR review comment runs with --dry-run. It prints the comment body to
the job log. Two pollers cannot fight over the hermes-ci-review-bot
comment.
- The gate job is renamed to "[newci] All checks pass (informational)". The
production check "All required checks pass" stays the only merge gate.
- The shadow runs on pull_request only. The push trigger is removed.
- docker publish and merge jobs are unreachable. Their conditions require a
push to main or a release.
Runner infrastructure, in the shadow copies only:
- Jobs go to three scale sets: arc-runner-small for short gate jobs,
arc-runner-set for general work, arc-runner-docker and arc-runner-arm64
for image builds. dind is only on the docker sets, so the other jobs stop
paying for a privileged sidecar.
- The runner image supplies node 26, npm 12, uv, Python, and ripgrep. The
setup-node, setup-uv, and per-job install steps are gone.
- Checkout uses a node-local git mirror, seeded from the runner pod env.
- buildx layer cache moved to Artifact Registry in us-central1, the same
region as the runners. Reads are keyless through GKE Workload Identity.
Writes use GitHub OIDC and happen only on main pushes and releases, so
pull-request code cannot write a layer that the publish job reads.
Merge-base work, in the shadow copies only:
- A new composite action, .github/actions/merge-base, deepens a shallow
clone until the two histories connect. fetch-depth: 0 fetches all ~1400
refs and measured 76-81s, against 3-6s for a shallow checkout.
- The action fails by default when no merge base exists. A three-dot diff
over a missing merge base scans nothing and reports clean, so the
supply-chain audit must stop. history-check sets fail-on-missing to false,
because absence is the result it measures.
- lint diffs against the base commit directly. The job checks out the PR
merge ref, so base.sha is already the correct comparison point.
- contributor-check uses origin/main..HEAD. The result equals the merge-base
form, and the extra git call also expanded a SHA without quotes.
Other changes:
- .github/actionlint.yaml declares the four ARC labels. actionlint knows
only GitHub-hosted labels, so every runs-on in the repo was reported as an
unknown label: 40 warnings that hid real findings.
- scripts/ci/resource_profile.py records CPU and memory for a job step. The
timing report shows the data per step.
- run_tests_parallel.py can list test files from the git index. The slice
generator then needs no blobs.
- Docker test files are split so boot-heavy tests run in parallel.
- Container-environment parity fixes in doctor, gateway, and skill_utils,
with tests.
To retire the shadow: delete .github/workflows/newci-*.yml.
CI-caught: test_verification_stop_caching and test_tui_gateway_server::test_native_vision_turn_persists_a_renderable_image_ref both assert on append_message.call_args, but the flush loop now calls append_messages_batch. Same class of test-fake fallout fixed in 5 other files — these two were missed.
Sibling sites of the per-message flush pattern: both branch-seed
paths (session.branch in methods_session.py and the lazy seed persist
in server.py) copied the parent history row-by-row -- one transaction
per row, and a branch seed can be hundreds of rows. Route both through
SessionDB.append_messages_batch. The server.py path also gains real
atomicity: _branch_seed_persisted assumed every row landed, which the
per-row loop could not guarantee.
Salvage of #69926: omit_messages support ported from the PR's
tui_gateway/server.py base onto the post-split methods_session.py
layout. When a Desktop client passes omit_messages=true on
session.resume / session.activate, the RPC returns messages: [] with
messages_omitted: true and an accurate message_count, skipping the
potentially multi-megabyte compression-lineage serialization over the
WebSocket; Desktop hydrates the transcript via the authenticated REST
route in parallel.
The PR's bundled cron-outputs endpoint and codex quiet-timeout bump
were dropped from this salvage as unrelated (invited back separately).
The original PR #77274 used positional slicing (current_history[len(history):])
to detect the model-switch-only mutation. But _append_model_switch_marker
strips prior markers in-place before appending the new one, so when a prior
marker existed (every switch after the first in a session), the net length
delta is zero and the slice produces an empty list — the merge path is dead
code for the common case.
Replace with a content-based diff: strip markers from both the turn-start
snapshot and the current history, then check that the non-marker content is
identical. This correctly handles the strip-and-replace behavior.
Also guard against auto-compression making result["messages"] shorter than
the turn-start history — use the full result as the base when that happens.
Added test covering both no-prior-marker and prior-marker cases.
Deferred model switches append a marker and bump history_version at
turn start; the dispatcher was snapshotting history before that
mutation, so the version-mismatch guard rejected the turn's own
result as a stale/concurrent write. Move the snapshot to after
_apply_pending_model_switch/_sync_agent_model_with_config, under
history_lock, so the turn's own preparatory mutation is included in
its baseline while the anti-stale guard still catches real external
writes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Simplify-pass follow-up on the #66355 salvage:
1. _config_settings runs on EVERY trim attempt (before the cooldown
check) and only reads — swap load_config for load_config_readonly.
Deep-copying the whole config per attempt generates exactly the
allocator garbage this module exists to release. Tests re-seamed.
2. Trim-failure logs demoted warning->debug at all 3 periodic sites
(gateway housekeeping, idle reaper, slash worker): sibling failure
branches in the same loops log at debug, and a persistent failure
(e.g. broken import after a partial update) would otherwise warn
every 60s forever.
3. The frame-inspection test now asserts the expected locals exist
before reading them — a rename in _run_prompt_submit fails the test
loudly instead of vacuously passing on None.
Add config-driven glibc malloc_trim for long-lived Hermes processes:
- hermes_cli/mem_trim.py: trim_memory() with configurable cooldown,
RSS snapshot telemetry, and forced-trim INFO logging
- gateway/run.py: periodic trim in gateway housekeeping loop
- tui_gateway/server.py: trim in idle reaper (~every 5 min)
- tui_gateway/slash_worker.py: trim on turn boundary
- run_agent.py: force trim on agent close
- hermes_cli/config.py: context.memory_trim config section
(enabled, cooldown_seconds, log_every_n, info_log_min_delta_mb)
CSA tier-4 reviewed (4 rounds, 0 HIGH/MEDIUM/CRITICAL remaining).
Supersedes PR #63708 + #64591 with enhanced telemetry and gateway/slash_worker coverage.
alongside every set_hermes_home_override() call site on the tui_gateway
path, and converted session.create, session.resume (both the _make_agent
and _init_session scopes), the lazy _build resume and the per-turn submit
handler. session.branch was missed.
The branch handler already binds the parent's HERMES_HOME and its
profile-scoped state.db — its own comment says it mirrors
session.create/resume — but builds the branched agent with no secret scope.
get_secret() then falls through to process os.environ, which in an
app-global/remote backend is the LAUNCH profile's environment: a session
branched off profile X authenticates with profile Y's credentials. That is
the same cross-profile resolution #67605 fixed for the sibling paths, and it
is silent (multiplexing is only activated by the messaging gateway, so the
fail-closed UnscopedSecretError path never fires here).
Install set_secret_scope(build_profile_secret_scope(parent_home)) for the
build and release it in the same finally block as the home override.
Salvage-note: code relocated from tui_gateway/server.py to tui_gateway/methods_session.py (session handlers moved after the PR was opened); test patch targets unchanged — HandlerRegistry rebinds handlers onto server.py globals
Desktop edit / regenerate / restore-checkpoint send truncate_before_user_ordinal
on prompt.submit. The handler rewrote session['history'] first, then called
replace_messages, and on failure only printed to stderr and still started the
turn. When the durable write fails, in-memory history is already truncated
and history_version is bumped while state.db still holds the pre-edit tail.
The agent flush is append-only for history-dict identities, so the new
exchange is appended on top of the 'undone' turns — durable zombie history.
Fix: persist first; only then mutate memory. On failure return 5008 and
leave memory/DB unchanged.
Based on #72876 by @necoweb3. Adapted: the handler moved from
tui_gateway/server.py to tui_gateway/methods_prompt.py since the PR's base.
The `/` menu was a flat first-30 slice of the completer's output, and the
completer emits every registry command before the first skill. On a
230-skill install that meant a bare `/` filled all 30 rows with commands
and offered no skill at all, while `/p` cut off inside the alphabetical
skill block — dropping /proving-a-fix-works (471 invocations) and
/pr-update (160) but keeping /pretext (2).
Spend the limit per kind and rank the skill block by recorded usage
(the same .usage.json count Capabilities shows), most-used first and A-Z
within a tie. A bare `/` is browsing, so bundled skills that shipped with
Hermes and were never opened are dropped as noise; a typed query is a
search, and a search that hides a match is broken, so there nothing is
pruned and the ranking only reorders. An argument stage keeps the order
its own command chose.
The two exact-list assertions in test_tui_gateway_server froze the toolset
list and broke the moment _RECENTLY_SHIPPED_TOOLSETS back-filled bfl onto a
saved platform list — the exact behavior the sibling change ships on purpose.
Assert the invariant instead: the expected base set is present, and anything
extra must be inside _RECENTLY_SHIPPED_TOOLSETS (vacuously exact again once
that set empties between releases).
A model picked mid-turn is applied at the next turn start, but the end-of-turn
session.info (_emit_settled_session_info) reads the still-live agent — the OLD
model — and clobbered the optimistic paint, so the UI showed the new model, then
snapped back to the old one when the turn ended, then switched for real on the
next turn. Report the queued pick's model/provider in _session_info while a
switch is pending (it IS the model the next turn runs), so the display stays on
the user's choice through the settle. Cleared once the switch applies.
* feat(tui): Ctrl+O opens the model picker without clearing your draft
Reaching the model picker meant typing /model, which forces you to wipe
whatever you'd already drafted. Bind Ctrl+O to open the same picker overlay
directly, leaving the composer untouched. Ctrl+O is added to the textInput
pass-through allowlist so the composer doesn't swallow it, mirroring the
existing Ctrl+X session-switcher path.
* feat(tui): apply a mid-turn model switch at the next turn instead of rejecting it
Picking a model while a turn was streaming hit a 4009 'session busy' reject:
switch_model() mutates the agent's model/provider/base_url/client in place and
the worker thread reads those every iteration. Now config.set queues the pick
in session[pending_model_switch] and _apply_pending_model_switch applies it on
the turn thread at the next turn start, before any model call — no race, no
interrupt, no waiting on the client rebuild. The TUI paints the pick optimistically
and notes '(applies next turn)'.
Slice 1 fell over on eight DB fakes with frozen get_messages_as_conversation
signatures — the new opt-in kwarg is part of the method's contract now, so
the fakes accept **_kwargs like the real SessionDB. Also opts the child-watch
resume projection into row ids: it feeds the same _history_to_messages as the
desktop resume, so reactions on a watched child session address rows the same
way.
test_session_create_close_race_does_not_orphan_worker asserted the
process-global len(unregistered_keys) >= 1: a leaked _build thread from
another session.create test in the same shard can append a foreign key
and falsely satisfy the assert. Scope both the wait loop and the assert
to this test's own stored_session_id, matching the own-key scoping the
no-race companion test already uses for the same flake class.
Salvaged from PR #46189 by @AIalliAI (rebased onto the pruned suite).
_load_approval_mode now delegates to tools.approval._get_approval_mode,
which reads config via hermes_cli.config.load_config — that path resolves
HERMES_HOME from the environment, not the server module's _hermes_home
attribute. The four approval-mode tests only monkeypatched the module
attribute, so the resolver silently read the developer/CI real config
(default 'smart') instead of the temp fixture. Behavior assertions are
unchanged (invalid → manual fail-safe, YAML off-bool → 'off', three-way
persist + live emit); the fixtures now also set HERMES_HOME so the
canonical read path sees the same temp config.
Replaces the half-duplex per-playback barge monitors with ONE listener
that runs for the entire agent turn in continuous voice mode: armed at
utterance-submit, disarmed when the turn is fully done (response + TTS
finished). Fixes Teknium's live report that voice interruption never
works: (a) not while the LLM is generating, (b) not while TTS plays.
Root causes:
- HALF-DUPLEX GAP: the barge monitor only spawned when TTS playback
STARTED (cli.py streaming/whole-file paths, gateway _tts_stream_begin).
During LLM generation there was NO microphone listener at all.
- PLAYBACK DEAFNESS: the monitor calibrated its VAD noise floor WHILE the
speaker was blasting TTS (speaker bleed baked into the floor), then
multiplied it by 8x with a 1s strictly-consecutive block requirement —
normal speech could rarely reach the trigger, and the 2s grace
swallowed early interjections.
New model — tools/voice_mode.full_duplex_listen():
- Pre-playback calibration: quiet-room noise floor established at turn
start and HELD through playback (never recalibrated against bleed).
- Phase-aware trigger: generation = floor x voice.barge_in_threshold_multiplier
(new config, default 3.0, justified by synthetic-frame tests);
playback = additionally clamped to a 1500-RMS minimum so bleed alone
can't trip; 4000-RMS ceiling keeps speech always reachable.
- Windowed-majority detection (>=80% of a 300ms window) instead of the
strictly-consecutive counter that reset on intra-word energy dips.
- Grace on playback ONSET only (voice.barge_in_grace_seconds, default
down 2.0 -> 0.5) — suppresses the onset transient, not the mic.
- Debug diagnostics at every decision point (calibrated floor, per-window
RMS above 50% of trigger, trip/no-trip, grace suppressions) — always
logger.debug, mirrored to stderr under HERMES_VOICE_DEBUG=1.
Phase behavior (CLI cli.py + tui_gateway/server.py, same model):
- generation: speech interrupts the in-flight turn via the SAME seam the
typed/Ctrl+C interrupt uses (agent.interrupt()), cuts any pending TTS
pipeline so the stale reply never plays, and submits the captured
interjection (pre-roll capture, first syllable kept) as the next turn.
- playback: cuts TTS (streaming pipeline stop + fallback speak stop
events + file player) and submits the capture.
- stop phrase honored in BOTH phases: mid-generation 'stop' interrupts
the turn AND ends the voice chat (stop everything).
- one listener instance spans generation -> playback (no re-arm race);
double-arm refused (CLI _voice_fd_active / gateway _fd_listener_active).
Gateway specifics: _arm_full_duplex_listener() at _run_prompt_submit turn
start and inside _tts_stream_begin; _speak_text_with_barge registers its
(stop, done) pair in _fd_speak_pipelines so fallback speaks are cut and
tracked; _tts_stream_barge_in_monitor kept as a shim that arms the new
listener. Desktop renderer owns its own mic path (voice-barge-in.ts) and
is unaffected; if desktop backend-mic mode is used it inherits via the
gateway.
Tests: full_duplex_listen synthetic-RMS suite (speech-over-bleed trips,
bleed alone doesn't, quiet floor held through playback, grace window,
multiplier math 3x vs 8x, windowed-majority dips), CLI listener phase
tests (generation interrupt seam, playback cut, lifecycle spans phases,
double-arm, config forwarding, stop-phrase-mid-generation), gateway
generation-phase interrupt + stop-phrase tests. Generation-interrupt
test sabotage-verified.
Premise check on live main: barge-in machinery EXISTS for the per-turn
STREAMING pipeline only — cli.py chat() arms _voice_barge_in_monitor and
tui_gateway _tts_stream_begin arms _tts_stream_barge_in_monitor. What was
actually broken for spoken interruptions:
1. CLI whole-file fallback (_voice_speak_response_async — used whenever
streaming TTS cannot start: sounddevice missing, requirement probe
fails): NO monitor was ever armed, so talking over the reply did
nothing. Now arms _voice_barge_in_monitor in continuous voice mode.
2. Gateway fallback speak (tts_queue None → speak_text thread) and the
voice.tts RPC (desktop-triggered speech): speak_text ran bare, and
its internal streaming dispatch created a PRIVATE stop event nothing
could reach — uninterruptible even by stop_playback(). New
_speak_text_with_barge() runs the same barge monitor beside the speak
thread; hermes_cli.voice.speak_text/_speak_text_streaming accept an
external stop_event so a barge cuts the streaming pipeline too.
Stop-phrase handling and voice.transcript submission are inherited
from the shared monitor (merged #73933 behavior preserved).
3. False barge during TTS (the reason interruption "worked" then
self-cancelled or fired randomly): salvaged PR #71083 by @beardedeagle
(previous commit, kept authorship) — rolling-window VAD floor, 8x
multiplier, 4000-RMS trigger ceiling, barge_in_grace_seconds (2s)
before the mic opens, min-floor clamp. barge_in_grace_seconds is now
documented in DEFAULT_CONFIG.
Desktop spoken barge (renderer mic via voice-barge-in.ts) already covers
both its live-stream and fallback speech paths — verified, no change.
One owner for the wording: voice_stop_hint() in tools/voice_mode.py —
sources the phrase from voice.stop_phrases (first entry) so a custom
phrase renders correctly, and returns "" when the feature is disabled
(stop_phrases: []) so no surface shows a hint.
- CLI: printed in /voice on output (style-matched dim notice).
- TUI: voice.toggle action=on now carries stop_hint; the Ink client
renders it in the "Voice mode enabled" block (older gateways omit
the field — no hint, no crash).
- Desktop: the renderer voice loop never touches tools/voice_mode.py,
so the phrase is read from config (voice.stop_phrases → $voiceStopPhrase
store, seeded in use-hermes-config) and shown as an info toast when a
voice conversation starts. i18n: en/ja/zh/zh-hant/ar.
The continuous-voice no-speech counter (3 strikes -> voice off) counted
every silent capture cycle unconditionally. During a long agent turn
(thinking/tool-calling for minutes) or while TTS is speaking, the user
is CORRECTLY silent — those cycles ended the voice chat under them.
- hermes_cli/voice.py: new set_voice_busy_probe() seam + _voice_activity_held()
(TTS-playing via the existing _tts_playing Event, agent-busy via the
registered probe). Both the continuous-loop strike path and the
force-transcribe single-shot strike path skip counting while held.
Fail-open: a broken probe counts cycles as before.
- tui_gateway/server.py: registers _any_session_running() as the probe
on voice.record start (voice is process-global; any running session holds).
- cli.py: classic CLI strike path skips counting while _agent_running
or TTS playback is in flight.
Stop phrase and barge-in still work during the hold (own paths).
Includes a fixture fix for the #71083 cherry-pick: the fake tools.tts_tool
module needs _load_tts_config (main's tts_streaming imports it).
Replace one-shot VAD calibration with a rolling deque window that
continuously recalibrates the noise floor throughout TTS playback,
preventing false barge-in triggers from stale calibration. Add a
grace period before VAD activates so TTS playback establishes first.
Suppress duplicate text rendering when token streaming is enabled.
Mirror the barge-in and TTS stream stop logic to the TUI gateway path
so both CLI and gateway use the same VAD semantics.
Rolling-window VAD:
- 90th percentile of rolling window (~3s) for noise floor
- 8x multiplier (was 5x) for TTS volume variation headroom
- 4000 RMS trigger ceiling so genuine speech can still trip
- min_floor clamped to SILENCE_RMS_THRESHOLD * 2
- sustained_ms=1000, calibration_ms=800
Barge-in grace period (barge_in_grace_seconds, default 2.0s):
Delays VAD activation so TTS playback establishes before the mic opens.
Duplicate render suppression:
When streaming_enabled, pass display_callback=None to
stream_tts_to_speaker so the token stream is the sole display path.
TUI gateway mirror:
Mirror _tts_stream_stop and _tts_stream_barge_in_monitor changes to
tui_gateway/server.py so both code paths use the same VAD parameters,
grace period, and TTS CUT diagnostic logging.
Profile-scoped session DB and MoA progress events in tui_gateway/server.py
were necessitated by the TTS pipeline changes affecting session state
and event routing.
Normal-exit flag and TTS CUT diagnostic logging at all cut paths.
Regression tests:
- test_quiet_then_loud_playback_does_not_trip
- test_8x_multiplier_absorbs_tts_volume_spikes
- test_trigger_ceiling_lets_genuine_speech_trip
- test_silence_calibration_does_not_false_trip_on_tts
- test_tts_stream_stop_latches_interruption_for_next_turn
- test_tts_stream_stop_after_natural_finish_does_not_latch
- Profile-scoped session DB tests (10 tests)
Saying OR typing a configured stop phrase (voice.stop_phrases, default
"stop") now ends the voice chat everywhere, not just classic CLI PTT:
- hermes_cli/voice.py: new explicit on_stop_phrase callback through
start_continuous/stop_continuous. The force-transcribe path previously
DISCARDED the stop phrase silently — with auto_restart=False the client
re-arms the next capture, so the conversation never ended. Both halt
paths now fire on_stop_phrase (fallback: on_silent_limit for legacy
callers) as user intent, distinct from the no-speech timeout.
- tui_gateway/server.py: voice.record wires on_stop_phrase and emits
voice.transcript {stop_phrase: true} after flipping HERMES_VOICE(_TTS)
off and stopping streaming TTS — same teardown as /voice off. The TTS
barge-in monitor stop-checks its transcript too. prompt.submit consumes
a TYPED bare stop phrase at the server-side choke point when voice mode
is on (returns {voice_stopped: true}, no turn starts).
- ui-tui: voice.transcript {stop_phrase} ends voice mode with a clear
'voice chat ended' notice (distinct from the no-speech-limit message);
submitPrompt releases the busy latch on a consumed voice_stopped reply.
- cli.py: _typed_voice_stop in process_loop — typing a bare stop phrase
while voice mode/continuous is active ends voice mode instead of
sending 'stop' to the agent; typed 'stop' outside voice mode is
unchanged. Voice transcripts skip the check (already stop-checked).
- desktop: interceptsTypedVoiceStop — the composer's onSubmit ends the
live voice conversation (same path as clicking end on the pill) when a
bare stop command is typed with no attachments; renderer-owned loop, so
handled client-side like the existing spoken isVoiceStopCommand.
- tools/voice_mode.py: transcribe_recording never lets the Whisper
hallucination filter swallow a configured stop phrase (e.g. 'bye'
configured as a stop phrase is both a hallucination-blocklist entry and
a stop phrase — stop-phrase check now wins).
Tests: continuous-loop signal (sabotage-verified), force-transcribe stop
signal + legacy fallback, hallucination-filter ordering, typed-stop CLI
unit tests (voice on/off/longer text), prompt.submit typed-stop gateway
tests, TUI vitest for stop_phrase event handling, desktop vitest for the
typed-stop interceptor.
The catalog advertises every skill command but nothing about which ones
the user actually reaches for, so consumers can only sort them
alphabetically. Add a `skills` map keyed by slash command carrying the
activity count and origin (hub / bundled / local) already tracked in the
skills sidecars, read once per catalog build.
Additive: older clients ignore the field, and an unreadable sidecar
degrades to zero usage rather than failing the catalog.
Review follow-up: the sweeper is right that the first commit only cured
half the dead config — the TUI gateway builds its recorder params
explicitly, so the cap never reached recordings started from the TUI.
- start_continuous() grows a max_recording_seconds param (default 0.0 =
disabled, so existing callers keep today's behaviour) and applies it to
the shared recorder next to the silence params
- tui_gateway voice.record start forwards the validated cap; corruption
semantics now mirror the silence params everywhere: non-numeric/bool
falls back to the documented 120 default (a hand-edited
`max_recording_seconds: true` must not become a 1-second cap), while
an explicit numeric <= 0 disables the cap
- cli.py wiring updated to the same corruption semantics
New coverage, per review:
- TestMaxRecordingCap drives the mocked InputStream callback past the
cap during continuous loud speech (the silence branch physically can't
fire there) and asserts the one-shot callback fires exactly once; plus
the disabled-cap negative
- TestMaxRecordingSecondsConfigReal pins the CLI config assignment for
the valid / zero / bool / garbage cases via _voice_start_recording
- test_voice_record_start_forwards_max_recording_seconds pins the TUI
forwarding for the same matrix
The prior commit made save_config_value resolve its target via
get_hermes_home() (live env) instead of the import-time cli._hermes_home
constant. Two _persist_model_switch tests patched only cli._hermes_home
and wrote/read tmp_path/config.yaml, so the write now landed in the real
HERMES_HOME and the readback saw stale values. Set HERMES_HOME=tmp_path
(kept the _hermes_home patch for belt-and-suspenders). No production
change.
An open chat window took a session-cap slot at session.create/resume time.
Every desktop tile paint and every background reconnect-resume opens one, so
on a websocket-flappy host they accumulated: five parked desktop tabs filled a
5-slot cap and locked the messaging gateway (which shares the cap) out for
fourteen minutes while running no agents at all.
A slot held that way is invisible everywhere. An unprompted draft has no DB row
and the sidebar filters it out with min_messages=1, so the only way to diagnose
it was reading runtime/active_sessions.json by hand.
Claim on the first turn instead, mirroring the lazy contract
_ensure_session_db_row already uses for the row itself. Capacity now means an
agent can run rather than that a window exists, and anything holding a slot is
something the user can see.
Also reclaim leases whose session skipped teardown. _prune_dead only fires when
the owning pid dies, and a dashboard/serve backend runs for days, so a leaked
lease was held until restart. The owning process reconciles against the leases
it still holds, which is exact and needs no heartbeat write on the turn path.
Clicking the desktop ear button or running /wake on|off now writes
wake_word.enabled to config.yaml (live, saved for future sessions), so the
feature no longer requires hand-editing config before the UI toggle works.
- wake.start accepts persist:true (explicit gesture): flips
wake_word.enabled on in config before arming; response reports
enabled_persisted. Passive auto-arm paths (desktop gateway-ready,
TUI reconnect) never pass it, so a mic can't become persistently
enabled without a deliberate user action.
- wake.stop accepts persist:true: writes wake_word.enabled: false so
auto-arm stays off next session; reports disabled_persisted.
- Split the refusal reason: 'disabled' (feature off in config — a
persisted gesture turns it on) vs 'disabled_for_surface' (explicit
wake_word.surface scoping, which persist does NOT override).
- Classic CLI /wake on|off and bare-toggle persist the flag too
(skips the write when config already matches).
- Desktop tooltip now maps refusal codes to friendly text (mirrors
the TUI's START_REASON_TEXT) instead of showing raw codes like
disabled_for_surface.
- Docs: quick-start notes the toggle persists; ear-button mention.
One sherpa listener now enrolls every wake-enabled profile's phrase
(defaulting to "hey <profile name>") and reports WHICH phrase fired.
wake.detected gains a profile field; the desktop live-switches to the
matching profile (same path as the profile rail), opens a fresh session
there, and starts hands-free voice. The single-profile CLI/TUI print the
hermes -p switch command for foreign-profile phrases instead of
answering as the wrong profile. Opt out per listener with
wake_word.profile_routing: false.
Phase 2 review found two sibling sites with the same bug class:
- truncate_before_user_ordinal in prompt.submit counted display_kind
timeline rows as user turns, shifting the truncation target
- rollback.restore used the old pop-loop pattern that would pop a
display_kind marker instead of the last real exchange
Both now use the same predicate (role==user and not display_kind)
matching list_recent_user_messages, /undo, /retry, and CLI resume.
Added tests for both paths.
Pass the display type into the turn so the row is born typed, and
recognize the note's fixed prefix in _history_to_messages so the
untyped rows already on disk stop painting as user bubbles.
A slash-skill invocation is persisted expanded — activation note plus the
entire skill body. _history_to_messages is the single display projection
every surface reads, so that payload rendered as a chat bubble anywhere a
session was resumed.
Project it here onto the invocation the user typed, and tag skill/bundle
dispatches with the same string so the live send matches. Rewind and
regenerate replay from what the transcript shows, so re-expand the
invocation server-side before running the turn: the replayed prompt is
identical to the original and no client ever holds the body.
A mid-stream steer persists an interrupted-turn checkpoint so the model knows
its reply was cut off. That scaffolding — "[This response was interrupted by a
user correction.]" and the "Visible response before the interruption:" header —
was written straight into message content, so every reload painted the raw
machinery as an assistant bubble (and merged it into the preceding tool-call
bubble). Steered transcripts became unreadable.
Reuse the existing display/replay split instead of inventing new surface:
- Carry the scaffolded form in the server-only api_content sidecar (the exact
bytes replayed to the provider), keep content the user's/agent's real words.
- When nothing reached the screen there is no clean form, so mark the row
display_kind=hidden — replayed to the model, dropped by every transcript
surface, exactly like compaction-reference rows.
- Honor display_kind=hidden in the gateway's _history_to_messages projection
(it only sniffed the [System: convention), so the checkpoint can't leak
through the live/resume path to the TUI/CLI either.
The model still receives the full interrupted context on the wire; the
transcript shows the partial reply and the user's correction.
An accepted mid-turn redirect wrote its correction over inflight_turn["user"].
That field is the only user text session.resume can replay, so the prompt that
started the turn was gone the moment the user typed again while it ran. On the
next resume the client rebuilt the thread without it.
Record corrections in their own list instead, alongside the prompt. Renamed
_replace_inflight_user to _record_inflight_correction now that it appends.
_start_inflight_turn rebuilds the dict wholesale, so corrections cannot leak
into a later turn.