mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
fix(profiles): switch-and-relaunch flow + active-profile chip + structured logs
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 <bundleURL>` 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) <noreply@anthropic.com>
This commit is contained in:
@@ -95,15 +95,20 @@ public enum HermesProfileResolver {
|
|||||||
let defaultHome = defaultRootHome()
|
let defaultHome = defaultRootHome()
|
||||||
let activeFile = defaultHome + "/active_profile"
|
let activeFile = defaultHome + "/active_profile"
|
||||||
|
|
||||||
// Absent file → default profile. This is the common case for users
|
// Absent file → default profile. Common case for users who
|
||||||
// who haven't run `hermes profile use ...` and shouldn't generate
|
// haven't run `hermes profile use ...`. We still log at
|
||||||
// any log noise.
|
// `.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 {
|
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)
|
return ("default", defaultHome)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let raw = try? String(contentsOfFile: activeFile, encoding: .utf8) else {
|
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)
|
return ("default", defaultHome)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +116,7 @@ public enum HermesProfileResolver {
|
|||||||
|
|
||||||
// Empty file or explicit "default" → default profile.
|
// Empty file or explicit "default" → default profile.
|
||||||
if trimmed.isEmpty || trimmed == "default" {
|
if trimmed.isEmpty || trimmed == "default" {
|
||||||
|
logger.info("Resolved active Hermes profile: name=default, home=\(defaultHome, privacy: .public), source=file-default")
|
||||||
return ("default", defaultHome)
|
return ("default", defaultHome)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +135,7 @@ public enum HermesProfileResolver {
|
|||||||
return ("default", defaultHome)
|
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)
|
return (trimmed, profileHome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 <bundleURL>`, 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
|
||||||
|
/// <bundleURL>` 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 <bundleId>) 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import AppKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import ScarfCore
|
import ScarfCore
|
||||||
import os
|
import os
|
||||||
@@ -50,8 +51,65 @@ final class ProfilesViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the active profile via `hermes profile use <name>` 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) {
|
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) {
|
func create(name: String, cloneConfig: Bool, cloneAll: Bool) {
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ struct ProfilesView: View {
|
|||||||
@State private var renameTarget: HermesProfile?
|
@State private var renameTarget: HermesProfile?
|
||||||
@State private var renameNewName = ""
|
@State private var renameNewName = ""
|
||||||
@State private var pendingDelete: HermesProfile?
|
@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`
|
/// Remote-import sheet visibility. Local imports use `NSOpenPanel`
|
||||||
/// inline; remote imports route through `RemoteProfilePathSheet`
|
/// inline; remote imports route through `RemoteProfilePathSheet`
|
||||||
/// because the zip the user wants to import lives on the remote
|
/// because the zip the user wants to import lives on the remote
|
||||||
@@ -63,6 +69,18 @@ struct ProfilesView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("This removes the profile directory and all data within it. This cannot be undone.")
|
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) {
|
.sheet(isPresented: $showRemoteImportSheet) {
|
||||||
RemoteProfilePathSheet(
|
RemoteProfilePathSheet(
|
||||||
context: viewModel.context,
|
context: viewModel.context,
|
||||||
@@ -160,7 +178,9 @@ struct ProfilesView: View {
|
|||||||
}
|
}
|
||||||
.tag(profile.id)
|
.tag(profile.id)
|
||||||
.contextMenu {
|
.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)
|
.disabled(profile.isActive)
|
||||||
Button("Rename") {
|
Button("Rename") {
|
||||||
renameTarget = profile
|
renameTarget = profile
|
||||||
@@ -215,16 +235,17 @@ struct ProfilesView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
if !profile.isActive {
|
if !profile.isActive {
|
||||||
Button {
|
Button {
|
||||||
viewModel.switchTo(profile)
|
pendingSwitch = profile
|
||||||
} label: {
|
} label: {
|
||||||
Label("Switch to This Profile", systemImage: "arrow.triangle.swap")
|
Label("Switch & Relaunch", systemImage: "arrow.triangle.2.circlepath")
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
|
.help("Set as active profile and relaunch Scarf so every tab loads from \(profile.name)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !profile.isActive {
|
if !profile.isActive {
|
||||||
profileSwitchWarning
|
profileSwitchInfo
|
||||||
}
|
}
|
||||||
SettingsSection(title: "Details", icon: "info.circle") {
|
SettingsSection(title: "Details", icon: "info.circle") {
|
||||||
if !profile.path.isEmpty {
|
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) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
Image(systemName: "exclamationmark.triangle")
|
Image(systemName: "info.circle")
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.secondary)
|
||||||
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.")
|
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/<name>/` directory.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.padding(10)
|
.padding(10)
|
||||||
.background(.orange.opacity(0.1))
|
.background(ScarfColor.backgroundSecondary)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ struct SidebarView: View {
|
|||||||
@Environment(\.serverContext) private var serverContext
|
@Environment(\.serverContext) private var serverContext
|
||||||
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
@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
|
/// Capability-gated sections. Curator is v0.12+ only; older Hermes
|
||||||
/// hosts get the same Interact section minus the Curator row.
|
/// hosts get the same Interact section minus the Curator row.
|
||||||
/// Building the list lazily off the env keeps the sidebar honest
|
/// Building the list lazily off the env keeps the sidebar honest
|
||||||
@@ -62,6 +70,14 @@ struct SidebarView: View {
|
|||||||
.background(.regularMaterial)
|
.background(.regularMaterial)
|
||||||
.background(ScarfColor.backgroundTertiary.opacity(0.4))
|
.background(ScarfColor.backgroundTertiary.opacity(0.4))
|
||||||
.splitViewAutosaveName("ScarfMainSidebar.\(serverContext.id)")
|
.splitViewAutosaveName("ScarfMainSidebar.\(serverContext.id)")
|
||||||
|
.onAppear {
|
||||||
|
HermesProfileResolver.invalidateCache()
|
||||||
|
activeProfileName = HermesProfileResolver.activeProfileName()
|
||||||
|
}
|
||||||
|
.onChange(of: coordinator.selectedSection) { _, _ in
|
||||||
|
HermesProfileResolver.invalidateCache()
|
||||||
|
activeProfileName = HermesProfileResolver.activeProfileName()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
@@ -76,6 +92,18 @@ struct SidebarView: View {
|
|||||||
Text("Scarf")
|
Text("Scarf")
|
||||||
.scarfStyle(.bodyEmph)
|
.scarfStyle(.bodyEmph)
|
||||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
.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()
|
Spacer()
|
||||||
Text(serverContext.displayName.lowercased())
|
Text(serverContext.displayName.lowercased())
|
||||||
.font(ScarfFont.caption2)
|
.font(ScarfFont.caption2)
|
||||||
|
|||||||
Reference in New Issue
Block a user