feat(desktop): live session-list refresh for externally-created sessions - #284
Merged
Conversation
`refreshSessions` cleared `$sessionsLoading` only when the settling request was still the newest one. On 2026-06-21 an app/backend version skew made every `hermes:api` call hang to its 45s timeout while a 30s interval poll kept firing, so each refresh was superseded before it settled, no `finally` ever matched the newest id, and the flag stayed true forever — a recoverable backend stall presented as a permanently skeletal sidebar. Two independent releases now, covering both halves: - A successful response clears the flag whether or not it was superseded. The backend answered, so skeletons are wrong; an empty result should render the empty state. This holds even under unbounded overlap. - An in-flight counter clears the flag when the LAST refresh settles, which covers the all-rejected case (unreachable backend) where the success path never runs. `set` on a nanostore only notifies on an actual change, so neither release adds store churn when the flag is already false — the existing no-flicker guarantee over a populated list is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sidebar only re-pulled its list on boot and on the app's OWN sessions' `message.complete` events. A headless `hermes -z …` run, a cron job, or a `hermes` CLI session in another terminal writes into the same profile stores, but nothing told the renderer to look — those chats stayed invisible until the user hit View > Reload. Electron main now watches each profile's transcript directory and pings the renderer, which runs its existing refresh path. Which path to watch was decided by measurement, not assumption (macOS, a ~/.hermes holding 5.5k transcripts and a 2.5 GB state.db): - The profile HOME directory is unusable. Over a quiet 120s window it fired continuously — kanban.db-wal/-shm renames every 5s, each profile's state.db-wal every 10s, plus cron/ and the skills snapshot. Watching it would re-query every profile DB forever, whether or not a session existed. - `<home>/sessions/` fired only for the transcript writes of the run under test. The agent writes it through a temp file + atomic rename ~3s after a run starts, and that write lands together with the state.db row reaching message_count >= 1 — exactly when the row becomes sidebar-eligible. SQLite WAL is why the obvious alternative fails: state.db itself is barely touched between checkpoints, so watching the db file misses live writes, and watching -wal breaks whenever a checkpoint recreates it. The throttle schedules one notify per burst rather than restarting a timer per event. A trailing debounce would STARVE — an agent mid-run rewrites its transcript every turn, so the notify could be deferred for the whole run, which is the case this exists for. A focused-only poll backs the watch up for what fs.watch structurally cannot see (network mounts, a watch that failed to bind, events dropped while backgrounded), plus a refresh when the window regains focus. No timer runs while the window is blurred or hidden. Refreshes are single-flight with one trailing re-run, so this cannot reproduce the overlapping-poll wedge fixed in the previous commit. Remote profiles are untouched: the watch only covers local stores, remotes keep arriving via the existing splice, and the unchanged refresh path keeps its 3s probe deadlines and 30s unreachable cooldown. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner
Author
|
Merging with the two red checks explicitly noted: |
OmarB97
added a commit
that referenced
this pull request
Aug 2, 2026
…262 (#286) `uv lock --check` fails on pristine `main` itself, so every PR opened against this fork inherits a red `Check uv.lock` — including PRs that touch zero Python files (e.g. #284, a desktop-only TypeScript change). Root cause is NOT an upstream merge dropping a lock update. Fork PR #262 (f78ed1f, 2026-07-10) re-declared `pytest-timeout==2.4.0` in the `dev` extra of pyproject.toml, but the commit that landed on main contains only: pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) Its description states it regenerated uv.lock ("Net: +1 pyproject dep, -1 uv.lock line") and that `uv lock --check` was verified clean locally — but the lockfile half of that change never reached the merged commit. `git log -S pytest-timeout -- uv.lock` confirms nothing has re-added the entry since upstream removed it in 2f9d187 (2026-06-10). So main has been red on this check for three weeks. Fix: `uv lock` (no --upgrade). The result is purely additive — +14 lines, 0 deletions, 233 -> 234 packages, `revision = 3` unchanged: * `[package.optional-dependencies] dev` gains `pytest-timeout` * `[package.metadata] requires-dist` gains the `extra == 'dev'` pin * a new `[[package]] pytest-timeout 2.4.0` block with sdist + wheel hashes No existing pin moved, so nothing the repo pins deliberately was bumped (Dependency Pinning Policy, AGENTS.md). Blast radius is wider than the uv.lock check alone: `tests.yml` (both jobs), `e2e-desktop.yml` and `docker.yml` all install via `uv sync --locked`, which hard-fails the same way. On #284 the Desktop E2E job fails at "Install Python dependencies" with `error: The lockfile at uv.lock needs to be updated, but --locked was provided.` — Playwright never ran. The Python shards only escape it there because path filters skip them on a TypeScript-only PR. Verified on macOS 15 (darwin 25.6.0, uv 0.11.6, CPython 3.13.13): * `uv lock --check` — clean (was exit 1 on this same tree before) * `uv sync --locked --python 3.11 --extra dev` — installs, pytest-timeout 2.4.0 * plugin registers under the repo's pytest config (`--timeout` in `pytest --help`) * `@pytest.mark.timeout(1)` on a 30s sleep aborts at 1.02s with `Failed: Timeout (>1.0s) from pytest-timeout` — i.e. the dep is live, not vestigial Co-authored-by: Omar Baradei <omar@kostudios.io> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
The desktop sidebar only re-pulled its session list on boot and on the app's own sessions'
message.completeevents. A headlesshermes -z …run, a cron job, or ahermesCLI session in another terminal writes into the same profile stores the sidebar lists — but nothing told the renderer to look, so those chats stayed invisible until the user hit View > Reload. Observed on packaged v0.19.0 with externally-spawned delegation sessions.Electron main now watches each profile's transcript directory and pings the renderer, which runs its existing refresh path. A focused-only poll backs it up.
The first commit is a prerequisite fix, not scope creep: adding a second refresh source makes a previously-documented wedge reachable again (see below), so the loading-flag hazard is closed first.
Related Issue
No tracking issue — reported directly against the packaged app.
Type of Change
Changes Made
fix(desktop): stop overlapping sidebar refreshes stranding the skeletonsapps/desktop/src/app/session/hooks/use-session-list-actions.ts—refreshSessionscleared$sessionsLoadingonly when the settling request was still the newest. On 2026-06-21 an app/backend version skew made everyhermes:apicall hang to its 45s timeout while a 30s interval poll kept firing; each refresh was superseded before it settled, nofinallyever matched the newest id, and the flag stayed true forever — a recoverable backend stall presented as a permanently skeletal sidebar. Two independent releases now: a successful response clears the flag whether or not it was superseded (holds even under unbounded overlap), and an in-flight counter clears it when the last refresh settles (covers the all-rejected case where the success path never runs). A nanostoresetonly notifies on a real change, so neither release adds churn — the existing no-flicker behaviour over a populated list is unchanged.feat(desktop): show sessions this app did not create, without a reloadapps/desktop/electron/session-store-watch.ts(new) — enumerates every<profile home>/sessionsdirectory (default home + each named profile under<home>/profiles, mirroringhermes_cli/profiles.pylist_profiles()including its_PROFILE_ID_REfilter), watches them, and throttles events into one notify. A 60s rescan picks up profiles created after boot; missing directories are skipped, never created.apps/desktop/electron/main.ts— starts the watcher onwhenReady, fans the event out to every window, stops it onbefore-quit. A watcher that fails to construct logs and degrades to the poll rather than failing boot.apps/desktop/electron/preload.ts,apps/desktop/src/global.d.ts—onSessionsStoreChangedbridge (optional in the type, so an older preload degrades cleanly).apps/desktop/src/app/session/hooks/use-external-session-sync.ts(new) — subscribes to the IPC signal and runs a focused-only 30s safety-net poll plus a refresh on regaining focus. Single-flight with one trailing re-run.apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts— one-line wiring next to the existing cross-windowonSessionsChangedbus.Why watch the transcript directory and not the DB
Decided by measurement on macOS against a
~/.hermesholding 5.5k transcripts and a 2.3 GBstate.db:kanban.db-wal/-shmrenames every 5s, each profile'sstate.db-walevery 10s, pluscron/and the skills snapshot. Watching it would re-query every profile DB forever, whether or not a session existed.<home>/sessions/fired only for the transcript writes of the run under test. The agent writes it via temp file + atomic rename ~3s after a run starts, and that write lands together with thestate.dbrow reachingmessage_count >= 1— exactly when the row becomes sidebar-eligible.SQLite WAL is why the obvious alternative fails:
state.dbitself is barely touched between checkpoints, so watching the db file misses live writes, and watching-walbreaks whenever a checkpoint recreates it.Why the throttle is leading-scheduled
A trailing debounce restarts its timer on every event, and an agent mid-run rewrites its transcript each turn — so the notify could be deferred for the entire run, which is the case this feature exists for. The first event of a burst schedules one notify; the rest are absorbed. Bounded rate, guaranteed delivery.
Remote profiles
Untouched. The watch covers local stores only; remote rows keep arriving through the existing splice, and the refresh path is unchanged, so the 3s probe deadlines and 30s unreachable cooldown from #140 are preserved. Remote changes are covered by the focused poll.
How to Test
Verified end-to-end in an isolated sandbox (
scripts/dev-sandbox.sh) so the running packaged app was never touched — it stayed up on pid 82115 with its own backend throughout.The externally-created session appears in the sidebar in about two seconds, with no reload.
Checklist
Code
pytest tests/N/A: no Python changed. The desktop vitest projects are the relevant gate.)Documentation & Housekeeping
cli-config.yaml.example— N/A (no config keys)CONTRIBUTING.md/AGENTS.md— N/A (no architecture or workflow change)fs.watchnon-recursive on a directory is supported on macOS (FSEvents), Linux (inotify — one watch per directory, not per file), and Windows (ReadDirectoryChangesW). Path joins go throughpath.join; the Windows%LOCALAPPDATA%\hermeshome resolves through the existingHERMES_HOMEconstant. A watcher that cannot bind logs and degrades to the poll.Screenshots / Logs
Measured signal-to-noise (why
sessions/won). 120sfs.watchprobe over a live~/.hermes, idle except for one external run:End-to-end trace (temporary instrumentation, removed before commit; app booted against an empty store, no user interaction):
The sandbox's sidebar endpoint then served exactly that row — the store had been empty at boot, so it can only have arrived via the live refresh:
Electron main bound the watch to the real store on boot:
Test results
npm run typecheck(all three projects) — clean.electron/session-store-watch.test.ts, 9 inuse-external-session-sync.test.tsx, 2 added touse-session-list-actions.test.tsx.requestId === currentgate strands the loading flag.src/app/skills/index.test.tsxandsrc/app/messaging/index.test.tsx. These reproduce identically on pristineorigin/mainand are pre-existing cold-start flakes (cf.fix(desktop): bump skills test timeout to fix cold-start flake(fix(desktop): bump jsdom test timeout to 15s to fix cold-start flake NousResearch/hermes-agent#68235)) — unrelated to this change, which touches no shared surface.Not included
Live transcript append for an open session still running in another process was scoped as a stretch goal and is deliberately not here. It is not a small diff:
hydrateFromStoredSessionrequires aruntimeSessionId, so following a foreign session needs a genuine read-only spectate mode, and opening a still-running foreign session would have this backend resume a session another process owns (sessions.pending_owner/pending_generation). That deserves its own PR with the ownership question settled first.