From bb33a39b42c039c5443470f973fbc18a0319733d Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Mon, 27 Apr 2026 13:10:33 +0200 Subject: [PATCH] fix(profiles): respect Hermes v0.11 active_profile (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes v0.11's `hermes profile` feature gives each profile its own HERMES_HOME directory: the default profile is ~/.hermes, named profiles live at ~/.hermes/profiles//. Each has its own state.db, sessions/, config.yaml, .env, memories/, cron/, etc. The active profile is recorded in ~/.hermes/active_profile. Pre-fix Scarf hardcoded ~/.hermes and ignored active_profile, so `hermes profile use coder` followed by a Scarf relaunch left Scarf reading the wrong state.db — the new profile's chat sessions silently never appeared. Add HermesProfileResolver in ScarfCore that reads active_profile and returns the effective home path. HermesPathSet.defaultLocalHome becomes a static var backed by the resolver; every derived path (stateDB, sessionsDir, configYAML, memoriesDir, cron paths, plugins, gateway state, auth.json, etc.) automatically follows the active profile through the existing `home + suffix` plumbing — no downstream call sites need to change. Resolver semantics: - Absent / empty / "default" file → ~/.hermes (today's behavior) - Valid profile name pointing to an existing dir → that dir - Invalid name OR missing target → fall back to ~/.hermes with a one-line os.Logger warning (so worst case is "Scarf shows what it always showed") Validation regex mirrors Hermes's hermes_cli/profiles.py exactly ([a-z0-9][a-z0-9_-]{0,63}). 5-second cache via OSAllocatedUnfairLock keeps hot-path filesystem hits negligible. SessionInfoBar gains a leftmost profile chip when not "default" so users can see which profile Scarf is reading from. Tooltip explains how to switch (`hermes profile use ` + relaunch). Out of scope (deferred): - In-app profile picker that writes to active_profile. Switching mid-session is messy (open ACP processes are bound to whichever HERMES_HOME spawned them); the reporter's "switch + restart" flow is what we fix here. - Remote SSH profile awareness. defaultRemoteHome stays "~/.hermes" — remote profile selection is a separate, larger feature needing its own UI. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ScarfCore/Models/HermesPathSet.swift | 20 ++- .../Services/HermesProfileResolver.swift | 142 ++++++++++++++++++ .../Features/Chat/Views/SessionInfoBar.swift | 19 +++ 3 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesProfileResolver.swift diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift index ffbe5ac..d27d8bb 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift @@ -35,10 +35,22 @@ public struct HermesPathSet: Sendable, Hashable { self.isRemote = isRemote self.binaryHint = binaryHint } - public nonisolated static let defaultLocalHome: String = { - let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() - return user + "/.hermes" - }() + /// Resolved path to the active local Hermes profile (issue #50). + /// + /// Hermes v0.11+ supports multiple profiles via `hermes profile use`; + /// each profile is a fully independent `HERMES_HOME` directory. We + /// delegate to `HermesProfileResolver` (which reads + /// `~/.hermes/active_profile`) so every derived path — `state.db`, + /// `sessions/`, `config.yaml`, `memories/`, etc. — automatically + /// follows the active profile. Returns the pre-profile default + /// `~/.hermes` whenever no named profile is active, so existing + /// (non-profile) installations are unaffected. + /// + /// Backed by a 5-second cache inside the resolver, so frequent + /// `HermesPathSet` constructions don't hammer the filesystem. + public nonisolated static var defaultLocalHome: String { + HermesProfileResolver.resolveLocalHome() + } /// Default remote home when the user doesn't override it in `SSHConfig`. /// We leave `~` unexpanded on purpose — the remote shell resolves it. diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesProfileResolver.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesProfileResolver.swift new file mode 100644 index 0000000..4be4535 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesProfileResolver.swift @@ -0,0 +1,142 @@ +import Foundation +import os + +/// Resolves Hermes's active profile (v0.11+) for local installations. +/// +/// Hermes v0.11 introduced `hermes profile`: each profile is an independent +/// `HERMES_HOME` directory. The "default" profile is `~/.hermes` itself; +/// named profiles live at `~/.hermes/profiles//` and have their own +/// `state.db`, `sessions/`, `config.yaml`, `.env`, `memories/`, `cron/`, +/// `gateway_state.json`, etc. +/// +/// The active profile is recorded in `~/.hermes/active_profile` (a single +/// line text file containing the profile name, or absent / empty when the +/// default profile is active). The Hermes CLI consults this file to set +/// `HERMES_HOME` for each invocation. +/// +/// Pre-v0.11 Scarf hardcoded `~/.hermes` and ignored `active_profile`, +/// which meant `hermes profile use ` left Scarf reading the wrong +/// state.db (issue #50). This resolver is the single seam: it reads +/// `active_profile` and returns the effective home directory; everything +/// else in `HermesPathSet` derives from `home`, so once the seam is +/// correct every read path follows automatically. +/// +/// **Caching.** The resolver is called from `HermesPathSet.defaultLocalHome`, +/// which is in turn called whenever a `HermesPathSet` is constructed via +/// the default helper. To avoid filesystem hits on hot paths we cache the +/// resolved name for `cacheTTL` seconds (default 5s). That's tight enough +/// that `hermes profile use other` followed by a Scarf operation picks up +/// the change within seconds, and loose enough that no realistic UI loop +/// causes more than a handful of file reads per minute. +public enum HermesProfileResolver { + + /// Cache lifetime for resolved profile state. Tunable for tests. + public static var cacheTTL: TimeInterval = 5 + + private static let lock = OSAllocatedUnfairLock(initialState: CacheState()) + private static let logger = Logger(subsystem: "com.scarf.app", category: "HermesProfileResolver") + + private static let profileNameRegex: NSRegularExpression = { + // Mirrors Hermes's own validation in hermes_cli/profiles.py. + try! NSRegularExpression(pattern: "^[a-z0-9][a-z0-9_-]{0,63}$") + }() + + private struct CacheState { + var resolvedName: String = "default" + var resolvedHome: String = HermesProfileResolver.defaultRootHome() + var resolvedAt: Date = .distantPast + } + + /// Effective Hermes home directory for the active profile. + /// Returns the default `~/.hermes` when no profile is active OR when + /// the configured profile is invalid (logged) — so the worst-case + /// failure mode is "Scarf shows what it always showed before." + public static func resolveLocalHome() -> String { + return refreshIfNeeded().home + } + + /// Name of the active profile — `"default"` or the profile id. + /// Surfaced in UI chrome so users can see which profile Scarf is + /// reading from (issue #50 follow-up: prevents the next variant + /// of "where's my data — wrong profile" by making it visible). + public static func activeProfileName() -> String { + return refreshIfNeeded().name + } + + /// Force a re-read on the next call, regardless of TTL. Test helper. + public static func invalidateCache() { + lock.withLock { $0.resolvedAt = .distantPast } + } + + // MARK: - Internals + + private static func refreshIfNeeded() -> (name: String, home: String) { + let now = Date() + let snapshot = lock.withLock { state -> CacheState? in + if now.timeIntervalSince(state.resolvedAt) < cacheTTL { + return state + } + return nil + } + if let snapshot { + return (snapshot.resolvedName, snapshot.resolvedHome) + } + + let (name, home) = readActiveProfileFromDisk() + lock.withLock { state in + state.resolvedName = name + state.resolvedHome = home + state.resolvedAt = now + } + return (name, home) + } + + private static func readActiveProfileFromDisk() -> (name: String, home: String) { + let defaultHome = defaultRootHome() + let activeFile = defaultHome + "/active_profile" + + // Absent file → default profile. This is the common case for users + // who haven't run `hermes profile use ...` and shouldn't generate + // any log noise. + guard FileManager.default.fileExists(atPath: activeFile) else { + return ("default", defaultHome) + } + + guard let raw = try? String(contentsOfFile: activeFile, encoding: .utf8) else { + logger.warning("Found active_profile but could not read it; falling back to default profile.") + return ("default", defaultHome) + } + + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + + // Empty file or explicit "default" → default profile. + if trimmed.isEmpty || trimmed == "default" { + return ("default", defaultHome) + } + + // Validate format. Hermes itself rejects malformed names, so this + // would only fire if the file is corrupted or hand-edited. + let range = NSRange(trimmed.startIndex.. String { + let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() + return user + "/.hermes" + } +} diff --git a/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift index a7a97bd..33a2d5e 100644 --- a/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift +++ b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift @@ -21,9 +21,28 @@ struct SessionInfoBar: View { /// git repos. var gitBranch: String? = nil + /// Active Hermes profile name (issue #50). Resolved on each body + /// re-evaluation; the resolver caches for 5s so this is cheap. + /// Chip renders only when not "default" so existing (non-profile) + /// installations see no change in the bar. + private var activeProfile: String { + HermesProfileResolver.activeProfileName() + } + var body: some View { HStack(spacing: 16) { if let session { + // Profile chip leftmost — surfaces which Hermes profile + // Scarf is reading (issue #50). Without this users couldn't + // tell whether the visible session list came from the + // profile they thought they switched to. + if activeProfile != "default" { + Label(activeProfile, systemImage: "person.crop.square") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.warning) + .lineLimit(1) + .help("Scarf is reading from Hermes profile \"\(activeProfile)\". Switch profiles with `hermes profile use ` and relaunch Scarf.") + } // Project indicator first — visually anchors the session // as "scoped to project X" before the working dot and // title. Hidden for non-project chats so the bar looks