Both primary recovery paths fenced the route lookup but not the WebSocket
ticket mint that follows it. When a connection apply (boot hook) or a
foreground activation (request recovery) landed while the ticket was in
flight and the newer socket had already dropped, the stale attempt dialed the
old primary route. connect() no-ops on an open socket, so the gap only shows
once the applied socket is closed, but then it re-attached the window to the
gateway the user had just left.
The boot reconnect now compares the route revision it recorded against the
current one again before dialing; request recovery re-checks ownsForeground().
useGatewayRequest's primary recovery resolves the connection profile-less, so
main answers through the window's CURRENT route. When the user activated
another source while that lookup was in flight, the late answer was published
with setConnection() over the newer foreground descriptor (and a failed
lookup cleared it), so REST and filesystem helpers routed to the old backend
while the newer source was in front.
Recovery now snapshots the gateway activation epoch and publishes, clears, or
dials only while the primary still owns the foreground. The boot hook's
own-route reconnect loop keeps restoring the primary socket, and the replay
guard already refuses to resend the original request.
Reported-by: dokterdok
Address review on #132237: an in-flight primary reconnect whose route lookup
resolved after a connection apply recorded its old descriptor over the newly
applied primary. The next socket drop then dialed the old gateway by its
explicit route, where the profile-less lookup on base followed the applied
window route.
Every primary route record now bumps a revision; a reconnect snapshots it
before the lookup and drops the result if a boot or apply recorded a newer
route meanwhile, so it no longer records, publishes or dials the old route.
The source-qualified primary lookup is unchanged.
Address the ordinary-switch draft-intent race from review on #132237: a
draft started on another gateway while a source switch was still doing its
bookkeeping (rememberConnection) was cleared by the switch's late re-home,
so the next session.create went to the switched-to source instead.
Every new-chat owner intent goes through captureNewChatSource, which now
bumps an intent revision. selectConnection snapshots it when the switch
starts and only re-homes the draft if no newer intent arrived, so a newer
intent wins even when it repeats the same route values. Completed switches
with no newer intent re-home as before.
Address review on #132237:
- Project-tree rows go through the canonical stampRowsWithOwningConnection:
a row that already names an owner is never re-owned, and local stays bare
on the primary path so the legacy profile door (#94166) is unchanged. local
is stamped only when it is an active registry secondary.
- Resume remembers an ambient owner hint only for a row that actually
resolved; an unresolved id no longer gets pinned to the foreground source.
- The silent boot restore keeps an explicit new-chat route pinned while it
was in flight; the route clear lives in one helper.
- Drop the untested pre-recovery route check in useGatewayRequest (the
post-recovery check covers it) and note why the two primary reconnect
paths resolve differently.
- Tests: move the primary-reconnect cases into the reconnect describe with an
inline resolver fake, assert the explicit primary profile exactly, and drop
an overlapping parametrized case.
Primary reconnect called profile-less getConnection(), which main resolves from
the window's foreground route. A drop while a secondary was foregrounded
re-dialed the primary socket to that secondary's backend. Reconnect by the
primary's recorded registry route (or legacy profile); boot and connection
apply keep following the foreground.
useGatewayRequest recovered a transport failure through the current active
route and retried the original payload there. A gateway switch between
dispatch and the delayed rejection replayed the request on the other source.
Retry only while the dispatch route is still active; otherwise surface the
original error.
Resume kept the ambient connection only for remote mode, so an untagged row
while This device was an active registry secondary reduced to a bare profile
and routed to the window primary. Keep the legacy primary-local profile door
(#94166) unchanged, and remember the resolved owner for later prompts.
projects.tree rows were published without connection_id, unlike the
Electron-tagged REST lists. Opening a tree-only session fell back to the bare
profile, which resolves to the window primary, so resume/prompt ran on Home
while This device was active. Tag rows with the same registry connection REST
requests are pinned to, and drop late responses after a source change.
A draft started from a gateway-group + pins $newChatRoute. selectConnection
captured the new source but left that route, and resolveNewChatOwnerRoute lets
an explicit route win when profile names match, so the next session.create
landed on the previous gateway while the UI showed the new one. Superseded or
failed switches leave a newer draft untouched.
Allowlists (*_ALLOWED_USERS, LINE groups/rooms, SimpleX groups) rendered as a
single empty input with a redacted "«redacted:…»" placeholder, so a saved list
looked like a hidden secret and could only be replaced wholesale.
- Backend: messaging env rows carry is_list, and list rows carry their plain
value (IDs are not secrets; password fields still only leave redacted).
- Desktop + dashboard: one visible text box per entry, x to remove, "+ Add
another" (Enter also adds); a pasted comma list splits into boxes. Saved as
the same comma-separated value the gateway already parses; removing every
entry and saving clears the key.
- Desktop help copy for the allowlist fields says "one per box" in all locales.
b11146 ships ubuntu-cuda-13.4-arm64 (+ cudart), so llamacpp-cuda gains
linux-arm64, auto resolves NVIDIA arm64 Linux to CUDA, and the desktop's
localModels flag ships on Linux arm64 too.
Every upstream Linux llama.cpp build (CPU included) links the system
libgomp, which minimal hosts (WSL, containers) lack, and a normal install
never touches the system package manager. Each Linux engine now pins
Ubuntu 24.04's libgomp1 .deb (glibc 2.38, the floor upstream already sets)
and stages libgomp.so.1 beside llama-server, where the $ORIGIN RUNPATH
finds it; the artifact mirror covers the pool URL after 24.04's EOL.
DebPackage's ar+tar unpack becomes pm.package.unpack_deb and reads zstd
data.tar (Ubuntu's default) with the stdlib codec PM's 3.14 runtime has.
Pre-PM engines on Linux carry only llama.cpp's own archives, so adoption
maps their manifests to those and records them as outdated (usable now,
update offered) instead of failing to match.
Verified on an RTX Spark N1X (WSL, Ubuntu 26.04, no system libgomp):
ensure_engine() for cuda, cpu and vulkan through PM's real download/stage/
verify path; ldd resolves libgomp from the entry; llama-server serves
completions (CUDA0 at 445 tok/s). linux-x64 cpu/vulkan staged through the
same path with no NEEDED library missing beyond glibc/libstdc++/OpenSSL 3.
A layout matrix (single/fleet × strip/condensed × local/remote active,
starting from a named profile or the all view) asserts the active
gateway's default is one click away in each. The four fleet+condensed
cases fail on main.
The core E2E lane only starts on PRs touching its guarded paths, so a
profile-rail edit would have met fleet-condensed-default.spec.ts only on
main. Add the rail sources to the lane.
With a second gateway registered the sidebar rail drops its default/all
home pill, and past the condensed threshold the profile menu is the only
pointer route to a default. The menu listed every at-rest gateway's
default but never the active one's, so on the usual setup the local
default vanished while the remote one stayed listed. The trigger also
fell back to "Profiles" while on default.
Prepend the active default in fleet mode, give its row and the trigger
the home glyph, and route the row like the home pill does.
Hovering that row would have warmed the wrong backend: rail and dropdown
rows prewarmed by bare name, which resolves on the legacy door, so a
remote source's `default` warmed This device's. Route current-source
picks' prewarm through the same connection the click dials.
Fixes#106017Fixes#131632
Co-authored-by: NingCiQiu <138146243+NingCiQiu@users.noreply.github.com>
Co-authored-by: Ugur Aydogdu <ugur.aydogdu@epigra.com>
Co-authored-by: Suhasan Kanagala <57094456+suhas-svg@users.noreply.github.com>
Pins the ultrafast-only service_tier send in desktopSessionCreateParams: a Priority pick
must reach a pre-Ultrafast backend without the field it rejects.
Settings had its own isFastTier alias list next to the composer's composerServiceTier; a
new Fast spelling added to one would make the two surfaces disagree about the same
config value. composerServiceTier moves to lib/model-status-label and Settings derives
its Standard/Fast/Ultrafast value from it.
Review finding (FASTTIER r1, Medium regression): the composer copied
`agent.service_tier` verbatim, so a raw OpenAI tier word in config.yaml
(`flex`, `scale`, `auto`, ...) reached session.create, which only accepts
normal/priority/ultrafast and answers 4002 — every Desktop new chat failed.
Map the config value onto the three tiers the composer speaks: `ultrafast`
stays Ultrafast, the existing Fast spellings become Priority, anything else is
Standard, unset stays unset. The Ultrafast-only send in the previous commit
already keeps such a value off the wire; this keeps the composer state, the
speed menu and the pill honest about it too.
Review finding (FASTTIER r1, High regression): Desktop sent `service_tier` on
every session.create whenever a tier was selected, and a backend predating the
field rejects the whole create at contract admission (4000 "invalid params for
session.create: service_tier: Extra inputs are not permitted ...") — every new
chat failed on a Desktop newer than its backend.
Send the tier only for Ultrafast (`fast` already pins Priority / normal on every
backend) and, like the existing cwd_explicit fallback, drop a rejected
`service_tier` and resend once with `fast` kept, so an old backend serves
Priority instead of failing. The matcher now covers both droppable fields, one
resend per field, on the same stable prefix + field name.
Trims #130990's tests to the headline behaviour: Ultrafast must never read
as plain Fast. Python: one relationship test in test_openai_ultrafast.py
(inventory reports ultrafast only where the request path can apply it;
session.create keeps the exact tier, explicit tier beats legacy fast,
unknown words are rejected). Desktop: the composer pill label assertion
joins the existing Fast-label test, and the preset round-trip test stays.
The rollback/race and component tests duplicated coverage of the same
mapping.
Carries the substantive hunks from #130990's merge commit 5afdf9d9db (a
merge of origin/main into the PR branch that also changed PR code):
the confirmed-value snapshot is taken per write chain and released together
with pendingWrites in a finally, so confirmedValues no longer grows per
(connection, profile, session, dimension) for the life of the renderer
(AI review on #130990). Also fills the ar/zh-hant speed labels.
Applied on BASE rather than cherry-picking the merge, which would drag in
unrelated main changes.
(hunks from commit 5afdf9d9db0233d48b0422b4ef77128cd89654a7)
Ultrafast collapsed into ordinary Fast across the stack: the catalog only
reported `fast`, presets and drafts stored a boolean, session.create took
only `fast`, and Settings had a single Fast switch, so an Ultrafast default
read as Fast and a new chat could lose it.
- Inventory reports `ultrafast` per model from the same route-aware
resolver that builds the request; Daybreak aliases offer no accelerated
speed (their priority request is ignored).
- session.create accepts an exact `service_tier` that wins over the legacy
`fast` flag; unknown tiers are rejected.
- Desktop keeps the exact tier through presets, draft creation, session
options and labels; an unsupported remembered tier falls back to
Standard, and failed edits reconcile to the confirmed state.
- Settings → Model → Main model offers one Speed choice (Standard / Fast /
Ultrafast) on models that support Ultrafast; others keep the Fast switch.
Split out of #129251 (which keeps Daybreak).
(cherry picked from commit 4e332fb850c989ef9f34bd70aaca174042beecc6)
A portable plugin can now declare `requires: {gpu: nvidia}` for an MCP
server. On a host without an NVIDIA GPU, Hermes then refuses the
install, hides the server's tools, and the catalog card says "needs an
NVIDIA GPU". The check is the existing
`hermes_platform.host.facts.gpu_class()` read.
The requirement is independent of `app:`. The RTX Remix Toolkit is the
first consumer: it is a package the user unpacks anywhere, so there is
no install location to check, but it needs an NVIDIA GPU.
An unreadable GPU (`unknown`) passes, so a failed registry or sysfs
read does not lock out a machine that has the GPU. Only `nvidia` is
accepted: gpu_class() reports the highest-priority vendor, which
answers the question exactly for NVIDIA and not for AMD or Intel.
A registered server on such a host reports the new `unsupported_gpu`
state, carried through the gateway contract and the Plugins tab, with
a sentence that names the GPU instead of "Install <app>".
The scheduler persists last_delivery_unverified as a list of unconfirmed
platform:chat_id[:thread_id] targets and cron.manage passes it through, but
CronJobRow typed it bool | None, so contract validation rejected any job
with an unconfirmed delivery. Regenerate the TS/OpenRPC contract.
Follow-up to #131853 and #131867 from an independent review, each finding
reproduced in native Electron against two real serve backends.
- Cold composer path: the cache reconciliation (mergeServerMeta) ran only in
the Bots pane's publish effect, so a launch that never opened the pane kept
a stale cached title ahead of the backend's own in @handles and group
prompts ("You are @local-stale-name"). It now runs where every snapshot is
produced (data.ts::fetchRosterSnapshot), with the same fetch-time write
fence, so the pane query and primeRoster reconcile alike.
- Pane label vs @handle: displayName ranked display_name above the backend's
Bot Mode title on a thin remote row (and a titled remote `default` with no
display_name fell to the connection label), so the row read "Remote Human
Name" while mentions and prompts said @remote-bot-title. It now takes the
same backend-title rung botFriendlyNames uses.
- Relay trace: a connection removed and re-added under a new id was logged
`MISROUTED (same install as <removed id>)`; the remembered owner is now
pruned against the current connection set.
The roster-pane-lifecycle test probed "republished" via mergeServerMeta's call
count; reconciliation now happens per fetch by design, so that invariant is
asserted via trackInboundActivity alone.
shouldFocusToTakeKeyboard destructured `{ isFocused }` from its argument
and called it unbound. On a real Electron BrowserWindow the native method
needs its receiver, so every call threw `TypeError: Object has been
destroyed` (proven in Electron 40). activateWindow (second-instance, tray
restore, dock activate, live deep-link delivery) threw before focus(), so
deep links were dropped; main.ts focusWindow hit the same throw. The bug
has been latent on main since #83998 introduced the policy; plain-object
test mocks hid it.
Call `window.isFocused()` on the receiver. The kept activateWindow
regression test now drives a class instance whose methods read `this`,
so destructuring fails it (red before, green after).
Gate r2 finding 2c: the #130810 fallback counted popped-out Browser windows
as surfaces that keep the app alive, but only ran from a chat window's
'closed' handler. Closing the last Browser pop-out after the primary was
gone therefore never re-checked, stranding a windowless single-instance
lock holder on Windows/Linux.
Extract quitIfNoSurfaceLeft() and call it from both registerChatWindow's
and spawnBrowserWindow's 'closed' handlers. The browser call is deferred
with setImmediate because the browserWindows registry attaches its own
self-delete 'closed' listener after the factory returns, so its size is
only accurate on the next tick.
Gate findings (reuse + quality, Low, convergent):
- handleDeepLink's live-window delivery branch hand-rolled
restore -> show-if-hidden -> focus-if-unfocused, step-for-step the new
activateWindow; call it instead. The close-preview branch is left alone:
its unconditional focus() has different foreground semantics (#83998).
- activateWindow now uses shouldFocusToTakeKeyboard from
window-focus-policy, so the #83998 focus rule has one owner.
- Import activateWindow directly; the activateRestoredWindow alias had no
name clash.
- shouldQuitOnLastChatClosed is one boolean expression.
- The #130810 rationale lives only in the shouldQuitOnLastChatClosed
docstring; the quitInProgress declaration and the closed handler keep
one-line pointers.
- The predicate test is table-driven, dropping the two duplicate rows
(linux remaining=2, linux quitInProgress).
Gate finding (2c, Low REGRESSION): the #130810 last-chat fallback counted
only chatWindows, but popped-out Browser windows (spawnBrowserWindow) are
user-visible and never registered as chat windows. Closing the primary on
Windows/Linux with a pop-out open therefore app.quit()ed and killed it,
whereas before the fallback window-all-closed did not fire and the app kept
running.
Count the existing browserWindows session registry (it self-deletes on
'closed') alongside chatWindows. This is the smallest correct option:
registerChatWindow would also attach the active-work close guard to a
pop-out, and a BrowserWindow.getAllWindows() visibility filter would need a
helper-window classifier that does not exist.
Gate finding (2c, Low): a proceeding before-quit sets quitInProgress and
then preventDefaults (managed-update wait / teardown coordinator). If the
re-entered app.quit() is held by the active-work guard and the user picks
"Keep Running", quitInProgress stayed true for the rest of the process, so
every later last-chat close skipped the #130810 fallback and could strand a
windowless single-instance lock holder again. Reset it next to appQuitting.
Keep the shouldQuitOnLastChatClosed decision matrix and the activateWindow
restore -> show -> focus test. The two scenario tests rebuilt main.ts state
in local variables and then asserted it (tautological), and the remaining
activateWindow cases only pin no-op paths.
The salvaged fix added decideSecondInstanceAction, which re-implemented the
create / defer / focus branches ensureMainWindow already owns, plus a
shouldQuitOnAllClosed helper that only restated the existing
window-all-closed condition. Route second-instance through
ensureMainWindow(focusWindow: activateRestoredWindow) like the tray path,
keep one rememberLog line so a relaunch stays visible in desktop.log, and
inline the window-all-closed check back.
activateWindow wrapped restore/isVisible/show/focus in try/catch and
typeof-probed each method; the window is already checked with
isDestroyed() and Electron's calls do not throw here, so type the window
with required methods and call them directly. Also fixes a comment typo.
activeWorkByWebContents dropped an entry only on 'destroyed'. A renderer
that dies keeps its webContents, and some render-process-gone exits never
reload, so its last in-flight count stayed in the merged picture until
quit: stream throttling stayed off and, now, the 'while-working' blocker
stayed held. Forget the entry on render-process-gone too; a reloaded
renderer re-reports its live turns on mount.
The mode union and its validator were written three times: power-save.ts,
the renderer store and global.d.ts. The store and the preload typing now
import KeepAwakeMode and parseKeepAwakeMode from electron/power-save (no
electron import, as pool-limits-setting already does), so a new mode
cannot drift between main and the picker.
The three-way keep-awake setting shipped labels in en, ja, ru, zh and
zh-hant only, so German, French and Spanish showed English "Off / While
working / Always" beside a description that still promised the old
always-on behaviour. Translate the labels and the new description, add
"while working" / "turn" to the settings-search keywords, and register the
three new keys in locales/_keys.desktop.json for language-pack validation.
运行时 / 執行時 parse as "runtime" at first glance; 运行期间 / 執行期間 are
the unambiguous "while a run is in progress". Review nit from #102209.
(cherry picked from commit 176c77ea693af9c2d398c63fac2c7f1857afed5d)
"Keep computer awake" was a boolean: off, or a machine-global power-save
blocker held for as long as Hermes Desktop is open. That is the wrong shape
for its own stated purpose. An unattended overnight run dies when the
machine sleeps unless the user predicted, before starting, that they would
need the toggle — and a user who did flip it leaves a laptop pinned awake
all week, including the idle hours after the run finished (#101991).
Main already knows, live, whether any turn is in flight:
`mergeActiveWork(activeWorkByWebContents.values()).count > 0` is the exact
expression that drives stream throttling. This change wires the blocker to
that same signal behind a three-way mode:
off never hold the blocker (unchanged default)
while-working hold it only while a turn is in flight; release at idle
always hold it whenever the app is open (what `on` used to mean)
Both persisted copies migrate in place. The renderer's localStorage boolean
(`hermes.desktop.keepAwake.v1`) and main's `keep-awake.json` `{ on }` are
read once and mapped `true -> always`, `false -> off`, so an existing user
sees exactly the behaviour they had. The IPC channel keeps its name and
accepts either the mode or the old boolean. An unrecognised saved value is
refused rather than trusted and falls back to `off`.
No new listener, timer, or IPC channel: `updateStreamThrottleFromActiveWork`
computes the merged picture once and hands it to both consumers, and the
cold-launch apply reads the mode instead of the flag. No trailing delay
between back-to-back turns — a sub-second release cannot let a machine
sleep, and `powerSaveBlocker` start/stop are cheap.
The policy is three pure functions in power-save.ts (parse, read, wanted)
so it is testable without the main process; main only wires them.
(cherry picked from commit c6dab931a2891ea7026eea2c07c271bcf48446b9)
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.