Review feedback: publishing the map and its platform tag as two separate
global assignments is not atomic. A reader landing between them sees the
NEW map still carrying the OLD tag, and if that stale tag matches its own
platform it accepts the map without rescanning — serving another
platform's disabled-skill view, the leak #14536 closed.
Guard the pair with a module lock. scan_skill_commands publishes both
under it; get_skill_commands resolves its platform first, then reads the
map and tag together under the same lock to make the freshness decision.
Scanning stays outside the lock — it does file I/O and deferred imports,
and concurrent scans are already independent after the local-map change.
get_skill_commands now returns the scan's own completed map rather than
re-reading the global, so a concurrent publish cannot swap the result
between the decision and the return.
Adds a regression test that holds the publish lock and asserts a reader
cannot complete its lookup until it is released.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codex Responses streams can emit partial_image_b64 previews without a final
image_generation_call.result. The provider treated any b64 as success and could
let a partial overwrite a coexisting final in the same payload, delivering
smeared intermediates as finished GPT Image 2 outputs.
Request partial_images=0, prefer final over partial in extraction, fail closed
(with one content-agnostic retry) unless source=final, and surface image_source
plus pixel_size for QA.
Widens #90327's line_input() to the whole bug class: all 46 bare input()
free-text prompt sites across the setup wizards (model_setup_flows, setup,
config, gateway, auth, auth_commands, plugins_cmd, skills_hub, bundles,
setup_whatsapp_cloud, main) now route through line_input(), and the shared
cli_output.prompt() / setup.prompt() helpers do too — so every CLI wizard
gets cursor editing, not just the custom model prompt.
Redirected stdin and missing prompt_toolkit keep builtin input() behavior.
E2E: real PTY with raw escape bytes through line_input, cli_output.prompt,
and setup.prompt (arrows + Ctrl+A/E edit correctly); redirected-stdin
fallback verified.
Worker sessions are deny-listed out of every conversation list, so a
profile grinding through a 30-minute kanban task read idle ('3 hr ago')
with no ACTIVE NOW entry the entire run.
- tui_gateway/methods_profiles.py: profiles.list rows gain worker_session
— the newest kanban/tool row (id, source, title, last_active). Workers
heartbeat last_activity_at every <=60s while running (#72016), so the
field stays fresh exactly while work is happening. last_session keeps
its deny-list contract; include_sessions:false omits the field; older
clients ignore it.
- hermes-bots plugin: workerActiveAt() (150s window, one missed heartbeat
of slack) feeds ACTIVE NOW, the row pulse dot ('Working on a task right
now'), and the row age label while a worker runs. Chat semantics are
untouched when no worker is live.
- Tests: 4 new pytest (real SessionDB on temp HERMES_HOME), 2 new node
behavior tests; sabotage-verified.
Session-list visibility of workers (the issue's first half) is left as-is
by design — auto-resume and shared lists must keep excluding workers; the
roster signal was the actionable gap.
test_progress_advances_while_the_orchestrator_blocks raced its subject on
both edges within one hour of PR CI (#90358):
- Run 1: sampled right after the shim URL printed, before the orchestrator
published its stage — caught the page boot default
('Hermes will open once done.' != 'Testing quiet update').
- Run 2 (rerun): with HOLD=4s on a slow runner, the second sample slid past
the hold and caught the cleared terminal state ('' != 'Testing quiet
update').
Fix: wait (<=10s) for the published stage to actually land before starting
the 1.5s stability window, and raise the hold to 10s so both samples land
inside it. Same assertions, same contract — just anchored to the event the
test is about instead of wall-clock luck.
The #89503/#70058/#74295/#87279 bug class kept regenerating because every
transport and provider profile hand-rolled its own effort translation map
(9 sites, 4 distinct policies). New agent/reasoning_effort.py is the single
source of truth:
- EFFORT_LADDER: canonical low->high ordering (superset check against
VALID_REASONING_EFFORTS pinned by test)
- clamp_effort(): one policy — supported passes verbatim, otherwise nearest
WEAKER supported level (never escalate, never invert the ladder), floor
when nothing weaker, 'none' never a degradation target, declared
vendor-documented overrides win, bespoke names pass through
- declared wire vocabularies as data: OpenAI-compat, Codex Responses,
xAI (4.6/legacy), Actual relays, Kimi K3/K2, TokenHub, GLM-5.2,
DeepSeek V4, Ollama Cloud, Meta, Solar
Converted sites (all behavior-preserving except noted):
- chat_completions chokepoint, Kimi + TokenHub paths
- codex transport (backend branches now pick a declared set)
- auxiliary_client Responses path
- hermes_cli.models clamp_reasoning_effort_to_supported -> thin wrapper
- plugins: kimi-coding, zai, opencode-zen, deepseek, ollama-cloud,
meta-ai, upstage, custom (copilot already routes via the wrapper)
Behavior fixes the shared policy surfaces:
- ollama-cloud/opencode-go 'minimal' now degrades to 'low' instead of
being dropped (drop left the server default = MORE thinking than asked)
New tests: ladder contract (every configurable level is clamped by every
declared wire set; monotonicity across the full ladder for every set).
Archived rows render from $archivedSessions (their own capped store —
they're excluded from $sessions by design), but removeSession only pruned
$sessions. Deleting from the Archived filter left the row in place; a
click on it resumed a hard-deleted id: resume 404 -> goneSessionVerdict
saw the row still listed -> 'retry' -> unrecoverable spinner.
removeSession now resolves the row from either store, evicts both
optimistically, restores the archived row on RPC failure, and forwards
the archived row's owning profile to deleteSession.
Community report (X @Cobalt_Peak): Reconnect and Restart gateway in the
statusbar gateway popover rendered the same RefreshCw icon side by side,
so users triggered full gateway restarts when they meant to reconnect.
- Restart now uses a Power icon with a destructive hover tint
- Moved restart to the end of the row, after the system-panel button,
behind a visual divider separating it from the benign actions
Both cron empty states used search-flavored copy unconditionally; a fresh
panel with zero jobs and no query told users 'Try a broader search
query'. Copy now follows the query state, reusing existing i18n keys.
The backend's session search wraps matched terms in sqlite snippet()
delimiters '>>>'/'<<<' (hermes_state_search.py). The sidebar rendered the
snippet as plain text via searchResultToSession(), so searching 'foo'
painted rows literally titled '>>>foo<<<'. Strip the markers before the
snippet becomes the row preview.
/api/model/options probed Copilot auth via `gh auth token` four separate
times per payload build. When gh has no credential store for the backend's
HOME (fresh profile, desktop-spawned backend, CI), each probe blocks its
full 5s subprocess timeout on keyring/D-Bus, so every open of the Desktop
Models or Providers settings page took 20s — past the renderer's 15s IPC
budget, painting 'Error invoking remote method hermes:api: Timed out'.
Fix: cache the gh-CLI probe result (hit or miss) for 5 minutes with an
invalidation hook, feed gh stdin=DEVNULL, and disable gh interactive
prompts/update notifier in the probe env.
Measured on the failing profile: 20.5s -> 5.3s cold (one bounded probe),
0.03s warm.
Live incident (Alice canary 2026-08-20, job 8e21a957b77b): the continuable
thread seed created its session with chat_type='thread', but a Slack DM
in-thread reply arrives chat_type='dm' and build_session_key routes DM
threads through the DM arm (...:dm:<chat>:<thread>). Seed row and reply row
never matched — the reply had no brief in context (continuation amnesia).
is_dm on _seed_cron_thread_session selects the seeded chat_type at both call
sites (opened-thread and the companion in_channel thread seed); channel
threads are unchanged. Sibling lane of the flat seed's is_dm fix. Tests pin
the key-equality contract: seeded key == the key the reply builds.
Continuability is explicit via attach_to_session or cron.mirror_delivery;
those knobs control transcript mirroring for the ordinary thread/default
surface. However, the in_channel surface must still receive its delivery
text to seed the continuation session. Previously mirror_text was populated
only when the optional mirror knob was enabled, so in_channel jobs created
with the default false settings passed an empty string to the seed helper,
which returned False. The live symptom was a delivered cron message with no
continuation context; Alice reproduced it three times (latest ef7bd2869d15).
Keep cleaned delivery text available for continuable surface seeding while
retaining mirror_enabled for the separate _maybe_mirror_cron_delivery path.
Targeted cron/in_channel regression suite: 1008 passed, 1 skipped.
The 19:53 canary run failed the in_channel seed EVEN WITH the deterministic
session_id fix live, and the mirror's two failure paths (no-session bail,
append exception) both logged at debug — invisible in production. WARNING
both, with the explicit session id in the exception path, so the next run
names the failing branch instead of another blind retest.
Two live failures from the Alice canary (2026-08-19, jobs 28a24afebd81 /
83b93f8be379), both leaving a continuable in_channel cron with amnesia:
1. Seed mirrored via origin heuristics and silently dropped the brief.
_seed_cron_channel_session created the flat session row, then
mirror_to_session RE-DISCOVERED the target via find_session_by_origin —
whose multi-candidate bail-out returns None on a populated chat (flat
session + N per-message thread sessions sharing one chat_id, mixed
user_ids). Receipt: 'in_channel seed did NOT land on slack:D0BJTDCSR7C'.
mirror_to_session now accepts an explicit session_id and both cron seeds
pass the exact row they just created; origin-scan remains the fallback
for callers that genuinely don't know the target.
2. The brief's OWN THREAD was never seeded. in_channel delivers flat, but
a flat Slack message still invites a thread reply (the natural mobile
affordance — exactly what the user did). That reply keys to
(chat, thread=<brief ts>), which no seed touched. The delivery's
message_id now anchors a companion _seed_cron_thread_session so BOTH
reply surfaces (plain channel message AND in-thread reply) continue the
job.
Also: thread-seed failures upgraded debug→WARNING (silent seed failure IS
the user-facing bug), and the thread seed reports landed/not-landed.
Regression tests drive both against the live failure shapes: exact-session
mirror asserted via session_id kwarg; thread companion asserted via the
SendResult message_id anchor. Clean-fixture blind spot noted: the E2E
harness used a fresh store with one row, which is why heuristic rediscovery
looked fine pre-production.
Seed failure was logger.debug — invisible in production while being the
exact 'agent has no idea about its own brief' symptom. WARNING on: seed
exception (with reason), seed returning False, and in_channel delivery to
a non-origin target.
Live regression (Alice, 2026-08-19): a continuable cron with
cron_continuable_surface=in_channel delivered its brief flat, but the
flat-session seed was gated on mirror_this_target = mirror_enabled AND
origin-match. Without attach_to_session (and with cron.mirror_delivery
defaulting False) the seed never ran; the next plain reply resolved to a
blank (slack, chat, None) session and the agent had no idea about its own
delivery message — not continuable in channel OR thread (in_channel mode
correctly skips thread creation, so there was no thread session either).
in_channel IS the continuation surface, not a mirror nicety: gate the seed
on origin-match alone, and resolve origin_user_id for any origin-matching
target so the seeded key still carries the scheduling user on
per-user-isolated chats. attach_to_session remains the opt-in for the
separate default-surface mirror behavior.
Regression test drives the delivery path with attach_to_session=False and
asserts the seed fires with the right user_id (fails on pre-fix code).
Threading timeoutMs/signal through requestForSessionProfile and
requestGatewayForProfile handed every session-scoped RPC a trailing
`undefined, undefined`. Only the plugin host bridge actually supplies those,
so the rest of the app's calls changed observed arity for no reason — and the
resume/activate paths assert on the exact call shape.
Forward the deadline args only when the caller set them; the plugin bridge
keeps the full four-argument route it needs.
Opening a plugin/Bot Mode session is navigation, not a workspace switch.
keepAllProfilesScope (default true) now dials the named backend without
moving $activeGatewayProfile or setApiRequestProfile. Session-owned RPCs
still route to the session owner. Pass false to switch chrome and collapse
the Sessions sidebar.
Bot Mode passed keepAllProfilesScope:false, which re-homed the sidebar
onto the bot profile. That profile forever-chat is hidden, so Sessions
and the roster looked empty. Opening a bot is navigation, not a workspace
switch. Also restore all-profiles when the bot backend is already live.
Related: #89789
A review of the previous commit found that retrying at the plugin layer
(openStoredBotChat catching and re-calling host.openSession) didn't fix
the reported bug: host.openSession's own catch block unconditionally
calls setResumeExhaustedSessionId on a hydration timeout before
rethrowing, and only an explicit resumeSession() (the manual Retry
button) clears that latch for the currently-routed session. A
plugin-side retry is a different code path that can hydrate the
transcript fine while the full-screen "Couldn't load this session"
overlay stays latched over it.
host.openSession now takes a retryHydrationTimeoutOnce option and
retries the open+hydration-wait internally, before the latch is ever
set, so a successful retry never arms the overlay. openStoredBotChat
just opts in via that option.
host.openSession awaited ensureGatewayProfile with no deadline. That await
gates waitForFocusedSessionHydration, which arms the only timer on the path,
so a profile dial that never settles left the open pending for the life of the
window: the pane froze with no error, no Retry and - the part that made this
hard to recognise - no timeout either. The gateway log signature is a bare
`ws accepted` with no matching `ws closed`.
Bound the activation with its own copy of the wake budget rather than folding
it into the hydration one. A cold profile backend can legitimately spend most
of the hydration budget painting a large transcript, and that race is already
tight enough to lose, so charging activation to the same clock would trade a
wedge for a regression. The timeout reuses the hydration message prefix on
purpose - openSession keys the core stranded-session surface off it - and the
[bot-wake] support log now names which phase expired, so a stuck dial is not
read as a slow transcript.
Scoped to callers that passed awaitHydration. A plain open never asked for a
deadline and has nowhere to render one, so its behaviour is unchanged.
Two existing tests counted microtask ticks between the call and the core open.
The bounded activation adds a tick, so they now flush a macrotask instead,
which asserts the same thing without depending on the await count.
Refs #89556
The exit chip floated over the composer in a 26px transparent strip reserved
for it (--hud-chip-strip), hidden until you hovered the bar. Under glass that
strip is bare untinted material across the top of the HUD — a band of chrome
above the surface, present in every state, holding a control you cannot see.
It rides the composer's controls row now, next to send. That costs no
reserved space and takes about 120 lines of CSS with it: the chip needed its
own placement, hover reveal, leave-hold, and an opaque card to stay legible
over an unknown desktop. None of that applies to a button on the bar, which
is already our surface — the problem was the placement, not the control.
Trade-off worth naming: the way out is now always visible in the HUD rather
than revealed on hover. It is one more permanent glyph on a Spotlight bar, in
exchange for an escape hatch that no longer depends on discovering it.
Dictation, spoken replies, the wake word and start-conversation were four
separate icon buttons in a Spotlight bar a few hundred pixels wide — most of
the row spent on toggles that are set once and rarely touched. In the HUD
they collapse into a single menu; the docked composer has the width and
keeps them inline, same controls and same state.
The trigger is not a static glyph. It reports the loudest live voice state —
recording, transcribing, listening for the wake word, speaking replies — and
lights while any is on, because a folded menu that looked idle with the mic
open would be a worse trade than the space it saves. The three toggles are
checkbox rows that hold the menu open on select, so the state you just
changed is the state you can see.
The shared control class names move to a module of their own so the row and
the menus it renders can wear them without importing each other, and the
pressed-toggle tint stops being written out at each of its four sites.
The band wore its own card tint at a hardcoded 80/92%, so a HUD beside the
docked window read as a lookalike rather than the same surface, and the Tint
slider moved one and not the other. It now paints --ui-bg-chrome at
--translucency-glass-keep: one painter, one token, one lever.
That needed the setting and the surface rewrite to stop being one flag.
data-hermes-glass means "this window's field surfaces may be rewritten" and
is deliberately false in the HUD, which owns its own backgrounds; the new
data-hermes-glass-on means "the user's Glass setting is live" and is
published everywhere, along with the tint number the band reads.
The 0.5rem side inset drops to zero while glass is on. It exists to keep an
opaque sheet clear of the bar's corner controls, but the frost is the whole
window — an inset sheet left a hairline of bare untinted material down both
sides.
An open completion drawer now drops the frost along with the band it belongs
to. The drawer takes the band to 25% and blurs it while the native material
stayed at full strength, which is the same bare slab in a different
disguise. It mounts without a focus change, so it is observed rather than
passed in, coalesced to a frame because the shell mutates with every
streamed token.
The HUD asked for vibrancy directly and always with the 'hud' material —
one of the two rungs the macOS census rejected, because it collapses into
under-window on blur and so changed the frost the moment another app took
focus. It also ignored the translucency setting entirely: Glass off still
frosted, and Windows got nothing at all.
hudFrostFor is the mapping for a transparent window, beside vibrancyFor in
the shared module both processes read. Two gates give it its answer: the
renderer's report that the band actually covers the window, and the user's
Glass setting. Off resolves to no material rather than a resting one, since
a transparent window has no opaque page to hide an unwanted frost behind.
Windows 11 rides setBackgroundMaterial through the same call, so the HUD
follows the frost ladder on both platforms. Main self-diffs and keys the
latch to the window, so a Settings change re-frosts a live HUD, a tint drag
touches nothing native, and a HUD respawned on another profile is not
mistaken for the window that already carried the material.
Composio eval traces showed Hermes wasting turns re-issuing identical tool
calls (same tool, same args, same result — 3x/4x in one run) and ending
turns by announcing an action it never took. Two conservative, config-gated
guards (agent.stall_guards, default true):
- Identical-call loop breaker: ToolCallGuardrailController.observe_identical_call
tracks the consecutive streak of (tool, canonical args, result-hash); on
the 3rd identical call a compact one-line notice is appended to that tool
RESULT at construction time (cache-safe — tool results are append-only).
Never blocks the call. Pollers (process, *_get_result, *_poll) are exempt
via STALL_GUARD_REPEATABLE_TOOLS. Streak resets on any different call,
changed result, or new turn. Observed on the raw result before the
tool-loop warning suffix so its changing count can't defeat matching.
- Said-continue-but-stopped recovery: trailing_continue_intent() detects a
short reply ENDING on an announced next action ('Let me now…', 'I will
now…', 'Next, I…'); the conversation loop feeds it into the EXISTING
intent-ack continuation path (same interim-assistant + user-nudge
mechanism, same codex_ack_continuations cap of 2), preserving message
alternation — no parallel recovery machinery.
Config: agent.stall_guards in DEFAULT_CONFIG; docs in configuration.md;
unit tests for streak/allowlist/reset/gate and detector pos/neg cases.