The salvaged helper exported serializeJsonBody but main.cjs still inline-built
the request body, leaving the export dead and the test decoupled from the real
path. Use it at the fetchJsonViaOauthSession site so the helper's coverage
exercises production body construction. Byte-identical output.
Follow-up to #39921. That PR scoped session.resume + prompt.submit to a
session's profile, but a BRAND-NEW chat (session.create) under a non-launch
profile was still built and persisted against the dashboard's launch profile.
Two visible symptoms in app-global remote mode (one dashboard, many profiles):
1. "who are you" in profile S replied as the launch (default) profile/agent —
the agent was built with the launch HERMES_HOME, so config/SOUL/identity
came from the wrong profile.
2. "session not found" on later resume — _ensure_session_db_row persisted the
row into the launch profile's state.db via _get_db(), so the session lived
in the wrong db, the unified list mis-tagged it (it showed up under BOTH
profiles), and resume routed to the wrong one.
Fix — carry the owning profile through the create path too:
- session.create accepts an optional `profile`; resolves its home and stores
`profile_home` on the session (alongside what resume already set).
- _start_agent_build binds that profile's HERMES_HOME while building the agent
(config/skills/model/identity resolve to it) and hands the agent the profile's
state.db so turns persist there.
- _ensure_session_db_row writes the row into the profile's state.db, not the
launch db — fixing the duplicate row + mis-tag + resume 404.
- desktop sends the new-chat profile on session.create.
None/launch profile → unchanged (single-profile and per-profile-remote setups
take the same path). Verified live against a one-dashboard / multi-profile
remote: a new chat under `work` builds as work's agent (correct SOUL identity),
persists ONLY to work's state.db (launch db stays empty), the unified list tags
it `work` exactly once, and it resumes cleanly.
tests/test_tui_gateway_server.py: _make_agent mocks updated for the session_db
param added in #39921's build path.
Introduce a lightweight React context-based i18n layer for the desktop
app and translate the UI into Simplified Chinese.
- New apps/desktop/src/i18n module: typed Translations interface, en + zh
locale tables, I18nProvider/useI18n, localStorage-persisted locale
(defaults to English), and language endonym metadata for the picker.
- Wire I18nProvider at the app root in main.tsx.
- Refactor 24 desktop screens/components to read strings from the `t`
object instead of hard-coded English.
- Add a unit test for the i18n context.
* fix(desktop): cross-profile session history in app-global remote mode
#39894 made remote-profile sessions first-class for PER-PROFILE remote
overrides. But the common setup — Settings → Gateway → "All profiles" → Remote
— writes app-GLOBAL remote mode (connection.json top-level mode:'remote', empty
profiles map), which the intercept didn't recognize. Switching to a non-launch
profile then 404'd every session read, so no history showed for it.
In global remote mode a SINGLE backend serves every profile via ?profile= (it
reads each profile's state.db off the remote host's own disk — verified: one
dashboard returns /api/profiles and /api/profiles/sessions?profile=all across
all profiles). The fix: when no per-profile override matches but global remote
mode is active, route per-session reads/mutations to that one backend and KEEP
the ?profile= param so it opens the right state.db (instead of bailing to the
local path and dropping the profile scope).
- new globalRemoteActive() — true for connection.json mode:'remote' or the
HERMES_DESKTOP_REMOTE_URL env override.
- per-session branch: per-profile override → route sans profile (own db);
global mode → route to the single backend WITH ?profile= preserved.
- unified list is unchanged in global mode: it already passes through to the one
backend, which aggregates all profiles natively.
Verified live against a one-dashboard / multi-profile remote (Austin's topology):
cross-profile transcript reads load (was 404), rename/delete route to the right
profile, unified list spans both profiles.
Known limitation (architectural, not fixed here): LIVE chat as a non-launch
profile still needs a per-profile dashboard on the remote — the dashboard binds
HERMES_HOME once at process start, so one global backend can't run an agent
turn as another profile. Session history/read/mutate now work regardless.
* fix(gateway): resume + chat any profile over one global-remote dashboard
The REST half of this branch made cross-profile session history visible in
app-global remote mode, but resume + chat still went over the WebSocket gateway,
which was hard-bound to the dashboard's launch profile. Resuming a non-launch
profile's session 404'd ("session not found") and sending spawned a new session
— because session.resume/prompt.submit had no profile concept and the live
agent + state.db were process-global to the launch profile's HERMES_HOME.
Make the WS gateway per-session profile-aware so ONE dashboard can serve every
local profile on its host (the app-global remote topology):
- session.resume accepts an optional `profile`. _profile_home() resolves that
profile's home on this host; resume opens THAT profile's state.db, binds its
HERMES_HOME (ContextVar override) while building the agent so config/skills/
model resolve to it, and passes the profile db to the agent so turns persist
to the right state.db. The owning profile_home is stored on the session.
- prompt.submit re-binds the stored profile_home for the turn thread (mid-turn
home reads — memory, skills — resolve to the resumed profile), reset in finally.
- _make_agent gains an optional session_db param (defaults to _get_db()).
- _load_cfg honors the home override (falls back to _hermes_home) so a resumed
profile loads its own config; cache keyed on resolved path.
- desktop: session.resume now sends the owning profile.
Omitted/launch profile → unchanged (single-profile and per-profile-remote setups
are byte-for-byte the same path). Verified live against a one-dashboard /
multi-profile remote: resuming a non-launch profile's session loads its history,
runs a real turn against THAT profile's home/env, and persists to its state.db.
tests/tui_gateway/test_protocol.py: _make_agent mocks updated for the new param.
Follow-up to the read-routing fix: make remote-profile sessions fully
first-class, not just resumable.
Mutations (rename/archive/delete) went through the same hermes:api handler but
never carried the owning profile, so they hit the local primary's state.db --
which has no row for a remote session. Deleting/archiving/renaming a remote
session silently no-op'd or 404'd, and the row reappeared on next refresh.
- hermes.ts: setSessionArchived/deleteSession/renameSession take the owning
profile and pass it as request.profile so Electron routes to that profile's
backend (matching the read path). Callers now forward session.profile.
- main.cjs: generalize the intercept (read -> request) to also reroute
DELETE/PATCH on /api/sessions/{id} for remote profiles, stripping the profile
param (the remote serves its own state.db; no cross-profile semantics there).
- web_server.py: DELETE /api/sessions/{id} gains a profile param for parity with
GET/PATCH (local cross-profile delete).
Also fix the unified-list merge: it concatenated each remote's page onto the
primary's without re-windowing, so a limit=N request could return up to
N*(1+remotes) rows and report the primary's (stale) total. Now it over-fetches
limit+offset from each remote (from offset 0), re-sorts by recency, re-windows
to the page, and recomputes total/profile_totals from the remote counts.
Verified live against a remote backend: rename/archive/delete mutate the remote
db; page 1 windows to limit, profile_totals reflect remote counts, page 2 has no
overlap with page 1. tsc -b clean; connection-config tests pass.
Per-profile remote hosts (#39778) wired the chat/resume socket to a profile's
remote backend, but session list + transcript reads still assumed every
profile's state.db is a local file the primary can open. For a remote profile
the local file is absent or stale, so the IDs the sidebar shows 404 the moment
resume runs against the remote -- the "session not found -> new session" bug.
Intercept the three session-read GETs in the hermes:api handler and route them
to the owning remote backend (which serves its own state.db natively):
GET /api/profiles/sessions -> splice each remote profile's real rows in
GET /api/sessions/{id}[/messages] -> read from the remote for remote profiles
No remote profiles configured -> untouched local fast path. A dead remote
contributes nothing rather than breaking the sidebar.
Verified end-to-end against a live remote backend: a remote-profile session
resumes from remote history and continues on the remote across turns (history
grows in place, no new session spawned).
* docs: remove --include-desktop install instructions
Drop the --include-desktop curl one-liner from the desktop app docs.
The flag remains in scripts/install.sh; these docs now point to the
desktop installer / website and the 'hermes desktop' path instead.
* docs: remove --include-desktop from install docs
Drop the redundant 'Hermes Desktop installer on Linux' block (which
used --include-desktop) from quickstart, installation, and index docs.
The website installer covers macOS/Windows desktop; the CLI-only path
covers Linux. Removes the flag from all user-facing docs.
The in-app updater (Hermes-Setup --update) runs `hermes update`, which lazily
imports the freshly-pulled modules — but the dependency-install step runs the
already-in-memory PRE-pull code for one invocation. When a release changes an
updater-path contract across that boundary, the FIRST update on the parked
population crashes even though the fix is already on disk.
Concretely this is #39780's `_UvResult`: its `__iter__` yields (path, bool), so
Windows `subprocess.list2cmdline([uv_bin, "pip", ...])` injects the bool and
dies with `TypeError: sequence item 1: expected str instance, bool found`
(fixed in #39820). A parked Windows user clicking Update pulls #39820 to disk,
then still crashes on the in-memory pre-merge module; only the SECOND click runs
clean. Field repro: ryanc's bootstrap.log (2026-06-05 12:41:41).
Fix: when the first `hermes update` exits non-zero (and it isn't the
concurrent-instance guard, exit 2, which a retry can't fix), retry once
automatically. The retry loads the now-current module from the start and
succeeds — so the parked user gets a working one-click update instead of a
scary crash + manual second attempt.
Verified: cargo check clean.
* fix(desktop/windows): stop racing our own backend during in-app update
The Windows in-app update (Update button -> hermes-setup.exe --update handoff)
bricked because it raced a still-locked hermes.exe: the desktop quit
fire-and-forget without reaping its backend child + grandchildren, so when
the updater ran `hermes update`, the venv shim was still open. The quarantine
rename then failed, uv's `pip install -e .` hit "Access is denied", the git
path bailed to a full ZIP re-download, and the deps still couldn't write the
locked shim -- leaving a half-applied install. macOS is fine because it never
blocks REPLACE on a running executable.
Three coordinated fixes restore Mac-style parity (click Update -> progress ->
relaunch, no terminal):
A. Desktop (main.cjs): before spawning the updater, releaseBackendLockForUpdate()
tree-kills the primary + pool backends (taskkill /T /F on Windows, to catch
REPL/pty/gateway grandchildren that SIGTERM misses) and polls the venv shim
until it is actually writable (bounded 15s) -- so the lock is gone before we
hand off. Also fixes resolveHermesCliBinary to use venv\Scripts\hermes.exe on
Windows.
B. Updater (update.rs): wait_for_venv_free no longer "proceeds anyway" on
timeout -- it force-kills any lingering hermes.exe (excluding itself) and
re-checks, so a straggler can't doom the install.
C. Updater (update.rs): pass --force to `hermes update`. By contract the desktop
has exited + waited, and the wait force-kills stragglers, so the running-exe
guard would only produce a false "Hermes is still running" dead-end.
Verified: node --check on main.cjs, cargo check on the updater (clean), and the
Windows-gated taskkill body type-checks standalone. Field repro: ryanc's
update.log (manual + handoff both hit the same lock cascade).
* review: scope backend kill+wait to Windows; drop meaningless POSIX pgid kill
Two switch-time regressions from the multi-profile rail work:
- "Session not found" (4007): pruneSecondaryGateways idle-reaps a
non-active profile's backend; switching back respawns a *fresh*
backend that mints new runtime ids, but runtimeIdByStoredSessionId is
never pruned. resumeSession's cache fast-path then makes a dead runtime
id active and returns, so session.usage + the next prompt 404. Probe
the cached id; on rejection drop the stale mapping and fall through to
a full resume that rebinds a live id.
- "Forgets the LLM setting": $currentModel is a nanostore set only by
refreshCurrentModel (gatewayState->open, etc). A swap fires
invalidateQueries() (react-query only) and keeps the socket 'open', so
the model/pill kept showing the previous profile. Re-pull both when
$activeGatewayProfile changes.
* feat(desktop): per-profile remote gateway hosts
Profile switching silently failed whenever the desktop was connected to a
remote backend: the rail routed non-active profiles to a local pool backend,
but spawnPoolBackend hard-threw "Profiles are unavailable when connected to a
remote Hermes backend", and the renderer swallowed the error into an infinite
reconnect backoff while still marking the profile active. Remote was also a
single app-global setting, so there was no way to give a profile its own host.
Add per-profile remote hosts so each profile can point at its own backend:
- connection.json gains a validated `profiles` map; profileRemoteOverride()
(pure, unit-tested) selects an explicit per-profile remote.
- resolveRemoteBackend(profile) precedence: per-profile override → env override
→ global remote → local spawn. spawnPoolBackend now connects to a profile's
remote (no local child) instead of throwing; startHermes resolves the primary
profile's remote.
- coerce/sanitize connection config are scope-aware (global vs named profile)
and preserve each other's entries; IPC get/save/apply/test thread an optional
profile. Per-profile apply drops only that profile's pool backend.
- Settings → Gateway adds an "Applies to" scope selector reusing the existing
URL/token/OAuth/test UX per profile.
Tests: connection-config pure suite (+6) and desktop platform suite pass;
tsc/eslint/vitest clean.
* refactor(desktop): DRY per-profile remote helpers
Share connectionScopeKey + normAuthMode from connection-config.cjs (drop the
main.cjs copy), collapse the scope/auth ternaries, route the env remote through
buildRemoteConnection, and fold the duplicated remote-block validation into
buildRemoteBlock. No behavior change; pure suite + live E2E still green.
* Revert "fix(update): require managed marker before destructive clean"
This reverts commit c8e80cd0bf.
* Revert "fix(update): stop stash/restore from clobbering desktop source on managed clones (#38542)"
This reverts commit 8a19884bf3.
* chore(install): keep npm ci desktop-build fix after stash revert
The destructive-clean reverts (#38542/#39568) pulled the desktop
workspace install back to bare `npm install`. The npm ci -> npm install
fallback is orthogonal build-correctness (avoids the Windows
workspace-hoisting flake where install reports up-to-date against a
stale marker while node_modules is empty, breaking tsc -b). Preserve it.
* feat(update): settable stash-or-discard for non-interactive local changes
Adds updates.non_interactive_local_changes (stash | discard, default
stash). Governs ONLY non-interactive updates (desktop/chat app, gateway,
--yes) — interactive terminal updates always stash-and-ask, unchanged.
- config.py: new key under existing updates section; _config_version 26->27.
- main.py: _cmd_update_impl detects non-interactive (gateway/--yes/no-TTY),
reads the setting; new _discard_stashed_changes() drops the stash
(stash-and-drop, never reset --hard/clean -fd, so ignored paths survive).
Post-pull restore site branches on it; the bail-out and up-to-date
restores always preserve work.
- web_server.py + apps/desktop settings: exposes it as a stash/discard
select (Advanced section, In-App Update Local Changes).
- docs + tests (discard drops, stash restores, interactive ignores setting,
missing section defaults to stash).
* fix(install.ps1): stash/restore instead of reset --hard on Windows update
The PR reverted the destructive update path to stash/restore everywhere
except scripts/install.ps1, whose managed-clone update path still ran
`git reset --hard HEAD` before checkout — silently destroying agent-edited
tracked source on Windows (the same #38542 data-loss class the PR fixes).
- Replace `git reset --hard HEAD` with stash-before-checkout +
restore-after-checkout, mirroring install.sh. Untracked files are
included so agent-created dirs (e.g. tinker-atropos/) survive.
- Keep `core.autocrlf false` (it prevents the phantom CRLF dirt that made
the stash necessary; it's also load-bearing for a clean restore).
- Wrap all three checkout modes (Commit/Tag/Branch); Branch case now uses
`git pull --ff-only` so local commits are never clobbered.
- Only prompt to restore when a real console is attached (UserInteractive
+ non-redirected stdin/stdout + ConsoleHost); the desktop Update button
and bootstrap have no usable console, so they default to restore and
never hang on Read-Host.
- On restore conflict or a failed update, the stash is preserved with
recovery instructions — work is never silently dropped.
Validated on Windows (PowerShell 5.1, git 2.54): AST parse clean;
E2E non-conflicting restore applies+drops cleanly with ignored paths
(node_modules) untouched; conflicting restore preserves the stash.
---------
Co-authored-by: alt-glitch <balyan.sid@gmail.com>
The desktop OAuth remote-gateway path gated connectivity on
hasOauthSessionCookie(), which checks only the access-token cookie
(hermes_session_at, ~15 min TTL). The moment that cookie's Max-Age
lapsed, Electron's cookie jar dropped it and both resolveRemoteBackend()
and sanitizeDesktopConnectionConfig() reported "not signed in" — forcing
a full IDP re-login every ~15 min — even though a valid 24h refresh-token
cookie (hermes_session_rt) was sitting in the same jar.
The desktop OAuth code (2026-06-04) was written against the obsolete
"contract v1 issues no refresh token" model, two days after #37247
re-introduced server-side transparent refresh: Portal now issues a 24h
rotating, reuse-detected refresh token, and the gateway middleware
(_attempt_refresh) rotates a fresh AT from the RT on the next
authenticated request. So an expired-AT/live-RT session is fully
connectable — the desktop just never let the request through.
Fix:
- connection-config.cjs: add RT_COOKIE_VARIANTS + cookiesHaveLiveSession()
(true when EITHER a live AT or RT cookie is present). Keep
cookiesHaveSession() AT-only for callers that need that specific signal.
- main.cjs: add hasLiveOauthSession(); resolveRemoteBackend()'s oauth
branch now early-outs only when NEITHER cookie is present, otherwise
uses the ws-ticket mint as the authoritative liveness probe (that POST
carries the RT cookie and triggers the server-side AT rotation). A real
401 still surfaces as needsOauthLogin. Settings indicator + oauth-logout
report against the same AT-or-RT notion.
- Remove the stale "contract v1 / NO refresh token" docstrings in
cookies.py and the verify_session comments in the Nous provider that
contradicted #37247.
Tests: +57 lines in connection-config.test.cjs covering the RT-only
"still connectable" case. node --test: 32/32. dashboard-auth +
nous-provider Python suites: 223/223.
Note: server-side files (hermes_cli/dashboard_auth/, plugins/dashboard_auth/)
are comment/docstring-only here, but this touches outside apps/desktop/ so
it needs Teknium review.
Clears the npm-audit React Router advisory CVE-2026-42342 in the web
and apps/desktop workspaces by bumping react-router-dom 7.14.x -> ^7.17.0
(patched in 7.15.0; both react-router and react-router-dom now resolve
to 7.17.0 in the root lockfile).
Note: the advisory's DoS only affects React Router *Framework Mode*
(the __manifest server endpoint). Both workspaces use Declarative Mode
(web: <BrowserRouter>, desktop: <HashRouter>) as pure client-side SPAs,
so we were never actually exploitable -- this is audit-hygiene only.
npm audit --omit=dev: 0 vulnerabilities. Web + desktop + ui-tui builds
and tsc typecheck all green on 7.17.0.
Youssef's review caught a residual false-positive: resolveTestWsUrl
swallowed an OAuth ticket-mint failure and returned null, so the caller
skipped the WS probe and reported the remote test as reachable. But the
real boot path (resolveRemoteBackend) treats a mint failure as a hard
'session expired' auth error and refuses to connect — so an expired OAuth
session passed the test then failed boot, the exact false-positive this
PR exists to kill.
Extract resolveTestWsUrl into the electron-free connection-config.cjs
(injectable mintTicket) so it's unit-testable, and make OAuth mint
failure throw an actionable needsOauthLogin error instead of skipping.
Adds the three cases Youssef requested plus a mintTicket-required guard.
The "Test remote" button only checked HTTP GET /api/status, but the chat
surface depends on the renderer opening a live WebSocket to /api/ws — a
separate transport with separate server-side guards (Host/Origin checks,
ws-ticket/token auth, peer-IP checks). A gateway could pass the HTTP check yet
reject the WebSocket, so the test reported "reachable" while boot still failed
with the opaque "Could not connect to Hermes gateway".
testDesktopConnectionConfig now mirrors the renderer's connect: after the
status check it opens the WS URL (token/local) or a freshly minted ws-ticket
(OAuth) and confirms the upgrade is accepted and not immediately torn down by
a post-handshake auth rejection. Failures surface an actionable message instead
of a false-positive. The WS leg is skipped when the runtime lacks a global
WebSocket so it never fails spuriously.
Adds electron/gateway-ws-probe.cjs: a small helper that opens a gateway
WebSocket URL and classifies the handshake (open/frame → ok; error or close
before open → fail; open-then-early-close → credential rejected; never-opens →
timeout). The WebSocket implementation is injected so it can be unit-tested
without a real socket.
Wires gateway-ws-probe.test.cjs into test:desktop:platforms, covering every
handshake outcome plus constructor-throw and missing-impl.
The first-run provider picker was a hard gate — the only way out was
connecting a provider. Add an 'I'll choose a provider later' link that
dismisses the overlay and persists the skip to localStorage so it never
re-nags on subsequent launches. Users connect a provider any time from
Settings -> Providers (manual onboarding already bypasses the skip gate).
- onboarding.ts: firstRunSkipped state seeded from localStorage
(hermes-onboarding-skipped-v1) + dismissFirstRunOnboarding() action;
completeDesktopOnboarding clears the flag once a provider connects.
- overlay: skip gate (firstRunSkipped && !manual returns null); ChooseLaterLink
rendered in both the OAuth picker footer and the API-key fallback, first-run only.
- tests: skip persists + hidden in manual mode; full-state fixtures updated.
Mirror the workspace-group "+": each profile header in the all-profiles
session list gets a new-session button. Unlike selecting the profile, it
leaves the browse scope untouched (newSessionInProfile keeps
$showAllProfiles), so creating a chat doesn't collapse the unified view.
Keep one persistent socket per profile with live work instead of closing
the single socket on every profile swap, so background sessions across
profiles keep streaming at once. A gateway registry owns the primary
(window) socket plus lazy secondaries (own backoff/reconnect); all feed
the same session-keyed event handler. Secondaries are pruned to profiles
with a working/needs-input session, the keepalive pings every open
backend, and LRU eviction spares freshly-touched backends so the soft cap
can't abort a running agent. Approval/sudo/secret prompts are parked
per-session (surfaced via the needs-input badge) so a background turn can
block without hijacking the foreground. Single-profile users only ever
have the primary, so their path is unchanged.
Resolve conflicts in desktop settings/cron/messaging/sidebar: adopt main's
ListRow + actions-menu refactors for credential rows; keep our profileColor
import on the sidebar. Drop the now-orphaned Tip-based helpers.
Hold (~450ms) a profile square — or right-click → Color… — to open a
shadcn Popover of swatches and override its rail color, with Auto to fall
back to the deterministic hue. The hold timer rides alongside the dnd
pointer listener (a real drag cancels it, the trailing click is
suppressed), so reorder/select/recolor stay distinct gestures.
Overrides persist in localStorage ($profileColors), resolved via
resolveProfileColor (override wins, else the name-hashed hue). Cosmetic
and gated on the multi-profile rail, so single-profile users are
unaffected. Adds a reusable ui/popover.tsx (radix-ui umbrella).
When reasoning text grows during streaming, new parts can be appended
beyond endIndex. The pending check used slice(startIndex, endIndex)
which excluded these new parts — if the original part completed, the
block would close while new reasoning was still streaming.
Fix: remove the endIndex cap from slice() so all parts from startIndex
onward are checked. During non-streaming, the array is stable and
all parts are within range anyway.
Centralize the fallback in DeleteProfileDialog (the single delete choke
point) so both the rail and the Profiles view inherit it. Reset *after*
the host's onDeleted refresh so a refreshActiveProfile racing the dying
backend can't clobber the pill back to the deleted profile, and set
$activeProfile too (selectProfile only moved the gateway, leaving the
statusbar pill stranded on the dead profile).
Drag a sidebar session into the composer to drop an @session:<profile>/<id>
chip the agent resolves via session_search. New READ shape dumps a whole
session by id (head+tail when large); a `profile` param reads another
profile's DB read-only, and a cross-profile locate scan resolves bare ids
when the model drops the owning profile from the link.
Also: ASCII "waking up <profile>" overlay during lazy gateway swaps,
global haptic rate-limit to kill the reconnect-storm "clickity" buzz, and
reauth toasts surfaced once per disconnect instead of every backoff tick.
The desktop `/title <name>` command 404s with "Session not found" on
every platform (reported on Windows in #38508).
Root cause: `session.create` returns two distinct ids — a *runtime*
session id (held in `activeSessionIdRef`) and a `stored_session_id` (the
DB `sessions.id`) — and deliberately does NOT persist a DB row until the
first turn. Routing `/title` through the REST `PATCH /api/sessions/{id}`
endpoint (as #38576 proposed) resolves the id against the `sessions`
table, so the runtime id — or any brand-new, not-yet-persisted session —
never resolves and returns 404. This is an id-type mismatch, not a
Windows file-locking quirk, so it fails on macOS and Linux too.
Fix: route `/title <name>` through the gateway's `session.title` RPC —
the exact path the TUI already uses (`ui-tui/.../slash/commands/core.ts`).
The RPC maps the runtime id to the in-memory session, writes through the
gateway's own DB connection, and queues the title (`pending: true`) when
the row isn't persisted yet, so it works for a fresh chat. The sidebar is
then refreshed via the existing `refreshSessions()` plumbing.
Keeps the sidebar-refresh wiring and `refreshSessions` threading from
#38576; replaces only the broken REST/slash-worker write path. A bare
`/title` (no arg) still falls through to the worker to show the current
title.
Tests rewritten to assert `session.title` routing with the runtime-vs-
stored id distinction (which the original mock collapsed), plus the
queued/`pending` fresh-chat case and the error path.
Supersedes #38576. Fixes#38508.
Co-authored-by: xxxigm <54813621+xxxigm@users.noreply.github.com>
When a remote gateway with username/password (or OAuth) auth restarts, its
session cookie lapses and Desktop boots into the recovery overlay with a
session-expired error. That overlay only exposed local-recovery actions —
Retry (resets the local bootstrap latch) and Repair (re-runs the installer) —
neither of which can re-establish a remote session, so the user is stuck in a
no-op Retry loop with no way to sign in again.
The overlay now detects a remote-reauth boot failure from the saved connection
config (remote + gated + not currently connected + has a URL) and surfaces a
primary 'Sign in to remote gateway' button that opens the gateway login window
(the username/password form for a basic gateway, the OAuth redirect otherwise)
and reloads on success. Button copy is driven by a best-effort provider probe,
matching the gateway-settings page. Detection and copy logic live in a pure
helper module with unit coverage.
- right-click a profile square to rename or delete it, via shared
self-contained dialogs (also reused by the profiles page)
- switching or creating a profile now resets to a fresh new-session
draft so the prior session doesn't stay sticky across contexts
- deleting the profile you're currently in falls back to default
instead of stranding the gateway on a dead profile
- shared ConfirmDialog: Enter/Space confirm from anywhere in the dialog;
profile-delete and cron-delete both route through it
The per-session icon picker added more noise than value — rip it out end
to end (sessions.icon column, set_session_icon, the PATCH field, the
picker UI, and the SessionInfo.icon type).
The cross-profile session aggregator now opens each profile's state.db
read-only (mode=ro, no schema init), so listing other profiles on every
sidebar refresh never DDLs or takes a write lock on their live DBs. The
single-profile hot path stays on par with /api/sessions.
Left-align the default's home icon next to the create "+" in the
single-profile state (toggle/squares/Manage still appear only once a
second profile exists).
Always mount the profile rail, but when only the default profile exists
render just the create-profile "+" (hide the default/all toggle, the
draggable squares, and Manage). Gives a first-profile affordance without
the full switcher chrome; everything else appears once a 2nd profile exists.
If a user drops back to a single profile while scope is still ALL
(persisted), the rail is hidden — they'd be stuck in the grouped view
with no toggle out. Fall back to the scoped view when only one profile.
- Wheel maps vertical scroll → horizontal so the rail is navigable with a
plain mouse (trackpad x-scroll still passes through).
- Springy easeOutBack reflow; dragged square glides between snapped cells
(no scale — overflow-x strip would clip it) with a subtle lift.
- Haptic 'selection' tick per crossed cell + 'success' on a committed reorder.
Snap the drag transform to whole cells (no free glide) and clamp it to the
occupied squares strip via a relative wrapper as offsetParent, so a square
can't float past the last profile onto the "+" and break the layout.
overflow-x-auto makes overflow-y compute to auto, so a vertical drag
translate faulted in a cross-axis scrollbar. Pin the drag transform to
y:0 with a modifier — squares only slide horizontally now.
Bind Cmd/Ctrl+P to the command palette alongside Cmd+K (VS Code quick-open
muscle memory); Cmd+. stays the command center. No Print accelerator
competes, so the renderer preventDefault is enough.
Make the named-profile squares reorderable via dnd-kit (horizontal sort,
4px activation so a tap still selects). Order persists in localStorage
($profileOrder); unordered/new profiles alphabetize at the tail.
- Add a "+" in the profile rail that opens a self-contained CreateProfileDialog
(name + clone toggle + optional SOUL.md); extract it and ActionStatus from
the profiles view so both surfaces share one flow.
- Keep the profile rail pinned to the bottom when a profile has no sessions by
rendering a flex-1 spacer (previously the rail floated up to the nav).
Add first-class profile support to the desktop app without app reloads.
- Swap the single live gateway onto a session's profile lazily (spawned on
demand by the Electron backend pool), so one backend serves the active
profile and others stay cold — no OOM with many profiles.
- Aggregate sessions across profiles by reading each profile's state.db
read-only; unified "All profiles" view groups sessions per profile with
per-profile pagination, while the default view stays scoped to one profile.
- Add an Arc-style profile rail at the sidebar foot: a default<->all toggle
pinned left, colored named-profile squares scrolling between, Manage pinned
right. Profile identity is a deterministic per-name color.
- Route profile-scoped REST (config/env/skills/tools/model) to the active
gateway profile and invalidate React Query caches on swap. Single-profile
users never trigger a swap, so their path is unchanged.
Backend:
- web_server: profile-aware active/list endpoints + per-profile session
totals; hermes_state: session_count(exclude_children); main.py: honor
--profile over HERMES_HOME env for pooled backends.
UI primitives:
- Add a position-aware Tip tooltip (instant, themed) as a drop-in for native
title=, and strip redundant tooltips from self-descriptive chrome.
Two fixes for desktop app slash command handling:
1. Slash commands submitted while the agent is busy now execute
immediately instead of being queued. Previously submitDraft()
unconditionally queued any draft when busy, but slash commands
are client-side operations or self-contained gateway RPCs that
should run regardless of busy state (matching TUI behavior).
executeSlashCommand already has its own per-command busy guard
for commands that genuinely need an idle session.
2. Slash command trigger items no longer leak the "|index" suffix
from their item.id into the serialized chip text. The
toItem callback now sets rawText in metadata so
hermesDirectiveFormatter.serialize takes the direct-insertion
path instead of the legacy @type:id fallback. This also means
slash commands enter the composer as plain text (not chips),
matching selectSkinSlashCommand and TUI behavior.
search_sessions_by_id previously fetched up to 10k sessions via
list_sessions_rich and filtered them in Python — O(n) per keystroke.
Push the id match into SQL instead.
- list_sessions_rich gains an optional id_query param: a case-insensitive
LIKE pushed into the outer WHERE, matched against each surfaced row's id
AND every id in its forward compression chain (via the existing chain
CTE). Searching a compression root id or a tip id both resolve to the
same projected conversation. LIKE wildcards in the needle are escaped.
- search_sessions_by_id now fetches only matching rows (limit*4) and ranks
exact > prefix > substring in Python over that small set.
- web_server /api/sessions/search: route ID matches and content matches
through one lineage-keyed dedup helper so an id-hit and a content-hit on
the same conversation collapse to a single result (the contributor's
version keyed ID hits by raw sid and content hits by root, which could
double-list a compression tip).
- command-center haystack also matches _lineage_root_id for parity.
E2E verified against a real DB: exact match over 3000+ sessions
materializes 1 row in Python (was ~3000), 5ms; root-id resolves to tip;
LIKE-wildcard escaping holds.
Follow-up to @0xharryriddle's feat(desktop): search sessions by id.