# NousResearch/hermes-agent · Profile deletion and rename leave stale state

## Open now

- Nothing open on GitHub.

A cluster of bug reports and fixes addressing profile deletion and rename operations that leave behind stale directories, backend processes, or sockets, causing deleted or renamed profiles to reappear or respawn. The fixes target CLI and desktop layers to stop backends before deletion and prevent respawn loops.

## TL;DR

- 11 member(s): 0 open issue(s), 4 closed issue(s), 0 open PR(s), 4 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #49435. Verify it addresses your case.
- **Recommended:** read PR #49435 (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: `partially_resolved` · deciding PR(s): `pr-88732`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.15`: 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

- #45474: Bug: \`hermes profile rename\` leaves stale profile directory that GUI keeps reviving
- #47368: \[Bug\]: Desktop "Delete profile" silently fails and the profile keeps reappearing
- #50175: \[Bug\]: Deleting a profile fails when dashboard/gateway child processes are alive; profile reappears after every restart until full reboot
- #52279: Deleted profile respawns indefinitely, accumulating zombie backend processes

## Prior & competing PRs

- **#49435: Prevent deleted profiles from being recreated by stale dashboards**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/config.py`, `tests/hermes_cli/test_config.py`
  - fix hunks:
  - `hermes_cli/config.py`

    ```diff
    @@ -847,6 +847,15 @@ def ensure_hermes_home():
         any files created (e.g. SOUL.md) are group-writable (0660).
         """
         home = get_hermes_home()
    +    # Named profiles must be created explicitly (e.g. ``hermes profile create``).
    +    # If a stale process keeps running after the profile was renamed/deleted,
    +    # silently mkdir-ing the old HERMES_HOME would resurrect an empty skeleton
    +    # and make the deleted profile reappear in Desktop/profile lists.
    +    if home.parent.name == "profiles" and not home.exists():
    +        raise FileNotFoundError(
    +            f"Named profile home does not exist: {home}. "
    +            "Create the profile explicitly before using it."
    +        )
         if is_managed():
             old_umask = os.umask(0o007)
             try:
    ```
  - `tests/hermes_cli/test_config.py`

    ```diff
    @@ -89,6 +89,22 @@ def test_preserves_legacy_template_with_user_persona(self, tmp_path):
                 ensure_hermes_home()
                 assert soul_path.read_text(encoding="utf-8") == mixed
     
    +    def test_existing_named_profile_still_bootstraps_subdirs(self, tmp_path):
    +        profile_home = tmp_path / ".hermes" / "profiles" / "coder"
    +        profile_home.mkdir(parents=True)
    +        with patch.dict(os.environ, {"HERMES_HOME": str(profile_home)}):
    +            ensure_hermes_home()
    +            assert (profile_home / "cron").is_dir()
    +            assert (profile_home / "sessions").is_dir()
    +            assert (profile_home / "memories").is_dir()
    +
    +    def test_missing_named_profile_is_not_recreated(self, tmp_path):
    +        profile_home = tmp_path / ".hermes" / "profiles" / "coder"
    +        with patch.dict(os.environ, {"HERMES_HOME": str(profile_home)}):
    +            with pytest.raises(FileNotFoundError, match="Named profile home does not exist"):
    +                ensure_hermes_home()
    +        assert not profile_home.exists()
    +
     
     class TestLoadConfigDefaults:
         def test_returns_defaults_when_no_file(self, tmp_path):
    ```
