From fa12ebb97106f8bf9cd3fc6f2f993bacdde810a1 Mon Sep 17 00:00:00 2001 From: Uttam Deb <105539415+uttamdeb@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:01:04 +0600 Subject: [PATCH] Find Hermes sessions in Windows local app data (#1617) * Add Hermes Windows home regression tests * Resolve Hermes home from Windows local app data * Re-derive Hermes data from Windows home --------- Co-authored-by: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> --- docs/providers/hermes.md | 8 +- src/daily-cache.ts | 4 +- src/providers/hermes.ts | 12 +- src/session-cache.ts | 2 +- tests/providers/hermes.test.ts | 228 ++++++++++++++++++++++++++++++++- tests/session-cache.test.ts | 3 +- 6 files changed, 248 insertions(+), 9 deletions(-) diff --git a/docs/providers/hermes.md b/docs/providers/hermes.md index c3d66b9d..4421e3c5 100644 --- a/docs/providers/hermes.md +++ b/docs/providers/hermes.md @@ -10,8 +10,10 @@ Hermes Agent CLI profiles. | Source | Path | |---|---| -| Default Hermes profile | `$HERMES_HOME/state.db` if set, otherwise `~/.hermes/state.db` | -| Named Hermes profiles | `$HERMES_HOME/profiles//state.db` | +| Default Hermes profile | `$HERMES_HOME/state.db` if set; otherwise `%LOCALAPPDATA%/hermes/state.db` on Windows (falling back to `AppData/Local/hermes` under the home directory), or `~/.hermes/state.db` on macOS/Linux | +| Named Hermes profiles | `/profiles//state.db` | + +`HERMES_HOME` takes precedence on every platform, so it can point CodeBurn to a legacy or custom Hermes installation. Windows uses the same home for its default database and named profiles. ## Storage format @@ -55,7 +57,7 @@ Terminal command arguments are exposed as `bashCommands` for CodeBurn's command ## Caching -The shared session cache fingerprints Hermes state DB files. `HERMES_HOME` is included in the provider environment fingerprint so changing the runtime home invalidates stale cached results. +The shared session cache fingerprints Hermes state DB files. `HERMES_HOME` and `LOCALAPPDATA` are included in the provider environment fingerprint so changing either configured home invalidates stale cached results. ## Quirks diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 5b7cf419..0aa11929 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -252,7 +252,9 @@ import type { DateRange, ProjectSummary } from './types.js' // read path's equal-call rule keeps them; the bump re-derives surviving days. // Call counts are unchanged, so no PENDING_REDERIVE_PROVIDER_VERSIONS entry is // needed. -export const DAILY_CACHE_VERSION = 49 +// v50: Hermes uses its native Windows LOCALAPPDATA root; finalized days can +// miss sessions there and must be re-derived after the default path is fixed. +export const DAILY_CACHE_VERSION = 50 const MIN_SUPPORTED_VERSION = 28 /// Providers whose per-day CALL COUNT means something different at diff --git a/src/providers/hermes.ts b/src/providers/hermes.ts index d9c43cc5..df419a70 100644 --- a/src/providers/hermes.ts +++ b/src/providers/hermes.ts @@ -104,7 +104,17 @@ const toolNameMap: Record = { } function getHermesHome(override?: string): string { - return override ?? process.env['HERMES_HOME'] ?? join(homedir(), '.hermes') + if (override !== undefined) return override + + const configuredHome = process.env['HERMES_HOME']?.trim() + if (configuredHome) return configuredHome + + if (process.platform === 'win32') { + const localAppData = process.env['LOCALAPPDATA']?.trim() + return join(localAppData || join(homedir(), 'AppData', 'Local'), 'hermes') + } + + return join(homedir(), '.hermes') } function displayProjectForProfile(profile: string): string { diff --git a/src/session-cache.ts b/src/session-cache.ts index 581509f9..f6f033fa 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -301,7 +301,7 @@ export const PROVIDER_ENV_VARS: Record = { codebuff: ['CODEBUFF_DATA_DIR'], codewhale: ['CODEWHALE_HOME'], codex: ['CODEX_HOME'], - hermes: ['HERMES_HOME'], + hermes: ['HERMES_HOME', 'LOCALAPPDATA'], 'lingtai-tui': ['LINGTAI_HOME', 'LINGTAI_TUI_HOME', 'LINGTAI_TUI_GLOBAL_DIR'], droid: ['FACTORY_DIR'], dsh: ['DSH_HOME'], diff --git a/tests/providers/hermes.test.ts b/tests/providers/hermes.test.ts index 6a9dec98..0df44f6c 100644 --- a/tests/providers/hermes.test.ts +++ b/tests/providers/hermes.test.ts @@ -7,6 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { calculateCost } from '../../src/models.js' import { createHermesProvider } from '../../src/providers/hermes.js' import { isSqliteAvailable } from '../../src/sqlite.js' +import { aggregateProjectsIntoDays } from '../../src/day-aggregator.js' +import { currentTzKey, DAILY_CACHE_VERSION, ensureCacheHydrated, toDateString, type DailyEntry } from '../../src/daily-cache.js' import type { ParsedProviderCall } from '../../src/providers/types.js' import type { DateRange } from '../../src/types.js' @@ -22,12 +24,18 @@ let tmpDir: string let cacheDir: string let originalHermesHome: string | undefined let originalCodeburnCacheDir: string | undefined +let originalLocalAppData: string | undefined +let originalHome: string | undefined +let originalUserProfile: string | undefined beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'hermes-provider-test-')) cacheDir = await mkdtemp(join(tmpdir(), 'hermes-provider-cache-')) originalHermesHome = process.env['HERMES_HOME'] originalCodeburnCacheDir = process.env['CODEBURN_CACHE_DIR'] + originalLocalAppData = process.env['LOCALAPPDATA'] + originalHome = process.env['HOME'] + originalUserProfile = process.env['USERPROFILE'] process.env['HERMES_HOME'] = tmpDir process.env['CODEBURN_CACHE_DIR'] = cacheDir const { resetHermesSessionLedgerForTests } = await import('../../src/hermes-session-ledger.js') @@ -39,10 +47,27 @@ afterEach(async () => { else process.env['HERMES_HOME'] = originalHermesHome if (originalCodeburnCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR'] else process.env['CODEBURN_CACHE_DIR'] = originalCodeburnCacheDir + if (originalLocalAppData === undefined) delete process.env['LOCALAPPDATA'] + else process.env['LOCALAPPDATA'] = originalLocalAppData + if (originalHome === undefined) delete process.env['HOME'] + else process.env['HOME'] = originalHome + if (originalUserProfile === undefined) delete process.env['USERPROFILE'] + else process.env['USERPROFILE'] = originalUserProfile await rm(tmpDir, { recursive: true, force: true }) await rm(cacheDir, { recursive: true, force: true }) }) +async function withProcessPlatform(platform: NodeJS.Platform, action: () => Promise): Promise { + const original = Object.getOwnPropertyDescriptor(process, 'platform') + if (!original?.configurable) throw new Error('process.platform cannot be mocked in this runtime') + Object.defineProperty(process, 'platform', { ...original, value: platform }) + try { + return await action() + } finally { + Object.defineProperty(process, 'platform', original) + } +} + function createHermesDb(homeDir: string): string { const { DatabaseSync: Database } = requireForTest('node:sqlite') const dbPath = join(homeDir, 'state.db') @@ -122,6 +147,23 @@ async function createProfileHermesDb(hermesHome: string, profile: string): Promi return createHermesDb(profileDir) } +async function createHermesDbWithSession(hermesHome: string, sessionId: string): Promise { + await mkdir(hermesHome, { recursive: true }) + const dbPath = createHermesDb(hermesHome) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: sessionId, + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + startedAt: 1779549200, + }) + }) + return dbPath +} + function insertSession(db: TestDb, values: { id: string source?: string @@ -187,8 +229,9 @@ function dayRange(): DateRange { } } -async function loadParserWithHermesHome(hermesHome: string, codeburnCacheDir: string) { - process.env['HERMES_HOME'] = hermesHome +async function loadParserWithHermesHome(hermesHome: string | undefined, codeburnCacheDir: string) { + if (hermesHome === undefined) delete process.env['HERMES_HOME'] + else process.env['HERMES_HOME'] = hermesHome process.env['CODEBURN_CACHE_DIR'] = codeburnCacheDir vi.resetModules() const parser = await import('../../src/parser.js') @@ -207,6 +250,187 @@ async function collectCalls(hermesHome: string, sourcePath: string): Promise { + it.each([ + ['unset', undefined], + ['empty', ''], + ['whitespace', ' \t '], + ])('uses LOCALAPPDATA for the Windows default with %s HERMES_HOME and discovers default and named profiles', async (_label, configuredHome) => { + await withProcessPlatform('win32', async () => { + if (configuredHome === undefined) delete process.env['HERMES_HOME'] + else process.env['HERMES_HOME'] = configuredHome + const localAppData = join(tmpDir, 'local-app-data') + process.env['LOCALAPPDATA'] = localAppData + const hermesHome = join(localAppData, 'hermes') + const rootDbPath = await createHermesDbWithSession(hermesHome, 'windows-default') + const profileDbPath = await createHermesDbWithSession(join(hermesHome, 'profiles', 'coder'), 'windows-profile') + + const provider = createHermesProvider() + expect(await provider.probeRoots()).toEqual([{ path: hermesHome, label: 'home' }]) + const sessions = await provider.discoverSessions() + expect(sessions.map(session => session.path).sort()).toEqual([ + `${profileDbPath}#hermes-session=windows-profile`, + `${rootDbPath}#hermes-session=windows-default`, + ].sort()) + expect(sessions.map(session => session.project).sort()).toEqual(['coder', 'hermes']) + }) + }) + + it('re-derives a finalized v47 daily cache from the Windows Hermes home and carries unavailable history', async () => { + const now = new Date() + const sessionStartedAt = Math.floor((now.getTime() - 4 * 24 * 60 * 60 * 1000) / 1000) + const hermesDate = toDateString(new Date(sessionStartedAt * 1000)) + const sourceGoneDate = toDateString(new Date(now.getTime() - 9 * 24 * 60 * 60 * 1000)) + const yesterday = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)) + + const localAppData = join(tmpDir, 'local-app-data') + const hermesHome = join(localAppData, 'hermes') + delete process.env['HERMES_HOME'] + process.env['LOCALAPPDATA'] = localAppData + await mkdir(hermesHome, { recursive: true }) + const dbPath = createHermesDb(hermesHome) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'daily-cache-windows-session', + inputTokens: 123, + outputTokens: 45, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + actualCost: 0.75, + startedAt: sessionStartedAt, + }) + }) + + const cachedDay = (date: string, provider: string, calls: number, cost: number, inputTokens: number): DailyEntry => { + const modelStats = { + calls, + cost, + savingsUSD: 0, + inputTokens, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + } + const slice = { + calls, + cost, + savingsUSD: 0, + sessions: 1, + inputTokens, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + models: { 'gpt-5.5': modelStats }, + categories: {}, + } + return { + date, + cost, + savingsUSD: 0, + calls, + sessions: 1, + inputTokens, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + editTurns: 0, + oneShotTurns: 0, + models: { 'gpt-5.5': modelStats }, + categories: {}, + providers: { [provider]: slice }, + } + } + + await writeFile(join(cacheDir, 'daily-cache.v47.json'), JSON.stringify({ + version: 47, + savingsConfigHash: '', + tzKey: currentTzKey(), + lastComputedDate: yesterday, + days: [ + cachedDay(hermesDate, 'codex', 2, 0.4, 80), + cachedDay(sourceGoneDate, 'claude', 3, 1.2, 300), + ], + complete: true, + watermarkTrusted: true, + }), 'utf-8') + + const { clearSessionCache, parseAllSessions } = await withProcessPlatform('win32', () => + loadParserWithHermesHome(undefined, cacheDir), + ) + clearSessionCache() + let reparsedProjects: Awaited> = [] + const hydrated = await ensureCacheHydrated( + async range => { + reparsedProjects = await parseAllSessions(range) + return reparsedProjects + }, + aggregateProjectsIntoDays, + ) + + expect(hydrated.version).toBe(DAILY_CACHE_VERSION) + expect(reparsedProjects.flatMap(project => project.sessions)).toHaveLength(1) + const refreshedHermesDay = hydrated.days.find(day => day.date === hermesDate) + expect(refreshedHermesDay?.providers['hermes']).toMatchObject({ + calls: 1, + cost: 0.75, + inputTokens: 123, + outputTokens: 45, + }) + expect(refreshedHermesDay?.providers['codex']).toMatchObject({ calls: 2, cost: 0.4, inputTokens: 80 }) + expect(hydrated.days.find(day => day.date === sourceGoneDate)).toMatchObject({ + carried: true, + providers: { claude: { calls: 3, cost: 1.2, inputTokens: 300 } }, + }) + }) + + it('falls back to the Windows home AppData path when LOCALAPPDATA is blank', async () => { + await withProcessPlatform('win32', async () => { + delete process.env['HERMES_HOME'] + process.env['HOME'] = tmpDir + process.env['USERPROFILE'] = tmpDir + process.env['LOCALAPPDATA'] = ' \t ' + const hermesHome = join(homedir(), 'AppData', 'Local', 'hermes') + await createHermesDbWithSession(hermesHome, 'windows-fallback') + + const provider = createHermesProvider() + expect(await provider.probeRoots()).toEqual([{ path: hermesHome, label: 'home' }]) + expect((await provider.discoverSessions()).map(session => session.project)).toEqual(['hermes']) + }) + }) + + it('keeps Unix home discovery and explicit Hermes home overrides', async () => { + await withProcessPlatform('linux', async () => { + delete process.env['HERMES_HOME'] + process.env['HOME'] = tmpDir + process.env['USERPROFILE'] = tmpDir + delete process.env['LOCALAPPDATA'] + const unixHome = join(homedir(), '.hermes') + await createHermesDbWithSession(unixHome, 'unix-default') + + const defaultProvider = createHermesProvider() + expect(await defaultProvider.probeRoots()).toEqual([{ path: unixHome, label: 'home' }]) + + const localAppData = join(tmpDir, 'local-app-data') + const legacyHome = join(tmpDir, 'legacy-hermes-home') + process.env['LOCALAPPDATA'] = localAppData + process.env['HERMES_HOME'] = legacyHome + const envOverrideDb = await createHermesDbWithSession(legacyHome, 'env-override') + const envProvider = createHermesProvider() + expect(await envProvider.probeRoots()).toEqual([{ path: legacyHome, label: 'home' }]) + expect((await envProvider.discoverSessions()).map(session => session.path)).toEqual([ + `${envOverrideDb}#hermes-session=env-override`, + ]) + + const constructorOverride = join(tmpDir, 'constructor-hermes-home') + const constructorDb = await createHermesDbWithSession(constructorOverride, 'constructor-override') + const constructorProvider = createHermesProvider(constructorOverride) + expect(await constructorProvider.probeRoots()).toEqual([{ path: constructorOverride, label: 'home' }]) + expect((await constructorProvider.discoverSessions()).map(session => session.path)).toEqual([ + `${constructorDb}#hermes-session=constructor-override`, + ]) + }) + }) + it('discovers state.db sessions with token usage', async () => { const dbPath = createHermesDb(tmpDir) withTestDb(dbPath, (db) => { diff --git a/tests/session-cache.test.ts b/tests/session-cache.test.ts index e267e815..076a11d7 100644 --- a/tests/session-cache.test.ts +++ b/tests/session-cache.test.ts @@ -362,8 +362,9 @@ describe('provider env overrides invalidate the fingerprint (#920)', () => { ['goose', 'GOOSE_PATH_ROOT'], ['crush', 'CRUSH_GLOBAL_DATA'], ['codex', 'CODEX_HOME'], + ['hermes', 'LOCALAPPDATA'], ] - const VARS = CASES.map(([, varName]) => varName) + const VARS = [...CASES.map(([, varName]) => varName), 'HERMES_HOME'] // Save and restore every var we touch (beforeEach/afterEach), so a leaked // env var never breaks unrelated tests in the same worker — and an ambient