diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index fcd6c3759f5..3273b255141 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -3924,11 +3924,17 @@ function configuredRemoteProfileNames() { // GET a profile's resolved backend (remote pool or local primary), parsed JSON. async function fetchJsonForProfile(profile, path) { + return requestJsonForProfile(profile, path, 'GET') +} + +// Issue an arbitrary method against a profile's resolved backend, parsed JSON. +async function requestJsonForProfile(profile, path, method, body) { const conn = await ensureBackend(profile) const url = `${conn.baseUrl}${path}` + const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS } return conn.authMode === 'oauth' - ? fetchJsonViaOauthSession(url, { method: 'GET', timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }) - : fetchJson(url, conn.token, { method: 'GET', timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }) + ? fetchJsonViaOauthSession(url, opts) + : fetchJson(url, conn.token, opts) } async function probeRemoteAuthMode(rawUrl) { @@ -4720,16 +4726,20 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => { return systemPreferences.askForMediaAccess('microphone') }) -// Re-route remote-profile session reads to the owning remote backend. Returns -// `undefined` when the request isn't an interceptable session GET (caller takes -// the normal local path), else the response. Mutations (DELETE/PATCH) carry -// their own profile semantics and are out of scope. -// GET /api/profiles/sessions → splice each remote profile's real rows in -// GET /api/sessions/{id}[/messages] → for a remote profile, read from remote -async function interceptSessionReadForRemote(request) { - if ((request?.method || 'GET').toUpperCase() !== 'GET' || typeof request?.path !== 'string') { +// Re-route remote-profile session requests to the owning remote backend. Returns +// `undefined` when not interceptable (caller takes the normal local path), else +// the response. Reads tag the profile as ?profile=; mutations carry it in +// request.profile. Either way, a remote profile's session lives only on its +// remote host, so the request must go there (where it serves its own state.db). +// GET /api/profiles/sessions → splice each remote profile's rows in +// GET /api/sessions/{id}[/messages] → read from remote +// DELETE /api/sessions/{id} → delete on remote +// PATCH /api/sessions/{id} → rename/archive on remote +async function interceptSessionRequestForRemote(request) { + if (typeof request?.path !== 'string') { return undefined } + const method = (request.method || 'GET').toUpperCase() let parsed try { @@ -4739,7 +4749,7 @@ async function interceptSessionReadForRemote(request) { } const { pathname, searchParams } = parsed - if (pathname === '/api/profiles/sessions') { + if (method === 'GET' && pathname === '/api/profiles/sessions') { const remoteProfiles = configuredRemoteProfileNames() if (remoteProfiles.length === 0) { return undefined // no remote profiles → local fast path @@ -4751,11 +4761,20 @@ async function interceptSessionReadForRemote(request) { return mergeRemoteProfileSessions(searchParams, remoteProfiles) } - // Per-session detail/messages. The renderer tags the owner as ?profile=; - // for a remote profile, drop it and let the remote serve its own state.db. + // Per-session read/mutation. Owner is in ?profile= (reads) or request.profile + // (mutations); route to the remote sans profile param — it serves its own + // state.db, with no cross-profile semantics. if (/^\/api\/sessions\/[^/]+(\/messages)?$/.test(pathname)) { - const profile = (searchParams.get('profile') || '').trim() - return profile && profileHasRemoteOverride(profile) ? fetchJsonForProfile(profile, pathname) : undefined + const profile = (searchParams.get('profile') || request.profile || '').trim() + if (!profile || !profileHasRemoteOverride(profile)) { + return undefined + } + if (method === 'GET') { + return fetchJsonForProfile(profile, pathname) + } + const body = request.body && typeof request.body === 'object' ? { ...request.body } : request.body + if (body) delete body.profile + return requestJsonForProfile(profile, pathname, method, body) } return undefined @@ -4777,31 +4796,55 @@ async function remoteSessionList(profile, searchParams) { } // Unified list: primary's local aggregate, with each remote profile's stale local -// rows swapped for the remote's real ones, re-sorted by recency. A dead remote -// contributes nothing rather than breaking the sidebar. +// 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 +// than breaking the sidebar. async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { + const limit = Math.max(1, Number(searchParams.get('limit')) || 20) + const offset = Math.max(0, Number(searchParams.get('offset')) || 0) + const order = searchParams.get('order') === 'created' ? 'started_at' : 'last_active' + const primary = await ensureBackend(null) const base = await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, { method: 'GET', timeoutMs: DEFAULT_FETCH_TIMEOUT_MS - }).catch(() => ({ sessions: [], total: 0 })) + }).catch(() => ({ sessions: [], total: 0, profile_totals: {} })) + + // Over-fetch each remote from offset 0 (limit+offset rows) so the merged window + // is correct for this page — mirrors the primary's per-profile over-fetch. + const remoteParams = new URLSearchParams(searchParams) + remoteParams.set('limit', String(limit + offset)) + remoteParams.set('offset', '0') const remoteSet = new Set(remoteProfiles) const merged = rowsOf(base).filter(s => !remoteSet.has(s?.profile)) - const remoteRows = await Promise.all(remoteProfiles.map(name => remoteSessionList(name, searchParams).then(rowsOf, () => []))) - for (const rows of remoteRows) merged.push(...rows) + const profileTotals = { ...(base.profile_totals || {}) } + let total = (Number(base.total) || 0) - remoteProfiles.reduce((n, p) => n + (profileTotals[p] || 0), 0) - const recency = s => s?.last_active ?? s?.started_at ?? 0 + // Swap each remote profile's stale local rows/total for the remote's real ones. + await Promise.all(remoteProfiles.map(async name => { + const list = await remoteSessionList(name, remoteParams).catch(() => null) + if (!list) { + delete profileTotals[name] // dead remote → drop its stale local total too + return + } + const rows = rowsOf(list) + merged.push(...rows) + profileTotals[name] = Number(list.total) || rows.length + total += profileTotals[name] + })) + + const recency = s => s?.[order] ?? s?.started_at ?? 0 merged.sort((a, b) => recency(b) - recency(a)) - return { ...base, sessions: merged } + return { ...base, sessions: merged.slice(offset, offset + limit), total, profile_totals: profileTotals } } ipcMain.handle('hermes:api', async (_event, request) => { - // Remote-profile session reads would otherwise hit the local primary off each - // profile's on-disk state.db — fine for local profiles, but a remote profile's - // sessions live on its remote host, so the UI's IDs 404 the moment resume runs - // there (the "session not found → new session" bug). Route them to the remote. - const rerouted = await interceptSessionReadForRemote(request) + // Remote-profile session requests would otherwise hit the local primary off + // each profile's on-disk state.db — fine for local profiles, but a remote + // profile's sessions live on its remote host, so the UI's IDs 404 (or mutations + // no-op) the moment they run there. Route reads + mutations to the remote. + const rerouted = await interceptSessionRequestForRemote(request) if (rerouted !== undefined) { return rerouted } diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index bdd9dd15b87..4d63dda2790 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -763,7 +763,7 @@ export function useSessionActions({ await requestGateway('session.close', { session_id: closingRuntimeId }).catch(() => undefined) } - await deleteSession(storedSessionId) + await deleteSession(storedSessionId, removed?.profile) clearQueuedPrompts(storedSessionId) if (closingRuntimeId) { @@ -839,7 +839,7 @@ export function useSessionActions({ } try { - await setSessionArchived(storedSessionId, true) + await setSessionArchived(storedSessionId, true, archived?.profile) notify({ durationMs: 2_000, kind: 'success', message: 'Archived' }) } catch (err) { if (archived) { diff --git a/apps/desktop/src/app/settings/sessions-settings.tsx b/apps/desktop/src/app/settings/sessions-settings.tsx index af3861d098d..060088ba160 100644 --- a/apps/desktop/src/app/settings/sessions-settings.tsx +++ b/apps/desktop/src/app/settings/sessions-settings.tsx @@ -57,7 +57,7 @@ export function SessionsSettings() { setBusyId(session.id) try { - await setSessionArchived(session.id, false) + await setSessionArchived(session.id, false, session.profile) setLocalSessions(prev => prev.filter(s => s.id !== session.id)) // Surface it again in the sidebar without waiting for a full refresh. setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)]) @@ -78,7 +78,7 @@ export function SessionsSettings() { setBusyId(session.id) try { - await deleteSession(session.id) + await deleteSession(session.id, session.profile) setLocalSessions(prev => prev.filter(s => s.id !== session.id)) triggerHaptic('warning') } catch (err) { diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index d1f78efaf28..216d96a8738 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -166,8 +166,13 @@ export async function listAllProfileSessions( } } -export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> { +// 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 +// that hit the local primary would no-op or 404. Omit for the current/default. +export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ + ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}`, method: 'PATCH', body: { archived } @@ -180,8 +185,10 @@ export function searchSessions(query: string): Promise { }) } -// `profile` reads another profile's transcript straight off its state.db via the -// primary backend (no spawn). Omit for the current/default profile. +// Reads another profile's transcript. For a remote profile Electron reroutes +// this GET to the remote backend (which serves its own state.db); for a local +// profile the primary opens that profile's state.db via ?profile=. Omit for +// the current/default profile. export function getSessionMessages(id: string, profile?: string | null): Promise { const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' @@ -190,8 +197,9 @@ export function getSessionMessages(id: string, profile?: string | null): Promise }) } -export function deleteSession(id: string): Promise<{ ok: boolean }> { +export function deleteSession(id: string, profile?: string | null): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ + ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}`, method: 'DELETE' }) @@ -203,6 +211,7 @@ export function renameSession( profile?: string | null ): Promise<{ ok: boolean; title: string }> { return window.hermesDesktop.api<{ ok: boolean; title: string }>({ + ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}`, method: 'PATCH', body: { title, ...(profile ? { profile } : {}) } diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 98107dcbe95..c45a21961bf 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -5257,9 +5257,11 @@ async def get_session_messages(session_id: str, profile: Optional[str] = None): @app.delete("/api/sessions/{session_id}") -async def delete_session_endpoint(session_id: str): - from hermes_state import SessionDB - db = SessionDB() +async def delete_session_endpoint(session_id: str, profile: Optional[str] = None): + # ``profile`` deletes a session belonging to another (local) profile by + # opening its state.db directly. Remote profiles never reach here — the + # desktop routes their DELETE to the remote backend. Omit for current/default. + db = _open_session_db_for_profile(profile) try: if not db.delete_session(session_id): raise HTTPException(status_code=404, detail="Session not found")