fix(desktop): open HUD mode on the focused conversation's profile - #82325
Merged
Conversation
The HUD is a full app renderer that adopted the PRIMARY backend's profile at boot, so toggling HUD mode from a conversation on any other profile resolved the session id against the wrong backend — the lookup missed and the HUD fell back to the default profile's last session (#82285). - openHud() resolves the target's owning profile (session's stamped owner, else the active gateway profile) and passes it through hermes:hud:open. - hudUrl() carries the profile in the query string next to win=hud; the HUD renderer's gateway boot honors it as an override for both getConnection() and profile adoption, so the window dials and adopts the right backend from first paint. - Retargeting a live HUD onto a session from a DIFFERENT profile respawns the window against that profile's backend (a renderer adopts its backend exactly once at boot; an in-place goto would repeat the wrong-backend lookup). No profile in the URL means no override — ordinary windows and single-profile users boot exactly as before.
Contributor
૮ >ﻌ< ა ci reviewran on cbfebd4
|
…sted hudUrl() built the query string inline in main.ts, where the part that actually breaks — `?win=hud&profile=` must sit BEFORE the '#' or HashRouter eats it as the route — had no coverage. Move it next to buildSessionWindowUrl's split (pure piece out of the monolith, unit tested) and pin the contract: flag order, profile encoding, trailing slash on the dev server, empty profile omitted, packaged file URL. Co-authored-by: rainbowgore <rainbowgore@users.noreply.github.com>
…ing ladder openHud() had its own copy of "stamped owner, else active gateway, else default" — the same ladder rememberedSessionProfile() already owns for the remembered-navigation key, down to sessionMatchesStoredId and the default fallback. One resolver per policy, so the two can't drift.
OutThisLife
approved these changes
Aug 9, 2026
OutThisLife
left a comment
Collaborator
There was a problem hiding this comment.
Premise verified on main: hudUrl() carried only ?win=hud, adoptPrimaryProfile() adopts the primary unconditionally, and the session id resolves against the wrong backend exactly as #82285 describes. Respawn rather than in-place goto on a cross-profile retarget is the right call — a renderer adopts its backend once at boot.
Pushed two salvage commits:
electron/hud-url.ts+ 6 contract tests, carried over from #82310 (@rainbowgore). The query-before-hash rule is the part that silently breaks HashRouter and it was the one piece with no coverage.openHud()now callsrememberedSessionProfile()instead of re-deriving the same stamped-owner ladder. One resolver per policy.
Closing #82310 as superseded.
19 tasks
OutThisLife
enabled auto-merge (squash)
August 9, 2026 09:04
7 tasks
ma1138569845
pushed a commit
to ma1138569845/dechnicAuditor-agent
that referenced
this pull request
Aug 10, 2026
…usResearch#82325) * fix(desktop): open HUD mode on the focused conversation's profile The HUD is a full app renderer that adopted the PRIMARY backend's profile at boot, so toggling HUD mode from a conversation on any other profile resolved the session id against the wrong backend — the lookup missed and the HUD fell back to the default profile's last session (NousResearch#82285). - openHud() resolves the target's owning profile (session's stamped owner, else the active gateway profile) and passes it through hermes:hud:open. - hudUrl() carries the profile in the query string next to win=hud; the HUD renderer's gateway boot honors it as an override for both getConnection() and profile adoption, so the window dials and adopts the right backend from first paint. - Retargeting a live HUD onto a session from a DIFFERENT profile respawns the window against that profile's backend (a renderer adopts its backend exactly once at boot; an in-place goto would repeat the wrong-backend lookup). No profile in the URL means no override — ordinary windows and single-profile users boot exactly as before. * refactor(desktop): extract the HUD renderer URL so its contract is tested hudUrl() built the query string inline in main.ts, where the part that actually breaks — `?win=hud&profile=` must sit BEFORE the '#' or HashRouter eats it as the route — had no coverage. Move it next to buildSessionWindowUrl's split (pure piece out of the monolith, unit tested) and pin the contract: flag order, profile encoding, trailing slash on the dev server, empty profile omitted, packaged file URL. Co-authored-by: rainbowgore <rainbowgore@users.noreply.github.com> * refactor(desktop): resolve the HUD's target profile through the existing ladder openHud() had its own copy of "stamped owner, else active gateway, else default" — the same ladder rememberedSessionProfile() already owns for the remembered-navigation key, down to sessionMatchesStoredId and the default fallback. One resolver per policy, so the two can't drift. --------- Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Co-authored-by: rainbowgore <rainbowgore@users.noreply.github.com>
randlee
pushed a commit
to randlee/hermes-agent
that referenced
this pull request
Aug 11, 2026
…usResearch#82325) * fix(desktop): open HUD mode on the focused conversation's profile The HUD is a full app renderer that adopted the PRIMARY backend's profile at boot, so toggling HUD mode from a conversation on any other profile resolved the session id against the wrong backend — the lookup missed and the HUD fell back to the default profile's last session (NousResearch#82285). - openHud() resolves the target's owning profile (session's stamped owner, else the active gateway profile) and passes it through hermes:hud:open. - hudUrl() carries the profile in the query string next to win=hud; the HUD renderer's gateway boot honors it as an override for both getConnection() and profile adoption, so the window dials and adopts the right backend from first paint. - Retargeting a live HUD onto a session from a DIFFERENT profile respawns the window against that profile's backend (a renderer adopts its backend exactly once at boot; an in-place goto would repeat the wrong-backend lookup). No profile in the URL means no override — ordinary windows and single-profile users boot exactly as before. * refactor(desktop): extract the HUD renderer URL so its contract is tested hudUrl() built the query string inline in main.ts, where the part that actually breaks — `?win=hud&profile=` must sit BEFORE the '#' or HashRouter eats it as the route — had no coverage. Move it next to buildSessionWindowUrl's split (pure piece out of the monolith, unit tested) and pin the contract: flag order, profile encoding, trailing slash on the dev server, empty profile omitted, packaged file URL. Co-authored-by: rainbowgore <rainbowgore@users.noreply.github.com> * refactor(desktop): resolve the HUD's target profile through the existing ladder openHud() had its own copy of "stamped owner, else active gateway, else default" — the same ladder rememberedSessionProfile() already owns for the remembered-navigation key, down to sessionMatchesStoredId and the default fallback. One resolver per policy, so the two can't drift. --------- Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Co-authored-by: rainbowgore <rainbowgore@users.noreply.github.com>
stefanpieter
added a commit
to stefanpieter/hermes-agent-upstream-fork
that referenced
this pull request
Aug 11, 2026
* chore: AUTHOR_MAP for drissman@gmail.com (PR #82061)
* fix(desktop): detach relaunched Desktop from the hand-off console + UTF-8 child streams
First real-world run of the #82328/#82366 hand-off (2026-08-09, ryanc)
surfaced two defects:
1. The console window never closes after the update finishes -- and
closing it manually KILLS the freshly relaunched GUI. Root cause:
Start-DesktopRelaunch spawned Hermes.exe as a child of the console
PowerShell. Electron/Chromium calls AttachConsole(ATTACH_PARENT_
PROCESS) at boot, so the new Desktop latched onto the hand-off's
console: the console can't close while an attached process lives,
and closing it takes the attached GUI down with it. Fix: create the
process via WMI (Win32_Process.Create) -- parent becomes WmiPrvSE,
no console to inherit or attach, same detachment explorer.exe gives
a normal launch. Start-Process fallback retained (tethered Desktop
beats no Desktop).
2. Both the console and the progress box render hermes update's UTF-8
glyphs (checkmarks, arrows) as mojibake. PS 5.1 defaults redirected
child streams to the OEM codepage. Fix: StandardOutput/ErrorEncoding
= UTF8 on the child, PYTHONIOENCODING/PYTHONUTF8 so Python emits
UTF-8, and [Console]::OutputEncoding = UTF8 for our own echo.
Verified live on the incident machine: WMI-created process parents to
WmiPrvSE.exe (not the shell); UTF-8 glyph round-trip through the exact
ProcessStartInfo shape reads back byte-correct (15/15 chars). PS 5.1
parse clean, check-windows-footguns clean.
* fix(model-metadata): auto-extend provider prefixes from registered profiles
_PROVIDER_PREFIXES was a hand-maintained frozenset, so providers that ship
as plugins (bundled like fireworks, or user plugins under
$HERMES_HOME/plugins/model-providers/) were never recognised as
provider: prefixes in model strings, and metadata/context-window lookups
received the unstripped string. Mirror the _URL_TO_PROVIDER auto-extend
that already sits below it: add each registered profile's name and
aliases after discovery. The _OLLAMA_TAG_PATTERN guard keeps model:tag
strings intact.
Fixes #66106
* fix(model-metadata): resolve provider prefixes from live registry
* test(model-metadata): use explicit fixture encodings
* fix(desktop): focus the update progress window, then hand focus to the relaunched Desktop
Two focus polish items from the first fully-working hand-off run
(ryanc, 2026-08-09):
1. The progress window came up backgrounded: the script is spawned via
`cmd start /min`, and Form.Show() + TopMost keeps it above other
windows without ACTIVATING it. Claim activation explicitly
(Form.Activate + SetForegroundWindow) right after Show.
2. The relaunched Desktop came up behind whatever the user had focused:
a WMI-spawned process starts unfocused and cannot take foreground by
itself. Since the hand-off owns foreground while its progress window
is up, delegate it: AllowSetForegroundWindow(new pid), poll up to 20s
for Electron's MainWindowHandle, then ShowWindow(SW_RESTORE) +
SetForegroundWindow. Best-effort at every step -- a focus failure
never affects the update result.
Sequence on success: progress window foreground during the update ->
window closes -> freshly relaunched Hermes.exe takes foreground.
Verified live on the incident machine: Add-Type shim compiles under
PS 5.1; WMI spawn + AllowSetForegroundWindow + MainWindowHandle poll +
ShowWindow all execute against a real spawned window. (In the bg test
shell SetForegroundWindow returns False by OS design -- only the
current foreground owner may delegate; the real flow's TopMost progress
window IS that owner.) PS parse clean, check-windows-footguns clean.
* test(desktop): widen HUD composer containment regression coverage (#82319)
Extend the packaged-app HUD geometry test from horizontal-only to full
containment: both axes for the dock and the input, plus an explicit
assertion that no percentage translate survives on the composer dock.
The vertical clipping reported on Windows (#82203) and macOS (#82214)
is the same escape class on the other axis, and the computed-translate
probe makes a future optimizer regression fail with a diagnosis instead
of a bare coordinate mismatch.
* fix(desktop): keep the HUD on the session it was opened for (#82360)
* fix(desktop): keep the HUD on the session it was opened for
The HUD is a full app renderer, so the main window's cold-start
'restore last session' logic ran inside it: opening HUD on a blank new
chat (#/) navigated it to the remembered session instead of the new one,
because a blank draft has no stored id and the HUD boots at the default
route. Guard the restore/remember effect with isHudWindow() — the HUD's
destination is always chosen explicitly at open time.
Also stops the HUD from clobbering the main window's remembered
navigation while it is up.
* fix(desktop): use type-only import for the windows-store mock in HUD restore test
consistent-type-imports forbids inline import() type annotations; use the
established import type * as pattern (same as session-row.test.tsx).
* fix(desktop): open HUD mode on the focused conversation's profile (#82325)
* fix(desktop): open HUD mode on the focused conversation's profile
The HUD is a full app renderer that adopted the PRIMARY backend's
profile at boot, so toggling HUD mode from a conversation on any other
profile resolved the session id against the wrong backend — the lookup
missed and the HUD fell back to the default profile's last session
(#82285).
- openHud() resolves the target's owning profile (session's stamped
owner, else the active gateway profile) and passes it through
hermes:hud:open.
- hudUrl() carries the profile in the query string next to win=hud;
the HUD renderer's gateway boot honors it as an override for both
getConnection() and profile adoption, so the window dials and adopts
the right backend from first paint.
- Retargeting a live HUD onto a session from a DIFFERENT profile
respawns the window against that profile's backend (a renderer adopts
its backend exactly once at boot; an in-place goto would repeat the
wrong-backend lookup).
No profile in the URL means no override — ordinary windows and
single-profile users boot exactly as before.
* refactor(desktop): extract the HUD renderer URL so its contract is tested
hudUrl() built the query string inline in main.ts, where the part that
actually breaks — `?win=hud&profile=` must sit BEFORE the '#' or
HashRouter eats it as the route — had no coverage. Move it next to
buildSessionWindowUrl's split (pure piece out of the monolith, unit
tested) and pin the contract: flag order, profile encoding, trailing
slash on the dev server, empty profile omitted, packaged file URL.
Co-authored-by: rainbowgore <rainbowgore@users.noreply.github.com>
* refactor(desktop): resolve the HUD's target profile through the existing ladder
openHud() had its own copy of "stamped owner, else active gateway, else
default" — the same ladder rememberedSessionProfile() already owns for
the remembered-navigation key, down to sessionMatchesStoredId and the
default fallback. One resolver per policy, so the two can't drift.
---------
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
Co-authored-by: rainbowgore <rainbowgore@users.noreply.github.com>
* fix(desktop): keep the HUD exit chip clickable when the composer has no focus (#82403)
The chip was pointer-events: none until [data-slot='composer-rich-input']
had :focus, which made the only visible way out of HUD mode conditional
on the thing most likely to be broken when someone wants out. When focus
never lands (#81893 on macOS) you can neither type nor click your way
out: the HUD is a transparent always-on-top rectangle over the desktop
with no in-app dismiss.
It is now always clickable and dim (0.45) at rest, brightening on hover,
focus-visible, and composer focus. That keeps the original intent — not
a loud chip over the app behind — without gating the escape hatch on the
failure mode it exists for.
Salvaged from #82317 by @Ne0teric. The centering half of that PR is
dropped: #82233 already fixed the dock offset, and its 'translate: none'
is the exact literal Lightning CSS folds into 'transform', which is the
bug #82233 fixed.
Co-authored-by: Ne0teric <Ne0teric@users.noreply.github.com>
* feat(desktop): mark an unsent session with its own status dot
A draft got no dot at all, so the one tab that has never done anything
looked identical to a settled session. Give it the faintest mark the app
has — a hollow outline, weakest claim in the dot's priority order, so the
first thing that actually happens speaks over it.
The row's own message_count is the tiebreaker for what counts as a draft:
a session RESUMING also holds an empty message list for a moment, and
calling that a draft flashes the wrong mark on a conversation with years
of history in it.
* feat(desktop): name a draft after what you have typed into it
Every unsent tab was called "New session", so a row of them said nothing
about which was which. Name each one from its composer, using the same
first-line, word-boundary rule the backend's derive_title applies a moment
after the draft is finally sent — so the name the tab already shows is the
name it keeps.
The title moves with the composer, which is far faster than a pane
contribution should be re-registered. Panes can now render a tab label
instead of declaring one, so the label subscribes to its own key and a
rename repaints one string rather than the panes area.
* fix(agent): stop cron and subagent runs auto-titling their sessions
The turn prologue titles every session, and it is shared by every agent —
including the ones no person is reading. A cron job already names its own
session after the job in its finally block, so the titler spent a side-LLM
call per fire to write the delivery scaffolding over it for the length of
the run. A delegated child's session is hidden from every picker, so a
batch at max_concurrent_children paid N title calls for N names nobody
opens.
Both are the same class of run that already sets skip_memory to stay off
the auxiliary path, so keep the titler off it too.
* fix(gateway): rename a Discord thread once, after the reply lands
Titling is two-stage — a slice of the user's own words lands inline, the
model's version replaces it a second later — and the platform rename lanes
fired on both. That is two rate-limited calls to reach one name, and
Discord allows two channel renames per ten minutes, so the throwaway could
be the one that survived. The callback now carries which stage it is, and
the lanes take the model's.
The relay lane also asked where the reply landed at title time, which is
before the model has answered: it polled the send-result cache for ten
seconds and read the timeout as "never auto-threaded", so any turn with
tool calls in it silently kept its raw thread name. Wait on the send
itself instead — the adapter already owns that cache, so it can say when a
reply arrives and, just as usefully, that one arrived carrying nothing.
* fix(models): let the titler actually see a provider's model catalog
The fast-model picker reads /v1/models to find the small model a provider
currently serves, and it asked anonymously. Most of those endpoints need a
key, so the fetch 401'd and the empty result read as "this provider has no
small model" — the picker fell back to its curated list and never noticed.
Worse, a failed fetch cached its empty result forever, so one bad moment
during startup disabled live model discovery for the life of the process,
and the processes that read this run for weeks. Give the failure an expiry
and pass the provider's credentials.
The bare family rungs (-mini, -flash, haiku) also picked whichever id
sorted first, which is the oldest generation a provider still serves:
gpt-3.5-mini over gpt-5.4-mini, claude-3-haiku over claude-haiku-4.5.
Compare the digit runs as numbers so the rung meant to keep us current
does.
* fix(agent): name the sessions the titler used to leave nameless
An opener is not always titleable — an image with no caption, a compaction
handoff, a bare slash command — and those sessions stayed unnamed for
life, because the guard that stops re-titling a named session also stopped
the nameless one from ever asking again. Let a later turn name a session
that still has no title.
The derived title also ran the collision dedupe inline on the turn.
It is a slice of the user's own words, so it collides constantly — people
open sessions with "hi" — and resolving "hi #47" is a widening scan on the
critical path for a name the model replaces a second later. Decline it
there and let the background stage, which can afford the scan, pick it up.
* fix(agent): stop titling a session after our own scaffolding, or a TTS model
Two lookalike gaps found auditing the titler.
_MACHINE_PREFIXES missed the compressor's legacy summary opener and the
"[System note:" injections, so a compacted or resumed session could be
named after the note that carried it. Take the summary prefix from the
compressor that emits it rather than keeping a fourth local copy.
The fast-model exclude list covered embedders but not the other non-chat
siblings a provider names after its chat model — "gpt-4o-mini-tts"
satisfies the "-mini" rung and cannot answer a prompt.
* fix(title): stop model-switch marker from becoming the session title
Switching models before sending the first real message titled the session
"[System: The active model for this chat has…" instead of the user's actual
question.
`_append_model_switch_marker` persists its notice with `role="user"` because
strict OpenAI-compatible providers reject a system message that is not first
(#48338). Titling had no way to tell that apart from a genuine opening turn,
which caused two distinct failures:
1. `_MACHINE_PREFIXES` did not cover the marker. Its `[System: ` prefix
matches none of `[CONTEXT COMPACTION`, `[Runtime note:`, or `[SYSTEM]`
(different case, no closing bracket), so `is_titleable_user_message()`
returned True and the marker was formatted into the title.
2. `maybe_auto_title()` counted the marker as a user message. With the marker
present, the first real question arrived at `user_msg_count == 2` and the
`> 1` guard returned early, so the session was never titled at all and its
`title` column stayed NULL. Fixing only (1) would therefore have traded a
wrong title for a permanently missing one.
Add the marker prefix to `_MACHINE_PREFIXES` (kept in sync with
`tui_gateway.server._MODEL_SWITCH_MARKER_PREFIX`) and count only titleable
user messages when detecting the opening turn.
The guard stays narrow: ordinary user text that happens to start with
"[System:" still titles normally.
Adds 6 regression tests, verified to fail without the fix.
* refactor(title): decide on the stored title and the real turns behind it
Folds the model-switch fix in with the untitled retry. They answer
different halves and each is wrong alone: counting alone left a session
that merely opened with machinery nameless forever, because nothing
reconsidered it, and the stored title alone would never title at all on a
store too old to report one. Skip only when both agree — past the opening
turn, and already named.
Counting a turn now judges a multimodal one on its text, so "here's a
screenshot, fix the login" counts as the question it is rather than
reading as machinery and undercounting the conversation.
Co-authored-by: yy28 <yy28@vip.sina.com>
* chore(contributors): map yy28's email for the cherry-picked title fix
* feat(desktop): read the window below through Hyprland's IPC (#82226)
`read_window_below` enumerates through get-windows, which on Linux reads
`_NET_CLIENT_LIST_STACKING` via xprop. That is an X11 protocol, and Wayland
deliberately refuses to tell one application about another's windows. Under
XWayland it is worse than nothing: it finds the few legacy X11 clients and
silently misses every native Wayland window, which on a Hyprland desktop is
most of them — so the HUD floats over an app it cannot name.
Hyprland answers the question directly. `j/clients` on its command socket
returns every window with class, title, position, size, pid and focus history.
Ask it first when HYPRLAND_INSTANCE_SIGNATURE is set, fall back to get-windows
everywhere else, and keep the picking logic shared and unchanged.
Three things the provider has to get right, all covered by tests: order comes
from focusHistoryID rather than the list; windows on other workspaces are
dropped, since they share coordinates with the visible ones and would win the
overlap test; and our own window is left out, because focus history is not
stacking order — the HUD floats on top while the user works underneath it, so
slicing after ourselves would skip past the very app we are trying to report.
One request per tool call, opened and closed immediately: Hyprland evaluates
this socket synchronously and freezes until a five-second timeout on a
connection left hanging.
* fmt(js): `npm run fix` on merge (#82417)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* perf(cache): split skill turns at a builder-declared stable/volatile boundary (#81867)
Webhook/cron skill invocations concatenate a large static scaffold
(activation note + expanded skill body) with a small volatile tail
(ticket payload, timestamps) into one user string, and the Anthropic
cache planner marked that whole string as a single atomic block — so a
few changed tail bytes forced a full cache rewrite on every invocation.
Instead of re-parsing scaffold marker strings out of the message at
request time (fragile when a payload or skill body quotes the marker),
the builders now register the exact stable-prefix bytes in a small
process-local LRU registry at construction time. The cache planner
splits a registered user string into [marked stable prefix, unmarked
volatile tail] request-locally; canonical session history stays a plain
string, and the failover stripper flattens the split back byte-exactly
via an O(1) registry lookup. Unregistered messages keep the existing
whole-message policy.
Covers the single-skill builder (webhook + slash command + TUI) and the
cron job prompt assembler (multi-skill, bundles, skipped-skill notice),
with registration guarded against injection-scanner sanitization.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(cache): harden the stable-prefix boundary against eviction and memory growth
Follow-up review of the builder-declared cache boundary (#81867) found three
ways the split could silently stop paying off, or keep paying more than it
should, on a long-lived gateway process.
Flattening no longer consults the registry. `strip_anthropic_cache_control`
matched the decorated split by looking the first block up in the prefix
registry, so a mid-turn failover that re-decorates a request built many
messages earlier (#72626) would fail to flatten once _MAX_ENTRIES newer
scaffolds had been registered in between, and would hand the next provider
the two-part shape instead of the canonical string. The split is now matched
by its shape: a marker on the *first* part of a user message is something no
other decoration produces (list content otherwise gets its marker on the last
part, and the two-part [static, volatile] split is role-gated to system), so
the ""-join stays provably byte-exact without any process state. This drops
`is_registered_stable_prefix` and one lock acquisition per stripped message.
Lookups now refresh LRU position. A scaffold fired every minute by cron could
be evicted by a burst of one-off skill invocations while still being the
hottest prefix in the process, silently reverting it to whole-message caching.
Registration now also evicts by total retained bytes (4 MiB). Entries hold
whole expanded skill bodies, so a 32-entry cap alone does not bound memory.
The newest entry is always kept, so a single oversized scaffold still gets a
boundary instead of disabling the split.
Tests: eviction-then-failover round-trip, LRU refresh on hit, byte-cap
eviction, and oversized-single-entry survival.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(cache): share the boundary-declaration helper and simplify the registry
Follow-ups from review of #82049:
- extract append_user_instruction() into agent/skill_commands so the
stable-prefix construction cannot drift between the skill and cron
builders (the registered prefix must stay a byte-prefix of the built
message); cron no longer imports the private _SINGLE_SKILL_INSTRUCTION
- add the startswith guard to the skill builder registration site,
matching the stronger cron guard
- rename _MAX_BYTES to _MAX_CHARS (sum(map(len, ...)) counts characters,
not bytes) and correct the comment
- collapse find_stable_prefix's two-lock dance into a single critical
section (scan is <=32 short-circuiting startswith calls, measured
2-4us; drops the snapshot copy and the TOCTOU re-check)
- document the split-shape lifetime (marked-endpoint window) in the
module docstring
- add a contract test for the helper's byte-prefix invariant
(mutation-checked)
* fix(agent): cancel in-flight background review before a new live turn
A background memory/skill review (agent/background_review.py) forks a
second, complete AIAgent in a daemon thread that deliberately shares the
live agent's own session_id for prompt-cache warmth. Nothing previously
stopped a user's next live turn from starting while that fork was still
mid-conversation, letting both stream against the same session_id and
credentials concurrently. That produced two observable failures:
- Doubled prompt-token accounting on the live turn's own calls (the two
concurrent request/response streams under one session_id confuse the
token-usage bookkeeping), triggering premature context compression.
- A lockup that a normal interrupt could not clear: the review fork is a
fully independent AIAgent with its own _interrupt_requested flag, and
was never added to the parent's _active_children list -- the only list
AIAgent.interrupt() actually walks for cross-agent cancellation -- so a
live-turn Ctrl+C had no propagation path to it at all.
Fix, three files:
1. agent/agent_init.py -- add _background_review_agent /
_background_review_lock tracking state to every AIAgent, mirroring the
existing _active_children pattern.
2. agent/background_review.py -- the review fork now registers itself on
the parent's _active_children right after construction (reusing the
same list/lock interrupt() already fans out to for real subagent
delegation), and unregisters on every exit path (success, the
tool-whitelist finally, and the outer exception safety-net). All
registration is defensive (getattr/try-except) so an AIAgent built
without going through agent_init.py's setup degrades to "no
cross-turn cancellation" instead of aborting the whole review.
3. agent/conversation_loop.py -- at the very start of every
run_conversation() turn, if a prior background review is still
in-flight, it is now proactively cancelled via interrupt() before the
live turn proceeds -- fire-and-forget, non-blocking, adds no latency.
Adds 3 regression tests to tests/run_agent/test_background_review.py,
confirmed to fail against the pre-fix code via a scripted revert.
Verified: ruff clean on all touched files; 66/66 background-review and
interrupt-propagation tests pass; 256/256 across turn_finalizer +
run_agent regression suites; no fork-only symbols in the diff.
* chore: add contributor mapping for adam@exo.ai (PR #82070)
* simplify: match delegate_tool.py hasattr pattern, drop change-detector test
Simplify registration/unregistration to match delegate_tool.py's
hasattr+getattr pattern instead of over-defensive try/except Exception
blocks. Delete inspect.getsource() change-detector test (breaks on
rename, proves nothing the behavioral test doesn't cover).
Net: -73 lines, +35 lines = -38 lines.
* fix(agent): recognize the retry loop's other synthetic nudges during compaction
aed114a69 taught _is_synthetic_compression_user_turn to recognize the
max-iteration nudge as ephemeral runtime scaffolding rather than a human
turn, since its role="user" metadata flag doesn't survive SessionDB
projection and a crash/interrupt mid-turn can persist it durably — becoming
the compaction anchor / auto-focus topic in place of the real task.
conversation_loop.py's retry loop appends several more role="user" rows
with the exact same "ephemeral, metadata-tag-only" shape, none of them
recognized by the classifier:
- The three _get_continuation_prompt variants (length-continuation nudge,
tagged _length_continuation_nudge) — two fixed strings plus a third that
interpolates the dropped-tool-call list.
- _CODEX_INCOMPLETE_NUDGE (codex/responses reasoning-only retry).
- The codex ack-continuation nudge (acknowledgment-only reply re-prompt).
- The dropped-tool-call nudge (tagged _dropped_toolcall_nudge) — persisted
across up to 3 consecutive retries before the finalization pop-loop
strips it; an interrupt/crash before that pop can persist it same as the
max-iteration case.
Promote the previously-inline nudge strings to named module-level constants
in conversation_loop.py (single source of truth for both construction and
recognition), then extend the classifier to recognize all of them — exact
match for the five fixed-content nudges, a stable-prefix check for the
dropped-tool-call continuation variant (its tool list is interpolated so it
can't be exact-matched, same treatment TODO_INJECTION_HEADER already gets).
Imported lazily inside the classifier to avoid a module-load-order cycle —
conversation_loop.py already imports FROM context_compressor.py at call
time for the same reason.
* fix: double-paren bug in dropped-tools prefix + add empty-response nudge sibling
Fix: _LENGTH_CONTINUATION_DROPPED_TOOLS_PREFIX ended with '(' but
_get_continuation_prompt still had f'({tool_list})', producing
'((write_file)' instead of '(write_file)'. Removed the '(' from
the prefix constant — the parenthesis belongs in the interpolation.
Widened: promoted the empty-response nudge (line 6993,
'You just executed tool calls but returned an empty response...')
to _EMPTY_TOOL_RESPONSE_NUDGE constant and added it to the
classifier's recognition set. Same bug class — its
_empty_recovery_synthetic metadata flag doesn't survive SessionDB
projection either.
Test: added parametrize case for the empty-response nudge (7→8 cases).
E2E: verified byte-for-byte string equivalence for all nudge constants.
* feat(desktop): resolve a session's pull request
A session row can say whether its work is open, merged or closed, and link
to it. The join is the session's own repo + branch, asked of GitHub in one
batched GraphQL request per repo (branch aliases, not a `gh pr list` page
that a busy repo crowds ours out of), through the remote-aware git facade so
a desktop on a remote gateway asks the backend's `gh`.
Two ways a session's branch can't answer, both covered:
- It ran on trunk. Fork PRs share our branch namespace, so asking about
`main` badges a stranger's PR onto it — trunk is never asked about, and
cross-repository PRs are dropped server-side either way.
- It worked in a worktree, so the branch it recorded at start isn't where
the PR came from. Creating a PR from the review pane binds the session to
the branch it actually used, and for sessions that predate that, the PR is
recovered from the transcript: `gh pr create` prints a bare PR url and
nothing else, so a tool result whose whole output is one is a claim rather
than a mention. Scanned read-only across profiles, once per session ever.
* refactor(desktop): one profile glyph
The rail, the profiles page and the session-row chip each drew the same
tinted initial square from scratch, so a row tag could disagree with the
rail about a profile's color. One component owns the square, its tint, and
the home icon the default profile gets instead of a letter.
* feat(desktop): sidebar filter menu
The sessions header's project/list toggle was one binary choice standing in
for a view. It becomes a menu: group by date, project or status; order by
updated, created, status, tokens or cost; show tokens, cost, PR, profile or
an always-visible timestamp per row; filter by status, pull request, project
or archived. Everything persists, and one reset puts it all back.
The pieces that make it read right:
- Status groups reuse the date dividers rather than inventing a second
separator, and a magnitude sort (tokens, cost) drops the calendar
entirely — "Today" above the priciest session you have ever had is a lie.
- Row metadata shares the trailing slot the kebab covers on hover, so only
the last fact steps aside and the number you switched on stays readable.
- A filter deepens the loaded page to 300 rows and hands the window back
when cleared, so "merged PRs" doesn't quietly answer for the last 50.
- Archived is a view of its own set, and dragging is still what picks a
manual order — the menu only offers a way back out of one.
* test(desktop): stub repoStatusForCwd in the review store tests
Binding a new PR to its session reads the repo's live branch, which the
suite's coding-status mock didn't provide.
* fmt(js): `npm run fix` on merge (#82468)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* perf(gateway): stop spawning git for paths that cannot answer
The project tree probes every distinct session cwd, and on a long-lived
history most of those directories are deleted worktrees — `git -C` there
can only fail, at the price of a fork each. Stat first.
The second elision is `common_repo_root`: only repos have a common dir,
and the parallel warm never covers that probe because `resolve()` reaches
it only for cwds that already resolved. Every non-repo cwd was therefore
paying a serial `git` spawn on the discovery pass.
* perf(gateway): quit reading system prompts the project tree discards
`_project_tree_row` keeps about eighteen fields and drops the rest, but
the query behind it selected `s.*` plus the resolved system prompt — 37MB
of blob per build on my session history, read out of the B-tree and then
thrown away.
* perf(gateway): warm every path the project tree will resolve
The warm covered session cwds, but build_tree also resolves each declared
project folder and each discovered repo root. Those were the last probes
running one directory at a time while the sidebar showed a skeleton.
* fix(agent): keep interrupt scaffold off the tool-tail redirect placeholder
The incomplete #73146 else branch still wrote the interrupt checkpoint into
the placeholder assistant row. Mid-tool steers then replayed that scaffold as
the model's own prior reply, which it echoed into a self-replicating ghost
loop. Carry the scaffold only on the user correction's api_content, matching
the assistant-tail branch.
* fix(agent): drop legacy interrupt-scaffold ghost rows from API replay
Sessions already poisoned by the incomplete #73146 else branch still replay
hidden assistant rows whose content is the raw interrupt scaffold. Skip those
rows when building provider messages so old state.db history cannot keep
seeding the echo loop.
* fix: move ghost filter before alternation repair + promote scaffold constant
Move the legacy ghost-row filter from inside the api_messages loop to
BEFORE repair_message_sequence_with_cursor. Dropping a ghost assistant
row between two user messages creates user→user which the repair can
now fix (previously the repair ran first and missed it).
Promote '[This response was interrupted by a user correction.]' to
module-level _INTERRUPT_SCAFFOLD_MARKER constant — used in both
_apply_active_turn_redirect (checkpoint_parts) and the ghost filter,
so they can never drift.
Update ghost-row test: the two consecutive user messages are now
merged by repair, so check for content as substring.
* fix(gateway): make the restart-loop breaker see slow crash cycles (#81642)
The auto-resume restart-loop breaker (#30719, defense-3) pruned its boot
log against an absolute `window_seconds` (default 60s). That prune is
period-sensitive: a crash cycle slower than the window drops its own
history on every boot, so the counter never leaves 1 and the breaker can
never trip, no matter how long the loop runs.
The cycle reported in #81642 is ~150s — a wedged event loop, the liveness
watchdog hard-exiting at ~90s, a supervisor respawn, and auto-resume
replaying the same session that wedges it again. Structurally invisible to
a 60s window: `gateway/restart_loop.json` kept a single timestamp across 15
kills in one morning. Because every cycle leaves a gateway that cannot
process SIGTERM, `hermes update` has no drainable gateway to stop, which is
the reported hang.
Chain boots on the inter-boot GAP instead of an absolute window: two boots
belong to the same loop when they are no more than `max_gap_seconds` apart
(default 300s, floored by `window_seconds` so widening the window never
makes the breaker less sensitive). The verdict becomes period-agnostic —
the original ~10s respawn loop still trips in 3 boots, and so does a 150s
one — while a boot after real quiet resets the chain, so occasional
operator restarts still never accumulate. The persisted chain is capped at
50 entries.
- gateway/restart_loop_guard.py: gap-chained pruning (`_chain_ending_at`),
`DEFAULT_MAX_GAP_SECONDS`, `max_gap_seconds` kwarg on the three entry
points, clock-step tolerance, bounded state file
- gateway/run.py: `_restart_loop_guard_config` reads and returns
`max_gap_seconds`; the auto-resume call site passes it through
- hermes_cli/config_defaults.py: `gateway.restart_loop_guard.max_gap_seconds`
Tests: 7 new cases in TestRestartLoopGuard covering the slow cycle, chain
persistence, quiet-period reset, the #30719 fast loop, the config knob, the
window floor, and the disabled breaker. Verified RED before the fix (the
slow-cycle case asserted `[1300.0] == [1000.0, 1150.0, 1300.0]`, exactly
the single-timestamp state file from the report) and GREEN after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(process-registry): keep CLI workers off controlling tty
* fix(process-registry): bind gateway scope identity to pid
* refactor: clean up gateway scope identity predicate and tests
- Remove dead use_systemd_scope = False assignment (leftover from
the old try/except pattern, immediately overwritten).
- Update stale log label supervisor= -> in_supervised_gateway=
to match the renamed variable.
- Convert autouse _mark_gateway_process fixture to opt-in
_gateway_identity so negative tests start from a clean slate
instead of undoing the fixture's env/PID mocks.
- Parametrize 4 near-duplicate negative tests (2 scenarios x
pipe/PTY) into 2 parametrized tests, reducing ~130 lines to ~80.
76 tests pass, ruff clean, net -32 LOC.
* fix(compression): preserve live tail before snapshot adoption
* chore: AUTHOR_MAP for afgl_mk93@icloud.com (PR #81851)
* fix(gateway): shield fatal-error handler from carrier task cancellation
When an adapter escalates a retryable fatal error from inside one of its
own tasks (e.g. Telegram's _polling_error_task after exhausting polling
network retries), the gateway's _handle_adapter_fatal_error tears the
adapter down via disconnect() — which cancels that very task. The
propagating CancelledError killed the handler between popping the
adapter from the adapter map and queueing the platform in
_failed_platforms, leaving a zombie gateway: process alive, zero
connected platforms, zero pending retries, until a manual restart.
Run the handler as a detached task under asyncio.shield so carrier
cancellation no longer aborts teardown/queueing mid-flight. The carrier
still observes CancelledError (teardown semantics unchanged); only the
handler is protected. A done-callback consumes the detached task's
exception to avoid 'Task exception was never retrieved' noise.
Fixes #81335
* fix: store strong ref to detached fatal handler task to prevent GC
asyncio.ensure_future(result) creates a task with only a weak ref in
the event loop's task table. After the carrier raises CancelledError,
the local 'task' variable goes out of scope and the loop can GC the
handler before it finishes — the exact 'handler killed mid-flight'
class we are fixing, just via GC instead of cancellation.
Add _detached_fatal_tasks set on BasePlatformAdapter (matching the
gateway-level pattern in _handle_adapter_fatal_error). Uses getattr
fallback for test stubs built via object.__new__().
* fix(personality): single-owner personality state + one-time reset migration
Personality persistence used to be split per surface: the TUI/desktop wrote
the NAME to display.personality while the CLI/gateway wrote rendered TEXT
into agent.system_prompt (and their /personality none only blanked the
text, leaving the name behind). When #81946 made display.personality
authoritative everywhere, stale names written long ago resurrected
personalities users had turned off - kawaii defaulting on after updating.
- hermes_cli/personality.py: new single owner of personality state.
Built-in personality definitions, neutral-name normalization, rendering,
availability (built-ins overlaid by agent.personalities), overlay
resolution, and the ONLY sanctioned persistence path
(persist_personality -> display.personality; never agent.system_prompt).
- v34 config migration: one-time reset of display.personality to none
(announcing which personality was cleared and how to re-enable), plus a
scrub of agent.system_prompt when it verbatim-equals a known personality
render (machine-written by the old CLI/gateway). Hand-written manual
prompts are never touched.
- All surfaces rewired through the module: CLI /personality (incl. active
marker in the list), gateway /personality, TUI config.set + slash path
(which previously applied without persisting), TUI config.get (reports
the EFFECTIVE personality), completer, hermes config display, and the
tui_gateway health probe.
- cli.py/config duplicates removed: built-ins now defined once; the
desktop mirrors them from one lib module (src/lib/personalities.ts).
- Docs updated: selection lives in display.personality, built-ins always
available, one-time reset note.
* chore: remove old plan files
* fix(gateway): make session identity durable so chat continuity survives crashes and restarts
Root cause of #82616: gateway session identity (session_key/chat_id/
origin_json) was written best-effort in a separate UPDATE after row
creation, both reset-path DB writes swallowed failures silently
(logger.debug / bare print), transcript reads ignored the reroute map
that writes follow, and restart recovery ranked candidate rows by
started_at while hard-rejecting empty rows. A single failed write could
therefore strand the live conversation in an unroutable orphan row while
a days-old zombie kept the routing key — after any gateway restart the
chat silently resumed the zombie (user-visible context loss, 5 confirmed
incidents on one install since June).
Four class fixes:
1. Identity lands atomically in the session INSERT: origin_json and
display_name join _insert_session_row's column list + COALESCE
backfill; both gateway creation paths (get_or_create + reset) pass
full identity including parent_session_id lineage (fixes #12857).
2. record_gateway_session_peer self-heals: when the target row is
missing (failed/deferred create, crash window) it INSERTs the row
with full identity instead of silently no-opping — every per-turn
peer refresh is now a repair opportunity, and an identity-less lazy
writer (update_token_counts/record_auxiliary_usage) can never leave
a gateway session permanently unroutable.
3. load_transcript follows the write-side reroute chain and the durable
compression tip before querying, so reads can no longer return 0
rows for a session whose messages live under its compression child;
read exceptions are WARNING, distinguishable from an empty result.
4. find_latest_gateway_session_for_peer ranks by
COALESCE(last_activity_at, started_at) (message-bearing rows first)
and returns an empty-but-keyed row instead of None — a zombie
predecessor can no longer beat the live conversation, and recovery
never mints a fresh id when a keyed row exists.
Reset-path DB write failures now log at WARNING with the routing
consequence spelled out.
Tests: tests/gateway/test_session_continuity_82616.py (11 tests) —
sabotage-verified: 6/11 fail without the fixes. E2E incident replay
(real SessionDB, temp HERMES_HOME) confirms the production shape now
resolves to the live session.
Fixes #82616. Related: #12857, #78182 (read-path half), #79576.
* ci: move the review comment and the image build out of the CI run
The CI run stayed in progress until its last job ended. Two advisory jobs
set that time: the review-comment poller (40 minutes) and the Docker image
build (45 minutes). Neither job was required to merge.
GitHub refuses `gh run rerun` on a run that is in progress. Thus a reviewer
who added the `ci-reviewed` label had to wait for the two slow jobs, and
label-rerun.yml carried a 2100-second wait loop for this reason. The fast
required jobs were ready long before.
Each slow job now runs in its own workflow:
- docker.yml owns its `pull_request` trigger and does its own change
detection. The new `detect` job runs the same composite action with the
same condition that ci.yml applied, so a tests-only PR still skips the
build. The `workflow_call` trigger is gone.
- ci-review-comment.yml starts on `workflow_run` when CI starts. It reads
the workflow and the scripts from the default branch, which is the trust
boundary that the old job got from its `ref: default_branch` checkout.
The poller reads job results through the API, so it can report on a run
that it does not belong to. `WATCH_WORKFLOWS` names sibling workflows for
the same commit, and `select_watched_runs` keeps the newest run for each
name. Thus the comment still shows the Docker results. The list is
newline-separated, because a workflow name can contain a comma.
The poller always exits 0 now. It reports on the CI run from a different
run, so a failed CI job is not a failure of the poller. The CI run has its
own gate for that.
Also correct a parse error in label-rerun.yml. STATUS came from the already
truncated RUN_ID, so its value was the run id and never "completed". Thus
the wait branch always ran.
ci.yml no longer needs `packages: write`, because the image build has left.
* fix(personality): preserve config comments in TUI/gateway config writes
tui_gateway/server.py:_save_cfg called yaml.safe_dump on a deep-loaded
config dict, which reordered top-level keys alphabetically, stripped
every user-edited comment, and re-escaped non-ASCII (kaomoji/Chinese)
personality prompts to \uXXXX. Every TUI setting change - /personality,
/reasoning, /details_mode, /skin, /prompt - rewrote the file top to
bottom.
Changes:
* Add atomic_roundtrip_yaml_save(path, new_state) in utils.py - a
comment-, ordering-, and unicode-preserving full-state replacement
for yaml.safe_dump(cfg, f). Uses ruamel round-trip mode like the
existing atomic_roundtrip_yaml_update, but accepts the whole cfg
dict so callers that mutate multiple keys before saving (the
_save_cfg pattern) don't have to be rewritten. Recurses into nested
dicts, deletes keys missing from new_state (preserves the
cfg.pop()-then-save semantic), and overwrites lists/scalars
wholesale.
* Fail closed on an unreadable existing config.yaml the same way
hermes_cli.config.atomic_config_write does, via a lazy import of
require_readable_config_before_write (avoids a module-level circular
import, since hermes_cli.config itself imports from utils). Also
preserves both file mode and owner across the write, matching the
existing atomic_roundtrip_yaml_update contract.
* Force-quote any new string value that YAML 1.1 would misparse as a
bool/null (yes/no/on/off/true/false/null/~). ruamel's round-trip
dumper resolves against the YAML 1.2 core schema and emits these
unquoted, but PyYAML-based readers elsewhere in the codebase parse
under YAML 1.1 rules - so an unquoted `approvals.mode: off` would
silently round-trip back as the boolean False.
* tui_gateway/server.py:_save_cfg now delegates to
atomic_roundtrip_yaml_save. Drop-in - all call sites (/personality,
/reasoning, /details_mode, /prompt, etc.) inherit comment
preservation and the fail-closed contract.
Tests:
* tests/test_utils_atomic_roundtrip_yaml_save.py - unit tests covering
create-from-empty, top-level key-order preservation, comment
preservation, readable Unicode, append-new-keys, delete-missing-keys,
scalar/list overwrite, nested-dict recursion, refusal on an
unreadable existing config, and owner preservation.
* tests/test_atomic_replace_symlinks.py - owner-preservation regression
test mirroring the existing atomic_roundtrip_yaml_update coverage.
* tests/test_tui_gateway_server.py - 4 new tests pinning _save_cfg
comment preservation, top-level key-order preservation, and
unicode-readability under unrelated writes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(tui): fix unreadable session-title chip contrast in the status bar
Fixes #82465.
The session-name chip at the right end of the TUI status bar rendered
white/near-white text (t.color.statusFg) on a raw, full-saturation
accent-hue background (t.color.accent, #FFBF00 -- bright yellow -- in
DARK_SEEDS). Two token problems stacked: accent is the accent
IDENTITY hue, never used elsewhere as a solid fill (fills are always
softened, e.g. activeRow = mix(surface, accent, 0.22)); and statusFg
is derived as a light gray lifted toward near-white text, a tone never
designed to sit on a saturated fill. Together: roughly 1.5-2:1
contrast, unreadable on the default dark theme.
Applied the issue's recommended first option: drop the background fill
entirely and render the title as accent-colored text on the normal
status bar background. Same highlight intent (the title still stands
out via its color), readable contrast on both dark and light seeds.
Updated the existing test that had encoded the buggy background-fill
expectation, and added an explicit contrast-regression assertion.
Verified as a genuine regression by reverting the fix and confirming
the test fails with the exact reported #FFBF00 background color.
54/54 pass across the three appChrome-related test files (no
regression).
* fix(state): recover gateway sessions stranded without a routing identity
When state.db's write path fails (corrupt FTS, or a crash landing between
routing publication and row creation), the live gateway conversation can end
up in a session row that never received its identity columns: session_key,
chat_id, chat_type and origin_json are all NULL. In-memory routing hides the
damage for as long as the gateway stays up. After a restart the chat is
resolved from the DB, and find_latest_gateway_session_for_peer cannot see
that row — both of its queries match on the very columns it lacks — so the
chat resumes the last keyed sibling instead, days older. The messages were
never lost, only unreachable.
Hardening the write side cannot reach a row that is already damaged, so add
the offline repair path the tracking issue asks for:
- SessionDB.find_orphaned_gateway_sessions() reports message-bearing rows
with no session_key, and names the predecessor each one continues only
when the evidence is unambiguous — a recorded parent_session_id
("lineage"), or exactly one keyed row of the same source and compatible
user_id that fell quiet within 15 minutes of the orphan's start
("contiguity"). Contested pairs are reported with a reason and left alone:
a wrong adoption would splice one person's conversation into another
person's chat. Branch, delegate and tool rows are excluded — they are
unkeyed by design, not by damage.
- SessionDB.adopt_orphaned_gateway_session() stamps the orphan from the
predecessor (never overwriting a column that already has a value), records
the lineage, and retires the predecessor under end_reason
'superseded_by_repair' — a reason recovery does not treat as resumable, so
the repaired row wins the chat from then on. The pair is re-verified inside
the write transaction, making a concurrent heal a no-op rather than a
conflicting write.
- `hermes sessions repair-routing` drives both. It reports without touching
the database; --apply confirms first and warns that a running gateway
still holds the old mapping in memory.
Refs #82616.
* fix(gateway): spool cap-dropped pending transcript messages instead of discarding
When the per-session pending transcript queue hits _MAX_PENDING_PER_SESSION
(200) while the session DB is broken, the gateway previously popped the
oldest message and discarded it permanently — silent user data loss during
live operation (#78182). The on-disk pending spool only ran at shutdown via
flush_pending_to_file.
Extend that existing spool machinery for runtime drops:
- gateway/shutdown_flush.py: add spool_dropped_transcript_message() and
drain_transcript_spool(), reusing _get_flush_dir/_write_payload (same
atomic-JSON pending_messages/ spool format). recover_pending_to_db()
now also replays transcript_cap_drop payloads left over across restarts.
- gateway/session.py: on cap eviction, spool the dropped message and log a
WARNING that includes the spool path; if spooling fails, degrade to the
previous drop-and-warn behavior. On the next fully successful transcript
flush for that session, drain and replay spooled messages in drop order;
replay failures keep the spool files for the next attempt.
- tests/gateway/test_pending_queue_spool.py: drop→spool→drain roundtrip,
per-session drain isolation, spool-failure degradation, replay-failure
retention, and spool primitive ordering/reason filtering.
No new config; extends existing flush_pending_to_file infrastructure per
AGENTS.md guidance.
Refs #82616, #78182
* fix(state): keep canonical writes available when FTS is corrupt
* fix(docker): per-session container isolation and session-scoped workspace mounts
Two bugs reported on the docker terminal backend (desktop app, sandboxed
profiles with container_persistent: false):
1. A NEW chat's container inherited the PREVIOUS session's workspace,
bind-mounted rw at /workspace, because the mount source was the
process-global TERMINAL_CWD env var (written by the workspace picker,
outliving its session) and all sessions shared one 'default' container.
2. Every command failed with exit 126 because the desktop gateway recorded
the HOST launch directory as the session cwd, and each command was
prefixed with 'cd /Users/<user>/...' inside the container.
Fixes (class-wide, single owners):
- container_persistent: false + docker now keys containers PER SESSION:
fresh container per chat, removed at session close/idle. delegate_task
children share the parent's container via an explicit alias registry.
container_persistent: true keeps the documented ONE-long-lived-container
contract unchanged.
- _resolve_task_host_cwd() is the single owner of the cwd->/workspace mount
policy across all four env-creation sites; under isolation it refuses
process-global cwd sources and mounts only the session's own attached
workspace (tui_gateway now tags overrides with cwd_source).
- _resolve_command_cwd() gains the same host-path guard the env-creation
sites already had (#50636/#54447 sibling site): a recorded host cwd is
discarded on container backends instead of cd-ing every command into a
nonexistent path.
E2E-tested against real Docker: distinct containers per session, no stale
mount in a fresh session, no exit 126 from host cwd records, containers
removed at session teardown.
* Port from code-yeongyu/oh-my-openagent: ast-grep structural search/codemod optional skill
Vendors the ast-grep skill from oh-my-openagent's shared-skills bundle
(upstream code-yeongyu/ast-grep-skill @ 3148c69, MIT) into
optional-skills/software-development/ast-grep with Hermes conventions:
- SKILL.md rewritten with Hermes frontmatter (platforms, tags, category)
and Hermes tool routing (search_files instead of raw rg, terminal for
sg invocations, patch-vs-ast-grep division of labor)
- scripts/ast_grep_helper.py: fixed argparse so trailing paths after an
optional flag parse (parse_known_args + fold extras into paths);
upstream errored 'unrecognized arguments: .' on the documented
'search PATTERN --lang js .' form
- 7 reference docs, install.sh/install.ps1 (pinned-release GitHub
fallback), smoke tests carried over verbatim
E2E validated: install (github method, ast-grep 0.45.0), doctor,
search, validate (regex rejection), replace dry-run + apply two-pass,
scan with YAML rule, tests/smoke.sh 15/15 pass.
* fix(desktop): send full tool args so expanded rows show the whole command
The gateway sent only an 80-char preview (context) for a tool call.
The desktop rebuilds the expanded tool row from the args of the part.
When the args were absent, the row showed the preview, and long
commands ended in '...' after the user expanded them.
Two paths had this fault:
- tool.start: the payload had no args until tool.complete, so the
expanded row was truncated while the tool ran. Now tool.start ships
the args, the same as tool.complete already does.
- _history_to_messages: the projection read the full arguments, then
discarded them. Hydration from this projection (watch windows,
compress, branch, seeded create) kept only the preview, so the
truncation was permanent. Now tool rows carry the args. This
projection is the display view of the transcript — each renderer
decides what to paint, and the preview stays for collapsed titles.
The DB rows do not change: the args already persist in tool_calls.
* fix(skills): trim ast-grep description to the 60-char hardline
test_authoring_standards.py::test_description_hardline red on main since
461c493972 landed with a 383-char description. The trimmed detail is all
preserved in the SKILL.md body (When-to-use, decision tree, search_files
comparison). Unbreaks every open PR's slice 4.
* fix(gateway): carry chat_id/thread_id/session_key into /branch child sessions too
Same defect as the compression-rotation fix in the prior commit, found
during a full-audit of every create_session() call site per the repo's
'fix the whole bug class, sibling call paths included' contribution
guidance.
_handle_branch_command() (gateway/slash_commands.py) creates the branched
child session via create_session() without chat_id/chat_type/thread_id.
The routing columns are only backfilled later, when switch_session() runs
at the end of the function and calls _record_gateway_session_peer(). In
between, the function copies the parent's conversation history to the new
session_id one message at a time, with each append_message() call
independently try/excepted (best-effort) — a crash/kill anywhere in that
window leaves the branched session permanently unroutable, same failure
mode as the compression bug: NULL chat_id/thread_id can never be found by
find_latest_gateway_session_for_peer, AND unreachable via /resume's IDOR
guard (which requires the row's chat_id/thread_id to match the caller's).
Fix: forward source.chat_id/chat_type/thread_id at create_session() time,
mirroring the existing correct pattern already used by /title's
auto-create path a few hundred lines up in the same file (which has an
explicit IDOR-scoping comment justifying it).
Tests: tests/gateway/test_branch_routing_columns.py drives the real
_handle_branch_command against a real SessionStore + SessionDB (SQLite in
tmp_path, no DB/session-store mocks). Patches switch_session to simulate a
crash landing before it runs (the exact gap the routing columns need to
survive), then asserts the branched child's chat_id/chat_type/thread_id
are already correct in state.db at that point. RED verified against
unpatched code (assert None == '170829464'), GREEN after the fix.
Regression: 102/102 across the new test + pre-existing /branch, session
boundary, compression rotation, DM thread seeding, session API, and
resume-command suites. Broader tests/gateway/ -k "branch or session_api or
resume or topic_mode or session_boundary" sweep: 255/255 passed, 1
(unrelated) skip.
* fix(gateway): also persist user_id and session_key in child-session creates
The sweeper flagged two gaps in the routing-columns fix:
1. /branch create_session() omitted user_id and session_key — the
fallback lookup path (find_latest_gateway_session_for_peer) requires
user_id to match the complete peer tuple when session_key lookup fails,
and /resume IDOR guards reject sessions without matching user_id.
2. Compression-rotation create_session() omitted agent._user_id — same
problem: rotated child cannot satisfy persisted /resume ownership proof
before the later gateway backfill.
Forward user_id and session_key at CREATE time in both call sites so
the child row is immediately fully routable with zero backfill gap.
Extended tests: compression rotation asserts user_id is carried (and None
for CLI sessions). Branch routing asserts both user_id and session_key on
the child row before switch_session runs.
* fix(gateway): carry origin_json/display_name into /branch child sessions too
Complete the /branch routing-identity fix (salvaged from PR #62278 by
@jcjc81): in addition to user_id/session_key/chat_id/chat_type/thread_id,
forward origin_json and display_name at create_session() time, matching
the reset-path db_create_kwargs pattern (#82633) so the branch row is
born with full identity — no backfill gap for state.db consumers
(mcp_serve, mirror, channel directory) if a crash lands before
switch_session().
The obsolete compression-rotation half of #62278 was dropped: rotation
now goes exclusively through publish_compression_child, which already
copies all identity columns in-transaction.
* fix(gateway): distinguish durable cached transcript rows
* chore: map TomAce7 contributor email for attribution audit
* fix(gateway): respect reset boundaries during recovery (#68539)
find_latest_gateway_session_for_peer filtered non-recoverable rows out of
candidacy BEFORE ordering, so recovery could search behind a /new reset
boundary and resurrect an older still-open row for the same peer —
silently restoring the exact context the user reset.
Rebuilt against the #82633 finder (has-messages ranking +
COALESCE(last_activity_at, started_at) recency): the fence is expressed
as a NOT EXISTS guard inside both the exact-key and peer-fallback
queries — a candidate is rejected when an intentional boundary row
(session_reset / session_switch / idle / daily / suspended /
resume_pending_expired) for the same peer ended after the candidate's
last activity. If the conversation's most recent event is an intentional
reset, recovery returns nothing rather than reaching behind it.
Cherry-picked from #68617 and adapted to the rewritten finder.
(cherry picked from commit bb2c562a165d91e00f64d42cf7495e6c8a5da9d7)
* fix(gateway): honor session_reset policy when recovering sessions
Both session recovery paths (the startup stale-entry repoint and the
lazy in-message recovery) rebuilt the routing entry with updated_at=now
and never consulted _should_reset, so an opt-in idle/daily session_reset
policy was silently dead across any gateway restart: a recovered session
always looked freshly active, and since every subsequent message bumps
updated_at, a session recovered stale could then never age out at all.
Fix in three parts:
- _create_entry_from_recovered_row derives updated_at from the durable
last_activity_at the finder already returns on the row (no extra DB
round-trip; the original PR added SessionDB.get_last_activity for
this, unnecessary post-#82633), falling back to created_at. An
invalid or missing started_at now maps to epoch 0 instead of now — an
invalid durable timestamp must look old, never freshly active.
reset_had_activity is set from the row's durable activity/message
signals so the continuity hint stays accurate.
- _recover_session_from_db evaluates _should_reset on the rebuilt entry:
an overdue session is durably promoted to a reset boundary
(promote_to_session_reset, falling back to end_session) and the stale
mapping is dropped instead of repointed.
- _query_recoverable_session no longer reopens the row; the
get_or_create_session recovery phase evaluates _should_reset first and
either feeds the normal auto-reset create path (reset notice,
prev_session_id continuity, durable promotion) or reopens and
publishes the recovered entry exactly as before.
Behavior is unchanged under the default session_reset mode "none":
_should_reset returns None there, so recovery still resumes every
recoverable row — only users who opted into idle/daily resets see the
policy actually applied across restarts.
Cherry-picked from #78618 and adapted to the #82633 finder.
(cherry picked from commit 31c71f762961638c199287fc6ffe836115c4892b)
* chore: map contributor email for hillimited
* fix(desktop-ssh): stop resolving exec-wrappers to python in locateHermes (#74411)
Problem 1: resolveLauncher() read bash 'exec <python> <script>' wrappers
and returned ONLY the python interpreter path, discarding the script.
This made probeHermesVersion() run '<python> --version', which always
printed 'Python x.y.z' instead of the Hermes version. And
remoteSupportsSshOwnership() ran '<python> serve --help' which failed
entirely because no 'serve' module exists in the python stdlib.
Problem 2: When the user set remoteHermesPath (an explicit override),
resolveLauncher() resolved it to the python interpreter, replacing the
user's specified path. The override was effectively ignored for version
checking and capability probing.
Fix: resolveLauncher now returns the candidate path directly. The hermes
binary or wrapper script is already executable and handles argument
forwarding (e.g. 'exec <python> <script> "$@"') correctly on its own.
No additional remote SSH round-trip or python script needed.
* test(desktop-ssh): cover wrapper preservation and explicit-path passthrough in locateHermes
Replaces the canonicalization test (which pinned the behavior #74425
removes) with wrapper-preservation coverage for auto-detection and an
explicit remoteHermesPath, both asserting no python3 -c parser call is
issued. Verified both fail against the pre-fix implementation.
* fmt(js): `npm run fix` on merge (#82771)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(desktop): make un-highlighted code readable while streaming in light theme
streaming code blocks in the light theme render near-white text on the
white code card until shiki's highlight lands, then snap to normal token
colors. the pale text is @tailwindcss/typography's pre foreground: its
prose theme styles pre as a dark slab (--tw-prose-pre-code = gray-200 on
a gray-800 bg). we strip the bg for our own code card but the near-white
foreground survives on the container. shiki's opaque per-token span
colors normally hide it — it shows through wherever text renders without
spans: the streaming delay window, the lazy-chunk suspense fallback, and
over-budget blocks that never highlight.
traced on the live renderer: computed color on the wrapper of mid-stream
code was oklch(0.928 0.006 264.531) (gray-200), supplied by the
.prose :where(pre) rule.
fix: prose-pre:text-foreground on the markdown container, so every
fenced path inherits the transcript foreground instead. the utility
layer is emitted after typography's base rule in the built css, so the
override wins by order at equal specificity.
* test: run os-specific tests on their real host, not a faked one
many tests patched sys.platform or a module's _IS_WINDOWS flag, then
ran on linux ci. the patch selects the branch under test, but the host
does not have the behavior the branch exists for. the test proves the
patch, not the platform. some gated assertions never ran on any host.
this commit adds three markers: linux_only, macos_only, windows_only.
a conftest hook skips a marked test on the other hosts, with a clear
reason. no test fakes a host now. two documented fakes remain
(android/termux, freebsd) because no ci runner exists for them.
each fake site got one of four treatments:
- gate it: the real host supplies the platform; mocks cover real
dependencies only, never host identity
- patch the module's own probe when the subject is the probe's consumer
- assert against the real host when the fake stood in for any non-x host
- delete the patch when it set the value the host already has
bare skipif(sys.platform != ...) guards became markers too. the lane
model skips these on linux and never imports them on windows, so they
ran on no host. platform parametrize tables are now one marked test
per os.
running on real hosts found real…
batumilove
added a commit
to batumilove/hermes-agent
that referenced
this pull request
Aug 11, 2026
* fix(desktop): detach relaunched Desktop from the hand-off console + UTF-8 child streams
First real-world run of the #82328/#82366 hand-off (2026-08-09, ryanc)
surfaced two defects:
1. The console window never closes after the update finishes -- and
closing it manually KILLS the freshly relaunched GUI. Root cause:
Start-DesktopRelaunch spawned Hermes.exe as a child of the console
PowerShell. Electron/Chromium calls AttachConsole(ATTACH_PARENT_
PROCESS) at boot, so the new Desktop latched onto the hand-off's
console: the console can't close while an attached process lives,
and closing it takes the attached GUI down with it. Fix: create the
process via WMI (Win32_Process.Create) -- parent becomes WmiPrvSE,
no console to inherit or attach, same detachment explorer.exe gives
a normal launch. Start-Process fallback retained (tethered Desktop
beats no Desktop).
2. Both the console and the progress box render hermes update's UTF-8
glyphs (checkmarks, arrows) as mojibake. PS 5.1 defaults redirected
child streams to the OEM codepage. Fix: StandardOutput/ErrorEncoding
= UTF8 on the child, PYTHONIOENCODING/PYTHONUTF8 so Python emits
UTF-8, and [Console]::OutputEncoding = UTF8 for our own echo.
Verified live on the incident machine: WMI-created process parents to
WmiPrvSE.exe (not the shell); UTF-8 glyph round-trip through the exact
ProcessStartInfo shape reads back byte-correct (15/15 chars). PS 5.1
parse clean, check-windows-footguns clean.
* fix(model-metadata): auto-extend provider prefixes from registered profiles
_PROVIDER_PREFIXES was a hand-maintained frozenset, so providers that ship
as plugins (bundled like fireworks, or user plugins under
$HERMES_HOME/plugins/model-providers/) were never recognised as
provider: prefixes in model strings, and metadata/context-window lookups
received the unstripped string. Mirror the _URL_TO_PROVIDER auto-extend
that already sits below it: add each registered profile's name and
aliases after discovery. The _OLLAMA_TAG_PATTERN guard keeps model:tag
strings intact.
Fixes #66106
* fix(model-metadata): resolve provider prefixes from live registry
* test(model-metadata): use explicit fixture encodings
* fix(desktop): focus the update progress window, then hand focus to the relaunched Desktop
Two focus polish items from the first fully-working hand-off run
(ryanc, 2026-08-09):
1. The progress window came up backgrounded: the script is spawned via
`cmd start /min`, and Form.Show() + TopMost keeps it above other
windows without ACTIVATING it. Claim activation explicitly
(Form.Activate + SetForegroundWindow) right after Show.
2. The relaunched Desktop came up behind whatever the user had focused:
a WMI-spawned process starts unfocused and cannot take foreground by
itself. Since the hand-off owns foreground while its progress window
is up, delegate it: AllowSetForegroundWindow(new pid), poll up to 20s
for Electron's MainWindowHandle, then ShowWindow(SW_RESTORE) +
SetForegroundWindow. Best-effort at every step -- a focus failure
never affects the update result.
Sequence on success: progress window foreground during the update ->
window closes -> freshly relaunched Hermes.exe takes foreground.
Verified live on the incident machine: Add-Type shim compiles under
PS 5.1; WMI spawn + AllowSetForegroundWindow + MainWindowHandle poll +
ShowWindow all execute against a real spawned window. (In the bg test
shell SetForegroundWindow returns False by OS design -- only the
current foreground owner may delegate; the real flow's TopMost progress
window IS that owner.) PS parse clean, check-windows-footguns clean.
* test(desktop): widen HUD composer containment regression coverage (#82319)
Extend the packaged-app HUD geometry test from horizontal-only to full
containment: both axes for the dock and the input, plus an explicit
assertion that no percentage translate survives on the composer dock.
The vertical clipping reported on Windows (#82203) and macOS (#82214)
is the same escape class on the other axis, and the computed-translate
probe makes a future optimizer regression fail with a diagnosis instead
of a bare coordinate mismatch.
* fix(desktop): keep the HUD on the session it was opened for (#82360)
* fix(desktop): keep the HUD on the session it was opened for
The HUD is a full app renderer, so the main window's cold-start
'restore last session' logic ran inside it: opening HUD on a blank new
chat (#/) navigated it to the remembered session instead of the new one,
because a blank draft has no stored id and the HUD boots at the default
route. Guard the restore/remember effect with isHudWindow() — the HUD's
destination is always chosen explicitly at open time.
Also stops the HUD from clobbering the main window's remembered
navigation while it is up.
* fix(desktop): use type-only import for the windows-store mock in HUD restore test
consistent-type-imports forbids inline import() type annotations; use the
established import type * as pattern (same as session-row.test.tsx).
* fix(desktop): open HUD mode on the focused conversation's profile (#82325)
* fix(desktop): open HUD mode on the focused conversation's profile
The HUD is a full app renderer that adopted the PRIMARY backend's
profile at boot, so toggling HUD mode from a conversation on any other
profile resolved the session id against the wrong backend — the lookup
missed and the HUD fell back to the default profile's last session
(#82285).
- openHud() resolves the target's owning profile (session's stamped
owner, else the active gateway profile) and passes it through
hermes:hud:open.
- hudUrl() carries the profile in the query string next to win=hud;
the HUD renderer's gateway boot honors it as an override for both
getConnection() and profile adoption, so the window dials and adopts
the right backend from first paint.
- Retargeting a live HUD onto a session from a DIFFERENT profile
respawns the window against that profile's backend (a renderer adopts
its backend exactly once at boot; an in-place goto would repeat the
wrong-backend lookup).
No profile in the URL means no override — ordinary windows and
single-profile users boot exactly as before.
* refactor(desktop): extract the HUD renderer URL so its contract is tested
hudUrl() built the query string inline in main.ts, where the part that
actually breaks — `?win=hud&profile=` must sit BEFORE the '#' or
HashRouter eats it as the route — had no coverage. Move it next to
buildSessionWindowUrl's split (pure piece out of the monolith, unit
tested) and pin the contract: flag order, profile encoding, trailing
slash on the dev server, empty profile omitted, packaged file URL.
Co-authored-by: rainbowgore <rainbowgore@users.noreply.github.com>
* refactor(desktop): resolve the HUD's target profile through the existing ladder
openHud() had its own copy of "stamped owner, else active gateway, else
default" — the same ladder rememberedSessionProfile() already owns for
the remembered-navigation key, down to sessionMatchesStoredId and the
default fallback. One resolver per policy, so the two can't drift.
---------
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
Co-authored-by: rainbowgore <rainbowgore@users.noreply.github.com>
* fix(desktop): keep the HUD exit chip clickable when the composer has no focus (#82403)
The chip was pointer-events: none until [data-slot='composer-rich-input']
had :focus, which made the only visible way out of HUD mode conditional
on the thing most likely to be broken when someone wants out. When focus
never lands (#81893 on macOS) you can neither type nor click your way
out: the HUD is a transparent always-on-top rectangle over the desktop
with no in-app dismiss.
It is now always clickable and dim (0.45) at rest, brightening on hover,
focus-visible, and composer focus. That keeps the original intent — not
a loud chip over the app behind — without gating the escape hatch on the
failure mode it exists for.
Salvaged from #82317 by @Ne0teric. The centering half of that PR is
dropped: #82233 already fixed the dock offset, and its 'translate: none'
is the exact literal Lightning CSS folds into 'transform', which is the
bug #82233 fixed.
Co-authored-by: Ne0teric <Ne0teric@users.noreply.github.com>
* feat(desktop): mark an unsent session with its own status dot
A draft got no dot at all, so the one tab that has never done anything
looked identical to a settled session. Give it the faintest mark the app
has — a hollow outline, weakest claim in the dot's priority order, so the
first thing that actually happens speaks over it.
The row's own message_count is the tiebreaker for what counts as a draft:
a session RESUMING also holds an empty message list for a moment, and
calling that a draft flashes the wrong mark on a conversation with years
of history in it.
* feat(desktop): name a draft after what you have typed into it
Every unsent tab was called "New session", so a row of them said nothing
about which was which. Name each one from its composer, using the same
first-line, word-boundary rule the backend's derive_title applies a moment
after the draft is finally sent — so the name the tab already shows is the
name it keeps.
The title moves with the composer, which is far faster than a pane
contribution should be re-registered. Panes can now render a tab label
instead of declaring one, so the label subscribes to its own key and a
rename repaints one string rather than the panes area.
* fix(agent): stop cron and subagent runs auto-titling their sessions
The turn prologue titles every session, and it is shared by every agent —
including the ones no person is reading. A cron job already names its own
session after the job in its finally block, so the titler spent a side-LLM
call per fire to write the delivery scaffolding over it for the length of
the run. A delegated child's session is hidden from every picker, so a
batch at max_concurrent_children paid N title calls for N names nobody
opens.
Both are the same class of run that already sets skip_memory to stay off
the auxiliary path, so keep the titler off it too.
* fix(gateway): rename a Discord thread once, after the reply lands
Titling is two-stage — a slice of the user's own words lands inline, the
model's version replaces it a second later — and the platform rename lanes
fired on both. That is two rate-limited calls to reach one name, and
Discord allows two channel renames per ten minutes, so the throwaway could
be the one that survived. The callback now carries which stage it is, and
the lanes take the model's.
The relay lane also asked where the reply landed at title time, which is
before the model has answered: it polled the send-result cache for ten
seconds and read the timeout as "never auto-threaded", so any turn with
tool calls in it silently kept its raw thread name. Wait on the send
itself instead — the adapter already owns that cache, so it can say when a
reply arrives and, just as usefully, that one arrived carrying nothing.
* fix(models): let the titler actually see a provider's model catalog
The fast-model picker reads /v1/models to find the small model a provider
currently serves, and it asked anonymously. Most of those endpoints need a
key, so the fetch 401'd and the empty result read as "this provider has no
small model" — the picker fell back to its curated list and never noticed.
Worse, a failed fetch cached its empty result forever, so one bad moment
during startup disabled live model discovery for the life of the process,
and the processes that read this run for weeks. Give the failure an expiry
and pass the provider's credentials.
The bare family rungs (-mini, -flash, haiku) also picked whichever id
sorted first, which is the oldest generation a provider still serves:
gpt-3.5-mini over gpt-5.4-mini, claude-3-haiku over claude-haiku-4.5.
Compare the digit runs as numbers so the rung meant to keep us current
does.
* fix(agent): name the sessions the titler used to leave nameless
An opener is not always titleable — an image with no caption, a compaction
handoff, a bare slash command — and those sessions stayed unnamed for
life, because the guard that stops re-titling a named session also stopped
the nameless one from ever asking again. Let a later turn name a session
that still has no title.
The derived title also ran the collision dedupe inline on the turn.
It is a slice of the user's own words, so it collides constantly — people
open sessions with "hi" — and resolving "hi #47" is a widening scan on the
critical path for a name the model replaces a second later. Decline it
there and let the background stage, which can afford the scan, pick it up.
* fix(agent): stop titling a session after our own scaffolding, or a TTS model
Two lookalike gaps found auditing the titler.
_MACHINE_PREFIXES missed the compressor's legacy summary opener and the
"[System note:" injections, so a compacted or resumed session could be
named after the note that carried it. Take the summary prefix from the
compressor that emits it rather than keeping a fourth local copy.
The fast-model exclude list covered embedders but not the other non-chat
siblings a provider names after its chat model — "gpt-4o-mini-tts"
satisfies the "-mini" rung and cannot answer a prompt.
* fix(title): stop model-switch marker from becoming the session title
Switching models before sending the first real message titled the session
"[System: The active model for this chat has…" instead of the user's actual
question.
`_append_model_switch_marker` persists its notice with `role="user"` because
strict OpenAI-compatible providers reject a system message that is not first
(#48338). Titling had no way to tell that apart from a genuine opening turn,
which caused two distinct failures:
1. `_MACHINE_PREFIXES` did not cover the marker. Its `[System: ` prefix
matches none of `[CONTEXT COMPACTION`, `[Runtime note:`, or `[SYSTEM]`
(different case, no closing bracket), so `is_titleable_user_message()`
returned True and the marker was formatted into the title.
2. `maybe_auto_title()` counted the marker as a user message. With the marker
present, the first real question arrived at `user_msg_count == 2` and the
`> 1` guard returned early, so the session was never titled at all and its
`title` column stayed NULL. Fixing only (1) would therefore have traded a
wrong title for a permanently missing one.
Add the marker prefix to `_MACHINE_PREFIXES` (kept in sync with
`tui_gateway.server._MODEL_SWITCH_MARKER_PREFIX`) and count only titleable
user messages when detecting the opening turn.
The guard stays narrow: ordinary user text that happens to start with
"[System:" still titles normally.
Adds 6 regression tests, verified to fail without the fix.
* refactor(title): decide on the stored title and the real turns behind it
Folds the model-switch fix in with the untitled retry. They answer
different halves and each is wrong alone: counting alone left a session
that merely opened with machinery nameless forever, because nothing
reconsidered it, and the stored title alone would never title at all on a
store too old to report one. Skip only when both agree — past the opening
turn, and already named.
Counting a turn now judges a multimodal one on its text, so "here's a
screenshot, fix the login" counts as the question it is rather than
reading as machinery and undercounting the conversation.
Co-authored-by: yy28 <yy28@vip.sina.com>
* chore(contributors): map yy28's email for the cherry-picked title fix
* feat(desktop): read the window below through Hyprland's IPC (#82226)
`read_window_below` enumerates through get-windows, which on Linux reads
`_NET_CLIENT_LIST_STACKING` via xprop. That is an X11 protocol, and Wayland
deliberately refuses to tell one application about another's windows. Under
XWayland it is worse than nothing: it finds the few legacy X11 clients and
silently misses every native Wayland window, which on a Hyprland desktop is
most of them — so the HUD floats over an app it cannot name.
Hyprland answers the question directly. `j/clients` on its command socket
returns every window with class, title, position, size, pid and focus history.
Ask it first when HYPRLAND_INSTANCE_SIGNATURE is set, fall back to get-windows
everywhere else, and keep the picking logic shared and unchanged.
Three things the provider has to get right, all covered by tests: order comes
from focusHistoryID rather than the list; windows on other workspaces are
dropped, since they share coordinates with the visible ones and would win the
overlap test; and our own window is left out, because focus history is not
stacking order — the HUD floats on top while the user works underneath it, so
slicing after ourselves would skip past the very app we are trying to report.
One request per tool call, opened and closed immediately: Hyprland evaluates
this socket synchronously and freezes until a five-second timeout on a
connection left hanging.
* fmt(js): `npm run fix` on merge (#82417)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* perf(cache): split skill turns at a builder-declared stable/volatile boundary (#81867)
Webhook/cron skill invocations concatenate a large static scaffold
(activation note + expanded skill body) with a small volatile tail
(ticket payload, timestamps) into one user string, and the Anthropic
cache planner marked that whole string as a single atomic block — so a
few changed tail bytes forced a full cache rewrite on every invocation.
Instead of re-parsing scaffold marker strings out of the message at
request time (fragile when a payload or skill body quotes the marker),
the builders now register the exact stable-prefix bytes in a small
process-local LRU registry at construction time. The cache planner
splits a registered user string into [marked stable prefix, unmarked
volatile tail] request-locally; canonical session history stays a plain
string, and the failover stripper flattens the split back byte-exactly
via an O(1) registry lookup. Unregistered messages keep the existing
whole-message policy.
Covers the single-skill builder (webhook + slash command + TUI) and the
cron job prompt assembler (multi-skill, bundles, skipped-skill notice),
with registration guarded against injection-scanner sanitization.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(cache): harden the stable-prefix boundary against eviction and memory growth
Follow-up review of the builder-declared cache boundary (#81867) found three
ways the split could silently stop paying off, or keep paying more than it
should, on a long-lived gateway process.
Flattening no longer consults the registry. `strip_anthropic_cache_control`
matched the decorated split by looking the first block up in the prefix
registry, so a mid-turn failover that re-decorates a request built many
messages earlier (#72626) would fail to flatten once _MAX_ENTRIES newer
scaffolds had been registered in between, and would hand the next provider
the two-part shape instead of the canonical string. The split is now matched
by its shape: a marker on the *first* part of a user message is something no
other decoration produces (list content otherwise gets its marker on the last
part, and the two-part [static, volatile] split is role-gated to system), so
the ""-join stays provably byte-exact without any process state. This drops
`is_registered_stable_prefix` and one lock acquisition per stripped message.
Lookups now refresh LRU position. A scaffold fired every minute by cron could
be evicted by a burst of one-off skill invocations while still being the
hottest prefix in the process, silently reverting it to whole-message caching.
Registration now also evicts by total retained bytes (4 MiB). Entries hold
whole expanded skill bodies, so a 32-entry cap alone does not bound memory.
The newest entry is always kept, so a single oversized scaffold still gets a
boundary instead of disabling the split.
Tests: eviction-then-failover round-trip, LRU refresh on hit, byte-cap
eviction, and oversized-single-entry survival.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(cache): share the boundary-declaration helper and simplify the registry
Follow-ups from review of #82049:
- extract append_user_instruction() into agent/skill_commands so the
stable-prefix construction cannot drift between the skill and cron
builders (the registered prefix must stay a byte-prefix of the built
message); cron no longer imports the private _SINGLE_SKILL_INSTRUCTION
- add the startswith guard to the skill builder registration site,
matching the stronger cron guard
- rename _MAX_BYTES to _MAX_CHARS (sum(map(len, ...)) counts characters,
not bytes) and correct the comment
- collapse find_stable_prefix's two-lock dance into a single critical
section (scan is <=32 short-circuiting startswith calls, measured
2-4us; drops the snapshot copy and the TOCTOU re-check)
- document the split-shape lifetime (marked-endpoint window) in the
module docstring
- add a contract test for the helper's byte-prefix invariant
(mutation-checked)
* fix(agent): cancel in-flight background review before a new live turn
A background memory/skill review (agent/background_review.py) forks a
second, complete AIAgent in a daemon thread that deliberately shares the
live agent's own session_id for prompt-cache warmth. Nothing previously
stopped a user's next live turn from starting while that fork was still
mid-conversation, letting both stream against the same session_id and
credentials concurrently. That produced two observable failures:
- Doubled prompt-token accounting on the live turn's own calls (the two
concurrent request/response streams under one session_id confuse the
token-usage bookkeeping), triggering premature context compression.
- A lockup that a normal interrupt could not clear: the review fork is a
fully independent AIAgent with its own _interrupt_requested flag, and
was never added to the parent's _active_children list -- the only list
AIAgent.interrupt() actually walks for cross-agent cancellation -- so a
live-turn Ctrl+C had no propagation path to it at all.
Fix, three files:
1. agent/agent_init.py -- add _background_review_agent /
_background_review_lock tracking state to every AIAgent, mirroring the
existing _active_children pattern.
2. agent/background_review.py -- the review fork now registers itself on
the parent's _active_children right after construction (reusing the
same list/lock interrupt() already fans out to for real subagent
delegation), and unregisters on every exit path (success, the
tool-whitelist finally, and the outer exception safety-net). All
registration is defensive (getattr/try-except) so an AIAgent built
without going through agent_init.py's setup degrades to "no
cross-turn cancellation" instead of aborting the whole review.
3. agent/conversation_loop.py -- at the very start of every
run_conversation() turn, if a prior background review is still
in-flight, it is now proactively cancelled via interrupt() before the
live turn proceeds -- fire-and-forget, non-blocking, adds no latency.
Adds 3 regression tests to tests/run_agent/test_background_review.py,
confirmed to fail against the pre-fix code via a scripted revert.
Verified: ruff clean on all touched files; 66/66 background-review and
interrupt-propagation tests pass; 256/256 across turn_finalizer +
run_agent regression suites; no fork-only symbols in the diff.
* chore: add contributor mapping for adam@exo.ai (PR #82070)
* simplify: match delegate_tool.py hasattr pattern, drop change-detector test
Simplify registration/unregistration to match delegate_tool.py's
hasattr+getattr pattern instead of over-defensive try/except Exception
blocks. Delete inspect.getsource() change-detector test (breaks on
rename, proves nothing the behavioral test doesn't cover).
Net: -73 lines, +35 lines = -38 lines.
* fix(agent): recognize the retry loop's other synthetic nudges during compaction
aed114a69 taught _is_synthetic_compression_user_turn to recognize the
max-iteration nudge as ephemeral runtime scaffolding rather than a human
turn, since its role="user" metadata flag doesn't survive SessionDB
projection and a crash/interrupt mid-turn can persist it durably — becoming
the compaction anchor / auto-focus topic in place of the real task.
conversation_loop.py's retry loop appends several more role="user" rows
with the exact same "ephemeral, metadata-tag-only" shape, none of them
recognized by the classifier:
- The three _get_continuation_prompt variants (length-continuation nudge,
tagged _length_continuation_nudge) — two fixed strings plus a third that
interpolates the dropped-tool-call list.
- _CODEX_INCOMPLETE_NUDGE (codex/responses reasoning-only retry).
- The codex ack-continuation nudge (acknowledgment-only reply re-prompt).
- The dropped-tool-call nudge (tagged _dropped_toolcall_nudge) — persisted
across up to 3 consecutive retries before the finalization pop-loop
strips it; an interrupt/crash before that pop can persist it same as the
max-iteration case.
Promote the previously-inline nudge strings to named module-level constants
in conversation_loop.py (single source of truth for both construction and
recognition), then extend the classifier to recognize all of them — exact
match for the five fixed-content nudges, a stable-prefix check for the
dropped-tool-call continuation variant (its tool list is interpolated so it
can't be exact-matched, same treatment TODO_INJECTION_HEADER already gets).
Imported lazily inside the classifier to avoid a module-load-order cycle —
conversation_loop.py already imports FROM context_compressor.py at call
time for the same reason.
* fix: double-paren bug in dropped-tools prefix + add empty-response nudge sibling
Fix: _LENGTH_CONTINUATION_DROPPED_TOOLS_PREFIX ended with '(' but
_get_continuation_prompt still had f'({tool_list})', producing
'((write_file)' instead of '(write_file)'. Removed the '(' from
the prefix constant — the parenthesis belongs in the interpolation.
Widened: promoted the empty-response nudge (line 6993,
'You just executed tool calls but returned an empty response...')
to _EMPTY_TOOL_RESPONSE_NUDGE constant and added it to the
classifier's recognition set. Same bug class — its
_empty_recovery_synthetic metadata flag doesn't survive SessionDB
projection either.
Test: added parametrize case for the empty-response nudge (7→8 cases).
E2E: verified byte-for-byte string equivalence for all nudge constants.
* feat(desktop): resolve a session's pull request
A session row can say whether its work is open, merged or closed, and link
to it. The join is the session's own repo + branch, asked of GitHub in one
batched GraphQL request per repo (branch aliases, not a `gh pr list` page
that a busy repo crowds ours out of), through the remote-aware git facade so
a desktop on a remote gateway asks the backend's `gh`.
Two ways a session's branch can't answer, both covered:
- It ran on trunk. Fork PRs share our branch namespace, so asking about
`main` badges a stranger's PR onto it — trunk is never asked about, and
cross-repository PRs are dropped server-side either way.
- It worked in a worktree, so the branch it recorded at start isn't where
the PR came from. Creating a PR from the review pane binds the session to
the branch it actually used, and for sessions that predate that, the PR is
recovered from the transcript: `gh pr create` prints a bare PR url and
nothing else, so a tool result whose whole output is one is a claim rather
than a mention. Scanned read-only across profiles, once per session ever.
* refactor(desktop): one profile glyph
The rail, the profiles page and the session-row chip each drew the same
tinted initial square from scratch, so a row tag could disagree with the
rail about a profile's color. One component owns the square, its tint, and
the home icon the default profile gets instead of a letter.
* feat(desktop): sidebar filter menu
The sessions header's project/list toggle was one binary choice standing in
for a view. It becomes a menu: group by date, project or status; order by
updated, created, status, tokens or cost; show tokens, cost, PR, profile or
an always-visible timestamp per row; filter by status, pull request, project
or archived. Everything persists, and one reset puts it all back.
The pieces that make it read right:
- Status groups reuse the date dividers rather than inventing a second
separator, and a magnitude sort (tokens, cost) drops the calendar
entirely — "Today" above the priciest session you have ever had is a lie.
- Row metadata shares the trailing slot the kebab covers on hover, so only
the last fact steps aside and the number you switched on stays readable.
- A filter deepens the loaded page to 300 rows and hands the window back
when cleared, so "merged PRs" doesn't quietly answer for the last 50.
- Archived is a view of its own set, and dragging is still what picks a
manual order — the menu only offers a way back out of one.
* test(desktop): stub repoStatusForCwd in the review store tests
Binding a new PR to its session reads the repo's live branch, which the
suite's coding-status mock didn't provide.
* fmt(js): `npm run fix` on merge (#82468)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* perf(gateway): stop spawning git for paths that cannot answer
The project tree probes every distinct session cwd, and on a long-lived
history most of those directories are deleted worktrees — `git -C` there
can only fail, at the price of a fork each. Stat first.
The second elision is `common_repo_root`: only repos have a common dir,
and the parallel warm never covers that probe because `resolve()` reaches
it only for cwds that already resolved. Every non-repo cwd was therefore
paying a serial `git` spawn on the discovery pass.
* perf(gateway): quit reading system prompts the project tree discards
`_project_tree_row` keeps about eighteen fields and drops the rest, but
the query behind it selected `s.*` plus the resolved system prompt — 37MB
of blob per build on my session history, read out of the B-tree and then
thrown away.
* perf(gateway): warm every path the project tree will resolve
The warm covered session cwds, but build_tree also resolves each declared
project folder and each discovered repo root. Those were the last probes
running one directory at a time while the sidebar showed a skeleton.
* fix(agent): keep interrupt scaffold off the tool-tail redirect placeholder
The incomplete #73146 else branch still wrote the interrupt checkpoint into
the placeholder assistant row. Mid-tool steers then replayed that scaffold as
the model's own prior reply, which it echoed into a self-replicating ghost
loop. Carry the scaffold only on the user correction's api_content, matching
the assistant-tail branch.
* fix(agent): drop legacy interrupt-scaffold ghost rows from API replay
Sessions already poisoned by the incomplete #73146 else branch still replay
hidden assistant rows whose content is the raw interrupt scaffold. Skip those
rows when building provider messages so old state.db history cannot keep
seeding the echo loop.
* fix: move ghost filter before alternation repair + promote scaffold constant
Move the legacy ghost-row filter from inside the api_messages loop to
BEFORE repair_message_sequence_with_cursor. Dropping a ghost assistant
row between two user messages creates user→user which the repair can
now fix (previously the repair ran first and missed it).
Promote '[This response was interrupted by a user correction.]' to
module-level _INTERRUPT_SCAFFOLD_MARKER constant — used in both
_apply_active_turn_redirect (checkpoint_parts) and the ghost filter,
so they can never drift.
Update ghost-row test: the two consecutive user messages are now
merged by repair, so check for content as substring.
* fix(gateway): make the restart-loop breaker see slow crash cycles (#81642)
The auto-resume restart-loop breaker (#30719, defense-3) pruned its boot
log against an absolute `window_seconds` (default 60s). That prune is
period-sensitive: a crash cycle slower than the window drops its own
history on every boot, so the counter never leaves 1 and the breaker can
never trip, no matter how long the loop runs.
The cycle reported in #81642 is ~150s — a wedged event loop, the liveness
watchdog hard-exiting at ~90s, a supervisor respawn, and auto-resume
replaying the same session that wedges it again. Structurally invisible to
a 60s window: `gateway/restart_loop.json` kept a single timestamp across 15
kills in one morning. Because every cycle leaves a gateway that cannot
process SIGTERM, `hermes update` has no drainable gateway to stop, which is
the reported hang.
Chain boots on the inter-boot GAP instead of an absolute window: two boots
belong to the same loop when they are no more than `max_gap_seconds` apart
(default 300s, floored by `window_seconds` so widening the window never
makes the breaker less sensitive). The verdict becomes period-agnostic —
the original ~10s respawn loop still trips in 3 boots, and so does a 150s
one — while a boot after real quiet resets the chain, so occasional
operator restarts still never accumulate. The persisted chain is capped at
50 entries.
- gateway/restart_loop_guard.py: gap-chained pruning (`_chain_ending_at`),
`DEFAULT_MAX_GAP_SECONDS`, `max_gap_seconds` kwarg on the three entry
points, clock-step tolerance, bounded state file
- gateway/run.py: `_restart_loop_guard_config` reads and returns
`max_gap_seconds`; the auto-resume call site passes it through
- hermes_cli/config_defaults.py: `gateway.restart_loop_guard.max_gap_seconds`
Tests: 7 new cases in TestRestartLoopGuard covering the slow cycle, chain
persistence, quiet-period reset, the #30719 fast loop, the config knob, the
window floor, and the disabled breaker. Verified RED before the fix (the
slow-cycle case asserted `[1300.0] == [1000.0, 1150.0, 1300.0]`, exactly
the single-timestamp state file from the report) and GREEN after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(process-registry): keep CLI workers off controlling tty
* fix(process-registry): bind gateway scope identity to pid
* refactor: clean up gateway scope identity predicate and tests
- Remove dead use_systemd_scope = False assignment (leftover from
the old try/except pattern, immediately overwritten).
- Update stale log label supervisor= -> in_supervised_gateway=
to match the renamed variable.
- Convert autouse _mark_gateway_process fixture to opt-in
_gateway_identity so negative tests start from a clean slate
instead of undoing the fixture's env/PID mocks.
- Parametrize 4 near-duplicate negative tests (2 scenarios x
pipe/PTY) into 2 parametrized tests, reducing ~130 lines to ~80.
76 tests pass, ruff clean, net -32 LOC.
* fix(compression): preserve live tail before snapshot adoption
* chore: AUTHOR_MAP for afgl_mk93@icloud.com (PR #81851)
* fix(gateway): shield fatal-error handler from carrier task cancellation
When an adapter escalates a retryable fatal error from inside one of its
own tasks (e.g. Telegram's _polling_error_task after exhausting polling
network retries), the gateway's _handle_adapter_fatal_error tears the
adapter down via disconnect() — which cancels that very task. The
propagating CancelledError killed the handler between popping the
adapter from the adapter map and queueing the platform in
_failed_platforms, leaving a zombie gateway: process alive, zero
connected platforms, zero pending retries, until a manual restart.
Run the handler as a detached task under asyncio.shield so carrier
cancellation no longer aborts teardown/queueing mid-flight. The carrier
still observes CancelledError (teardown semantics unchanged); only the
handler is protected. A done-callback consumes the detached task's
exception to avoid 'Task exception was never retrieved' noise.
Fixes #81335
* fix: store strong ref to detached fatal handler task to prevent GC
asyncio.ensure_future(result) creates a task with only a weak ref in
the event loop's task table. After the carrier raises CancelledError,
the local 'task' variable goes out of scope and the loop can GC the
handler before it finishes — the exact 'handler killed mid-flight'
class we are fixing, just via GC instead of cancellation.
Add _detached_fatal_tasks set on BasePlatformAdapter (matching the
gateway-level pattern in _handle_adapter_fatal_error). Uses getattr
fallback for test stubs built via object.__new__().
* fix(personality): single-owner personality state + one-time reset migration
Personality persistence used to be split per surface: the TUI/desktop wrote
the NAME to display.personality while the CLI/gateway wrote rendered TEXT
into agent.system_prompt (and their /personality none only blanked the
text, leaving the name behind). When #81946 made display.personality
authoritative everywhere, stale names written long ago resurrected
personalities users had turned off - kawaii defaulting on after updating.
- hermes_cli/personality.py: new single owner of personality state.
Built-in personality definitions, neutral-name normalization, rendering,
availability (built-ins overlaid by agent.personalities), overlay
resolution, and the ONLY sanctioned persistence path
(persist_personality -> display.personality; never agent.system_prompt).
- v34 config migration: one-time reset of display.personality to none
(announcing which personality was cleared and how to re-enable), plus a
scrub of agent.system_prompt when it verbatim-equals a known personality
render (machine-written by the old CLI/gateway). Hand-written manual
prompts are never touched.
- All surfaces rewired through the module: CLI /personality (incl. active
marker in the list), gateway /personality, TUI config.set + slash path
(which previously applied without persisting), TUI config.get (reports
the EFFECTIVE personality), completer, hermes config display, and the
tui_gateway health probe.
- cli.py/config duplicates removed: built-ins now defined once; the
desktop mirrors them from one lib module (src/lib/personalities.ts).
- Docs updated: selection lives in display.personality, built-ins always
available, one-time reset note.
* chore: remove old plan files
* fix(gateway): make session identity durable so chat continuity survives crashes and restarts
Root cause of #82616: gateway session identity (session_key/chat_id/
origin_json) was written best-effort in a separate UPDATE after row
creation, both reset-path DB writes swallowed failures silently
(logger.debug / bare print), transcript reads ignored the reroute map
that writes follow, and restart recovery ranked candidate rows by
started_at while hard-rejecting empty rows. A single failed write could
therefore strand the live conversation in an unroutable orphan row while
a days-old zombie kept the routing key — after any gateway restart the
chat silently resumed the zombie (user-visible context loss, 5 confirmed
incidents on one install since June).
Four class fixes:
1. Identity lands atomically in the session INSERT: origin_json and
display_name join _insert_session_row's column list + COALESCE
backfill; both gateway creation paths (get_or_create + reset) pass
full identity including parent_session_id lineage (fixes #12857).
2. record_gateway_session_peer self-heals: when the target row is
missing (failed/deferred create, crash window) it INSERTs the row
with full identity instead of silently no-opping — every per-turn
peer refresh is now a repair opportunity, and an identity-less lazy
writer (update_token_counts/record_auxiliary_usage) can never leave
a gateway session permanently unroutable.
3. load_transcript follows the write-side reroute chain and the durable
compression tip before querying, so reads can no longer return 0
rows for a session whose messages live under its compression child;
read exceptions are WARNING, distinguishable from an empty result.
4. find_latest_gateway_session_for_peer ranks by
COALESCE(last_activity_at, started_at) (message-bearing rows first)
and returns an empty-but-keyed row instead of None — a zombie
predecessor can no longer beat the live conversation, and recovery
never mints a fresh id when a keyed row exists.
Reset-path DB write failures now log at WARNING with the routing
consequence spelled out.
Tests: tests/gateway/test_session_continuity_82616.py (11 tests) —
sabotage-verified: 6/11 fail without the fixes. E2E incident replay
(real SessionDB, temp HERMES_HOME) confirms the production shape now
resolves to the live session.
Fixes #82616. Related: #12857, #78182 (read-path half), #79576.
* ci: move the review comment and the image build out of the CI run
The CI run stayed in progress until its last job ended. Two advisory jobs
set that time: the review-comment poller (40 minutes) and the Docker image
build (45 minutes). Neither job was required to merge.
GitHub refuses `gh run rerun` on a run that is in progress. Thus a reviewer
who added the `ci-reviewed` label had to wait for the two slow jobs, and
label-rerun.yml carried a 2100-second wait loop for this reason. The fast
required jobs were ready long before.
Each slow job now runs in its own workflow:
- docker.yml owns its `pull_request` trigger and does its own change
detection. The new `detect` job runs the same composite action with the
same condition that ci.yml applied, so a tests-only PR still skips the
build. The `workflow_call` trigger is gone.
- ci-review-comment.yml starts on `workflow_run` when CI starts. It reads
the workflow and the scripts from the default branch, which is the trust
boundary that the old job got from its `ref: default_branch` checkout.
The poller reads job results through the API, so it can report on a run
that it does not belong to. `WATCH_WORKFLOWS` names sibling workflows for
the same commit, and `select_watched_runs` keeps the newest run for each
name. Thus the comment still shows the Docker results. The list is
newline-separated, because a workflow name can contain a comma.
The poller always exits 0 now. It reports on the CI run from a different
run, so a failed CI job is not a failure of the poller. The CI run has its
own gate for that.
Also correct a parse error in label-rerun.yml. STATUS came from the already
truncated RUN_ID, so its value was the run id and never "completed". Thus
the wait branch always ran.
ci.yml no longer needs `packages: write`, because the image build has left.
* fix(personality): preserve config comments in TUI/gateway config writes
tui_gateway/server.py:_save_cfg called yaml.safe_dump on a deep-loaded
config dict, which reordered top-level keys alphabetically, stripped
every user-edited comment, and re-escaped non-ASCII (kaomoji/Chinese)
personality prompts to \uXXXX. Every TUI setting change - /personality,
/reasoning, /details_mode, /skin, /prompt - rewrote the file top to
bottom.
Changes:
* Add atomic_roundtrip_yaml_save(path, new_state) in utils.py - a
comment-, ordering-, and unicode-preserving full-state replacement
for yaml.safe_dump(cfg, f). Uses ruamel round-trip mode like the
existing atomic_roundtrip_yaml_update, but accepts the whole cfg
dict so callers that mutate multiple keys before saving (the
_save_cfg pattern) don't have to be rewritten. Recurses into nested
dicts, deletes keys missing from new_state (preserves the
cfg.pop()-then-save semantic), and overwrites lists/scalars
wholesale.
* Fail closed on an unreadable existing config.yaml the same way
hermes_cli.config.atomic_config_write does, via a lazy import of
require_readable_config_before_write (avoids a module-level circular
import, since hermes_cli.config itself imports from utils). Also
preserves both file mode and owner across the write, matching the
existing atomic_roundtrip_yaml_update contract.
* Force-quote any new string value that YAML 1.1 would misparse as a
bool/null (yes/no/on/off/true/false/null/~). ruamel's round-trip
dumper resolves against the YAML 1.2 core schema and emits these
unquoted, but PyYAML-based readers elsewhere in the codebase parse
under YAML 1.1 rules - so an unquoted `approvals.mode: off` would
silently round-trip back as the boolean False.
* tui_gateway/server.py:_save_cfg now delegates to
atomic_roundtrip_yaml_save. Drop-in - all call sites (/personality,
/reasoning, /details_mode, /prompt, etc.) inherit comment
preservation and the fail-closed contract.
Tests:
* tests/test_utils_atomic_roundtrip_yaml_save.py - unit tests covering
create-from-empty, top-level key-order preservation, comment
preservation, readable Unicode, append-new-keys, delete-missing-keys,
scalar/list overwrite, nested-dict recursion, refusal on an
unreadable existing config, and owner preservation.
* tests/test_atomic_replace_symlinks.py - owner-preservation regression
test mirroring the existing atomic_roundtrip_yaml_update coverage.
* tests/test_tui_gateway_server.py - 4 new tests pinning _save_cfg
comment preservation, top-level key-order preservation, and
unicode-readability under unrelated writes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(tui): fix unreadable session-title chip contrast in the status bar
Fixes #82465.
The session-name chip at the right end of the TUI status bar rendered
white/near-white text (t.color.statusFg) on a raw, full-saturation
accent-hue background (t.color.accent, #FFBF00 -- bright yellow -- in
DARK_SEEDS). Two token problems stacked: accent is the accent
IDENTITY hue, never used elsewhere as a solid fill (fills are always
softened, e.g. activeRow = mix(surface, accent, 0.22)); and statusFg
is derived as a light gray lifted toward near-white text, a tone never
designed to sit on a saturated fill. Together: roughly 1.5-2:1
contrast, unreadable on the default dark theme.
Applied the issue's recommended first option: drop the background fill
entirely and render the title as accent-colored text on the normal
status bar background. Same highlight intent (the title still stands
out via its color), readable contrast on both dark and light seeds.
Updated the existing test that had encoded the buggy background-fill
expectation, and added an explicit contrast-regression assertion.
Verified as a genuine regression by reverting the fix and confirming
the test fails with the exact reported #FFBF00 background color.
54/54 pass across the three appChrome-related test files (no
regression).
* fix(state): recover gateway sessions stranded without a routing identity
When state.db's write path fails (corrupt FTS, or a crash landing between
routing publication and row creation), the live gateway conversation can end
up in a session row that never received its identity columns: session_key,
chat_id, chat_type and origin_json are all NULL. In-memory routing hides the
damage for as long as the gateway stays up. After a restart the chat is
resolved from the DB, and find_latest_gateway_session_for_peer cannot see
that row — both of its queries match on the very columns it lacks — so the
chat resumes the last keyed sibling instead, days older. The messages were
never lost, only unreachable.
Hardening the write side cannot reach a row that is already damaged, so add
the offline repair path the tracking issue asks for:
- SessionDB.find_orphaned_gateway_sessions() reports message-bearing rows
with no session_key, and names the predecessor each one continues only
when the evidence is unambiguous — a recorded parent_session_id
("lineage"), or exactly one keyed row of the same source and compatible
user_id that fell quiet within 15 minutes of the orphan's start
("contiguity"). Contested pairs are reported with a reason and left alone:
a wrong adoption would splice one person's conversation into another
person's chat. Branch, delegate and tool rows are excluded — they are
unkeyed by design, not by damage.
- SessionDB.adopt_orphaned_gateway_session() stamps the orphan from the
predecessor (never overwriting a column that already has a value), records
the lineage, and retires the predecessor under end_reason
'superseded_by_repair' — a reason recovery does not treat as resumable, so
the repaired row wins the chat from then on. The pair is re-verified inside
the write transaction, making a concurrent heal a no-op rather than a
conflicting write.
- `hermes sessions repair-routing` drives both. It reports without touching
the database; --apply confirms first and warns that a running gateway
still holds the old mapping in memory.
Refs #82616.
* fix(gateway): spool cap-dropped pending transcript messages instead of discarding
When the per-session pending transcript queue hits _MAX_PENDING_PER_SESSION
(200) while the session DB is broken, the gateway previously popped the
oldest message and discarded it permanently — silent user data loss during
live operation (#78182). The on-disk pending spool only ran at shutdown via
flush_pending_to_file.
Extend that existing spool machinery for runtime drops:
- gateway/shutdown_flush.py: add spool_dropped_transcript_message() and
drain_transcript_spool(), reusing _get_flush_dir/_write_payload (same
atomic-JSON pending_messages/ spool format). recover_pending_to_db()
now also replays transcript_cap_drop payloads left over across restarts.
- gateway/session.py: on cap eviction, spool the dropped message and log a
WARNING that includes the spool path; if spooling fails, degrade to the
previous drop-and-warn behavior. On the next fully successful transcript
flush for that session, drain and replay spooled messages in drop order;
replay failures keep the spool files for the next attempt.
- tests/gateway/test_pending_queue_spool.py: drop→spool→drain roundtrip,
per-session drain isolation, spool-failure degradation, replay-failure
retention, and spool primitive ordering/reason filtering.
No new config; extends existing flush_pending_to_file infrastructure per
AGENTS.md guidance.
Refs #82616, #78182
* fix(state): keep canonical writes available when FTS is corrupt
* fix(docker): per-session container isolation and session-scoped workspace mounts
Two bugs reported on the docker terminal backend (desktop app, sandboxed
profiles with container_persistent: false):
1. A NEW chat's container inherited the PREVIOUS session's workspace,
bind-mounted rw at /workspace, because the mount source was the
process-global TERMINAL_CWD env var (written by the workspace picker,
outliving its session) and all sessions shared one 'default' container.
2. Every command failed with exit 126 because the desktop gateway recorded
the HOST launch directory as the session cwd, and each command was
prefixed with 'cd /Users/<user>/...' inside the container.
Fixes (class-wide, single owners):
- container_persistent: false + docker now keys containers PER SESSION:
fresh container per chat, removed at session close/idle. delegate_task
children share the parent's container via an explicit alias registry.
container_persistent: true keeps the documented ONE-long-lived-container
contract unchanged.
- _resolve_task_host_cwd() is the single owner of the cwd->/workspace mount
policy across all four env-creation sites; under isolation it refuses
process-global cwd sources and mounts only the session's own attached
workspace (tui_gateway now tags overrides with cwd_source).
- _resolve_command_cwd() gains the same host-path guard the env-creation
sites already had (#50636/#54447 sibling site): a recorded host cwd is
discarded on container backends instead of cd-ing every command into a
nonexistent path.
E2E-tested against real Docker: distinct containers per session, no stale
mount in a fresh session, no exit 126 from host cwd records, containers
removed at session teardown.
* Port from code-yeongyu/oh-my-openagent: ast-grep structural search/codemod optional skill
Vendors the ast-grep skill from oh-my-openagent's shared-skills bundle
(upstream code-yeongyu/ast-grep-skill @ 3148c69, MIT) into
optional-skills/software-development/ast-grep with Hermes conventions:
- SKILL.md rewritten with Hermes frontmatter (platforms, tags, category)
and Hermes tool routing (search_files instead of raw rg, terminal for
sg invocations, patch-vs-ast-grep division of labor)
- scripts/ast_grep_helper.py: fixed argparse so trailing paths after an
optional flag parse (parse_known_args + fold extras into paths);
upstream errored 'unrecognized arguments: .' on the documented
'search PATTERN --lang js .' form
- 7 reference docs, install.sh/install.ps1 (pinned-release GitHub
fallback), smoke tests carried over verbatim
E2E validated: install (github method, ast-grep 0.45.0), doctor,
search, validate (regex rejection), replace dry-run + apply two-pass,
scan with YAML rule, tests/smoke.sh 15/15 pass.
* fix(desktop): send full tool args so expanded rows show the whole command
The gateway sent only an 80-char preview (context) for a tool call.
The desktop rebuilds the expanded tool row from the args of the part.
When the args were absent, the row showed the preview, and long
commands ended in '...' after the user expanded them.
Two paths had this fault:
- tool.start: the payload had no args until tool.complete, so the
expanded row was truncated while the tool ran. Now tool.start ships
the args, the same as tool.complete already does.
- _history_to_messages: the projection read the full arguments, then
discarded them. Hydration from this projection (watch windows,
compress, branch, seeded create) kept only the preview, so the
truncation was permanent. Now tool rows carry the args. This
projection is the display view of the transcript — each renderer
decides what to paint, and the preview stays for collapsed titles.
The DB rows do not change: the args already persist in tool_calls.
* fix(skills): trim ast-grep description to the 60-char hardline
test_authoring_standards.py::test_description_hardline red on main since
461c493972 landed with a 383-char description. The trimmed detail is all
preserved in the SKILL.md body (When-to-use, decision tree, search_files
comparison). Unbreaks every open PR's slice 4.
* fix(gateway): carry chat_id/thread_id/session_key into /branch child sessions too
Same defect as the compression-rotation fix in the prior commit, found
during a full-audit of every create_session() call site per the repo's
'fix the whole bug class, sibling call paths included' contribution
guidance.
_handle_branch_command() (gateway/slash_commands.py) creates the branched
child session via create_session() without chat_id/chat_type/thread_id.
The routing columns are only backfilled later, when switch_session() runs
at the end of the function and calls _record_gateway_session_peer(). In
between, the function copies the parent's conversation history to the new
session_id one message at a time, with each append_message() call
independently try/excepted (best-effort) — a crash/kill anywhere in that
window leaves the branched session permanently unroutable, same failure
mode as the compression bug: NULL chat_id/thread_id can never be found by
find_latest_gateway_session_for_peer, AND unreachable via /resume's IDOR
guard (which requires the row's chat_id/thread_id to match the caller's).
Fix: forward source.chat_id/chat_type/thread_id at create_session() time,
mirroring the existing correct pattern already used by /title's
auto-create path a few hundred lines up in the same file (which has an
explicit IDOR-scoping comment justifying it).
Tests: tests/gateway/test_branch_routing_columns.py drives the real
_handle_branch_command against a real SessionStore + SessionDB (SQLite in
tmp_path, no DB/session-store mocks). Patches switch_session to simulate a
crash landing before it runs (the exact gap the routing columns need to
survive), then asserts the branched child's chat_id/chat_type/thread_id
are already correct in state.db at that point. RED verified against
unpatched code (assert None == '170829464'), GREEN after the fix.
Regression: 102/102 across the new test + pre-existing /branch, session
boundary, compression rotation, DM thread seeding, session API, and
resume-command suites. Broader tests/gateway/ -k "branch or session_api or
resume or topic_mode or session_boundary" sweep: 255/255 passed, 1
(unrelated) skip.
* fix(gateway): also persist user_id and session_key in child-session creates
The sweeper flagged two gaps in the routing-columns fix:
1. /branch create_session() omitted user_id and session_key — the
fallback lookup path (find_latest_gateway_session_for_peer) requires
user_id to match the complete peer tuple when session_key lookup fails,
and /resume IDOR guards reject sessions without matching user_id.
2. Compression-rotation create_session() omitted agent._user_id — same
problem: rotated child cannot satisfy persisted /resume ownership proof
before the later gateway backfill.
Forward user_id and session_key at CREATE time in both call sites so
the child row is immediately fully routable with zero backfill gap.
Extended tests: compression rotation asserts user_id is carried (and None
for CLI sessions). Branch routing asserts both user_id and session_key on
the child row before switch_session runs.
* fix(gateway): carry origin_json/display_name into /branch child sessions too
Complete the /branch routing-identity fix (salvaged from PR #62278 by
@jcjc81): in addition to user_id/session_key/chat_id/chat_type/thread_id,
forward origin_json and display_name at create_session() time, matching
the reset-path db_create_kwargs pattern (#82633) so the branch row is
born with full identity — no backfill gap for state.db consumers
(mcp_serve, mirror, channel directory) if a crash lands before
switch_session().
The obsolete compression-rotation half of #62278 was dropped: rotation
now goes exclusively through publish_compression_child, which already
copies all identity columns in-transaction.
* fix(gateway): distinguish durable cached transcript rows
* chore: map TomAce7 contributor email for attribution audit
* fix(gateway): respect reset boundaries during recovery (#68539)
find_latest_gateway_session_for_peer filtered non-recoverable rows out of
candidacy BEFORE ordering, so recovery could search behind a /new reset
boundary and resurrect an older still-open row for the same peer —
silently restoring the exact context the user reset.
Rebuilt against the #82633 finder (has-messages ranking +
COALESCE(last_activity_at, started_at) recency): the fence is expressed
as a NOT EXISTS guard inside both the exact-key and peer-fallback
queries — a candidate is rejected when an intentional boundary row
(session_reset / session_switch / idle / daily / suspended /
resume_pending_expired) for the same peer ended after the candidate's
last activity. If the conversation's most recent event is an intentional
reset, recovery returns nothing rather than reaching behind it.
Cherry-picked from #68617 and adapted to the rewritten finder.
(cherry picked from commit bb2c562a165d91e00f64d42cf7495e6c8a5da9d7)
* fix(gateway): honor session_reset policy when recovering sessions
Both session recovery paths (the startup stale-entry repoint and the
lazy in-message recovery) rebuilt the routing entry with updated_at=now
and never consulted _should_reset, so an opt-in idle/daily session_reset
policy was silently dead across any gateway restart: a recovered session
always looked freshly active, and since every subsequent message bumps
updated_at, a session recovered stale could then never age out at all.
Fix in three parts:
- _create_entry_from_recovered_row derives updated_at from the durable
last_activity_at the finder already returns on the row (no extra DB
round-trip; the original PR added SessionDB.get_last_activity for
this, unnecessary post-#82633), falling back to created_at. An
invalid or missing started_at now maps to epoch 0 instead of now — an
invalid durable timestamp must look old, never freshly active.
reset_had_activity is set from the row's durable activity/message
signals so the continuity hint stays accurate.
- _recover_session_from_db evaluates _should_reset on the rebuilt entry:
an overdue session is durably promoted to a reset boundary
(promote_to_session_reset, falling back to end_session) and the stale
mapping is dropped instead of repointed.
- _query_recoverable_session no longer reopens the row; the
get_or_create_session recovery phase evaluates _should_reset first and
either feeds the normal auto-reset create path (reset notice,
prev_session_id continuity, durable promotion) or reopens and
publishes the recovered entry exactly as before.
Behavior is unchanged under the default session_reset mode "none":
_should_reset returns None there, so recovery still resumes every
recoverable row — only users who opted into idle/daily resets see the
policy actually applied across restarts.
Cherry-picked from #78618 and adapted to the #82633 finder.
(cherry picked from commit 31c71f762961638c199287fc6ffe836115c4892b)
* chore: map contributor email for hillimited
* fix(desktop-ssh): stop resolving exec-wrappers to python in locateHermes (#74411)
Problem 1: resolveLauncher() read bash 'exec <python> <script>' wrappers
and returned ONLY the python interpreter path, discarding the script.
This made probeHermesVersion() run '<python> --version', which always
printed 'Python x.y.z' instead of the Hermes version. And
remoteSupportsSshOwnership() ran '<python> serve --help' which failed
entirely because no 'serve' module exists in the python stdlib.
Problem 2: When the user set remoteHermesPath (an explicit override),
resolveLauncher() resolved it to the python interpreter, replacing the
user's specified path. The override was effectively ignored for version
checking and capability probing.
Fix: resolveLauncher now returns the candidate path directly. The hermes
binary or wrapper script is already executable and handles argument
forwarding (e.g. 'exec <python> <script> "$@"') correctly on its own.
No additional remote SSH round-trip or python script needed.
* test(desktop-ssh): cover wrapper preservation and explicit-path passthrough in locateHermes
Replaces the canonicalization test (which pinned the behavior #74425
removes) with wrapper-preservation coverage for auto-detection and an
explicit remoteHermesPath, both asserting no python3 -c parser call is
issued. Verified both fail against the pre-fix implementation.
* fmt(js): `npm run fix` on merge (#82771)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(desktop): make un-highlighted code readable while streaming in light theme
streaming code blocks in the light theme render near-white text on the
white code card until shiki's highlight lands, then snap to normal token
colors. the pale text is @tailwindcss/typography's pre foreground: its
prose theme styles pre as a dark slab (--tw-prose-pre-code = gray-200 on
a gray-800 bg). we strip the bg for our own code card but the near-white
foreground survives on the container. shiki's opaque per-token span
colors normally hide it — it shows through wherever text renders without
spans: the streaming delay window, the lazy-chunk suspense fallback, and
over-budget blocks that never highlight.
traced on the live renderer: computed color on the wrapper of mid-stream
code was oklch(0.928 0.006 264.531) (gray-200), supplied by the
.prose :where(pre) rule.
fix: prose-pre:text-foreground on the markdown container, so every
fenced path inherits the transcript foreground instead. the utility
layer is emitted after typography's base rule in the built css, so the
override wins by order at equal specificity.
* test: run os-specific tests on their real host, not a faked one
many tests patched sys.platform or a module's _IS_WINDOWS flag, then
ran on linux ci. the patch selects the branch under test, but the host
does not have the behavior the branch exists for. the test proves the
patch, not the platform. some gated assertions never ran on any host.
this commit adds three markers: linux_only, macos_only, windows_only.
a conftest hook skips a marked test on the other hosts, with a clear
reason. no test fakes a host now. two documented fakes remain
(android/termux, freebsd) because no ci runner exists for them.
each fake site got one of four treatments:
- gate it: the real host supplies the platform; mocks cover real
dependencies only, never host identity
- patch the module's own probe when the subject is the probe's consumer
- assert against the real host when the fake stood in for any non-x host
- delete the patch when it set the value the host already has
bare skipif(sys.platform != ...) guards became markers too. the lane
model skips these on linux and never imports them on windows, so they
ran on no host. platform parametrize tables are now one marked test
per os.
running on real hosts found real errors: a chrome-sandbox failure in
test_gui_comm…
blut-agent
pushed a commit
to blut-agent/hermes-agent-fork
that referenced
this pull request
Aug 11, 2026
…usResearch#82325) * fix(desktop): open HUD mode on the focused conversation's profile The HUD is a full app renderer that adopted the PRIMARY backend's profile at boot, so toggling HUD mode from a conversation on any other profile resolved the session id against the wrong backend — the lookup missed and the HUD fell back to the default profile's last session (NousResearch#82285). - openHud() resolves the target's owning profile (session's stamped owner, else the active gateway profile) and passes it through hermes:hud:open. - hudUrl() carries the profile in the query string next to win=hud; the HUD renderer's gateway boot honors it as an override for both getConnection() and profile adoption, so the window dials and adopts the right backend from first paint. - Retargeting a live HUD onto a session from a DIFFERENT profile respawns the window against that profile's backend (a renderer adopts its backend exactly once at boot; an in-place goto would repeat the wrong-backend lookup). No profile in the URL means no override — ordinary windows and single-profile users boot exactly as before. * refactor(desktop): extract the HUD renderer URL so its contract is tested hudUrl() built the query string inline in main.ts, where the part that actually breaks — `?win=hud&profile=` must sit BEFORE the '#' or HashRouter eats it as the route — had no coverage. Move it next to buildSessionWindowUrl's split (pure piece out of the monolith, unit tested) and pin the contract: flag order, profile encoding, trailing slash on the dev server, empty profile omitted, packaged file URL. Co-authored-by: rainbowgore <rainbowgore@users.noreply.github.com> * refactor(desktop): resolve the HUD's target profile through the existing ladder openHud() had its own copy of "stamped owner, else active gateway, else default" — the same ladder rememberedSessionProfile() already owns for the remembered-navigation key, down to sessionMatchesStoredId and the default fallback. One resolver per policy, so the two can't drift. --------- Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Co-authored-by: rainbowgore <rainbowgore@users.noreply.github.com>
33hodl
pushed a commit
to 33hodl/hermes-agent
that referenced
this pull request
Aug 12, 2026
…usResearch#82325) * fix(desktop): open HUD mode on the focused conversation's profile The HUD is a full app renderer that adopted the PRIMARY backend's profile at boot, so toggling HUD mode from a conversation on any other profile resolved the session id against the wrong backend — the lookup missed and the HUD fell back to the default profile's last session (NousResearch#82285). - openHud() resolves the target's owning profile (session's stamped owner, else the active gateway profile) and passes it through hermes:hud:open. - hudUrl() carries the profile in the query string next to win=hud; the HUD renderer's gateway boot honors it as an override for both getConnection() and profile adoption, so the window dials and adopts the right backend from first paint. - Retargeting a live HUD onto a session from a DIFFERENT profile respawns the window against that profile's backend (a renderer adopts its backend exactly once at boot; an in-place goto would repeat the wrong-backend lookup). No profile in the URL means no override — ordinary windows and single-profile users boot exactly as before. * refactor(desktop): extract the HUD renderer URL so its contract is tested hudUrl() built the query string inline in main.ts, where the part that actually breaks — `?win=hud&profile=` must sit BEFORE the '#' or HashRouter eats it as the route — had no coverage. Move it next to buildSessionWindowUrl's split (pure piece out of the monolith, unit tested) and pin the contract: flag order, profile encoding, trailing slash on the dev server, empty profile omitted, packaged file URL. Co-authored-by: rainbowgore <rainbowgore@users.noreply.github.com> * refactor(desktop): resolve the HUD's target profile through the existing ladder openHud() had its own copy of "stamped owner, else active gateway, else default" — the same ladder rememberedSessionProfile() already owns for the remembered-navigation key, down to sessionMatchesStoredId and the default fallback. One resolver per policy, so the two can't drift. --------- Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Co-authored-by: rainbowgore <rainbowgore@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
HUD mode now opens on the focused conversation's own profile instead of silently rebinding to the default profile's backend.
Fixes #82285: on a multi-profile desktop, toggling HUD mode (Ctrl+Shift+H / titlebar button) from a conversation on profile X booted the HUD against the primary (default) backend, the session id wasn't found there, and the HUD fell back to the default profile's last session — showing a different conversation than the one in focus.
Supersedes #82310 by @rainbowgore, which fixed the same bug with the same query-string mechanism 28 minutes earlier. Its
hud-url.tsextraction and URL-contract tests are carried over here.Root cause
The HUD is a full app renderer, and
hudUrl()carried only?win=hud+ the session route — no profile. The HUD's gateway boot then ranadoptPrimaryProfile(), which always adopts the primary backend's profile, so the target session id was resolved against the wrong backend.Changes
src/store/hud.ts:openHud()resolves the target's owning profile and passes it throughhermes:hud:open. The ladder isrememberedSessionProfile()— the resolver the remembered-navigation key already uses — so stamped owner wins, and an unstamped/uncached target inherits the profile the user is looking at.electron/hud-url.ts(new, from fix(desktop): hand HUD mode the active profile, not just the session id #82310):buildHudWindowUrl()owns the URL shape, next tobuildSessionWindowUrl's split. The load-bearing part is that?win=hud&profile=sits before the#— anything after it is the HashRouter route.electron/main.ts:hudUrl()delegates to the builder; retargeting a live HUD onto a session from a different profile respawns the window against that profile's backend (a renderer adopts its backend exactly once at boot, so an in-placegotowould repeat the wrong-backend lookup).src/store/windows.ts:windowProfileOverride()reads the override fromlocation.search.src/app/gateway/hooks/use-gateway-boot.ts: boot + soft-switch honor the override for bothgetConnection(profile)(dials the pooled backend directly via the existingensureBackendladder) and profile adoption.src/global.d.ts:hud.openrequest gains the optionalprofilefield.src/store/hud.test.ts(5, resolution ladder) andelectron/hud-url.test.ts(6, URL contract — flag order, encoding, trailing slash, empty profile, packaged file URL).No profile in the URL means no override — ordinary windows and single-profile users boot exactly as before.
Validation
vitest run src/store/hud.test.tsvitest run --project electron electron/hud-url.test.tsvitest run src/app/gateway/hooks/use-gateway-boot.test.tsxnpm run typecheck --workspace apps/desktopnpm run lint --workspace apps/desktop -- --quietnpm run test:desktop:platformsnpm run test:uiFollow-up, not in this PR
win.removeAllListeners('closed')now appears at three HUD teardown sites (closeHudWindow, the app-quit path, and the cross-profile respawn). It strips the broadcast handler along with the cleanup listeners registered bystreamThrottle.registerandstartHudCursorFeed, so a destroyed window stays in the throttle set and the Linux cursor-poll interval is never cleared. Pre-existing onmain; worth its own fix.Infographic