- **#57329: fix(desktop): converge profile deletion — stop respawn + refresh rail (supersedes #52301, #49335)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `apps/desktop/electron/main.cjs`, `apps/desktop/electron/profile-delete-respawn.test.cjs`, `apps/desktop/src/app/profiles/index.tsx`, `apps/desktop/src/store/profile.test.ts`, `apps/desktop/src/store/profile.ts`
  - fix hunks:
  - `apps/desktop/electron/main.cjs`

    ```diff
    @@ -5419,19 +5419,24 @@ function profileNameFromDeleteRequest(request) {
       return name.toLowerCase()
     }
     
    +// Returns the profile name whose backend was torn down, or null when the
    +// request is not a profile-delete.  The caller uses this to skip ensureBackend
    +// for the just-torn-down profile — otherwise ensureBackend respawns a pool
    +// backend whose ensure_hermes_home() recreates the deleted profile directory.
     async function prepareProfileDeleteRequest(request) {
       const profile = profileNameFromDeleteRequest(request)
       if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) {
    -    return
    +    return null
       }
     
       if (profile === primaryProfileKey()) {
         writeActiveDesktopProfile('default')
         await teardownPrimaryBackendAndWait()
    -    return
    +    return profile
       }
     
       await teardownPoolBackendAndWait(profile)
    +  return profile
     }
     
     async function startHermes() {
    ```

    ```diff
    @@ -6465,10 +6470,15 @@ ipcMain.handle('hermes:api', async (_event, request) => {
         return rerouted
       }
     
    -  await prepareProfileDeleteRequest(request)
    +  const tornDownProfile = await prepareProfileDeleteRequest(request)
     
       const profile = request?.profile
    -  const connection = await ensureBackend(profile)
    +  // After tearing down a backend for profile deletion, route to the primary
    +  // backend instead of spawning a fresh pool backend.  A freshly spawned
    +  // backend calls ensure_hermes_home() which recreates the profile directory,
    +  // defeating the deletion and leaving a zombie process.
    +  const routeProfile = tornDownProfile ? null : profile
    +  const connection = await ensureBackend(routeProfile)
       const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
       const requestPath = pathWithGlobalRemoteProfile(request.path, profile, {
         globalRemote: globalRemoteActive(),
    ```
  - `apps/desktop/electron/profile-delete-respawn.test.cjs`

    ```diff
    @@ -0,0 +1,66 @@
    +'use strict'
    +
    +const test = require('node:test')
    +const assert = require('node:assert/strict')
    +const fs = require('node:fs')
    +const path = require('node:path')
    +
    +const ELECTRON_DIR = __dirname
    +
    +function readElectronFile(name) {
    +  return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
    +}
    +
    +// ---------------------------------------------------------------------------
    +// prepareProfileDeleteRequest must return the torn-down profile name so the
    +// caller can skip ensureBackend for that profile (issue #52279).
    +// ---------------------------------------------------------------------------
    +
    +test('prepareProfileDeleteRequest returns the torn-down profile name', () => {
    +  const source = readElectronFile('main.cjs')
    +
    +  // Locate the function definition and its closing brace.
    +  const fnStart = source.indexOf('async function prepareProfileDeleteRequest(')
    +  assert.notEqual(fnStart, -1, 'prepareProfileDeleteRequest function not found')
    +
    +  // The function must contain "return profile" (pool and primary paths).
    +  const fnBody = source.slice(fnStart, fnStart + 800)
    +  const returnProfileCount = (fnBody.match(/return profile/g) || []).length
    +  assert.ok(
    +    returnProfileCount >= 2,
    +    `expected at least 2 "return profile" statements (primary + pool paths), found ${returnProfileCount}`
    +  )
    +
    +  // The early-exit guard must return null (not void/undefined).
    +  assert.match(
    +    fnBody,
    +    /return null/,
    +    'early-exit guard should return null, not undefined'
    +  )
    +})
    +
    +test('hermes:api handler routes profile-delete requests to the primary backend', () => {
    +  const source = readElectronFile('main.cjs')
    +
    +  // The handler must capture prepareProfileDeleteRequest's return value.
    +  assert.match(
    +    source,
    +    /const tornDownProfile = await prepareProfileDeleteRequest\(request\)/,
    +    'handler should capture the return value of prepareProfileDeleteRequest'
    +  )
    +
    +  // The handler must use the return value to skip ensureBackend for the
    +  // torn-down profile, routing to the primary (null) instead.
    +  assert.match(
    +    source,
    +    /const routeProfile = tornDownProfile \? null : profile/,
    +    'handler should route to primary backend when a profile was just torn down'
    +  )
    +
    +  // ensureBackend must be called with the conditional route profile.
    +  assert.match(
    +    source,
    +    /const connection = await ensureBackend\(routeProfile\)/,
    +    'handler should pass routeProfile (not raw profile) to ensureBackend'
    +  )
    +})
    ```
  - `apps/desktop/src/app/profiles/index.tsx`

    ```diff
    @@ -19,7 +19,6 @@ import { Textarea } from '@/components/ui/textarea'
     import {
       createProfile,
       deleteProfile,
    -  getProfiles,
       getProfileSoul,
       type ProfileInfo,
       renameProfile,
    ```

    ```diff
    @@ -31,7 +30,7 @@ import { profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
     import { slug } from '@/lib/sanitize'
     import { cn } from '@/lib/utils'
     import { notify, notifyError } from '@/store/notifications'
    -import { $profileColors } from '@/store/profile'
    +import { $profileColors, refreshProfiles } from '@/store/profile'
     
     import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
     import {
    ```

    ```diff
    @@ -72,7 +71,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
     
       const refresh = useCallback(async () => {
         try {
    -      const { profiles: list } = await getProfiles()
    +      const list = await refreshProfiles()
           setProfiles(list)
           setSelectedName(current => {
             if (current && list.some(p => p.name === current)) {
    ```
  - `apps/desktop/src/store/profile.test.ts`

    ```diff
    @@ -2,6 +2,7 @@ import { atom } from 'nanostores'
     import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
     
     import type { HermesConnection } from '@/global'
    +import type { ProfileInfo } from '@/types/hermes'
     
     // Keep profile.ts's side-effecting imports inert: the gateway socket layer and
     // the REST query client must not run for real in a unit test.
    ```

    ```diff
    @@ -17,9 +18,20 @@ vi.mock('@/hermes', () => ({
     vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } }))
     vi.mock('@/store/starmap', () => ({ resetStarmapGraph }))
     
    -const { $activeGatewayProfile, ensureGatewayProfile } = await import('./profile')
    +const { $activeGatewayProfile, $profiles, ensureGatewayProfile, refreshProfiles } = await import('./profile')
     const { $connection } = await import('./session')
     const { queryClient } = await import('@/lib/query-client')
    +const { getProfiles } = await import('@/hermes')
    +
    +const profile = (name: string, isDefault = false): ProfileInfo => ({
    +  has_env: false,
    +  is_default: isDefault,
    +  model: null,
    +  name,
    +  path: `/tmp/hermes/${name}`,
    +  provider: null,
    +  skill_count: 0
    +})
     
     const remoteConn = (over: Partial<HermesConnection> = {}): HermesConnection =>
       ({ baseUrl: 'https://hermes-roy.tail.ts.net', mode: 'remote', profile: 'vps-remote', ...over }) as HermesConnection
    ```

    ```diff
    @@ -35,6 +47,7 @@ beforeEach(() => {
       $gateway.set({ id: 'live-socket' })
       $activeGatewayProfile.set('default')
       $connection.set(localConn())
    +  $profiles.set([])
       vi.stubGlobal('window', { hermesDesktop: { getConnection } })
       vi.mocked(queryClient.invalidateQueries).mockClear()
       resetStarmapGraph.mockClear()
    ```

    ```diff
    @@ -101,3 +114,23 @@ describe('profile-scoped cache invalidation', () => {
         expect(resetStarmapGraph).toHaveBeenCalledTimes(1)
       })
     })
    +
    +describe('refreshProfiles shared rail list (#49289)', () => {
    +  it('removes a deleted profile from the shared $profiles cache after Manage Profiles refreshes', async () => {
    +    $profiles.set([profile('default', true), profile('test1')])
    +    vi.mocked(getProfiles).mockResolvedValueOnce({ profiles: [profile('default', true)] })
    +
    +    await refreshProfiles()
    +
    +    expect($profiles.get().map(profile => profile.name)).toEqual(['default'])
    +  })
    +
    +  it('leaves the shared $profiles cache intact when the refresh fails', async () => {
    +    $profiles.set([profile('default', true), profile('test1')])
    +    vi.mocked(getProfiles).mockRejectedValueOnce(new Error('backend unavailable'))
    +
    +    await expect(refreshProfiles()).rejects.toThrow('backend unavailable')
    +
    +    expect($profiles.get().map(profile => profile.name)).toEqual(['default', 'test1'])
    +  })
    +})
    ```
  - `apps/desktop/src/store/profile.ts`

    ```diff
    @@ -38,6 +38,13 @@ export function setActiveProfile(name: string): void {
       $activeProfile.set(name || 'default')
     }
     
    +export async function refreshProfiles(): Promise<ProfileInfo[]> {
    +  const { profiles } = await getProfiles()
    +  $profiles.set(profiles)
    +
    +  return profiles
    +}
    +
     // ── Rail order ─────────────────────────────────────────────────────────────
     // User-defined order for the named (non-default) profile squares in the rail.
     // Names absent from the list fall back to alphabetical, appended at the tail —
    ```

    ```diff
    @@ -111,8 +118,7 @@ export async function refreshActiveProfile(): Promise<void> {
       }
     
       try {
    -    const { profiles } = await getProfiles()
    -    $profiles.set(profiles)
    +    await refreshProfiles()
       } catch {
         // Leave the cached list in place.
       }
    ```
