fix(profiles): respect Hermes v0.11 active_profile (#50)

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/<name>/. 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 <name>` + 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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-27 13:10:33 +02:00
parent e828538a2d
commit bb33a39b42
3 changed files with 177 additions and 4 deletions
@@ -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.
@@ -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/<name>/` 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 <name>` 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..<trimmed.endIndex, in: trimmed)
guard profileNameRegex.firstMatch(in: trimmed, range: range) != nil else {
logger.warning("active_profile contains invalid name \(trimmed, privacy: .public); falling back to default profile.")
return ("default", defaultHome)
}
let profileHome = defaultHome + "/profiles/" + trimmed
var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: profileHome, isDirectory: &isDir), isDir.boolValue else {
logger.warning("active_profile points to \(trimmed, privacy: .public) but \(profileHome, privacy: .public) does not exist; falling back to default profile.")
return ("default", defaultHome)
}
logger.info("Resolved active Hermes profile to \(trimmed, privacy: .public) at \(profileHome, privacy: .public).")
return (trimmed, profileHome)
}
/// Pre-profile default hermes home (`~/.hermes`). The reference point
/// for both the active_profile lookup and the fallback case.
fileprivate static func defaultRootHome() -> String {
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
return user + "/.hermes"
}
}
@@ -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 <name>` 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