group-chat-view now renders the hold-detection Switch, so every sibling test that wholesale-mocks
@hermes/plugin-sdk must return it; only the render test's mock had been updated.
The salvaged providerDisplayName duplicated onboarding PROVIDER_DISPLAY titles;
keep one table in the lib and let onboarding keep only its featured order.
The DecodeText scramble primitive replayed forever by default, so every
quiet-surface placeholder that uses it (the empty pane zone's "HERMES"
mark, the contrib LOGS/HERMES panes) kept a 45 ms setInterval + setState
alive for as long as it was on screen. Measured with the perf harness on a
seeded 200-message session with no turn running, that ticker alone held the
idle renderer at ~9.6 React commits/s (DecodeText: 16 renders/s); with it
resolved once, the same workspace idles at ~0.7 commits/s.
`loop` now defaults to false; the boot "CONNECTING" overlay — the one
progress surface that should replay while it waits — asks for it explicitly.
The vitest drives the real component under fake timers and is red on base
(the tail keeps scrambling after the hold).
Part of #98394
Review follow-ups on the rotation-foreground gate:
- Assert the residual a suppressed rotation leaves behind. Nothing
re-points the primary, so its selection and route keep the
PRE-rotation stored id; that is benign only because the lineage row
still resolves it to the tip, which is now asserted with the same
cachedSessionRow() lookup every sidebar/route resume goes through.
- Note in the suppression cases that the "hash route" shape IS the
pop-out/secondary-window shape: isSecondaryWindow() starts that
renderer with $sessionTiles empty and $layoutTree null, so
$focusedStoredSessionId collapses to the selection and the route is
the only voice left. A separate pop-out case would be a byte-identical
store state.
- Trim wrong-session-closeout.test.tsx to the one invariant this fix
owns: the real producer wired to the real route-follow consumer must
not navigate, re-select, or move focus. The queued-drain fence and the
attachment recovery it also composed are pinned by their own suites
(use-background-queue-drain, use-prompt-actions), so coupling this
fix's red signal to them only misattributes failures.
Composes the producers mapped on #86106 into one regression against the real
cache, session-actions and prompt-actions hooks plus the production
owner-routing dispatcher (gateway edge mocked, no injected bindings):
A active -> B queued send -> user focuses tile C -> B runtime reaped ->
A's delayed stored-id rotation -> B drain (text + image) -> B recovers
On origin/main the rotation consumer navigates the primary to A-next while
the user is on tile C (route/focus steal); with the #86359 gate the user stays
on C, B's attachment and prompt reach connection-B on B's recovered runtime,
and every stored id maps to exactly its own runtime.
Fold the six #86359 cases into two it.each tables: one negative over the
three surfaces that can name the on-screen session (stored selection,
HashRouter route, focused tile) and one positive over the shapes that still
belong to the rotating lineage. Same coverage, one assertion body per
invariant, so a future change to the foreground rule has one place to read.
isSessionInForeground now treats a missing route and store selection as
the on-screen chat, so a fresh session can still emit A -> A-next.
Tests drive the primary-route branch via history.pushState and pin the
no-route/no-selection path.
When a session stored id is rotated (e.g. by auto-compression), the
rotation event was emitted whenever the active runtime id matched,
ignoring whether the user had already switched focus to a different
session. This let a fast A -> B switch followed by A delayed rotation
pull the foreground route back to A new tip.
Add isSessionInForeground() in session-states.ts that checks the
current route/selected stored id against the rotating session lineage.
Apply the guard both in handleTransition() and in the no-op-updater
path inside useSessionStateCache.ensureSessionState() so all rotation
emission sites re-validate the user current focus.
Include a regression test that sets selectedStoredSessionId to an
unrelated session while the active runtime rotates, expecting no
activeSessionStoredIdRotation event.
os.getenv saw only the launch profile, so a DeepSeek key pasted in
another profile never appeared in Settings → Model until Refresh
models ran against that Bot's own backend.
The page only showed a read-only active-profile note, so endpoint
saves followed the left-rail Bot instead of the Settings chips
Accounts and API keys already share.
Desktop persisted a large paste under Electron's userData dir and attached
it as `@file:<abs path>`; the tui_gateway prompt path expands that reference
with `allowed_root=cwd`, so `_resolve_path` refused it with "path is outside
the allowed workspace" whenever the chat's cwd was not an ancestor of the
paste dir (always on Linux/macOS, and on Windows for any project cwd).
Electron now writes pastes under `<HERMES_HOME>/composer-pastes`, and
`agent/context_references.py::_resolve_path` admits exactly that anchored
directory (active profile home and global root, via file_safety._hermes_dirs)
as the one root besides `allowed_root`. A sibling directory that merely
contains the substring stays refused; the credential deny-list still runs
on the admitted path.
Supersedes the substring whitelist proposed in #117150.
Fixes#117149
Port the reporter's (@tianyu-liu) url_effective half: curl reports where it
landed via --write-out on a tail buffer that survives the body budget, and
isAuthWall decides on arrival URL -> sign-in title -> markup, so the Apps
Script ServiceLogin shape (no markup id) is a wall too and a wall's own title
is never used as the link label. Tests trimmed to two invariants.
Co-authored-by: tianyu-liu <tianyu-liu@users.noreply.github.com>
A Desktop whose active connection is remote (SSH/remote/cloud, including the
registry primary) owns no local messaging gateway, yet the update hand-off
always ran `hermes update --gateway`. On hosts where launchd/service recovery
fails, the updater falls back to a detached local `gateway run --replace`;
with the same Telegram bot token as the remote VPS gateway, the two processes
compete for getUpdates and Telegram rejects one consumer, taking the
production bot offline (#117529).
Pass the ownership down the hand-off: globalRemoteActive() now adds
--no-gateway (posix) / -NoGateway (windows) when the Desktop is remote-served,
and both orchestrators drop --gateway from every update invocation (initial +
retry). The local-ownership default keeps --gateway exactly as before.
The OS/Chromium can SIGKILL a renderer while the window is live (memory reclaim, an external kill). Hermes never does this itself and never reloads it (a killed-after-close window must not pop back up), so the window sat silent with only a desktop.log line.
- window-renderer-lifecycle.ts: optional onRendererTerminated(details) callback, invoked for an unrecoverable render-process-gone on a live (not destroyed) window; recoverable crashes still reload; expected teardown still does nothing.
- renderer-load-error-page.ts: optional escaped title for the recovery page.
- main.ts: wire the primary window's callback to a visible recovery page (reason + Reload), guarded against intentional quit/handoff (isQuittingForHandoff || backendShutdown.hasStarted()).
- tests: three-arm lifecycle invariant + custom-title test.
Electron-only slice harvested from #116592 (commit 9bb16bd6ae) per the split advised on #117084; the agent half of #116472 lives in #117084.
(cherry picked from commit 5bdfe7ed00c4b3f764d4044fdfa527f978140cf3)
enumerateRegistryAgentSources grew when the ssh branch started carrying the
install id learned by the inventory probe, so the 3_700-char slice in
backend-dial-claim.test.ts stopped before getJsonForBackend('/api/profiles')
and CI went red. Same fix the sibling update-all case already applied.
#88828 gave every connection a stable backend identity so one physical install is one roster row
per profile — but wired the probe into the non-ssh branch only. `probeConnectionInstallId` has a
single caller, in the enumerator's `else`; the other writer, `rememberConnectionInstallId`, sits
in `hermes:connections:test` after its ssh branch has already returned. So an ssh connection's
`installId` was always undefined, `buildAgentRoster` always fell back to the per-connection key,
and a Mac registered twice over ssh (alias + address, LAN + WAN) showed every bot twice with
`@name-label` handles, pushed two rows per bot into every gateway's relay roster
(`write_remote_roster` dedups by connection_id + profile), and made a bare-handle DM to it
`ambiguous` — asking the sender to disambiguate between two ids naming one machine.
The id is a plain file at the install root, and the ssh inventory probe already reads the remote
home over its own session without spawning a dashboard, so `readRemoteInstallId` reads it there:
from the INSTALL root (a connection pinned at `<root>/profiles/<name>` must report the same
backend as one pointed at the root), read-only — a missing file stays missing, since minting
identity is the install's job and not a visiting client's — and only a well-formed id counts, so
an older backend keeps today's no-id behaviour.
Fixes#117226
(cherry picked from commit 3d596f9b8fc898c74f96a378588a0b4601bfe39d)
Three main-process caches are keyed by connection id — the ssh profile list, its retry stamp and
the backend install id — and neither lifecycle event that invalidates them evicted anything.
`hermes:connections:remove` stopped pooled backends and told renderers to dispose their sockets;
`saveRegistryConnection`'s dial-material branch recycled sockets for exactly this reason, its own
comment noting they "point at the OLD target". The caches were not in scope for either.
Ids are recycled label slugs (`connectionIdForLabel` suffixes only against CURRENTLY taken ids),
and `shouldRetrySshInventory` never retries a cached success — "Cached successes never retry" —
so a connection re-added under the same label, or simply pointed at another host, kept serving the
previous machine's profiles for the rest of the app session. Clicking one of those bots dialled
the new box and failed, the relay pushed the phantom names to every peer gateway, and a peer bot
DMing one got 4092 `no profile '<name>' on this gateway` while the new machine's real bots stayed
invisible. Only a restart or a manual Test recovered.
The three maps move into `connection-caches.ts`, where the invariant they share can be stated and
tested — each is valid only while its id names the same machine — and `evictConnectionCaches()`
is called from both handlers. Every existing reference in main.ts is unchanged: the module
exports the same Map objects.
Fixes#117227
(cherry picked from commit 03847f66e8eae50f1972511ec89421b0298dbd22)
The chat bar was gated on `!loadingSession`, so any one-render flip of the
routed session into its loading state (periodic sidebar / live-status
refresh dropping the routed row, a hydrate swapping the transcript through
an empty frame) unmounted the composer: draft, attachments, caret and focus
vanished and came back every ~30s. Once a route has rendered with its
composer, a later transient loader for the same route now keeps it mounted;
a route change, the exhausted state and watch windows still hide it.
The unmount also fired `placeCaretEnd` / `placeCaretAtOffset` on the
detached editor from the draft repaint, throwing
`addRange(): The given range isn't in document` inside React's commit and
looping the reconciler (the visible flicker). Both caret helpers now bail
when the editor or the computed range is no longer connected.
Fixes#117375Fixes#117285
The Settings -> Gateway "Token stored in plain text" banner predates the
opt-in keychain policy: it was written when a plain token could only exist
on keyring-less Linux, and it fires off the token encoding alone. Since
keychain encryption became opt-in (default off), every saved token is
plain, so the banner shows for every default user, while
probeSecureTokenStorage deliberately reports availability in that mode
"so no plain-text warning banners fire: storing plaintext is the user's
chosen (default) mode, not a degraded state."
- Compute the renderer-facing signal in a new
hardening.resolveRemoteTokenPlainText helper: true only when the token
is plain AND secure storage is genuinely unavailable (the keyring-less
opt-in path the banner was written for), and never under the
HERMES_DESKTOP_REMOTE_URL env override.
- Behavioral tests for the truth table plus a source assertion pinning
the call site; both proven red against the ungated logic.
- Scope the Linux-only keyring names in the plain-text copy (banner and
save-time confirm, en/zh/zh-hant/ja/ru) to Linux, since both can render
on any platform.
Fixes#117269
A rail jump selects a bounded around-page, which drove `onEdit: undefined`
on the runtime. assistant-ui's `useActionBarEdit` only checks
`composer.isEditing`, so the edit button stayed enabled while `beginEdit`
threw "Runtime does not support editing" — the inline composer was dead
for every message after any far jump, infectious downward, and healed
only by the floating jump button's returnToLatest.
`isDisabled` still keeps the page static (no submit/reload/branch) and
`editMessage` resolves its target against the live session store, so the
edit rewinds the real transcript and drops the page.
(cherry picked from commit 326b245ad7cc19a1d4db2a06da7a01cd07043117)
Scoping the right rail per profile made session-states import preview.ts,
which imports layout.ts, which derives its grouping from $showAllProfiles.
The Manage Profiles page test mocks @/store/profile without that export, so
vitest rejected the module graph and the whole suite failed to load.
Drop the per-profile Electron session partition (a second mechanism that
would reset every secondary-profile window's persisted layout once) and keep
the rail-per-owner store change only; trim the scope tests to two
invariants and sort imports for the repo lint.
The right rail's tabs were persisted under ONE global localStorage key, so a
preview opened in one agent's chat appeared in every other agent's chat —
Tess's 3D model showed up in VEXA's rail and vice versa.
apps/desktop/AGENTS.md states the rule this violates: "Persisted state must
declare its scope in its own key: is this global, or does it belong to a
connection, a profile, a stored session, a project, or a window? Getting the
scope wrong is how one profile's setting bleeds into another."
The scope is the CHAT ON SCREEN, deliberately not the window's gateway socket:
a focused tab does not swap the socket, and every Bot Mode chat is served by one
pooled backend, so a socket-keyed rail shows one agent's previews in every
agent's chat. bot-row.tsx documents the same trap for the roster highlight
("Highlight follows the chat on screen (focused session's owner), not the
gateway socket's home — a focused tab doesn't swap the socket").
The rail therefore resolves its scope from the focused session's owner via the
existing knownOwnerForSession ladder, the same value the roster highlight
trusts, so the two cannot disagree about whose chat is on screen.
session-states.ts owns that resolver and already imports preview.ts, so it
pushes the scope in (setPreviewScope) rather than the rail reaching back, which
would be an import cycle.
The bucket map mirrors tilesByProfile, including a "view key" that follows a
rename (without it the persist subscriber re-creates the bucket the rename just
deleted) and lazy adoption of a legacy global array into the first scope that
arrives, so tabs the user can see survive the migration. Registered in both
dropTilesForProfile and migrateTilesForProfile, as the guide requires of any
profile-keyed localStorage family.
Also gives each non-primary profile its own Electron session partition on the
session pop-out and instance windows, so a per-agent window keeps its own rail
even across partitions.
Tests: preview-tabs-scope.test.ts holds the contract (no cross-agent bleed, per
agent buckets, rename moves rather than strands, and a socket change does NOT
re-home the rail — the regression that made the first cut look correct).
(cherry picked from commit dd40bd75fe7e27b9164af67b352884fc28242fef)
The Electron updater copied state.db to state.db.pre-update-emergency-*.bak on every
update hand-off regardless of updates.pre_update_backup, pinning ~3x state.db on disk.
preflightStateDb now asks the backend (`hermes config get updates.pre_update_backup --json`)
and skips the emergency copy when the mode is off/false/none/disabled; any probe failure
fails open to taking the backup.
Squashed from PR #116755 (4 commits) for a clean apply onto origin/main.
Agent tips are unbounded in number; only the ✕ ($retiredTips) needs to persist, so showTip no
longer writes agent: ids into $tipShownAt. Folds the duplicate store test into the first one.
macOS smart-quote substitution turns the straight quotes a user types into “ ” in the composer,
so the quoted-span mask covers both; the user-visible rule (stop words inside code, quotes or
blockquotes never hold) now has its sentence in bot-mode.md alongside the behaviour.
stopWordPlacement() tokenized the raw message string, so a stop/halt/pause
token inside a fenced code block, inline code span, straight-quoted span or
blockquote line still landed within the two-token window of an @token and
held the resolved member. Reporting or pasting a log that contains
"stop @impl" held the bot again, repeatedly — all three repro cases from
the report hold on main.
maskQuotedAndCodeSpans() collapses each such span to the @tokens it
contains (mentions resolve from raw text and must keep their place in the
proximity window, so quoting AT a bot still releases a held member) or to
one neutral filler word, so masking never manufactures adjacency:
"stop `service` @impl" still holds. An unclosed fence — what a cut-short
paste produces — swallows the rest of the message; blockquote lines are
masked line-wise.
Mention resolution, bare-mention release and the @all wake semantics stay
untouched: the plain directive forms, the German-prose and distant-stop
suites are green, with new invariant tests for the quoted/pasted cases.
Fixes#117040
refreshVoiceLiveStatus is the only caller and probes the active profile (engine selection is a
window-level capability), so the owner overload had no production reach; its direct-call test
pinned a surface that never shipped. Ownerless routing is now asserted inside the owner test.
The runtime plugin loader's specifier regex is unanchored (from\s*['\"]), so a
plugin whose own source contains a string ending in "from" — e.g. a 'Copy
keys from' UI label — was rejected as an "unsupported import", and a mapped
specifier quoted inside a string (documenting the import form) was rewritten
into a shim blob URL, corrupting the string at load.
Scan the source once for code ranges (string, template-literal and comment
text excluded; template ${…} interpolations stay code) and honour specifier
matches only inside them. Both failure modes are real: a plugin's own label
made the entire plugin fail to load in the desktop app.
Tests: 3 new invariants proven red on base (label/comment must not
load-block, in-string specifiers must stay verbatim), 2 guards stay green
(real unmapped import still rejected, real mapped import still rewritten).
(cherry picked from commit 3ac1d440cb1e21f8931a75bc07074363fccaa0a7)
The salvaged commit added https-proxy-agent, proxy-from-env and
@types/proxy-from-env as caret ranges; the repo pins every dependency to an
exact version so `npm ci` resolves the same tree everywhere. Pins are the
versions the lockfile already resolved (7.0.6 / 2.1.0 / 1.0.4), regenerated
with `npm install --package-lock-only`.
Documents that the Desktop update check now follows HTTPS_PROXY / HTTP_PROXY /
NO_PROXY in the environment-variables reference.
The provider-agnostic half of PR #105863, so a CLI-driven subscription provider can ship as a
standalone `kind: model-provider` plugin instead of a bundled one:
- ProviderProfile: `native_reasoning_details_type`, `model_aliases`, `get_model_context_length`,
`get_usage_cost`, `setup_status`, `discover_models` hooks (all default None / no-op).
- Chat Completions transport: provider-native `reasoning_details` carriers follow only their
declaring profile; standard records still replay on OpenRouter-style routes, strict routes
drop the field wholesale (#70233). Relay/stream accumulate `delta.reasoning_details` verbatim.
- `hermes model`: the generic plugin flow gates an external-process row on the CLI's own login
status (inline `login_command` on a TTY), offers `discover_models()` rows with per-row notes,
and never writes config when the executable is missing.
- `/model` and the pickers: process providers list their live catalog merged with the pinned
one, declared aliases/ids resolve inside the provider, and validation accepts a listed id
without probing `process://`.
- Delegation keeps the selected external-process provider and protocol for the child.
- Model metadata / usage pricing consult the profile's bound and cost hooks first.
- Desktop: `[1m]` renders as a "1M" tag and hyphenated Anthropic versions read "Haiku 4.5".
The bespoke `_model_flow_external_process` and hard-coded `hermes_cli/main.py` paths from the
PR were dropped in favour of main's `_model_flow_plugin_provider`.
Co-authored-by: unsupportedpastels <unsupportedpastels@users.noreply.github.com>
The statusbar workspace menu built its reveal item unconditionally while
the sidebar menus (file-actions.tsx, review/file-tree.tsx) already hid it
on a remote backend, and `hermes:fs:reveal` returned true after
`shell.showItemInFolder`, which silently no-ops on a missing item — so a
remote bot's workspace path gave a click that did nothing and reported
success.
- electron/fs-ipc.ts: `hermes:fs:reveal` answers false when nothing at the
(tilde-expanded) path exists on this computer.
- lib/desktop-fs.ts: revealDesktopPath surfaces that false as an error the
existing revealFile toast shows (new i18n key fileMenu.revealUnavailable;
locales fall back to English through defineLocale).
- store/file-actions.ts: shouldOfferLocalReveal() — the focused row's
Connections tag decides (a row tagged with another gateway is never
local, even under a local primary); an untagged row follows the window's
primary mode, the rule the sidebar already applies.
- use-statusbar-items.tsx: the reveal item is gated on it.
Slimmer redo of #115168 by @jonpol01 (19 files): same mechanism, without
the project-menu/workspace-header rewiring and the per-locale translations.
Fixes#115167
Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
Keep the two tests that pin the fix: the marker is written at submit and
cleared with the reply (production entry runGroupChatMemberTurn), and a
marker left by a previous Desktop process is harvested at the next
boundary. The live-poll-owns-marker and 4007-drop cases are implied by
the first (seen.live === true while the poll runs) and by the harvest
code path; salvage bar is two invariant tests per fix.
A `catalog=<name>` deep link now resolves the name against the live plugin
catalog feed (the same `/docs/api/plugins.json` the Capabilities → Plugins
picker renders) and opens the Install Plugin dialog in its reviewed/pinned
catalog mode — identical to an in-app catalog pick, via one shared
`openCatalogPluginInstall` helper the Plugins tab now uses too.
The `catalog` param claims the link outright: an unknown, invalid, or
unresolvable name is a clear error toast and nothing else. It is never
reinterpreted as a git identifier, so a link cannot smuggle an unreviewed
repo behind a familiar-looking name (a `repo=` riding along is ignored).
The live-work docks retire a finished background process ~60 s after it
ends; the registry only knew started_at, so the age of an exit was not
observable. _move_to_finished is the single choke point every exit path
(reader loop, reconcile, kill) passes through, so the stamp lives there.
completion_reason rides along so a killed process can read "killed"
instead of "exit -15".
Review follow-ups on the external-write mirror:
- Idle trigger: `openGroupChat` now runs `sweepExternalGroupWrites`, one
`session.resume` per stored member session whose thread the room still
shows, then the existing mirror. A Bot posting into its own room session
between rounds (the reporter's scenario) is posted the moment the user
opens the room, not only once the room next drives that member and it
happens to be a responder. No polling; a room mid-round is left to the
round, which sweeps its responders itself.
- Classifier: the single `[System:` skip becomes the canonical synthetic
user-row set (mirrors `agent/context_compressor.py::
_SYNTHETIC_USER_ROW_PREFIXES`, comment links both) plus the gateway's
`display_kind` on typed scaffolding rows. A compaction handoff, cron
delivery, delegation result or steer marker is never mirrored as member
speech, and the assistant row reacting to it closes the exchange instead
of inheriting the previous writer's origin.
- Stranded harvest picks the FIRST substantive assistant row after the
header-prefixed prompt and stops at the next outside user row, so a CLI
answer written after the late reply is no longer posted as the turn reply
and then mirrored again.
- Cursor edges (documented in the module header): first sight of a session
seeds the cursor at the transcript's current length — a room hydrated from
the gateway mirror (which carries no cursors) on a second Desktop does not
re-post its history; "late, never lost" holds from that moment on, at the
cost of no history replay. A cursor past the end after compaction is
reset to the end. A missing snapshot leaves the cursor alone.
Tests stay at 2 in group-external-writes.test.ts: the negative control now
also opens the room without a drive and asserts the peer exchange arrives
while the compaction/cron/auto-continue rows and their answers do not. Red
with the `openGroupChat` call removed and with the prefix set reverted.