- **#88228: fix(desktop): kill interpreter-exec'd profile backends on delete + refresh profile rail on focus**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `apps/desktop/src/app/chat/sidebar/profile-switcher.tsx`, `apps/desktop/src/app/chat/sidebar/use-profile-rail-refresh-on-active.test.ts`, `apps/desktop/src/app/chat/sidebar/use-profile-rail-refresh-on-active.ts`, `hermes_cli/profiles.py`, `tests/hermes_cli/test_profiles.py`
- **#88732: fix(desktop): re-home backend and retire sockets on profile rename**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `apps/desktop/electron/main.ts`, `apps/desktop/electron/profile-delete-routing.ts`, `apps/desktop/electron/profile-rename-routing.test.ts`, `apps/desktop/electron/profile-rename-routing.ts`, `apps/desktop/src/app/profiles/rename-profile-dialog.test.tsx`, `apps/desktop/src/app/profiles/rename-profile-dialog.tsx`
- **#52301: fix(desktop): skip ensureBackend after profile-delete teardown to prevent respawn loop (#52279)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#57329`, `#49435`, `#49335`, `#47368`, `#52279`, `#57329`
- **#59554: fix(cli): ignore stale missing profile homes at startup**. closed without merge
  - maintainer direction: category `volume_quota`; implication `active`
- **#72152: fix(desktop): work profile deletion silently reverted after app restart**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `1ed94d245`, `#88228`

## Fix status

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