The no-payload settle gate in gateway-event.ts held session.info
running=false off unconditionally while an optimistically armed turn
(busy/awaitingResponse from restore/edit/submit) had not gone live
backend-side. When the turn never went live at all — a rewind refused
after the optimistic arm, a submit response lost to a gateway bounce, a
terminal error event that never arrived — busy latched forever:
isTargetSessionBusy refused every send, the composer queued each message
('moves to the send area'), and the queue drain (gated on busy→false)
never fired. Only an app restart cleared it (#86795).
Bound the hold to PRE_TURN_LIVE_SETTLE_GRACE_MS (15s) measured from
turnStartedAt; past the window (or with no clock) the gateway's
running=false is authoritative and settles the session. Seed the clock +
reset turnLive in applyRewindOptimistic/applyReloadOptimistic (the
restore/edit/regenerate arm sites), and clear both on every rewind
rollback path in use-prompt-actions and session-tile-actions so a failed
rewind can't leave a stale seed.
Fixes#86795
Addresses review feedback from the hermes-sweeper (salvageability=high,
keep_open): "The new focus/visibility listener behavior lacks a runtime
UI regression test... no ProfileRail test."
Rendering the full ProfileRail component for this would drag in
drag-and-drop, dialogs, hotkeys, and i18n unrelated to what needs testing.
Instead, extracted the focus/visibilitychange wiring into its own
use-profile-rail-refresh-on-active hook, matching this exact directory's
own established convention (use-profile-prewarm.ts is the same shape:
a small side-effect hook pulled out of ProfileRail specifically so it's
unit-testable in isolation).
Added 6 tests covering exactly what the review asked for: refresh on
mount, refresh on window focus, refresh on visibilitychange while
visible, NO refresh on visibilitychange while hidden, listener cleanup
on unmount, and no listener accumulation across repeated mount/unmount
cycles.
Verified the tests have real teeth: simulated the exact bug this PR
originally fixed (dropped the cleanup return, leaving listeners attached
after unmount) and confirmed 4 of 6 tests correctly fail against it --
including "no accumulate listeners" showing 7 calls instead of 1, the
exact leaked-listener signature. Restored the real fix and all 6 pass.
ProfileRail itself is otherwise unchanged in behavior -- this is a pure
extraction (same effect, same dependencies, same cleanup), not a
behavior change. Full sidebar test suite: 93 passed across 12 files (up
from 87 across 11), 0 regressions. Python side unaffected: 158 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two independent bugs let a deleted profile reappear / leave orphaned
resources on next launch:
1. hermes_cli/profiles.py's backend-process scanner required argv[0] to
resolve to an executable literally named "hermes". Electron's
pool-backend spawn resolves the hermes console-script shim's path and
execs it via the interpreter directly (python3 /path/to/hermes ...), so
argv[0] reports as "python3" and the scanner never matched the running
backend -- delete removed the profile's files but left its live backend
process running (still bound to a port via uvicorn), which
accumulates across repeated delete/recreate cycles.
2. The desktop sidebar's ProfileRail only refreshed its cached profile
list once, on mount, so a delete/create/rename from another surface
(another window, or the CLI) left a stale ghost entry until something
unrelated triggered a refetch. Note: a delete via this window's own
Manage-Profiles view already refreshes the shared $profiles atom
ProfileRail subscribes to (confirmed by reading refreshProfiles() and
handleConfirmDelete()) -- this fix only covers the cross-window/cross-
process staleness gap, not a duplicate of the already-merged
#57329's Manage-Profiles rail-refresh work.
Fix 1: recognize a python-interpreter argv[0] exec'ing a hermes-named
console-script shim via argv[1]. Fix 2: refresh the profile list on window
focus/visibilitychange, matching the existing pattern used elsewhere in
the sidebar (sidebar/index.tsx, use-background-sync.ts, star-map.tsx,
use-gateway-boot.ts all use the same focus+visibilitychange pattern).
## Related work already on main
PR #57329 (merged) fixed the *headline* symptom from issue #52279
(deleted profile respawns) via a different, non-overlapping mechanism:
routing profile-delete through the primary backend instead of spawning a
fresh pool backend, plus a separate recreation guard in
ensure_hermes_home() (#49435, merged) that makes a backend spawned into a
deleted profile's directory raise FileNotFoundError instead of silently
recreating it.
This PR is NOT a duplicate of that fix. Verified: even with both of those
merged, a backend process that survives because of gap #1 above still
holds a bound port via uvicorn -- it just can no longer resurrect the
profile directory. That's real resource-hygiene, not a symptom already
covered. Gap #2 touches a different file/component (ProfileRail /
profile-switcher.tsx) than #57329's rail-refresh half (which touched the
Manage-Profiles view's own $profiles.ts / index.tsx) and covers a
distinct staleness path (cross-window/cross-process, not same-window
delete-then-refresh).
Tests: tests/hermes_cli/test_profiles.py -- 156 passed (existing +
regression coverage for the argv[0] python-interpreter detection case).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A user typed their root password into the Desktop SSH host field
(root@IP:PASSWORD form). Three failures compounded:
1. validateSshTarget() only checked for option injection (leading dash),
control chars, and port range — commas in an IP, whitespace ("ssh "
prefix pastes), and non-numeric ":<segment>" leftovers all dialed ssh
with garbage and failed silently five times.
2. normalizeSshConfig() only strips a ":<segment>" when it is numeric, so
a pasted password stayed glued to the hostname all the way into ssh
argv and the desktop.log connect line.
3. redactSecrets() had no pattern for ssh targets, so the password landed
verbatim in desktop.log and then in a PUBLIC debug-share paste.
Changes:
- validateSshTarget(): reject whitespace, commas, non-numeric colon
segments (with a "never put a password in the host field" hint that
does NOT echo the credential), and garbage hostnames; still accepts
bare IPv6 (::1, fe80::1%eth0). Reject whitespace/@ in user.
- redactSecrets(): new pattern masks any non-numeric segment where a
port belongs in user@host:... strings — defense in depth so future
parse gaps can't leak credentials into logs or debug shares.
- normalizeSshConfig(): strip a pasted leading "ssh " prefix.
- Tests for all three, including the exact incident shapes.
The @ popover only completed filesystem references; bot handles worked
when fully typed (mention middleware parses at submit) but were never
offered, so users had to know the exact handle — worse with multi-source
@name-device handles. Fixes#88060 (ported from Hermes-Bot-Mode#43).
- composer contrib: new 'composer.atCompletions' data area
(ComposerAtCompletionSource) — contributed rows merge AHEAD of path
results; a throwing source drops its rows, never the popover
- use-at-completions: merge contributed entries in all three fetch paths
(gateway results, gateway-less, fetch error)
- SDK: export the new area + types for plugins
- bundled Bot Mode plugin: registers 'mention-completions' — roster
handles from the query cache (\u22645s stale), active profile excluded,
'default' offered as @hermes, multi-source @name-device handles via
botHandle, display name + connection label in the row meta, capped at 8
- registered early in register(ctx) so vm harnesses reach it before the
pane/UI registrations that stubs can't fully model
The Bots pane header + is now a dropdown (New Agent / New Group Chat).
New Group Chat opens a checkbox-picker modal: searchable roster list,
member cap at GROUP_CHAT_MAX_MEMBERS, group-name input that defaults to
the selected members' names, and a Create button that assigns the
existing per-bot group meta field - so the room rides the ui_meta sync
path unchanged and the user lands directly in the new room.
The teammate-messaging protocol told bots to send with
`chat -c "Bot Chat" -Q -q` and, on 'No session found', fall back to a
manual two-step (send without -c, then sessions rename) — a dance the
CLI already made unnecessary when --create-if-missing landed (#86794).
Profiles that never went through the Bots-panel birth flow (CLI-created,
pre-Bot-Mode, remote-source) hit that miss on every first contact, and
background sends swallowed the error entirely (the original silent-drop
in Hermes-Bot-Mode#48 / #88059).
- tools/bot_mode_probe.py: protocol command gains --create-if-missing
- bundled plugin: Bot Chat prompt section + @mention handoff note use
the flag; rename-dance instructions deleted
The capability epoch hashes the protocol section, so existing eternal
Bot Chat sessions pick the new instructions up on their next message via
the established once-per-change rebuild — no per-turn cache drift.
Live-verified: fresh profile with zero sessions, protocol command
created 'Bot Chat' and delivered (PONG round-trip); second send resolved
the same session by title (no duplicate); missing-title send WITHOUT the
flag still errors loudly on stderr.
after a stale runtime-session drop (the sleep/wake 404 that resumes the
stored session and retries once), but two call sites still build their
gateway call directly instead of routing through withSessionNotFoundResume:
- session-tile-actions.ts's own cancelRun/steerPrompt/reloadFromMessage —
the tile's OWN UI handlers (wired directly by session-tile.tsx as
onCancel/onSteer/onReload), distinct from use-session-tile-delegate.ts's
interruptSession/submitToSession (used by external callers like
quick-entry-bridge), which #81261 did wrap.
- use-prompt-actions/index.ts's reloadFromMessage (the primary chat's own
"Regenerate") — it builds its prompt.submit call inline instead of going
through the shared send() helper every other action in this file uses,
so it never picked up the recovery wrapper.
After sleep/wake (the exact scenario #81261 targets), clicking Stop,
sending a steering correction, or clicking Regenerate on a tile or the
primary chat surfaces a raw "session not found" error instead of silently
resuming, even though #81261 landed the day before.
Wrap all four call sites in withSessionNotFoundResume, mirroring the
existing pattern each file already uses elsewhere (submitRewind/
syncAttachmentsForSubmit in session-tile-actions.ts, redirectPrompt/send in
index.ts) — resolve the stored session, resume once, retry, and rebind the
live runtime ref via onRecovered.
A tab/tile whose live runtime the backend reclaims (ws_orphan_reap,
idle_timeout, lru_evict) rendered an empty transcript under healthy
chrome, permanently: session.reclaimed dropped the runtime's cached
state but left the tile bound to the dead runtime id, and the tile's
resume effect is gated on !runtimeId so it never refired. Sidebar
re-click could not recover it; only close-tab or an app restart did
(tile persistence strips runtime ids, which is why a remount healed).
The reconnect-path resetTileRuntimeBindings() cannot cover this case:
the WS re-dials immediately while the orphan reaper fires a grace
window later, so the reclaim always lands after that unbind ran.
On session.reclaimed, unbind whichever tile holds the reclaimed
runtime (new unbindTileRuntime, the targeted sibling of
resetTileRuntimeBindings) so the existing resume effect refires
against the intact stored session, and purge the wiring cache's entry
so resumeTile's warm path can't hand the dead runtime straight back.
Live-reproduced both ways on an isolated dev instance (20s reap
grace): pre-fix the tile stays bound to the dead runtime with its
state gone (blank pane); post-fix it sheds the binding and repaints
the transcript within seconds, across two consecutive reap cycles.
Fixes#82620
After sleep/wake the gateway reconnect path called resetTileRuntimeBindings()
to force every tile to re-resume, but it only cleared the tile atoms'
runtimeId. resumeTile()'s warm path then re-bound each tile from the wiring
cache's stored->runtime map - the same dead pre-sleep runtime id, with a
released (empty) cached transcript. Result: every split pane except the
primary repainted as an empty pane with only its header, and prompts
submitted to it recovered into the primary view instead.
- resetTileRuntimeBindings() now also invalidates the delegate's wiring
cache (new optional SessionTileDelegate.invalidateRuntimeBindings, backed
by runtimeIdByStoredSessionIdRef.clear()) so post-reconnect resumes go
cold and bind a live runtime id.
- resumeTile()'s warm path now requires the cached state to carry a
transcript (or be mid-turn): a released/stale empty state goes through
to a real session.resume + transcript hydration instead of repainting
an empty tile.
Regression tests fail without the fix (verified via sabotage run) and pass
with it; tsc + eslint clean.
Wire the continuity flag through every cron-creation surface, not just the
model tool:
- dashboard (web/): checkbox in the cron job editor; form state round-trips
the stored reserved 'self' entry into the toggle and strips it from the
context_from textarea; web_server dashboard validator skips 'self'
(create precedes the job's existence)
- Bot Mode Routines tab (hermes-bots plugin): Continuity checkbox in the
New Cronjob dialog, forwarded through cron.manage
- tui_gateway cron.manage RPC: optional continuity param on action=add
vitest cron-job suite 10/10 (4 new), tsc app project clean, py_compile clean.
feat(desktop): resolve the user's login-shell PATH once at startup and
merge it into process.env before the backend spawns.
GUI launches (Finder/Dock on macOS, desktop launchers on Linux) inherit
a minimal PATH that never runs the user's shell profiles, so the
backend process — and everything it spawns or probes (shutil.which
availability checks like cua-driver, stdio MCP servers, Electron-side
git/gh/hermes resolvers) — cannot see Homebrew-, nvm-, pyenv-, cargo-,
or ~/.local/bin-installed tools. backend-env.ts's static sane-entry
list covers Homebrew//usr/local but not profile-added dirs.
Approach (ported from cline/cline#12429, mirrors VS Code's shell
environment resolution):
- new electron/shell-path.ts: run $SHELL -ilc (fallback -lc for the
macOS system-bash-3.2 swallow) printing $PATH between sentinel
markers so profile banners can't corrupt the capture
- merge login-shell entries first, current-only entries appended,
deduped via backend-env's appendUniquePathEntries
- single-flight, timeout-bounded, failure-hardened: a broken or slow
shell profile never blocks boot; win32 no-op
- warmed at app.whenReady, awaited before backend runtime resolution
12 unit tests + live E2E verified (GUI-minimal PATH enriched with
~/.local/bin, nvm, cargo, go entries on a real shell).
Cmd/Ctrl+Shift+B worktree flows on a remote gateway route through the
backend's /api/git mirror (hermes_cli/web_git.py), but that mirror had
drifted behind the Electron-local git ops the same UI drives locally, so
the flows broke exactly and only on remote connections:
- Convert-a-branch: the picker offers remote-tracking refs, and the
Electron op turns "origin/feature" into a local tracking branch. The
mirror ran `git worktree add <dir> origin/feature` verbatim, which
either fails or detaches HEAD. It now resolves the ref's remote via
git (never assuming "origin"), fetches best-effort, and creates the
worktree with `--track -b <short-name>`.
- branch_list omitted remote-tracking refs entirely and never set the
`isRemote` flag the renderer's HermesGitBranch contract requires —
the convert picker on a remote gateway couldn't reach a teammate's
branch and mislabeled every row's action.
- Branching off an `origin/…` base silently wired the new branch to the
remote upstream; the mirror now passes `--no-track` like the Electron
op does.
Renderer side, replace the silent degradation with a capability gate:
when a remote backend predates the /api/git worktree routes, worktree
creation failed with an opaque "Expected JSON … got HTML" toast. The
route-missing shapes now surface a clear "update the Hermes backend"
message (isGitEndpointMissingError, mirroring the sidebar batch-endpoint
detector); real git errors still pass through untouched.
Sibling audit (documented, no code change needed): repo status / review /
file-diff / git-root / default-cwd already route through desktopGit()'s
REST bridge or /api/fs on remote; repo scan is deliberately a no-op there.
Stale comments claiming "empty/false on a remote backend" in projects.ts
and coding-status.ts updated to describe the backend-routed reality.
Fixes#81724
The white catchlight dots in BotFace were static circles pinned at the
circle-face eye line (cy 16.5), while the animation clock moves the
pupils to the shape-aware eye line (cy 22 for the cloud). On the cloud
avatar the highlights floated above the eyes instead of inside them.
- Tag the catchlights (data-hb-hl-l/r) and move them with the pupils in
paintMathFace, offset upper-left of each pupil center.
- Render the initial eyes/catchlights/shut-lids at the shape-aware eye
line so the first frame matches the animated frames.
Completes PR #74468 (remote gateway headers for Cloudflare Access, #74466)
against the v2 multi-connection registry that landed after the PR was
authored, and closes the review blockers:
- connection-registry: additive optional `headers` field on remote/cloud
entries (normalized through the same forbidden-name filter, secret
envelopes like `token`); inherited on edit, treated as dial material by
connectionDialFieldsChanged, preserved by normalizeRegistry, and carried
through migrateV1ToRegistry. v2 registries without the field load
unchanged — no version bump.
- main.ts registry paths: connectRegistryBackend dials with the entry's
headers (readiness probe, ticket mint, descriptor REST via
getJsonForBackend/fetchJsonForBackend, registry ws-url minting with
rememberRemoteWsHeaders so renderer upgrades get them injected).
- saveRegistryConnection encrypts incoming plaintext header values with the
same safeStorage/allowPlainText seam as tokens; sanitizeRegistryConnection
exposes only header NAMES to the renderer — values never cross IPC.
- Connection tests exercise the leg they validate: both
hermes:connection-config:test and hermes:connections:test now send the
configured headers on the HTTP status call, the ws-ticket mint, AND the
live WebSocket probe (probeGatewayWebSocket grew an injectable `headers`
option passed as the undici WebSocket constructor's second argument).
- Settings → Connections gains an "Extra gateway headers" editor for
remote/cloud entries (name + secret value rows, stored values shown as
saved-but-hidden, clearable), with i18n keys (en + zh; other locales fall
back through defineLocale).
A dropped registered remote connection (SSH or HTTP) never recovered on
its own: the next boot attempt failed with a transient transport error
("Could not verify the existing SSH backend", ERR_CONNECTION_RESET,
mint timeout), the failure was correctly NOT latched, but nothing ever
re-attempted the boot — the renderer's reconnect machinery only arms
after a completed boot. The app parked on "Desktop boot failed" until
the user manually deleted and re-entered the same connection details,
which merely forced the fresh bootstrap an automatic retry would have
performed (issue 82679, feature ask 80430).
Root causes and fixes:
- electron/backend-start-failure.ts: new isRetryableRemoteBootFailure()
predicate — a remote, non-reauth boot failure is transient and may be
retried; local failures and confirmed 401/403 rejections are not
(a missing capability differs from a transient failure).
- electron/main.ts: the boot-failure progress broadcast now carries
`retryable` (rides with `error` through updateBootProgress), and a
failed reuse probe against a cached SSH master tears the stale
master/tunnel down so the next attempt bootstraps fresh — exactly
what manual re-entry did.
- use-gateway-boot.ts: bounded self-heal loop for a failed boot whose
progress is marked retryable — up to 5 re-attempts with the same
full-jitter backoff as the socket reconnect loop (2s base, 15s cap).
Exhausted retries end in the real boot-failure recovery overlay,
never an infinite spinner. Reset on success and on soft switch;
timer cleared on unmount.
- store/boot.ts: resumeDesktopBootForRetry() re-arms the overlay with a
retry status while an automatic retry is in flight.
Secondaries already had full-jitter backoff (store/gateway.ts); this
closes the same class for the PRIMARY/registered-connection path.
Tests: predicate matrix (retryable vs reauth-latch mutually exclusive),
plus renderer hook tests proving a transient SSH failure self-heals on
the next attempt, retries are bounded (6 total dials then the recovery
overlay, no further attempts), and non-retryable failures never enter
the loop. Sabotage-verified (disabling either half fails 4 tests).
Fixes#82679Fixes#80430
Expose the existing profile-aware gateway boot reconnect path through a
single-flight renderer action, and surface a Reconnect button in the
gateway status menu panel whenever the socket is not open. Repeated
clicks share one in-flight reconnect; failures surface through the
existing non-destructive notification UI. Localized copy for all
supported Desktop locales.
Salvaged from PR #80694 (net diff re-applied onto current main; panel
code lives in app/shell/gateway-menu-panel.tsx now).
Pulls the multi-source roster into the bundled plugin: profiles.list rows
from the active gateway are merged with the host.agents() union roster
(hermes-agent #86875), so the Bots panel shows agents from every registered
Desktop connection with @name-device handles for duplicates. Feature-detected
and best-effort — an older Desktop build or roster failure leaves the
single-source list untouched.
Adapted for the bundle:
- useRoster queryFn combines the bot_mode_protocol capability read (which
landed after #68 was cut) with the multi-source merge
- multi-source-roster tests updated for the namespace SDK import harness
- soul-protocol-backfill anchor widened for the new botHandle(name, bot)
signature
Plugin suite: 143/143.
Found in live desktop E2E: the generated-identity path of composeSoul
still appended the protocol section even when the backend injects it
into the system prompt. New agents now get a clean identity-only SOUL
against capable backends; older gateways keep the append. Covered in
the capability-suppression test.
apps/desktop/src/**/*.js is gitignored (stale tsc output shadows .tsx),
which silently dropped the hermes-bots plugin.js from the adoption
commit — tests shipped, source didn't, CI ENOENT'd. Negate the pattern
for src/plugins/*/plugin.js: adopted plain-ESM plugins have no .tsx
sibling, so the shadow hazard cannot apply.
Adopts the Hermes-Bot-Mode desktop plugin (NousResearch/Hermes-Bot-Mode)
into apps/desktop/src/plugins/hermes-bots/, registered by the bundled
vite glob and ON by default. It stays a pure @hermes/plugin-sdk consumer
in plain-ESM plugin.js form; users disable it live in Settings > Plugins.
- contrib/plugins.ts: bundled glob accepts plugin.js entries
- contrib/runtime-loader.ts: a disk/runtime copy of an id that ships
bundled is skipped (standalone installs predating adoption cannot
double-register)
- package.json: check:test:plugins runs the plugin's node:test suite in
CI (138 tests)
- source: Hermes-Bot-Mode @ c19baba, incl. today's #107/#103/#99 merges
The live suite previously called appendMidTurnUserMessage directly, leaving
redirectPrompt's appendAfterActiveReply guard — the production decision of
WHERE a correction lands — outside the harness. Both hooks now mount together
sharing one state map, exactly as the desktop wires them, so a regression in
the caller (not just the insert) goes red. Verified by mutation: disabling the
guard fails 2/4.
Also covers the rejected-redirect path: a not_running response discards the
optimistic bubble instead of stranding a correction the model never saw.
The hydration fixture now carries the durable row shape the client actually
receives (row_id, reasoning, provider call_id/response_item_id on tool_calls)
instead of a hand-simplified echo, so the 'mirrors real state.db rows' claim
is honest. The fake-timer steer id counter is gone with the local insert —
ids come from redirectPrompt itself.
Review follow-ups: steer ids now come from a monotonic counter instead of
Date.now() (frozen under fake timers — two steers without a clock advance
would have collided), and the settle-above assertion documents its
load-bearing sealed-bubble assumption.
A steered turn's contract — pre-steer output above the correction bubble,
post-steer output and the settled reply below it — was fixed across
several PRs (#73793/#83151 class, settle fixes) but only covered piecewise:
the mid-turn insert as a unit, the settle math as a unit. Nothing drove the
real stream reducer through a whole steered turn, and nothing asserted the
durable-row hydration renders the same order after reload.
Two suites close that:
- steer-arrival-order: full event sequences through useMessageStream's real
handler + the real optimistic insert — single steer with tool activity,
steer racing message.complete, double steer in one turn.
- steered-turn-hydration-order: toChatMessages over persisted row shapes
copied from a real state.db steered turn, including a tool result that
lands after the correction row.
When Hermes Desktop works against a REGISTERED gateway connection, cron
jobs execute on that gateway and persist their run sessions in the
gateway's state.db. But every REST call in the app — the cron surface
included — carried only `profile`, so `hermes:api` routed it through the
local profile pool and `_list_cron_job_runs_sync` read a local state.db
with zero `source='cron'` rows. Every job showed "No runs yet" while the
same endpoint on the gateway returned the real runs (#87882).
Fix at the routing seam:
- HermesApiRequest gains an optional `connectionId`. The renderer's cron
helpers (list/get/runs/delivery-targets/create/update/pause/resume/
trigger/delete/blueprints) now tag the active registry connection via a
new connectionScoped() twin of profileScoped(), fed from the same
setApiRequestConnection seam store/gateway already maintains for the
plugin socket.
- The hermes:api main-process handler resolves a tagged request through
ensureRegistryBackend — the SAME pool the job list and WS traffic use —
instead of the legacy profile route. Shared remote/cloud hosts (one
gateway, many profiles) get the path scoped with ?profile= via the new
pathWithProfileScope helper, factored out of pathWithGlobalRemoteProfile.
- '' / 'local' / absent connectionId keep the byte-identical v1 route, so
single-source and connection-config-remote users are unaffected.
This covers the run-history panel, the sidebar cron peek, and every other
cron surface in one place, since they all funnel through the same helpers.
Fixes#87882
A connection/mode apply (soft re-home) moves /api/profiles routing to a new
backend, but nothing deterministically re-fetched the rail's $profiles list
and a stale in-flight response from the previous backend could land last and
collapse the rail to Home (#85731).
- store/profile: epoch-guard refreshProfiles/refreshActiveProfile so a
response fetched against the previous backend never writes the shared cache
(invalidateProfileListFetches), and bump the epoch on live profile swaps.
- store/gateway-switch: strand in-flight profile-list fetches in the same
wipe every connection/mode apply funnels through.
- use-gateway-boot: explicitly re-pull the active profile + list from the NEW
backend during softSwitch, best-effort like its sibling fetches.
Fixes#85731
Multiple Desktop windows share one renderer origin (one localStorage
area) while each window can be connected to a DIFFERENT gateway. The
sidebar pin set (hermes.desktop.pinnedSessions), the manual session
order, and the remembered last-session/route navigation keys were all
persisted under single global (or profile-only) keys, so two windows on
different gateways read and reconciled the same lists: pin-sync's
pullRemotePins() in one window adopted/dropped pins belonging to the
other window's backend, producing the overlapping mixed PINNED/SESSIONS
lists reported after the v0.19.1 update relaunch.
Introduce a connection-scope persistence layer (connectionScopedAtom in
src/lib/connection-scoped.ts): the local connection keeps the bare
legacy key (byte-identical for single-backend users, same contract as
backendScopeKey), while remote connections persist under
`<key>.remote.<encoded baseUrl>.<encoded profile>` — the shape
workspaceCwdKey already established. setConnection() rescopes every
scoped atom when the window's connection changes (null descriptors keep
the current scope, as with syncCronModelImpactConnection), and pin-sync
resets its mirrored/pending/unconfirmed bookkeeping on rescope so a
reconcile never PATCHes one gateway's pins to another.
Legacy globally-keyed values are deliberately not migrated into remote
scopes: ownership of rows accumulated by every window is unknowable
(the #67709 precedent), and backend-mirrored pins self-heal from the
gateway's own `pinned` rows.
Fixes#77318
With a GH_TOKEN/GITHUB_TOKEN in the environment, electron-builder auto-selects
the github provider and resolves owner/repo from the repository field, falling
back to reading <projectDir>/.git/config. projectDir is apps/desktop, which has
no .git of its own, and app-builder-lib does not walk up to the workspace root
-- so resolution returned null and threw "Cannot detect repository by
.git/config".
On Linux this fires from onAfterPack for a plain `dir` target: the darwin and
Windows branches return early for non-installer targets, Linux has no such
guard. That is why the same build worked elsewhere.
--publish never keeps `pack` from reaching this at all, but `dist:*` and
test-desktop.mjs still resolve publish config on a machine with a token, so
declare the field too.
Tests call the real app-builder-lib resolver rather than asserting on the text
of package.json, so they track electron-builder's behavior instead of our
formatting.
Co-authored-by: airo7 <airo7@users.noreply.github.com>
Co-authored-by: frankmendes1979 <frankmendes1979@users.noreply.github.com>
`hermes desktop` runs `npm run pack` through _npm_lifecycle_env(), which
sets CI=1. electron-builder 26 reads that as an implicit publish request
(`onTagOrDraft`) when --publish is absent, so a local --dir build enters
publish resolution it has no business being in.
Pin `--publish never` on the pack script. This is also what electron-builder
asks for directly -- the implicit CI behavior is removed in v27.
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: fangliquanflq <fangliquanflq@users.noreply.github.com>
pluginSocket (hermes.ts) is documented as "the live twin of pluginRest,
scoped the same way", but it calls window.hermesDesktop.getConnection()
with no profile argument, while pluginRest passes the active profile via
profileScoped(). getConnection's IPC handler (ensureBackend in
electron/main.ts) falls back to the primary profile whenever the profile
argument is empty, so an unscoped call always resolves to the primary
profile's backend regardless of which profile is actually active.
For a plugin used from a non-primary profile (e.g. kanban), this means REST
calls go to the correct pooled backend while the plugin's WebSocket silently
connects to the wrong one — a multi-profile user sees one profile's data
with another profile's live events.
Fix (adapted to the post-#87600 registry-agent store shape during salvage):
resolve the plugin socket's connection through the same (connectionId,
profile) source of truth ensureGatewayProfile/ensureGatewayAgent maintain
for $connection — store/gateway's setActive now pushes the active scope's
registry connection id into the hermes module (setApiRequestConnection,
the no-store-import twin of setApiRequestProfile), and pluginSocket
resolves via getConnectionFor for registry-agent scopes and
getConnection(profile) for the local pool. The plugin socket therefore
follows registry-agent activations too, not just profile switches.
voice-playback.ts's resolveSpeakStreamUrl had the same gap originally, but
main has since fixed it independently (via the getApiRequestProfile()
getter rather than direct store access) — dropped from this PR as
redundant, keeping only the still-open pluginSocket gap.
Co-authored-by: Hermes Agent <hermes@nousresearch.com>