# NousResearch/hermes-agent · Desktop OAuth session routing and remote profile fixes

## Open now

- 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.

## TL;DR

- 5 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #67245. Verify it addresses your case.
- **Recommended:** read PR #67245 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-72835`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.40`: the share of member 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.

## Closed / duplicate issues

- #41529: \[Bug\]: Desktop hides global OAuth sessions when a token-auth remote profile is configured

## Prior & competing PRs

- **#67245: perf(desktop): batch sidebar session slices into one profile-DB pass**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
  - fix hunks:
  - `apps/desktop/electron/main.ts`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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'
     
    ```

    ```diff
    @@ -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' }))
     
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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 () => {
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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 () => {
    ```

    ```diff
    @@ -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[] = []
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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) {
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -10,7 +10,8 @@ import {
       getSessionMessages,
       getStatus,
       listAllProfileSessions,
    -  listSessions
    +  listSessions,
    +  listSidebarSessions
     } from './hermes'
     import { refreshActiveProfile } from './store/profile'
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
- **#41530: fix(desktop): route session fetches by auth mode**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `39d09453f`, `#67578`, `#67578`, `#67245`, `#41529`
- **#67578: fix(desktop): preserve OAuth sessions in sidebar**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#72835`, `#41529`

## Fix status

- Merged to main in PR #67245 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-27`.
- Subsystem: `apps/desktop`
