fix(hermes): use the Windows %LOCALAPPDATA%\hermes home for hooks and session history (#33413)

Hermes home resolves like Hermes itself on Windows (HERMES_HOME, else %LOCALAPPDATA%\hermes, else a legacy ~\.hermes that alone exists), shared by hook install/status, skill discovery and session reading; session reading also honors HERMES_HOME. Fixes #24966. Supersedes #25058.

Co-authored-by: innocarpe <2222333+innocarpe@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-24 22:56:51 -07:00
committed by GitHub
co-authored by innocarpe
parent 062ebbaf26
commit 10005e5d15
6 changed files with 110 additions and 40 deletions
@@ -28,6 +28,7 @@ import {
sessionRootDirs,
wslHomeSessionDirs
} from './session-scanner-roots'
import { getHermesHome } from '../hermes/hermes-default-home'
import { SUBAGENT_DIR_NAME } from './session-scanner-subagent-transcripts'
import type { AiVaultScanOptions } from './session-scanner-types'
import { normalizeAgentSessionsDir, primeAgentSessionsDirFromEnv } from './session-scanner-values'
@@ -40,7 +41,6 @@ const COPILOT_SESSIONS_DIR = join(
)
const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects')
const CODEBUDDY_PROJECTS_DIR = join(homedir(), '.codebuddy', 'projects')
const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions')
const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions')
export function piSessionHistorySessionsDir(): string {
return normalizeAgentSessionsDir(
@@ -182,7 +182,7 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = {
},
hermes: {
rootDirs: (options, wslHomeDirs) =>
sessionRootDirs(options.hermesSessionsDir ?? HERMES_SESSIONS_DIR, wslHomeDirs, [
sessionRootDirs(options.hermesSessionsDir ?? join(getHermesHome(), 'sessions'), wslHomeDirs, [
'.hermes',
'sessions'
]),
+56
View File
@@ -0,0 +1,56 @@
import { statSync } from 'node:fs'
import { homedir } from 'node:os'
import { isAbsolute, join, resolve } from 'node:path'
const LOCAL_APP_DATA_MAX_LENGTH = 32_768
export type DefaultHermesHomeInput = {
homeDir?: string
env?: NodeJS.ProcessEnv
platform?: NodeJS.Platform
directoryExists?: (candidate: string) => boolean
}
function usableLocalAppData(value: string | undefined): string | null {
const candidate = value?.trim()
if (
!candidate ||
candidate.length > LOCAL_APP_DATA_MAX_LENGTH ||
candidate.includes('\0') ||
!isAbsolute(candidate)
) {
return null
}
return resolve(candidate)
}
function isExistingDirectory(candidate: string): boolean {
return statSync(candidate, { throwIfNoEntry: false })?.isDirectory() === true
}
/**
* The Hermes home when `HERMES_HOME` is unset. Windows installs land in
* `%LOCALAPPDATA%\hermes`, not a dotfolder in the user profile. The dotfolder
* still wins on Windows when it exists and the LOCALAPPDATA tree does not,
* which is the pre-LOCALAPPDATA install Hermes Desktop keeps honouring.
*/
export function resolveDefaultHermesHome(input: DefaultHermesHomeInput = {}): string {
const dotfolderHome = join(input.homeDir ?? homedir(), '.hermes')
if ((input.platform ?? process.platform) !== 'win32') {
return dotfolderHome
}
const localAppData = usableLocalAppData((input.env ?? process.env).LOCALAPPDATA)
if (!localAppData) {
return dotfolderHome
}
const localAppDataHome = join(localAppData, 'hermes')
const directoryExists = input.directoryExists ?? isExistingDirectory
return !directoryExists(localAppDataHome) && directoryExists(dotfolderHome)
? dotfolderHome
: localAppDataHome
}
export function getHermesHome(env: NodeJS.ProcessEnv = process.env): string {
const explicit = env.HERMES_HOME?.trim()
return explicit ? explicit : resolveDefaultHermesHome({ env })
}
+1 -6
View File
@@ -1,9 +1,9 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { ConfigParseResult, HermesConfig } from './hermes-config-yaml'
import { parseHermesConfig } from './hermes-config-yaml'
import { getHermesHome } from './hermes-default-home'
import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path'
import { writeHooksJson } from '../agent-hooks/installer-utils'
import {
@@ -13,11 +13,6 @@ import {
getPluginManifest
} from './hermes-managed-plugin-source'
export function getHermesHome(env: NodeJS.ProcessEnv = process.env): string {
const explicit = env.HERMES_HOME?.trim()
return explicit ? explicit : join(homedir(), '.hermes')
}
export function getConfigPath(): string {
return join(getHermesHome(), 'config.yaml')
}
@@ -0,0 +1,44 @@
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getHermesHome } from './hermes-default-home'
import { HermesHookService } from './hook-service'
const realPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
describe('Hermes home on Windows', () => {
afterEach(() => {
vi.unstubAllEnvs()
if (realPlatform) {
Object.defineProperty(process, 'platform', realPlatform)
}
})
it('keeps an explicit HERMES_HOME ahead of the LOCALAPPDATA default', () => {
expect(getHermesHome({ HERMES_HOME: 'D:\\profiles\\hermes', LOCALAPPDATA: tmpdir() })).toBe(
'D:\\profiles\\hermes'
)
})
it('installs and reports hook status under %LOCALAPPDATA%\\hermes (#24966)', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-hermes-win-'))
try {
const hermesHome = join(root, 'Local', 'hermes')
mkdirSync(hermesHome, { recursive: true })
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })
vi.stubEnv('LOCALAPPDATA', join(root, 'Local'))
vi.stubEnv('HERMES_HOME', '')
expect(new HermesHookService().install().state).toBe('installed')
expect(new HermesHookService().getStatus()).toMatchObject({
state: 'installed',
configPath: join(hermesHome, 'config.yaml'),
managedHooksPresent: true
})
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
+1 -1
View File
@@ -16,9 +16,9 @@ import {
removeOrcaStatusContent,
updateConfigContent
} from './hermes-config-yaml'
import { getHermesHome } from './hermes-default-home'
import {
getConfigPath,
getHermesHome,
getPluginDir,
getPluginFilesState,
readConfigFile,
@@ -1,9 +1,12 @@
import { statSync } from 'node:fs'
import { homedir } from 'node:os'
import path, { isAbsolute, join, posix, resolve } from 'node:path'
import { SKILL_INSTALL_PROVIDERS } from '../../shared/skill-install-providers'
import { buildWslCapturedLoginShellCommand } from '../../shared/wsl-login-shell-command'
import { toWindowsWslPath } from '../../shared/wsl-paths'
import {
type DefaultHermesHomeInput,
resolveDefaultHermesHome
} from '../hermes/hermes-default-home'
import { runWslProcess } from '../wsl/wsl-runner'
import type { SkillProviderRootOverrides } from './skill-provider-destinations'
@@ -76,36 +79,8 @@ export function resolveEnvironmentHermesSkillsRoot(
return hermesHome ? join(hermesHome, 'skills') : null
}
function isExistingDirectory(candidate: string): boolean {
return statSync(candidate, { throwIfNoEntry: false })?.isDirectory() === true
}
/**
* The Hermes home when `HERMES_HOME` is unset. Windows installs land in
* `%LOCALAPPDATA%\hermes`, not a dotfolder in the user profile, so the POSIX
* `~/.hermes` default discovers nothing there. The dotfolder still wins on
* Windows when it exists and the LOCALAPPDATA tree does not, which is the
* pre-LOCALAPPDATA install Hermes itself keeps honouring rather than orphaning.
*/
export function resolveDefaultHermesSkillsRoot(input: {
homeDir: string
env?: NodeJS.ProcessEnv
platform?: NodeJS.Platform
directoryExists?: (candidate: string) => boolean
}): string {
const dotfolderHome = join(input.homeDir, '.hermes')
if ((input.platform ?? process.platform) !== 'win32') {
return join(dotfolderHome, 'skills')
}
const localAppData = normalizedRoot((input.env ?? process.env).LOCALAPPDATA)
if (!localAppData) {
return join(dotfolderHome, 'skills')
}
const localAppDataHome = join(localAppData, 'hermes')
const directoryExists = input.directoryExists ?? isExistingDirectory
return !directoryExists(localAppDataHome) && directoryExists(dotfolderHome)
? join(dotfolderHome, 'skills')
: join(localAppDataHome, 'skills')
export function resolveDefaultHermesSkillsRoot(input: DefaultHermesHomeInput): string {
return join(resolveDefaultHermesHome(input), 'skills')
}
export function withClaudeSkillProviderRoot(