Skip to content

feat(desktop): live session-list refresh for externally-created sessions - #284

Merged
OmarB97 merged 2 commits into
mainfrom
work/sidebar-autorefresh
Aug 2, 2026
Merged

feat(desktop): live session-list refresh for externally-created sessions#284
OmarB97 merged 2 commits into
mainfrom
work/sidebar-autorefresh

Conversation

@OmarB97

@OmarB97 OmarB97 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

What does this PR do?

The desktop sidebar only re-pulled its session 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 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

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)

Changes Made

fix(desktop): stop overlapping sidebar refreshes stranding the skeletons

  • apps/desktop/src/app/session/hooks/use-session-list-actions.tsrefreshSessions cleared $sessionsLoading only when the settling request was still the newest. 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; 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: 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 nanostore set only 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 reload

  • apps/desktop/electron/session-store-watch.ts (new) — enumerates every <profile home>/sessions directory (default home + each named profile under <home>/profiles, mirroring hermes_cli/profiles.py list_profiles() including its _PROFILE_ID_RE filter), 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 on whenReady, fans the event out to every window, stops it on before-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.tsonSessionsStoreChanged bridge (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-window onSessionsChanged bus.

Why watch the transcript directory and not the DB

Decided by measurement on macOS against a ~/.hermes holding 5.5k transcripts and a 2.3 GB state.db:

  • The profile home directory is unusable as a trigger. 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 via 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.

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.

cd apps/desktop
HERMES_DESKTOP_HERMES_ROOT="$HOME/.hermes/hermes-agent" \
HERMES_DESKTOP_PYTHON="$HOME/.hermes/hermes-agent/venv/bin/python" \
  ../../scripts/dev-sandbox.sh --persistent -- npm run dev

# with the app open and its session store EMPTY, from another shell:
HERMES_HOME=<sandbox>/hermes-home \
  "$HOME/.hermes/hermes-agent/venv/bin/python" -m hermes_cli.main \
  --provider ai-router -m deepseek-v4-flash-0731-iq3xxs -z "Reply with exactly OK"

The externally-created session appears in the sidebar in about two seconds, with no reload.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature
  • Tests pass — see below. (pytest tests/ N/A: no Python changed. The desktop vitest projects are the relevant gate.)
  • I've added tests for my changes
  • I've tested on my platform: macOS 15 (Darwin 25.6.0), Apple Silicon

Documentation & Housekeeping

  • Documentation — N/A (no user-facing config or docs surface; the reasoning lives in the module header and commit bodies)
  • cli-config.yaml.example — N/A (no config keys)
  • CONTRIBUTING.md / AGENTS.md — N/A (no architecture or workflow change)
  • Cross-platform impact considered: fs.watch non-recursive on a directory is supported on macOS (FSEvents), Linux (inotify — one watch per directory, not per file), and Windows (ReadDirectoryChangesW). Path joins go through path.join; the Windows %LOCALAPPDATA%\hermes home resolves through the existing HERMES_HOME constant. A watcher that cannot bind logs and degrades to the poll.
  • Tool descriptions/schemas — N/A

Screenshots / Logs

Measured signal-to-noise (why sessions/ won). 120s fs.watch probe over a live ~/.hermes, idle except for one external run:

  4.91  ~/.hermes            rename  kanban.db-shm      <- home root, every 5s, forever
  4.91  ~/.hermes            rename  kanban.db-wal
 10.71  ~/.hermes            change  state.db-wal
 ...    (repeats every 5s for the full window)
 13.94  ~/.hermes/sessions   rename  .session_..._bzeo1ci9.tmp   <- only real signal
 13.94  ~/.hermes/sessions   rename  session_20260801_210825_6249ef.json

End-to-end trace (temporary instrumentation, removed before commit; app booted against an empty store, no user interaction):

CLI_START                                       2026-08-02T04:30:35Z
[session-store-watch] notify                    2026-08-02T04:30:37.197Z   (T+2.2s)
[renderer] refreshSessions                      2026-08-02T04:30:37.197Z
[session-store-watch] notify                    2026-08-02T04:30:38.953Z   (throttle held: 1.756s >= 1500ms)
[renderer] refreshSessions                      2026-08-02T04:30:38.953Z

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:

GET /api/profiles/sessions/sidebar
recents total: 1
 ROW: 20260801_213036_db70e9 | source=cli | msgs=1 | ended=None | active=True

Electron main bound the watch to the real store on boot:

[session-store-watch] watching <sandbox>/hermes-home/sessions

Test results

  • npm run typecheck (all three projects) — clean.
  • New tests: 11 in electron/session-store-watch.test.ts, 9 in use-external-session-sync.test.tsx, 2 added to use-session-list-actions.test.tsx.
  • The three guard tests were mutation-checked — each fails against the naive implementation it protects: a restarting trailing debounce starves (0 notifies instead of 10); dropping the single-flight guard stacks 6 refreshes instead of 1; restoring the old requestId === current gate strands the loading flag.
  • Full desktop suite on this branch: 273 files, 2319 passed, 2 skipped.
  • Later runs showed 5 failures in src/app/skills/index.test.tsx and src/app/messaging/index.test.tsx. These reproduce identically on pristine origin/main and 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: hydrateFromStoredSession requires a runtimeSessionId, 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.

Omar Baradei and others added 2 commits August 1, 2026 21:49
`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>
@OmarB97

OmarB97 commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Merging with the two red checks explicitly noted: Check uv.lock and Desktop E2E fail identically on pristine origin/main @ 1d58011 (uv lock --check exits 1 with zero local changes) — fork-wide pre-existing breakage, being fixed in a separate session right now. This PR's own surface is green: Typecheck & Test (apps/desktop) plus every other workspace. Verified end-to-end in an isolated sandbox: external CLI session visible in the sidebar 2.2s after creation, no manual reload.

@OmarB97
OmarB97 merged commit 7b09c55 into main Aug 2, 2026
25 of 28 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant