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