NousResearch/hermes-agent

Desktop OAuth session routing and remote profile fixes

Cluster 41529 · apps/desktop. Data as of

Nothing open on GitHub. Fix merged in #67245. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #67245. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #72835. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and pull requests address bugs and features related to how the desktop application routes, preserves, and displays OAuth sessions alongside token-auth remote profiles. Work spans gateway connectivity, sidebar session visibility, and profile/pool lifecycle correctness.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #67245 perf(desktop): batch sidebar session slices into one profile-DB pass

    merged to main

    files: apps/desktop/electron/main.ts, apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx, apps/desktop/src/app/session/hooks/use-session-list-actions.ts, apps/desktop/src/hermes.test.ts, apps/desktop/src/hermes.ts, hermes_cli/web_server.py, tests/hermes_cli/test_web_server.py

    Show the diff
    • apps/desktop/electron/main.ts
      @@ -8089,6 +8089,71 @@ async function interceptSessionRequestForRemote(request) {
           return mergeRemoteProfileSessions(searchParams, remoteProfiles)
         }
       
      +  // Batched sidebar slices. With no remote profiles the local batched endpoint
      +  // (one DB open per profile) serves it directly — take the fast path. When
      +  // remotes exist, fan the three slices back out to the per-slice
      +  // /api/profiles/sessions path (which already merges remote rows correctly) and
      +  // reassemble; local profiles fall back to three primary reads there, but
      +  // remote correctness is preserved.
      +  if (method === 'GET' && pathname === '/api/profiles/sessions/sidebar') {
      +    const remoteProfiles = configuredRemoteProfileNames()
      +
      +    if (remoteProfiles.length === 0) {
      +      return undefined // local fast path → batched endpoint's single DB open
      +    }
      +
      +    const recentsProfile = (searchParams.get('recents_profile') || 'all').trim() || 'all'
      +
      +    const sliceParams = (limitKey, defaultLimit, extra) => {
      +      const sp = new URLSearchParams({
      +        limit: searchParams.get(limitKey) || defaultLimit,
      +        offset: '0',
      +        min_messages: '1',
      +        archived: 'exclude',
      +        order: 'recent',
      +        ...extra
      +      })
      +
      +      return sp
      +    }
      +
      +    const recentsSp = sliceParams('recents_limit', '20', { profile: recentsProfile })
      +    const recentsExclude = searchParams.get('recents_exclude')
      +
      +    if (recentsExclude) {
      +      recentsSp.set('exclude_sources', recentsExclude)
      +    }
      +
      +    const cronSp = sliceParams('cron_limit', '50', { profile: 'all', source: 'cron' })
      +
      +    const messagingSp = sliceParams('messaging_limit', '100', { profile: 'all' })
      +    const messagingExclude = searchParams.get('messaging_exclude')
      +
      +    if (messagingExclude) {
      +      messagingSp.set('exclude_sources', messagingExclude)
      +    }
      +
      +    const [recents, cron, messaging] = await Promise.all([
      +      fetchProfilesSessionSlice(recentsSp, remoteProfiles),
      +      fetchProfilesSessionSlice(cronSp, remoteProfiles),
      +      fetchProfilesSessionSlice(messagingSp, remoteProfiles)
      +    ])
      +
      +    return {
      +      recents: {
      +        sessions: rowsOf(recents),
      +        total: Number(recents?.total) || 0,
      +        profile_totals: recents?.profile_totals || {}
      +      },
      +      cron: { sessions: rowsOf(cron) },
      +      messaging: {
      +        sessions: rowsOf(messaging),
      +        total: Number(messaging?.total) || rowsOf(messaging).length
      +      },
      +      errors: []
      +    }
      +  }
      +
         // Per-session read/mutation. Owner is in ?profile= (reads) or request.profile
         // (mutations). Two remote shapes:
         //  - per-profile override: route to that profile's own remote, sans profile
      @@ -8153,6 +8218,30 @@ async function remoteSessionList(profile, searchParams) {
         return { ...(data as any), sessions: rowsOf(data) }
       }
       
      +// Resolve one /api/profiles/sessions slice with remote profiles spliced in —
      +// the same branch logic as the GET /api/profiles/sessions intercept, but always
      +// returns data (never `undefined`) so a batched caller can compose slices. A
      +// specific local profile reads from the local primary; a remote-override profile
      +// reads from its remote; 'all' merges every remote into the primary aggregate.
      +async function fetchProfilesSessionSlice(searchParams, remoteProfiles) {
      +  const requested = (searchParams.get('profile') || 'all').trim() || 'all'
      +
      +  if (requested !== 'all') {
      +    if (profileHasRemoteOverride(requested)) {
      +      return remoteSessionList(requested, searchParams)
      +    }
      +
      +    const primary = await ensureBackend(null)
      +
      +    return fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, {
      +      method: 'GET',
      +      timeoutMs: DEFAULT_FETCH_TIMEOUT_MS
      +    }).catch(() => ({ sessions: [], total: 0, profile_totals: {} }))
      +  }
      +
      +  return mergeRemoteProfileSessions(searchParams, remoteProfiles)
      +}
      +
       // Unified list: primary's local aggregate, with each remote profile's stale local
       // rows/totals swapped for the remote's real ones, re-sorted by recency and
       // re-windowed to the requested page. A dead remote contributes nothing rather
    • apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx
      @@ -1,8 +1,8 @@
       import { act, renderHook } from '@testing-library/react'
       import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
       
      -import type { SessionInfo } from '@/hermes'
      -import { $sessions, $sessionsLoading, setSessions, setSessionsLoading } from '@/store/session'
      +import type { SessionInfo, SidebarSessionsResponse } from '@/hermes'
      +import { $cronSessions, $messagingSessions, $sessions, $sessionsLoading, setCronSessions, setMessagingSessions, setSessions, setSessionsLoading } from '@/store/session'
       
       import { useSessionListActions } from './use-session-list-actions'
       
      @@ -29,29 +29,50 @@ const row = (id: string, over: Partial<SessionInfo> = {}): SessionInfo =>
           ...over
         }) as SessionInfo
       
      +// Batched sidebar response builder. `refreshSessions` now makes ONE
      +// listSidebarSessions call that returns all three slices, replacing the three
      +// separate listAllProfileSessions calls (each of which reopened every profile
      +// DB) — #66377-adjacent perf work from the desktop audit canvas.
      +const sidebar = (
      +  recents: { sessions: SessionInfo[]; total?: number; profile_totals?: Record<string, number> },
      +  cron: SessionInfo[] = [],
      +  messaging: SessionInfo[] = []
      +): SidebarSessionsResponse => ({
      +  recents: { sessions: recents.sessions, total: recents.total, profile_totals: recents.profile_totals },
      +  cron: { sessions: cron },
      +  messaging: { sessions: messaging, total: messaging.length }
      +})
      +
      +const listSidebarSessions = vi.fn()
       const listAllProfileSessions = vi.fn()
       
       vi.mock('@/hermes', async importOriginal => ({
         ...(await importOriginal<Record<string, unknown>>()),
         getCronJobs: vi.fn(async () => []),
      -  listAllProfileSessions: (...args: unknown[]) => listAllProfileSessions(...args)
      +  listAllProfileSessions: (...args: unknown[]) => listAllProfileSessions(...args),
      +  listSidebarSessions: (...args: unknown[]) => listSidebarSessions(...args)
       }))
       
       beforeEach(() => {
      +  listSidebarSessions.mockReset()
         listAllProfileSessions.mockReset()
         setSessions([])
      +  setCronSessions([])
      +  setMessagingSessions([])
         setSessionsLoading(false)
       })
       
       afterEach(() => {
         setSessions([])
      +  setCronSessions([])
      +  setMessagingSessions([])
         setSessionsLoading(false)
       })
       
       describe('refreshSessions identity + loading hygiene', () => {
         it('keeps the previous $sessions array when the refresh is content-identical', async () => {
           const rows = [row('a'), row('b')]
      -    listAllProfileSessions.mockResolvedValue({ sessions: rows, total: 2, profile_totals: { default: 2 } })
      +    listSidebarSessions.mockResolvedValue(sidebar({ sessions: rows, total: 2, profile_totals: { default: 2 } }))
       
           const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
       
      @@ -63,11 +84,9 @@ describe('refreshSessions identity + loading hygiene', () => {
           expect(first.map(s => s.id)).toEqual(['a', 'b'])
       
           // Second refresh returns fresh (but equal) row objects, as the API does.
      -    listAllProfileSessions.mockResolvedValue({
      -      sessions: [row('a'), row('b')],
      -      total: 2,
      -      profile_totals: { default: 2 }
      -    })
      +    listSidebarSessions.mockResolvedValue(
      +      sidebar({ sessions: [row('a'), row('b')], total: 2, profile_totals: { default: 2 } })
      +    )
       
           await act(async () => {
             await result.current.refreshSessions()
      @@ -77,7 +96,7 @@ describe('refreshSessions identity + loading hygiene', () => {
         })
       
         it('swaps the array when rows actually changed', async () => {
      -    listAllProfileSessions.mockResolvedValue({ sessions: [row('a')], total: 1, profile_totals: {} })
      +    listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
           const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
       
           await act(async () => {
      @@ -86,11 +105,9 @@ describe('refreshSessions identity + loading hygiene', () => {
       
           const first = $sessions.get()
       
      -    listAllProfileSessions.mockResolvedValue({
      -      sessions: [row('a', { last_active: 2000, title: 'Renamed' })],
      -      total: 1,
      -      profile_totals: {}
      -    })
      +    listSidebarSessions.mockResolvedValue(
      +      sidebar({ sessions: [row('a', { last_active: 2000, title: 'Renamed' })], total: 1, profile_totals: {} })
      +    )
       
           await act(async () => {
             await result.current.refreshSessions()
      @@ -101,7 +118,7 @@ describe('refreshSessions identity + loading hygiene', () => {
         })
       
         it('does not flicker the loading flag over a populated list', async () => {
      -    listAllProfileSessions.mockResolvedValue({ sessions: [row('a')], total: 1, profile_totals: {} })
      +    listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
           const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
       
           await act(async () => {
      @@ -121,7 +138,7 @@ describe('refreshSessions identity + loading hygiene', () => {
         })
       
         it('still shows loading for the initial (empty-list) fetch', async () => {
      -    listAllProfileSessions.mockResolvedValue({ sessions: [row('a')], total: 1, profile_totals: {} })
      +    listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
           const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
       
           const loadingStates: boolean[] = []
      @@ -135,3 +152,45 @@ describe('refreshSessions identity + loading hygiene', () => {
           expect(loadingStates).toEqual([false, true, false])
         })
       })
      +
      +describe('refreshSessions batches slices into one request', () => {
      +  it('makes a single sidebar call and distributes recents / cron / messaging', async () => {
      +    const recents = [row('a'), row('b')]
      +    const cron = [row('c1', { source: 'cron', title: 'nightly' })]
      +    const messaging = [row('m1', { source: 'telegram', title: 'tg chat' })]
      +
      +    listSidebarSessions.mockResolvedValue(sidebar({ sessions: recents, total: 2, profile_totals: { default: 2 } }, cron, messaging))
      +
      +    const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
      +
      +    await act(async () => {
      +      await result.current.refreshSessions()
      +    })
      +
      +    // One batched call, not three separate listAllProfileSessions reads.
      +    expect(listSidebarSessions).toHaveBeenCalledTimes(1)
      +    expect(listAllProfileSessions).not.toHaveBeenCalled()
      +
      +    // Each slice landed in its own store.
      +    expect($sessions.get().map(s => s.id)).toEqual(['a', 'b'])
      +    expect($cronSessions.get().map(s => s.id)).toEqual(['c1'])
      +    expect($messagingSessions.get().map(s => s.id)).toEqual(['m1'])
      +  })
      +
      +  it('forwards the active profile scope + section limits to the batched call', async () => {
      +    listSidebarSessions.mockResolvedValue(sidebar({ sessions: [], total: 0, profile_totals: {} }))
      +    const { result } = renderHook(() => useSessionListActions({ profileScope: 'work' }))
      +
      +    await act(async () => {
      +      await result.current.refreshSessions()
      +    })
      +
      +    expect(listSidebarSessions).toHaveBeenCalledWith(
      +      expect.objectContaining({
      +        recentsProfile: 'work',
      +        recentsExclude: expect.arrayContaining(['cron']),
      +        messagingExclude: expect.arrayContaining(['cron'])
      +      })
      +    )
      +  })
      +})
    • apps/desktop/src/app/session/hooks/use-session-list-actions.ts
      @@ -1,6 +1,6 @@
       import { useCallback, useRef } from 'react'
       
      -import { getCronJobs, listAllProfileSessions, type SessionInfo } from '@/hermes'
      +import { getCronJobs, listAllProfileSessions, listSidebarSessions, type SessionInfo } from '@/hermes'
       import { sameCronSignature } from '@/lib/session-signatures'
       import {
         isMessagingSource,
      @@ -75,22 +75,6 @@ interface UseSessionListActionsArgs {
       export function useSessionListActions({ profileScope }: UseSessionListActionsArgs) {
         const refreshSessionsRequestRef = useRef(0)
       
      -  // Cron-job sessions as their own list (latest N). Independent of the recents
      -  // page so the two never compete for slots. Cheap + bounded. Kept (even though
      -  // the sidebar now lists cron *jobs*, not run sessions) so a pinned cron run
      -  // still resolves into the Pinned section via sessionByAnyId.
      -  const refreshCronSessions = useCallback(async () => {
      -    try {
      -      const { sessions } = await listAllProfileSessions(CRON_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
      -        source: 'cron'
      -      })
      -
      -      setCronSessions(prev => (sameCronSignature(prev, sessions) ? prev : sessions))
      -    } catch {
      -      // Non-fatal: the cron section just stays empty/stale.
      -    }
      -  }, [])
      -
         // Messaging-platform sessions as their own slice, fetched separately from
         // local recents so each platform renders a self-managed section and never
         // competes with local chats for the recents page budget. One combined fetch
      @@ -171,37 +155,59 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
             // clutter the sidebar.
             // Unified cross-profile list (served read-only off each profile's
             // state.db; no per-profile backend is spawned). Single-profile users get
      -      // the same rows tagged profile="default". Cron sessions are excluded here
      -      // and fetched separately (refreshCronSessions) so the scheduler's
      -      // always-newest rows can't consume the recents page budget.
      -      // Scope the fetch to the active profile (not always 'all') so a profile
      +      // the same rows tagged profile="default".
      +      // Scope recents to the active profile (not always 'all') so a profile
             // with few recent sessions isn't windowed out of the cross-profile
      -      // recency page — the empty-history-on-profile-switch bug.
      +      // recency page — the empty-history-on-profile-switch bug. Cron + messaging
      +      // stay cross-profile.
             const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
       
      -      const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
      -        excludeSources: SIDEBAR_EXCLUDED_SOURCES
      +      // Batched: one request opens each profile DB once and returns all three
      +      // source-scoped slices, instead of three separate listAllProfileSessions
      +      // calls that each reopened + re-counted every profile DB per refresh.
      +      const result = await listSidebarSessions({
      +        recentsProfile: sessionProfile,
      +        recentsLimit: limit,
      +        recentsExclude: SIDEBAR_EXCLUDED_SOURCES,
      +        cronLimit: CRON_SECTION_LIMIT,
      +        messagingLimit: MESSAGING_SECTION_LIMIT,
      +        messagingExclude: MESSAGING_EXCLUDED_SOURCES
             })
       
             if (refreshSessionsRequestRef.current === requestId) {
      +        const recents = result.recents
      +
               // Signature-gate the swap (same pattern as cron/messaging): a refresh
               // that returns content-identical rows must keep the previous array
               // identity, or every sidebar memo keyed on $sessions recomputes and the
               // whole list re-renders once per turn/broadcast for nothing.
               setSessions(prev => {
      -          const next = mergeSessionPage(prev, result.sessions, sessionsToKeep())
      +          const next = mergeSessionPage(prev, recents.sessions, sessionsToKeep())
       
                 return sameCronSignature(prev, next) ? prev : next
               })
      -        setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
      +        setSessionsTotal(typeof recents.total === 'number' ? recents.total : recents.sessions.length)
               setSessionProfileTotals(prev => {
      -          const next = result.profile_totals ?? {}
      +          const next = recents.profile_totals ?? {}
                 const prevKeys = Object.keys(prev)
       
                 return prevKeys.length === Object.keys(next).length && prevKeys.every(key => prev[key] === next[key])
                   ? prev
                   : next
               })
      +
      +        // Cron section: latest N cron sessions (kept so a pinned cron run still
      +        // resolves via sessionByAnyId), signature-gated like above.
      +        setCronSessions(prev => (sameCronSignature(prev, result.cron.sessions) ? prev : result.cron.sessions))
      +
      +        // Messaging sections: drop any non-messaging source the broad exclude
      +        // didn't catch (custom sources stay in local recents), then split per
      +        // platform in the UI.
      +        const messagingRows = result.messaging.sessions.filter(s => isMessagingSource(s.source))
      +
      +        setMessagingSessions(prev => (sameCronSignature(prev, messagingRows) ? prev : messagingRows))
      +        // Hit the cap → at least one platform may have more on disk than loaded.
      +        setMessagingTruncated(result.messaging.sessions.length >= MESSAGING_SECTION_LIMIT)
             }
           } finally {
             if (showLoading && refreshSessionsRequestRef.current === requestId) {
      @@ -209,10 +215,9 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
             }
           }
       
      -    void refreshCronSessions()
      +    // Cron *jobs* are a distinct API (getCronJobs), not a session slice.
           void refreshCronJobs()
      -    void refreshMessagingSessions()
      -  }, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
      +  }, [profileScope, refreshCronJobs])
       
         const loadMoreSessions = useCallback(async () => {
           bumpSessionsLimit()
    • apps/desktop/src/hermes.test.ts
      @@ -10,7 +10,8 @@ import {
         getSessionMessages,
         getStatus,
         listAllProfileSessions,
      -  listSessions
      +  listSessions,
      +  listSidebarSessions
       } from './hermes'
       import { refreshActiveProfile } from './store/profile'
       
      @@ -59,6 +60,45 @@ describe('Hermes REST session helpers', () => {
           )
         })
       
      +  it('batches the sidebar slices into a single request with per-slice limits + excludes', async () => {
      +    api.mockResolvedValue({ recents: { sessions: [] }, cron: { sessions: [] }, messaging: { sessions: [] } })
      +
      +    await listSidebarSessions({
      +      recentsProfile: 'work',
      +      recentsLimit: 30,
      +      recentsExclude: ['cron', 'tool'],
      +      cronLimit: 50,
      +      messagingLimit: 100,
      +      messagingExclude: ['cron', 'desktop']
      +    })
      +
      +    expect(api).toHaveBeenCalledWith(
      +      expect.objectContaining({
      +        path:
      +          '/api/profiles/sessions/sidebar?recents_profile=work&recents_limit=30&cron_limit=50' +
      +          '&messaging_limit=100&recents_exclude=cron%2Ctool&messaging_exclude=cron%2Cdesktop',
      +        timeoutMs: 60_000
      +      })
      +    )
      +  })
      +
      +  it('defaults missing sidebar slices to empty session arrays', async () => {
      +    api.mockResolvedValue({})
      +
      +    const result = await listSidebarSessions({
      +      recentsProfile: 'all',
      +      recentsLimit: 20,
      +      recentsExclude: [],
      +      cronLimit: 50,
      +      messagingLimit: 100,
      +      messagingExclude: []
      +    })
      +
      +    expect(result.recents.sessions).toEqual([])
      +    expect(result.cron.sessions).toEqual([])
      +    expect(result.messaging.sessions).toEqual([])
      +  })
      +
         it('uses a longer timeout for profile listing during desktop startup', async () => {
           api.mockResolvedValue({ profiles: [] })
       
    • apps/desktop/src/hermes.ts
      @@ -359,6 +359,62 @@ export async function listAllProfileSessions(
         }
       }
       
      +// Batched sidebar slices in one request: recents (scoped to the active profile),
      +// cron, and messaging. The backend opens each profile's state.db once and runs
      +// all three filtered queries, replacing three separate listAllProfileSessions
      +// calls that each reopened + re-counted every profile DB per refresh. Electron
      +// splices remote profiles per slice (see interceptSessionRequestForRemote).
      +export interface SidebarSessionSlice {
      +  sessions: SessionInfo[]
      +  total?: number
      +  profile_totals?: Record<string, number>
      +}
      +
      +export interface SidebarSessionsResponse {
      +  recents: SidebarSessionSlice
      +  cron: SidebarSessionSlice
      +  messaging: SidebarSessionSlice
      +  errors?: Array<{ profile: string; error: string }>
      +}
      +
      +export interface SidebarSessionsRequest {
      +  recentsProfile: 'all' | (string & {})
      +  recentsLimit: number
      +  recentsExclude: string[]
      +  cronLimit: number
      +  messagingLimit: number
      +  messagingExclude: string[]
      +}
      +
      +export async function listSidebarSessions(req: SidebarSessionsRequest): Promise<SidebarSessionsResponse> {
      +  const params = new URLSearchParams({
      +    recents_profile: req.recentsProfile,
      +    recents_limit: String(Math.max(1, req.recentsLimit)),
      +    cron_limit: String(Math.max(1, req.cronLimit)),
      +    messaging_limit: String(Math.max(1, req.messagingLimit))
      +  })
      +
      +  if (req.recentsExclude.length) {
      +    params.set('recents_exclude', req.recentsExclude.join(','))
      +  }
      +
      +  if (req.messagingExclude.length) {
      +    params.set('messaging_exclude', req.messagingExclude.join(','))
      +  }
      +
      +  const result = await window.hermesDesktop.api<SidebarSessionsResponse>({
      +    path: `/api/profiles/sessions/sidebar?${params.toString()}`,
      +    timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
      +  })
      +
      +  return {
      +    recents: { ...result.recents, sessions: result.recents?.sessions ?? [] },
      +    cron: { ...result.cron, sessions: result.cron?.sessions ?? [] },
      +    messaging: { ...result.messaging, sessions: result.messaging?.sessions ?? [] },
      +    errors: result.errors
      +  }
      +}
      +
       // Mutations take the owning `profile` so Electron routes them to that profile's
       // backend (remote pool or local primary) via request.profile — matching the
       // read path. A remote session's row lives only on its remote host, so a mutation
    • hermes_cli/web_server.py
      @@ -4266,6 +4266,139 @@ def get_profiles_sessions(
           }
       
       
      [redacted:email]("/api/profiles/sessions/sidebar")
      +def get_profiles_sessions_sidebar(
      +    recents_profile: str = "all",
      +    recents_limit: int = 20,
      +    recents_exclude: str = None,
      +    cron_limit: int = 50,
      +    messaging_limit: int = 100,
      +    messaging_exclude: str = None,
      +):
      +    """Batched sidebar session slices — one profile-DB open per refresh.
      +
      +    The desktop sidebar needs three source-scoped windows per refresh: recents
      +    (local chats, scoped to the active profile), cron sessions (all profiles),
      +    and messaging-platform sessions (all profiles). Served as three separate
      +    ``/api/profiles/sessions`` calls they reopened every profile's ``state.db``
      +    three times and re-counted each refresh. This opens each DB once and runs
      +    the three filtered queries together, returning the three windows in one
      +    payload. Read-only and process-light, same row projection and 300s active
      +    heuristic as ``/api/profiles/sessions``.
      +
      +    The caller passes the source taxonomy (``recents_exclude`` /
      +    ``messaging_exclude`` CSV, ``source=cron`` is implicit) so this stays
      +    taxonomy-agnostic like the per-slice endpoint. All three slices use
      +    ``min_messages=1`` / ``archived=exclude`` / recency order, matching the
      +    desktop's per-slice calls.
      +    """
      +    from hermes_state import SessionDB
      +    from hermes_cli import profiles as profiles_mod
      +
      +    # cron + messaging are cross-profile; recents is scoped to recents_profile.
      +    # Scan every profile once regardless (each DB opened a single time).
      +    try:
      +        infos = profiles_mod.list_profiles()
      +        targets: List[Tuple[str, Path]] = [(info.name, info.path) for info in infos]
      +    except Exception:
      +        _log.exception("GET /api/profiles/sessions/sidebar: list_profiles failed")
      +        targets = []
      +    if not targets:
      +        targets.append(("default", profiles_mod.get_profile_dir("default")))
      +
      +    recents_scope = (recents_profile or "all").strip() or "all"
      +    recents_exclude_list = [s for s in (recents_exclude or "").split(",") if s.strip()]
      +    messaging_exclude_list = [s for s in (messaging_exclude or "").split(",") if s.strip()]
      +
      +    recents_cap = min(max(recents_limit, 1), 500)
      +    cron_cap = min(max(cron_limit, 1), 500)
      +    messaging_cap = min(max(messaging_limit, 1), 500)
      +
      +    recents_rows: List[Dict[str, Any]] = []
      +    cron_rows: List[Dict[str, Any]] = []
      +    messaging_rows: List[Dict[str, Any]] = []
      +    recents_total = 0
      +    recents_profile_totals: Dict[str, int] = {}
      +    errors: List[Dict[str, str]] = []
      +    now = time.time()
      +
      +    def _tag(rows: List[Dict[str, Any]], name: str) -> List[Dict[str, Any]]:
      +        for s in rows:
      +            s["profile"] = name
      +            s["is_default_profile"] = name == "default"
      +            s["is_active"] = (
      +                s.get("ended_at") is None
      +                and (now - s.get("last_active", s.get("started_at", 0))) < 300
      +            )
      +            s["archived"] = bool(s.get("archived"))
      +        return rows
      +
      +    def _slice(db, *, source=None, exclude=None, cap):
      +        return db.list_sessions_rich(
      +            source=source,
      +            exclude_sources=exclude or None,
      +            limit=cap,
      +            offset=0,
      +            min_message_count=1,
      +            include_archived=False,
      +            archived_only=False,
      +            order_by_last_active=True,
      +            compact_rows=True,
      +        )
      +
      +    for name, home in targets:
      +        db_path = Path(home) / "state.db"
      +        if not db_path.exists():
      +            continue
      +        try:
      +            db = SessionDB(db_path=db_path, read_only=True)
      +        except Exception as exc:
      +            errors.append({"profile": name, "error": str(exc)})
      +            continue
      +        try:
      +            if recents_scope == "all" or name == recents_scope:
      +                recents_rows.extend(
      +                    _tag(_slice(db, exclude=recents_exclude_list, cap=recents_cap), name)
      +                )
      +                rtotal = db.session_count(
      +                    exclude_sources=recents_exclude_list or None,
      +                    min_message_count=1,
      +                    include_archived=False,
      +                    archived_only=False,
      +                    exclude_children=True,
      +                )
      +                recents_total += rtotal
      +                recents_profile_totals[name] = rtotal
      +            cron_rows.extend(_tag(_slice(db, source="cron", cap=cron_cap), name))
      +            messaging_rows.extend(
      +                _tag(_slice(db, exclude=messaging_exclude_list, cap=messaging_cap), name)
      +            )
      +        except Exception as exc:
      +            errors.append({"profile": name, "error": str(exc)})
      +        finally:
      +            db.close()
      +
      +    def _window(rows: List[Dict[str, Any]], cap: int) -> List[Dict[str, Any]]:
      +        rows.sort(key=lambda s: s.get("last_active") or s.get("started_at") or 0, reverse=True)
      +        win = rows[:cap]
      +        _strip_session_list_rows(win)
      +        return win
      +
      +    return {
      +        "recents": {
      +            "sessions": _window(recents_rows, recents_cap),
      +            "total": recents_total,
      +            "profile_totals": recents_profile_totals,
      +        },
      +        "cron": {"sessions": _window(cron_rows, cron_cap)},
      +        "messaging": {
      +            "sessions": _window(messaging_rows, messaging_cap),
      +            "total": len(messaging_rows),
      +        },
      +        "errors": errors,
      +    }
      +
      +
       @app.get("/api/sessions/search")
       async def search_sessions(q: str = "", limit: int = 20, profile: Optional[str] = None):
           """Search sessions by ID plus full-text message content using FTS5.
    • tests/hermes_cli/test_web_server.py
      @@ -1473,6 +1473,52 @@ def test_profiles_sessions_rejects_unknown_archived_value(self):
               resp = self.client.get("/api/profiles/sessions?archived=bogus")
               assert resp.status_code == 400
       
      +    def test_profiles_sessions_sidebar_batches_three_slices(self):
      +        """The batched sidebar endpoint returns recents/cron/messaging in one
      +        pass, each source-scoped by the caller-supplied excludes, so the desktop
      +        stops reopening every profile DB three times per refresh."""
      +        from hermes_state import SessionDB
      +
      +        db = SessionDB()
      +        try:
      +            for sid, src in (
      +                ("sb-desktop", "desktop"),
      +                ("sb-cron", "cron"),
      +                ("sb-telegram", "telegram"),
      +            ):
      +                db.create_session(session_id=sid, source=src)
      +                db.append_message(session_id=sid, role="user", content="hi")
      +        finally:
      +            db.close()
      +
      +        resp = self.client.get(
      +            "/api/profiles/sessions/sidebar"
      +            "?recents_profile=all&recents_limit=20&recents_exclude=cron,telegram"
      +            "&cron_limit=50&messaging_limit=100"
      +            "&messaging_exclude=cron,cli,codex,desktop,gateway,local,tui"
      +        )
      +        assert resp.status_code == 200
      +        data = resp.json()
      +
      +        recents_ids = {s["id"] for s in data["recents"]["sessions"]}
      +        cron_ids = {s["id"] for s in data["cron"]["sessions"]}
      +        messaging_ids = {s["id"] for s in data["messaging"]["sessions"]}
      +
      +        # Each session lands only in its own slice.
      +        assert "sb-desktop" in recents_ids
      +        assert "sb-desktop" not in cron_ids and "sb-desktop" not in messaging_ids
      +        assert "sb-cron" in cron_ids
      +        assert "sb-cron" not in recents_ids and "sb-cron" not in messaging_ids
      +        assert "sb-telegram" in messaging_ids
      +        assert "sb-telegram" not in recents_ids and "sb-telegram" not in cron_ids
      +
      +        # Rows carry profile tagging like /api/profiles/sessions.
      +        row = next(s for s in data["recents"]["sessions"] if s["id"] == "sb-desktop")
      +        assert row["profile"] == "default"
      +        assert row["is_default_profile"] is True
      +        assert isinstance(data.get("errors"), list)
      +        assert data["recents"]["total"] >= 1
      +
           def test_sessions_endpoint_reads_requested_profile(self):
               """The machine dashboard's global profile switcher must retarget
               the Sessions page, not just config/skills/model pages."""
  • #72835 fix(desktop): repair remote profile routing, sessions, and pool lifecycle

    merged to main

    files: apps/desktop/electron/connection-config.test.ts, apps/desktop/electron/connection-config.ts, apps/desktop/electron/crash-forensics.test.ts, apps/desktop/electron/crash-forensics.ts, apps/desktop/electron/main.ts, apps/desktop/electron/profile-session-routing.test.ts, apps/desktop/electron/profile-session-routing.ts, apps/desktop/electron/remote-liveness.test.ts, apps/desktop/electron/remote-liveness.ts

2 PRs closed without merging

From the close comments: merged via another change 1 · superseded by another PR 1

  • #41530 fix(desktop): route session fetches by auth mode · superseded by another PR → 39d0945, #67578, #67245, #41529
  • #67578 fix(desktop): preserve OAuth sessions in sidebar · merged via another change → #72835, #41529
Closed issues · 1
  • Closed#41529[Bug]: Desktop hides global OAuth sessions when a token-auth remote profile is configured
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.40: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#67245, #72835
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
27 Jul 2026

For your agent

The same facts, typed.