The header said the duplicate paints "on current main" (stale once the
fix lands) and pointed at boundary "specs below" when only the
message.start spec remains. Also apply prettier to the delta helper,
which --check flagged.
collapseDuplicateFinalOntoIdenticalInterim has one caller and it sits
inside `if (streamIndex >= 0)`, so the `streamIndex < 0` check could
never fire. Note the precondition in the comment instead.
The query side reads identity through transcriptRowIds, which takes
sourceRowId from every part, while the index side still filtered on
`part.type === 'text'`. Only text parts are ever stamped, so the filter
did nothing but make the two sides use different rules; drop it.
Replace the comment that restated the rowId-vs-last-part ternary with
the reason for it: a fold with a rowId is addressed by its first source
row (hydration's key); without one, its last part row is the final
reply the settled live bubble holds, so pending rows land before it.
No behaviour change.
identicalInterimSiblingIndex now returns -1 for a failed completion, so
the stream path and the fallback settle path share one failure gate
(previously the fallback path settled a failed completion onto the
sealed interim). collapseDuplicateFinalOntoIdenticalInterim drops its
own hasFailure option and the dead guards a non-negative interim index
already implies, and shares the merge-and-drop tail with the tool-interim
collapse via a private helper. The no-fallback branch's
`identicalInterimIndex >= 0` arm was unreachable (any such index is a
visible assistant past lastUserIndex, which makes fallbackIndex >= 0) and
is removed.
partRowAnchor reuses transcriptRowIds instead of re-deriving the row
list; the anchor choice (message row first, else the last part row) is
unchanged.
The extra `existingPartRowIndices.get(message.rowId)` clause matched a
fetched fold as soon as its FIRST row was held as a part of another
bubble, short-circuiting partRowAnchor's every-row-held rule. Any unheld
row in that fold (e.g. the final reply) was then dropped and never
rendered. For single-row messages partRowAnchor already covers the
part-row match, so the clause only added the bug.
Add an it.each row for that shape and make the table assert explicit
merged ids instead of branching on a `kept` flag.
Keep the headline spec (red without the collapse) and the message.start
occurrence-bound spec (red without the guard); the distinct-segment and
tool-row boundary specs already pass on main, so they pinned nothing new.
The part-row anchor dropped a fetched fold as soon as ANY of its text rows
was already held, so a fold carrying a tool call and narration the store
did not hold lost both (Enough1122's review on #124472). Anchor through
the part rows only when all of the fold's source rows are held; otherwise
append it as before. Also drops the 'ponytail:' note this replaces and
folds the case-B spec into the existing test as a parametrized pair.
Refs #123801
Co-authored-by: finn763 <165816600+finn763@users.noreply.github.com>
Backfill merge matched only message-level row ids. A hydrated fold is
addressed by its first source row while the final reply rides inside as
a text part sourceRowId, so an overlapping older page appended the fold
next to the settled live bubble and the reply rendered twice. Closes#123801
(cherry picked from commit 50021e8c0a668797d54db0874c6cc4662d702369)
message.start starts a new occurrence while keeping the prior messages (a
chained or prompt-less turn) and resets interimBoundaryPending. Scanning back
only to the prompt row let interim('X') -> message.start -> delta('X') ->
complete('X') match the PREVIOUS turn's interim, settle it and delete this
turn's live bubble.
Gate the scan on the same flag collapseDuplicateFinalAfterToolInterim already
requires — it is exactly 'an interim was sealed since the last message.start' —
and add the same-text restart regression (fails without the guard). Copilot
review on #126023.
(cherry picked from commit 45ced2713efa4cd92acae3783205cdf94c6f5e72)
A reply the client sealed as an interim before it had the text on screen
(the interim materialized its own bubble because a superseded attempt's
frames had cleared the live stream) is painted twice when the turn then
streams the same text and completes: the sealed interim above, the settled
bubble below, while the store holds one row (#123801).
An interim whose text IS the final text is this turn's reply, not a second
segment, so completion now settles onto it and drops the live twin. Interims
carrying tool rows are never collapsed, and a distinct segment keeps its own
bubble.
Reproduced deterministically on main; the regression test fails without the
fix and the 222-file use-message-stream / chat-messages / chat suite passes
with it.
(cherry picked from commit f2980a9c9868a41e4ef3bf216c161acbc260e61b)
The app-server turn effort was clamped to the Responses vocabulary, so a Hermes
`ultra` pick reached codex as `max` and never ran codex's Ultra harness mode.
Live on codex-cli 0.147 with ChatGPT auth, `turn/start.effort = "ultra"` runs on
gpt-5.6-sol and gpt-5.6-luna (ladders reach `max`) and fails with 400
"'max' is not supported" on gpt-5.5, so `ultra` is now kept wherever the
model's ladder reaches `max` and clamped as before everywhere else. One rule,
route_supported_efforts(..., api_mode="codex_app_server"), drives the turn and
the labels: session.info's reasoning_effort_wire (the Desktop pill) and the CLI
and gateway /reasoning status read "ultra" instead of "ultra (sends max on this
route)" there. A named custom codex provider keeps the clamp.
From #130965 (Ultra passthrough and route-aware labels), narrowed to the
models that accept it.
Co-authored-by: FroRaut <79215537+Froraut@users.noreply.github.com>
The #121865 regression test bounded ALL dials in one virtual second at 5,
but the reconnect ladder uses full jitter (delay in [0, ceiling)), so how
often the ladder itself redials depends on Math.random: a small draw gave
6 on CI, a 0.01 draw gives 12, a 0 draw 62, all with the throttle intact.
Timers never fire inside an awaited request under fake timers, so the
dials observed during each requestGatewayForAgent call are exactly the
poll's own. Assert those are zero (the #121865 bug was one per poll) and
that the ladder still redials. Green for draws 0, 0.01, 0.5, 0.999; red
when the background cooldown gate in openSecondaryForRequest is removed.
Review findings on the bot-identity fix:
- Edit Profile sent every seeded field on save, so a color change
reverted a rename made elsewhere while the dialog was open (a thin
row with no local record even cleared the backend title). The dialog
now diffs against the values it opened with (edit-profile-look.ts)
and sends only what the user changed.
- Rows retained from an earlier paint (source unreachable or
connect-on-demand) were reconciled as fresh backend truth, reverting
a newer local title during an outage. They are now tagged `retained`
and mergeServerMeta skips them and any unreachable-source row.
- A title-only answer minted an empty direct record that shadowed a
configured alias's appearance in botRosterMeta. A title now only
corrects an existing record; the row already carries it.
Two machines with a profile named `default` (local "Agent B", VPS "Agent A")
showed the VPS bot as "Agent B", told it so in group prompts, and the next
save on that row wrote `title: Agent B` into the VPS profile.yaml, from
where every relay roster republished it.
Root causes, all on the Desktop side:
- Write path: `saveBotMeta` sent the whole local cache entry as the
server's `hermes-bots` namespace. Any field the cache held and the server
did not (a restored or legacy name-keyed record) became that bot's server
truth. A save now reads the bot's namespace from its own backend, writes
that plus the save's own patch, and uses the existing per-key CAS
(`ui_meta_expected_revisions`), re-reading on conflict.
- Read path, thin rows: Electron's enumeration read `profile.title` from
`/api/profiles`, which has always named it `bot_title`, so bots on a
non-active connection never carried their backend's title, and
`mergeServerMeta` only ran over the active source's rows. Electron now
maps `bot_title` (empty string included), and reconciliation runs over
every live row: the name is whatever the bot's backend reports, and a
local title it does not carry is dropped.
- Read path, bare keys: `botFriendlyNames` read `meta[bot.name]` for any
non-remote row and `groupSpeakerLabel` fell back to `meta[profile]`, so a
scoped `default` row picked up the bare-key title another machine's
`default` wrote in single-source days. Both now read the row's own key;
the bare legacy rung applies only to unscoped rosters. Mention completion
goes through `botRosterMeta` too.
- A remote `default` with a real `display_name` no longer trades it for the
connection label.
A connection apply made while a secondary scope was in front re-dialed the
primary socket to the new machine, but setPrimaryGatewayConnectionId
refused the id (#95628 hardening), leaving primaryConnectionId naming the
machine it had left. Owner routing then sent that machine's requests over
the primary socket to the other one. Live trace before:
primary-publish refused vps keeping=local active=conn:vps::alpha
conn:vps::default via=secondary primary=local/alpha dialed=vps
Every caller publishes the window's own primary socket (boot, reconnect,
connection apply, HMR survivor); #95692 already removed the secondary
write that #95628 was about. The id is now always recorded; the ambient
request connection still moves only while the primary is in front.
With a local and a remote backend that both run profile `default`, the
Bots pane showed the remote bot's title on "This device" (and the remote
profiles as "Status unknown" ghost rows) after a primary-connection
switch, and mergeServerMeta could carry that title into bot-meta-v2.
fetchRosterSnapshot binds activeConnectionId when it is called, but it
sent profiles.list over the ambient socket resolved later. A retry or late
run of a query keyed to the connection the window just left was answered
by the backend it switched to and cached under the old key. Live trace:
published connection=vps
[bot-roster] MISROUTED active=local answered=<vps install> expected=<local install>
Now the fetch refuses when its key is no longer the published connection
(stale retries never send), and asks the keyed connection by its exact
(connection, profile) route instead of the ambient socket. Without an
exact route (older Desktop host) it keeps the ambient request.
Change-only desktop.log / agent.log lines that name which writer moved a
bot identity when two connections share a profile name:
- [gateway-route] per (connection, profile): via=primary|secondary and the
window primary; `active` on every active-scope change (key, socket
connection, profile) and `published` on every published-connection
change, so a socket/label disagreement shows up side by side.
- [bot-roster] each Bots-pane snapshot: the install that ANSWERED
profiles.list vs the install expected for activeConnectionId;
MISROUTED when they differ.
- [bot-relay] rows per connection with the answering install; MISROUTED
when two connections answer from the same install.
- [bot-meta] every $botMeta title change per scoped key.
- backend: bot_relay.roster.sync and ui_meta['hermes-bots'].title writes.
profiles.list now echoes install_id (same value as /api/status) so a
client can prove which machine answered a routed list. Additive field;
older clients ignore it. Plugins reach the tracer through
host.traceIdentityChange?.() so wholesale SDK mocks stay valid.
The onExit handler forwarded the exit to the output gate but never killed
the session, leaking /dev/ptmx handles until macOS could not allocate any
PTY (#128942). Kill the PTY in onExit (the map entry still drops on flush,
so late attach keeps working) and dispose explicitly on renderer exit.
A committed test.only/describe.only shrinks the Playwright run to a
subset while the workflow still reports success — a silent partial
green. None of the three desktop Playwright configs set forbidOnly.
- Root config: forbidOnly: !!process.env.CI, mirroring the existing
retries: process.env.CI ? 1 : 0 CI switch — local devs keep the
ability to focus a test interactively.
- e2e/core and e2e/update configs: forbidOnly: true unconditionally.
These lanes already hard-set retries: 0 because a required job must
expose problems rather than hide them; the same contract forbids a
focused test quietly shrinking a required job.
Verified on base (red): CI=1 npx playwright test with a temporary
test.only probe passed and reported '1 passed'. After the fix (green):
the same probe fails with "item focused with '.only' is not allowed
due to the 'forbidOnly' option" under CI=1 for the root config and
without CI for the core/update lanes; without CI the root config still
runs the focused probe (local behavior unchanged).
Fixes#93142
The switch-back hydrate can be composed from the baseline that still held the
reply's streaming row, under the same id the store later settled. The overlay
replaced that row in place and never checked whether the page already had the
reply committed, so the answer painted twice until the next refresh (about one
run in three of the tool-turn race). The represented-on-page check now runs
before the in-place replace and drops the stale copy; a folded tool bubble is
also matched by its answer text when the completion carried no row receipt.
The tool-turn switch-back race still painted the answer twice, frozen on its
first chunk, in two places:
- appendLiveSessionProjection only stood the stale activate snapshot down for
a tool-free committed reply. A tool turn's snapshot carries the turn's whole
text and history folds narration, tools and answer into one bubble, so it
never matched. It now stands down when the committed bubble ends in a reply
that covers the snapshot; a mid-turn commit ends on a tool and still projects.
- overlayConcurrentMessageChanges only adds and replaces. A page composed from
the older baseline could carry a live row the store had already retired into
the committed reply. A baseline live row missing from the current store now
stays retired.
The steer matrix also covers the steered tool finishing in the background:
the oracle skips backend notice rows (process_complete and friends) the way
hydration draws them.
The new switch-back-race tool-turn case caught it: when message.complete
settles the live reply while the switch-back REST page is in flight, the
overlay kept the settled stream row unless the page carried an assistant
message with the same words. History folds a tool turn's narration, tool and
reply into one bubble, so its text never equals the reply and the answer
painted twice. The completion already binds the reply to its stored row;
match that row first and keep the text rule as the fallback.
The oracle now compares a steer stored at a tool boundary by the user's
words, not the out-of-band wrapper the model sees. The review-row scenario
does not reproduce #131626's duplicate on main, so it is a plain guard
instead of a KNOWN entry.
The in-page sampler already sees every frame; it now also fails a reply
rendered above its own prompt and two messages that swap places between
frames, so a wrong order that heals by the final state still fails.
reply-settle-paths.spec.ts forces the duplicate-reply shapes under the
transcript oracle: an answer the backend repeats as the final reply, with
a review.summary row delivered before that turn's message.complete
(#131626, KNOWN), a steer at each point of a tool turn, then reload and
switch-back over folded tool turns. switch-back-race.spec.ts runs both
orders with a tool turn too (#128809). The scripted provider can now
script marker-less traffic such as the background review fork.
A stale roster run finishing after a restart must not clear the new
run's rosterBusy flag. Dropping the generation check in
syncRelayRosters' finally left every existing test green, but let the
next 60s tick start a third concurrent roster writer while the
restarted run was still in flight (expected 4 profiles.list calls, got
6). Add a regression for that overlap, and apply eslint
padding-line-between-statements fixes to the lines the salvaged commit
added so the touched files stay at zero warnings.
Follow-up to #131028; mutation gap reported in review there.
On Windows, restore() or showInactive() on a window that the tray hid
paints the window, but it does not become the foreground window. The
window then drops all mouse and key input until the app restarts. A
relaunch from the taskbar or Start menu, a deep link, and a notification
click all restore the window this way. Only the tray menu and tray click
ran show() and focus().
The restore and show handlers now finish the activation for a window
that the tray hid: show(), then focus(). The call is deferred past the
native restore. Windows that the tray did not hide are not changed.
Fixes#127349
With shared metrics on, every provider stream runs through Relay's managed
pipeline. Relay pulls the next provider chunk before it delivers the current
one, and the provider read blocked the stream's private loop, so each chunk
was withheld until the next arrived. On Desktop the reply looked stopped while
the timer kept going and queued follow-ups waited on a turn that was still
running; a steer aborted text the user never saw, stacking user bubbles with
no output between them.
The off-loop read from the previous commit fixes it. Correct its comment to
the real cause, replace the two-loop starvation test (production gives every
stream its own loop, and it bounded wall-clock at 150ms) with a single-stream
contract that deadlocks on the old code, and add a core e2e spec that steers
and queues against a paused provider with Relay managed execution on.
The plugin deep link (hermes://plugin/install?catalog=) and the skill
deep link are separate features: one per kind. Reverting #119381's card
catalog should not leave skills without a link, so this restores the
skill route on its own:
- Desktop: skill-deeplink-install store, the skill route in
deeplink-routes, its use-desktop-integrations wiring, and the
confirm-store onConfirm/details/busy/done phases its dialog uses
(restored from main with their tests). Its nine strings move from the
deleted catalog.* namespace to skillDeepLink.* (en + the six locales
that translated them).
- Website: Skills Hub "Install in Hermes" button, plus the
source-qualified installIdentifier from extract-skills.py so the link,
the embedded picker and the CLI command all name the exact skill
(apps/shared/src/catalog-install.ts keeps only the skill helpers).
- Docs: skills.md "Install from the website" section, desktop.md pointer.
The card catalog UI stays reverted.
Reverts all seven commits of #119381 (59c54c78..476ff4dd), which
re-landed the native card-based Skills/Plugins catalog browser after it
was reverted twice (#113037 reverting #112669, #115787 reverting
#113128). Capabilities -> Skills and Plugins return to the list/detail
tabs with the embedded catalog picker they had before #119381.
Removed with it:
- Desktop catalog/ module (browser, cards, detail dialog, filters,
reels/masonry, install switch) and skill deep-link install
(hermes://skill/install); skills/embedded-hub-picker.tsx comes back.
- apps/shared catalog-browse.ts and catalog-install.ts.
- The `catalog` i18n namespace in every locale (+ regenerated
locales/_keys.desktop.json); the two i18n pack tests and the keys
test now exercise connectorsPage.searchPlaceholder instead.
- Website /skills "Open in Hermes Desktop" skill links and the
extract-skills install identifiers; docs return to the pre-#119381 text.
- Later fixes that only patched the reverted catalog code go with it:
5832a6eb93 + 7aba2cbbe9 (catalog download deadline),
9e63cdc4b8 (catalog lookalike rows), c756986db2 (catalog counts),
the catalog half of c13232e90c / f265273347.
Kept on purpose (later work that depends on code #119381 introduced):
- DialogContent `chrome` / `overlayClassName` / nested-dialog layering
(the image lightbox uses them).
- vitest.setup.ts / src/test/jsdom.ts stubs (other suites rely on them).
- Kanban toolset wiring on the Plugins Desktop switch (cd30fdadf6,
7ca2f5665f) and its tests, the `hermes_not_connected` server tone,
the SearchField flex-1 fix, skill provenance helpers. SkillDetail shows
a provenance pill only for agent/hub skills, since the pre-#119381
labels have no `external` entry.
The MSIX package carried a single Square44x44Logo.png, so every taskbar and
Start slot on a HiDPI Windows 11 machine was Windows upscaling a 44px bitmap,
and with no altform variants Windows plated the icon itself. The generator now
emits the qualified catalog Windows resolves by itself: <Logo>.scale-{125,150,
200,400} for all four manifest logos at Microsoft's pixel sizes, and
Square44x44Logo.targetsize-{16..256} in the three required theme forms —
unqualified and altform-lightunplated carry the light tile, altform-unplated
(dark theme) the dark tile, the same split macOS dark mode gets. before-build
stages the whole assets/appx directory; app-builder-lib runs makepri and
`makeappx /l` as soon as it sees a qualified asset, so nothing else changes.
The hand-listed *-dark.png logos, generated but never staged, are gone.
Exposed along the way: the commit badge sat outside the portrait's silhouette
clip, and at 16–30px its anti-aliased edge stacked coverage on the plate's
corner pixels and changed the alpha channel between build flavors. The badge
now shares the clip, is plain black, and is left off direct renders below
32px where no SHA is legible anyway.
The Desktop and TUI subcommand pickers offer /browser use (it is in the
registry's subcommands), but both handlers only knew connect, disconnect
and status, so picking it printed the usage line.
browser.manage gains a use action (enabled: bool) that writes the session
profile's browser.backend through the same set_browser_use_mode helper the
CLI now calls, and status/use report browser_use. The handler is profile
scoped, so a worker session writes its own profile. A live agent keeps its
tools (prompt cache), so Desktop and TUI say the switch applies to new
chats instead of swapping tools mid-conversation. use and status also run
on remote backends; connect/disconnect stay local-only.
Hovering a message reveals its time with the hover actions ("3:27 PM",
"Yesterday, 3:27 PM", "Sep 28, 3:27 PM"); the tip adds the age for same-day
messages and the full date. User bubbles show it beside Restore/Stop, replies
at the head of the action bar. It stands down while display.timestamps is on,
since the transcript already prints every stamp (#118405).
The Favorites section put a provider chip on every row beside the variant,
fast and effort chips, and with names as the only element allowed to shrink
they collapsed to a letter (#130349). A mixed Favorites section now names
each provider once over its rows; the variant tag stays a filled chip after
the name, while fast and effort move to outlined chips by the caret that
edits them. An inherited effort is no longer stamped on every row: it shows
on the active model and on rows whose preset chose one.
A packaged Windows build reported "Microsoft Store returned invalid
JSON" on every update check. The Store checker, the App Installer
checker and the relaunch waiter were resolved under the payload's repo
snapshot (<payload>/hermes-agent/apps/desktop/scripts/), but
INERT_SNAPSHOT_DIRS has excluded apps/ and scripts/ from that snapshot
since f6713f2762. The installed 2026.6523.3313.0 package has no apps/
directory at all. The payload python then failed to open the script,
exited 2 with empty stdout, and parseResult reported invalid JSON.
The two checkers are python that runs under the payload interpreter, so
they belong with the core code, like the other hermes_cli helpers the
desktop runs with -m:
- apps/desktop/scripts/check-store-update.py becomes
hermes_cli/windows_store_update.py and check-appinstaller-update.py
becomes hermes_cli/windows_appinstaller_update.py (git mv).
- The runner spawns `python -P -m <module>` with PYTHONPATH set to the
bundled core code, then the venv dependencies: the order the bin
launchers give sys.path. -P keeps the working directory off sys.path.
- The relaunch waiter is PowerShell run outside the package, not core
code. It ships as an app resource (win.extraResources) and resolves
from process.resourcesPath.
The Python tests now import the modules, and a real `python -P -m`
subprocess test proves a checker failure still reaches the desktop as
JSON. A vitest checks the packaging config ships the waiter.
The desktop DMG's volume icon defaulted to the packager's icns, which is
actool's 256px fallback whenever mac.icon is the Icon Composer package, and
dmgbuild's badge option can only paste onto the stock removable-drive icon.
Ship our own drive-with-the-girl artwork instead: assets/dmg-volume.png
(hand-made, one artwork since volume icons have no appearance variants) is
emitted as apps/desktop/packaging/dmg-volume.icns, beside the DMG background
so the `files` whitelist keeps it out of the app bundle, and dmg.icon points
at it.
Tauri always uses the app icns as the volume icon and offers no override, so
the bootstrap workflow runs scripts/bundles/set_dmg_volume_icon.sh on the
built DMG before signing (the same .VolumeIcon.icns + custom-icon flag
dmgbuild sets), and the verify step checks the icon is on the mounted image.
Verified on macOS 26.6.2: both DMGs render the identical volume icon.
Tauri's bundler (cli 2.11.4) compiles an Icon Composer package listed in
bundle.icon into Assets.car with actool >= 26 and sets CFBundleIconName, so
the bootstrap installer gets an unbranded twin of the desktop package under
src-tauri/icons/icon.icon (generator target; the bundle stays the plain
brand because flavours only apply under apps/desktop/). Tauri only logs when
actool is too old, so the macOS job selects Xcode 26 and the verify step
asserts Assets.car and the plist key are in the built .app.
The desktop DMG's volume icon defaulted to the packager's icns, which is
actool's 256px fallback whenever mac.icon is the .icon package; point
dmg.icon at the full-resolution assets/icon.icns. Tauri's DMG already takes
--volicon from the bundle's .icns, which the regenerated outputs cover.
Next to macOS 26's own icons the flat plate + girl reads better than the
ringed tile, so the ring is off in every output: the tiles' inward stroke,
the layered icon's border layer and its targets. BORDER_ENABLED keeps the
ring code one flag away.
Without a ring the girl's lowest nodes are dragged past the tile edge
(EDGE_OVERSHOOT) so the outline crops her instead of leaving a gap.
Clear light/dark and Tinted light/dark now come from one grayscale mono
layer with Liquid Glass on: the parts of the girl that are black in light
mode become a near-black frosted ink (0.13 @ 90%), her white parts stay
white, each pixel painted exactly once so materials never stack. The art
layer hides under "tinted" and the mono layer shows only there.
Mac girl at 1.06x (between main's 1.12x and the layers' 1.0x), for the icns
and the layered icon alike. Chosen from a six-appearance matrix of real
IconServices renders next to Finder.
main scaled the girl 1.12x for every mac target (a white tile with a ring
reads small beside full-colour peers), but the macOS 26 layers were still
registered on the full-bleed squircle box, so macOS 26 would have shown a
smaller girl than macOS 15.
The layered icon's canvas is the plate itself, so derive its portrait box
from the mac box in plate coordinates. Ink overlap with the icns girl goes
from 0.65 to 0.996 IoU; only the two art layers change.