From acd3692fafe206189ee5df9cd44f602858064962 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sun, 3 May 2026 13:18:10 +0200 Subject: [PATCH] fix(profiles): switch-and-relaunch flow + active-profile chip + structured logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profile selection had no apparent effect on Webhooks/Sessions/SOUL.md/Memory even after restart in some user setups. The path-resolution code reads ~/.hermes/active_profile correctly on paper, so the failure mode is likely environment-specific (HERMES_HOME exported in the shell, in-process state that didn't reset on what the user perceived as a restart, etc). Layer a defense that's correct regardless of root cause: * New AppRelauncher helper spawns a fresh `open -n ` and asks the current process to terminate after a 250ms delay. Refuses to fire from Xcode/DerivedData (the .debugBuild guard) so debug sessions don't lose their attached debugger. * ProfilesViewModel.switchAndRelaunch runs `hermes profile use`, calls HermesProfileResolver.invalidateCache(), then relaunches via the helper. Existing switchTo() also gains the cache-invalidation step so the context-menu "Set Active (no relaunch)" path stays self-consistent. * ProfilesView replaces the passive "Restart Scarf after switching" text with a confirmation-gated `Switch & Relaunch` primary button on the detail pane plus the same item in each row's context menu. Confirmation dialog flags that all Scarf windows will close. * SidebarView header gains a brand-tinted ScarfBadge showing the currently-active profile on local contexts. Click to jump to the Profiles tab. The chip refreshes on `selectedSection` change so a terminal-side `hermes profile use` is visible after the next nav. * HermesProfileResolver success logs gain `name=…, home=…, source=…` key=value structure across all three resolution paths (file / file-default / default-no-file). `log show … | grep ProfileResolver` now answers "what did the resolver decide?" unambiguously for support requests. Closes #70 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/HermesProfileResolver.swift | 16 ++- scarf/scarf/Core/Services/AppRelauncher.swift | 99 +++++++++++++++++++ .../ViewModels/ProfilesViewModel.swift | 60 ++++++++++- .../Profiles/Views/ProfilesView.swift | 39 ++++++-- scarf/scarf/Navigation/SidebarView.swift | 28 ++++++ 5 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 scarf/scarf/Core/Services/AppRelauncher.swift diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesProfileResolver.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesProfileResolver.swift index 4be4535..6b72c78 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesProfileResolver.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesProfileResolver.swift @@ -95,15 +95,20 @@ public enum HermesProfileResolver { 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. + // Absent file → default profile. Common case for users who + // haven't run `hermes profile use ...`. We still log at + // `.info` (key=value, not warning) so support requests can + // pull `log show … | grep ProfileResolver` and confirm the + // resolver IS running and IS resolving to the default — + // distinguishing "feature didn't fire" from "feature fired + // and chose default" (issue #70). guard FileManager.default.fileExists(atPath: activeFile) else { + logger.info("Resolved active Hermes profile: name=default, home=\(defaultHome, privacy: .public), source=default-no-file") 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.") + logger.warning("Found active_profile but could not read it; falling back to default. home=\(defaultHome, privacy: .public)") return ("default", defaultHome) } @@ -111,6 +116,7 @@ public enum HermesProfileResolver { // Empty file or explicit "default" → default profile. if trimmed.isEmpty || trimmed == "default" { + logger.info("Resolved active Hermes profile: name=default, home=\(defaultHome, privacy: .public), source=file-default") return ("default", defaultHome) } @@ -129,7 +135,7 @@ public enum HermesProfileResolver { return ("default", defaultHome) } - logger.info("Resolved active Hermes profile to \(trimmed, privacy: .public) at \(profileHome, privacy: .public).") + logger.info("Resolved active Hermes profile: name=\(trimmed, privacy: .public), home=\(profileHome, privacy: .public), source=file") return (trimmed, profileHome) } diff --git a/scarf/scarf/Core/Services/AppRelauncher.swift b/scarf/scarf/Core/Services/AppRelauncher.swift new file mode 100644 index 0000000..121f38c --- /dev/null +++ b/scarf/scarf/Core/Services/AppRelauncher.swift @@ -0,0 +1,99 @@ +import AppKit +import Foundation +import os + +/// Quits the running app and brings up a fresh instance of the same +/// bundle. Used by the Profile-switching flow (issue #70) so the new +/// active profile lands in a process that has never observed the old +/// one — sidesteps any in-process cache or service-state bug that +/// might still be reading from the previous profile's home directory. +/// +/// The pairing is intentional: +/// 1. Caller invokes `try AppRelauncher.relaunch()`. That spawns a +/// fresh `open -n `, captures stderr/exitCode, returns +/// success once the launcher has acknowledged the dispatch. +/// 2. Caller schedules `NSApp.terminate(nil)` 250ms later. The +/// 250ms gives macOS time to begin launching the second PID so +/// the dock-icon hand-off looks smooth (no flash of missing +/// icon). Without the gap, macOS can briefly show zero Scarf +/// icons in the dock. +/// +/// Refuses to relaunch when the running bundle is under +/// `DerivedData/` or `Build/Products/Debug` — that's an Xcode +/// debug session, and `terminate(nil)` would kill the run mid-debug +/// without giving the new instance any way to attach. The caller +/// surfaces a "restart manually" toast in that case. +@MainActor +enum AppRelauncher { + static let logger = Logger(subsystem: "com.scarf.app", category: "AppRelauncher") + + enum RelaunchError: Error, LocalizedError { + case debugBuild + case openFailed(exitCode: Int32, stderr: String) + + var errorDescription: String? { + switch self { + case .debugBuild: + return "Refusing to relaunch from an Xcode debug build." + case .openFailed(let code, let stderr): + return "open(1) exited \(code): \(stderr)" + } + } + } + + /// Spawns a fresh instance of the running app via `/usr/bin/open -n + /// ` and returns once the launcher process has dispatched + /// the new instance. The caller is responsible for the subsequent + /// `NSApp.terminate(nil)` (deferred ~250ms for a smooth dock hand-off). + /// Throws `.debugBuild` when launched from Xcode/DerivedData; + /// `.openFailed` when `open` itself errored. + static func relaunch() throws { + let bundleURL = Bundle.main.bundleURL + let path = bundleURL.path + if path.contains("/DerivedData/") + || path.contains("/Build/Products/Debug") + || path.contains("/Build/Products/Debug-") + { + logger.warning("Refusing relaunch — running from Xcode build (\(path, privacy: .public))") + throw RelaunchError.debugBuild + } + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/open") + // -n: force a NEW instance (without it, `open` activates the + // running app and we'd never get a fresh process). + // Pass the bundle URL directly (not -a ) so signed + // dev clones in `~/Applications` still resolve correctly. + // No -W: we want `open` to return immediately after dispatch, + // not block until the spawned app exits. + proc.arguments = ["-n", path] + + let stderrPipe = Pipe() + let stdoutPipe = Pipe() + proc.standardError = stderrPipe + proc.standardOutput = stdoutPipe + + do { + try proc.run() + } catch { + throw RelaunchError.openFailed(exitCode: -1, stderr: error.localizedDescription) + } + + proc.waitUntilExit() + + // Drain both streams BEFORE inspecting exit code so we don't leak fds. + let errData = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data() + _ = try? stdoutPipe.fileHandleForReading.readToEnd() + try? stderrPipe.fileHandleForReading.close() + try? stdoutPipe.fileHandleForReading.close() + + guard proc.terminationStatus == 0 else { + let stderr = String(data: errData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + logger.warning("open(1) failed (\(proc.terminationStatus)): \(stderr, privacy: .public)") + throw RelaunchError.openFailed(exitCode: proc.terminationStatus, stderr: stderr) + } + + logger.info("Relaunch dispatched for \(path, privacy: .public)") + } +} diff --git a/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift b/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift index fb88894..d71a4b6 100644 --- a/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift +++ b/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import ScarfCore import os @@ -50,8 +51,65 @@ final class ProfilesViewModel { } } + /// Set the active profile via `hermes profile use ` without + /// relaunching Scarf. Most users will reach for `switchAndRelaunch` + /// instead — kept here so the context-menu "Use" item stays + /// functional and so callers that genuinely want a no-relaunch + /// switch (tests, scripted setups) have a path. Invalidates the + /// resolver cache on success so the next `context.paths` access + /// picks up the new home directory. func switchTo(_ profile: HermesProfile) { - runAndReload(["profile", "use", profile.name], success: "Active profile set to \(profile.name)") + Task.detached { [fileService] in + let result = fileService.runHermesCLI(args: ["profile", "use", profile.name], timeout: 60) + await MainActor.run { + if result.exitCode == 0 { + HermesProfileResolver.invalidateCache() + self.message = "Active profile set to \(profile.name) — restart Scarf to refresh." + } else { + self.message = "Failed: \(result.output.prefix(120))" + } + self.load() + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + self?.message = nil + } + } + } + } + + /// Set the active profile and immediately relaunch Scarf. The + /// canonical user-facing switch path (issue #70): a fresh process + /// guarantees every service constructs from the new + /// `~/.hermes/active_profile` value, sidestepping any in-process + /// state that might still be holding the previous profile's + /// data. Failures fall back to a "restart manually" toast. + @MainActor + func switchAndRelaunch(_ profile: HermesProfile) { + Task.detached { [fileService] in + let result = fileService.runHermesCLI(args: ["profile", "use", profile.name], timeout: 30) + await MainActor.run { + guard result.exitCode == 0 else { + self.message = "Failed: \(result.output.prefix(120))" + self.load() + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + self?.message = nil + } + return + } + HermesProfileResolver.invalidateCache() + do { + try AppRelauncher.relaunch() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + NSApp.terminate(nil) + } + } catch AppRelauncher.RelaunchError.debugBuild { + self.message = "Profile switched to \(profile.name). Restart Scarf manually (Xcode-launched instance)." + self.load() + } catch { + self.message = "Profile switched to \(profile.name). Please quit and reopen Scarf manually." + self.load() + } + } + } } func create(name: String, cloneConfig: Bool, cloneAll: Bool) { diff --git a/scarf/scarf/Features/Profiles/Views/ProfilesView.swift b/scarf/scarf/Features/Profiles/Views/ProfilesView.swift index d51275e..21f48b5 100644 --- a/scarf/scarf/Features/Profiles/Views/ProfilesView.swift +++ b/scarf/scarf/Features/Profiles/Views/ProfilesView.swift @@ -20,6 +20,12 @@ struct ProfilesView: View { @State private var renameTarget: HermesProfile? @State private var renameNewName = "" @State private var pendingDelete: HermesProfile? + /// Profile the user has clicked "Switch & Relaunch" on, awaiting + /// confirmation before we run `hermes profile use` and exit. The + /// confirmation step is load-bearing — relaunching closes every + /// open Scarf window in the process, so the user needs an explicit + /// agreement. + @State private var pendingSwitch: HermesProfile? /// Remote-import sheet visibility. Local imports use `NSOpenPanel` /// inline; remote imports route through `RemoteProfilePathSheet` /// because the zip the user wants to import lives on the remote @@ -63,6 +69,18 @@ struct ProfilesView: View { } message: { Text("This removes the profile directory and all data within it. This cannot be undone.") } + .confirmationDialog( + pendingSwitch.map { "Switch to '\($0.name)' and relaunch Scarf?" } ?? "", + isPresented: Binding(get: { pendingSwitch != nil }, set: { if !$0 { pendingSwitch = nil } }) + ) { + Button("Switch & Relaunch") { + if let profile = pendingSwitch { viewModel.switchAndRelaunch(profile) } + pendingSwitch = nil + } + Button("Cancel", role: .cancel) { pendingSwitch = nil } + } message: { + Text("All Scarf windows will close and reopen. Unsaved chat input may be lost.") + } .sheet(isPresented: $showRemoteImportSheet) { RemoteProfilePathSheet( context: viewModel.context, @@ -160,7 +178,9 @@ struct ProfilesView: View { } .tag(profile.id) .contextMenu { - Button("Use") { viewModel.switchTo(profile) } + Button("Switch & Relaunch") { pendingSwitch = profile } + .disabled(profile.isActive) + Button("Set Active (no relaunch)") { viewModel.switchTo(profile) } .disabled(profile.isActive) Button("Rename") { renameTarget = profile @@ -215,16 +235,17 @@ struct ProfilesView: View { Spacer() if !profile.isActive { Button { - viewModel.switchTo(profile) + pendingSwitch = profile } label: { - Label("Switch to This Profile", systemImage: "arrow.triangle.swap") + Label("Switch & Relaunch", systemImage: "arrow.triangle.2.circlepath") } .buttonStyle(.borderedProminent) .controlSize(.small) + .help("Set as active profile and relaunch Scarf so every tab loads from \(profile.name)") } } if !profile.isActive { - profileSwitchWarning + profileSwitchInfo } SettingsSection(title: "Details", icon: "info.circle") { if !profile.path.isEmpty { @@ -255,16 +276,16 @@ struct ProfilesView: View { } } - private var profileSwitchWarning: some View { + private var profileSwitchInfo: some View { HStack(alignment: .top, spacing: 8) { - Image(systemName: "exclamationmark.triangle") - .foregroundStyle(.orange) - Text("Switching the active profile changes the `~/.hermes` directory hermes uses. Restart Scarf after switching so it re-reads from the new profile's files.") + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + Text("**Switch & Relaunch** sets this as the active profile (writes `~/.hermes/active_profile`) and relaunches Scarf so every tab — Webhooks, Sessions, SOUL.md, Memory — reloads from the new profile's `~/.hermes/profiles//` directory.") .font(.caption) .foregroundStyle(.secondary) } .padding(10) - .background(.orange.opacity(0.1)) + .background(ScarfColor.backgroundSecondary) .clipShape(RoundedRectangle(cornerRadius: 6)) } diff --git a/scarf/scarf/Navigation/SidebarView.swift b/scarf/scarf/Navigation/SidebarView.swift index 4832665..932b034 100644 --- a/scarf/scarf/Navigation/SidebarView.swift +++ b/scarf/scarf/Navigation/SidebarView.swift @@ -16,6 +16,14 @@ struct SidebarView: View { @Environment(\.serverContext) private var serverContext @Environment(\.hermesCapabilities) private var capabilitiesStore + /// Currently-active Hermes profile name, surfaced as a header + /// chip on local contexts so users always see which profile + /// Scarf is reading from (issue #70 follow-up). Refreshed on + /// every section change as a cheap proxy for "user is + /// interacting with the app" — covers the rare case where the + /// user runs `hermes profile use` from a terminal mid-session. + @State private var activeProfileName: String = HermesProfileResolver.activeProfileName() + /// Capability-gated sections. Curator is v0.12+ only; older Hermes /// hosts get the same Interact section minus the Curator row. /// Building the list lazily off the env keeps the sidebar honest @@ -62,6 +70,14 @@ struct SidebarView: View { .background(.regularMaterial) .background(ScarfColor.backgroundTertiary.opacity(0.4)) .splitViewAutosaveName("ScarfMainSidebar.\(serverContext.id)") + .onAppear { + HermesProfileResolver.invalidateCache() + activeProfileName = HermesProfileResolver.activeProfileName() + } + .onChange(of: coordinator.selectedSection) { _, _ in + HermesProfileResolver.invalidateCache() + activeProfileName = HermesProfileResolver.activeProfileName() + } } // MARK: - Header @@ -76,6 +92,18 @@ struct SidebarView: View { Text("Scarf") .scarfStyle(.bodyEmph) .foregroundStyle(ScarfColor.foregroundPrimary) + // Active-profile chip — local contexts only. Remote + // ServerContexts don't read this Mac's active_profile + // file, so the chip would be misleading there. + if !serverContext.isRemote { + Button { + coordinator.selectedSection = .profiles + } label: { + ScarfBadge("profile: \(activeProfileName)", kind: .brand) + } + .buttonStyle(.plain) + .help("Active Hermes profile — click to manage") + } Spacer() Text(serverContext.displayName.lowercased()) .font(ScarfFont.caption2)