Merge branch 'main' into scarf-mobile-development (v2.3.0)

Brings the iOS companion branch current with main's v2.2.0, v2.2.1,
and v2.3.0 landings — templates + configuration + catalog (v2.2),
projects folder hierarchy + per-project Sessions sidecar + AGENTS.md
context block + Tool Gateway + Nous Portal OAuth + hermes dashboard
webview (v2.3), and credential-pool OAuth expiry + Nous agent-key
rotation (post-v2.3).

Resolutions:
- ScarfCore Models (HermesConfig, ProjectDashboard, HermesPathSet) —
  forward-ported Tool Gateway's platformToolsets, project-registry v2
  folder/archived fields, and sessionProjectMap path into the moved
  ScarfCore copies. Deleted the old Mac-target paths.
- ScarfCore ModelCatalogService — merged main's overlay-only provider
  support (Nous Portal + OpenAI Codex + Qwen OAuth + …) so iOS and
  macOS pickers see the same provider list. Widened HermesProviderInfo
  / HermesProviderOverlay APIs to public.
- ScarfCore ProjectsViewModel — layered main's v2.3 registry verbs
  (moveProject / renameProject / archive / unarchive / folders) onto
  the M0d-extracted VM, keeping public surface for the Mac target.
- ScarfCore ConnectionStatusViewModel / RichChatViewModel — widened
  `private(set)` to `public private(set)` so Mac views can read
  status, lastSuccess, acp*Tokens, originSessionId, acpCommands,
  quickCommands.
- ScarfCore HermesConfig+YAML — added platform_toolsets parsing to
  the iOS YAML path so config.yaml round-trips the same as macOS.
- RichChatViewModel quick-commands — inlined the Mac-target's
  QuickCommandsViewModel.loadQuickCommands into ScarfCore using the
  existing HermesYAML parser, removing the cross-module dependency.
- HealthViewModel — took main's Tool Gateway + hermes-dashboard
  webview sections wholesale; file stays macOS-only.
- ChatView auto-merge — confirmed resume-session fix (5ae8db2) is
  present; made the PendingPermission.id extension public to satisfy
  Identifiable conformance across module boundary.
- ProjectSessionsViewModel — moved back to the Mac target since it
  depends on SessionAttributionService (also Mac-target). Defer the
  iOS SFTP parity of attribution to M7.
- LocalTransport.runProcess + SSHTransport.runLocal — wrapped the
  Process body in `#if !os(iOS)` with an explicit throw on iOS so
  ScarfCore compiles under the iOS SDK. iOS uses
  CitadelServerTransport (ScarfIOS) as the real implementation.
- CitadelServerTransport — updated `sftp.remove(atPath:)` to
  `sftp.remove(at:)` for the current Citadel API shape.

Cross-module imports: added `import ScarfCore` to 25 Mac-target files
that consumed ScarfCore types (13 v2.3 additions + 12 post-merge
errors caught by MemberImportVisibility: Settings tabs, SidebarView,
MCPServerEditorView, TemplateExportSheet, tests).

Version lockstep: bumped `scarf mobile` target to
MARKETING_VERSION=2.3.0, CURRENT_PROJECT_VERSION=25 to match main.

Builds green for both schemes:
- swift build (ScarfCore standalone)
- xcodebuild scarf -destination platform=macOS
- xcodebuild 'scarf mobile' -destination generic/platform=iOS

Deferred to M7 (iOS SFTP parity):
- NousSubscriptionService auth.json reader
- ProjectAgentContextService AGENTS.md write-before-chat
- SessionAttributionService session_project_map.json read/watch
All currently Mac-target-gated; iOS still builds without them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 10:53:23 +02:00
98 changed files with 5728 additions and 195 deletions
@@ -627,6 +627,13 @@ public struct HermesConfig: Sendable {
public var prefillMessagesFile: String
public var skillsExternalDirs: [String]
/// Per-platform toolset allowlists as written by `hermes setup tools`.
/// Keyed by platform (`cli`, `slack`, ) to enabled toolset identifiers
/// (`browser`, `messaging`, `nous-tools`, ). Hermes v0.10.0's Tool
/// Gateway; enabling `nous-tools` here is how subscribers opt-in per
/// platform. Scarf reads for display; edits go through Hermes CLI.
public var platformToolsets: [String: [String]]
// Grouped blocks
public var display: DisplaySettings
public var terminal: TerminalSettings
@@ -686,6 +693,7 @@ public struct HermesConfig: Sendable {
cronWrapResponse: Bool,
prefillMessagesFile: String,
skillsExternalDirs: [String],
platformToolsets: [String: [String]],
display: DisplaySettings,
terminal: TerminalSettings,
browser: BrowserSettings,
@@ -742,6 +750,7 @@ public struct HermesConfig: Sendable {
self.cronWrapResponse = cronWrapResponse
self.prefillMessagesFile = prefillMessagesFile
self.skillsExternalDirs = skillsExternalDirs
self.platformToolsets = platformToolsets
self.display = display
self.terminal = terminal
self.browser = browser
@@ -799,6 +808,7 @@ public struct HermesConfig: Sendable {
cronWrapResponse: true,
prefillMessagesFile: "",
skillsExternalDirs: [],
platformToolsets: [:],
display: .empty,
terminal: .empty,
browser: .empty,
@@ -65,6 +65,10 @@ public struct HermesPathSet: Sendable, Hashable {
public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
public nonisolated var scarfDir: String { home + "/scarf" }
public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
/// Maps Hermes session IDs to the Scarf project path a chat was
/// started for. Scarf-owned; Hermes never touches this file.
public nonisolated var sessionProjectMap: String { scarfDir + "/session_project_map.json" }
public nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
// MARK: - Binary resolution
@@ -17,15 +17,51 @@ public struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
public let name: String
public let path: String
/// Folder path for sidebar grouping. `nil` means top-level.
/// v2.3 registry schema v2; v2.2 files decode cleanly as `nil`.
public var folder: String?
/// Soft-archive flag. Archived projects are hidden from the sidebar
/// by default; non-destructive. v2.3 schema v2; defaults to `false`.
public var archived: Bool
public init(
name: String,
path: String
path: String,
folder: String? = nil,
archived: Bool = false
) {
self.name = name
self.path = path
self.folder = folder
self.archived = archived
}
public var dashboardPath: String { path + "/.scarf/dashboard.json" }
// MARK: - Codable (custom for backward compat)
private enum CodingKeys: String, CodingKey {
case name, path, folder, archived
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.name = try c.decode(String.self, forKey: .name)
self.path = try c.decode(String.self, forKey: .path)
self.folder = try c.decodeIfPresent(String.self, forKey: .folder)
self.archived = try c.decodeIfPresent(Bool.self, forKey: .archived) ?? false
}
public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(name, forKey: .name)
try c.encode(path, forKey: .path)
try c.encodeIfPresent(folder, forKey: .folder)
if archived {
try c.encode(archived, forKey: .archived)
}
}
}
// MARK: - Dashboard
@@ -205,6 +205,16 @@ public extension HermesConfig {
replyPrefix: str("whatsapp.reply_prefix")
)
// `platform_toolsets.<platform>` is a dict of lists in config.yaml
// parseNestedYAML flattens nested lists into dotted-path keys. Pull
// every key under the prefix and strip it.
var platformToolsets: [String: [String]] = [:]
for (key, items) in lists where key.hasPrefix("platform_toolsets.") {
let platform = String(key.dropFirst("platform_toolsets.".count))
guard !platform.isEmpty else { continue }
platformToolsets[platform] = items
}
// Home Assistant lives under `platforms.homeassistant.extra.*`.
let homeAssistant = HomeAssistantSettings(
watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [],
@@ -252,6 +262,7 @@ public extension HermesConfig {
cronWrapResponse: bool("cron.wrap_response", default: true),
prefillMessagesFile: str("prefill_messages_file"),
skillsExternalDirs: lists["skills.external_dirs"] ?? [],
platformToolsets: platformToolsets,
display: display,
terminal: terminal,
browser: browser,
@@ -70,19 +70,32 @@ public struct HermesProviderInfo: Sendable, Identifiable, Hashable {
public let envVars: [String] // e.g. ["ANTHROPIC_API_KEY"]
public let docURL: String?
public let modelCount: Int
/// True when this provider is surfaced only by the Hermes overlay list
/// i.e. no entry in `models_dev_cache.json`. The picker renders a
/// different right-column affordance (subscription CTA or free-form
/// model entry).
public let isOverlay: Bool
/// True for providers whose tool access is subscription-gated rather
/// than BYO API key. Nous Portal is the only such provider as of
/// hermes-agent v0.10.0.
public let subscriptionGated: Bool
public init(
providerID: String,
providerName: String,
envVars: [String],
docURL: String?,
modelCount: Int
modelCount: Int,
isOverlay: Bool = false,
subscriptionGated: Bool = false
) {
self.providerID = providerID
self.providerName = providerName
self.envVars = envVars
self.docURL = docURL
self.modelCount = modelCount
self.isOverlay = isOverlay
self.subscriptionGated = subscriptionGated
}
}
@@ -111,20 +124,49 @@ public struct ModelCatalogService: Sendable {
self.transport = LocalTransport()
}
/// All providers, sorted by display name.
/// All providers, sorted with subscription-gated providers first (Nous
/// Portal), then alphabetical by display name. Merges the models.dev
/// cache with `Self.overlayOnlyProviders` so Hermes-injected providers
/// (Nous Portal, OpenAI Codex, ) appear in the picker even when
/// they're absent from `models_dev_cache.json`.
public func loadProviders() -> [HermesProviderInfo] {
guard let catalog = loadCatalog() else { return [] }
return catalog
.map { (id, p) in
HermesProviderInfo(
providerID: id,
providerName: p.name ?? id,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0
)
let catalog = loadCatalog() ?? [:]
var byID: [String: HermesProviderInfo] = [:]
for (id, p) in catalog {
byID[id] = HermesProviderInfo(
providerID: id,
providerName: p.name ?? id,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0,
isOverlay: false,
subscriptionGated: false
)
}
for (id, overlay) in Self.overlayOnlyProviders where byID[id] == nil {
byID[id] = HermesProviderInfo(
providerID: id,
providerName: overlay.displayName,
envVars: [],
docURL: overlay.docURL,
modelCount: 0,
isOverlay: true,
subscriptionGated: overlay.subscriptionGated
)
}
return byID.values.sorted { lhs, rhs in
if lhs.subscriptionGated != rhs.subscriptionGated {
return lhs.subscriptionGated
}
.sorted { $0.providerName.localizedCaseInsensitiveCompare($1.providerName) == .orderedAscending }
return lhs.providerName.localizedCaseInsensitiveCompare(rhs.providerName) == .orderedAscending
}
}
/// Overlay metadata for a provider that isn't in the models.dev catalog
/// Scarf needs to surface these so the picker matches `hermes model` on
/// the CLI.
public func overlayMetadata(for providerID: String) -> HermesProviderOverlay? {
Self.overlayOnlyProviders[providerID]
}
/// Models for one provider, sorted by release date (newest first), then name.
@@ -167,7 +209,9 @@ public struct ModelCatalogService: Sendable {
providerName: p.name ?? providerID,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0
modelCount: p.models?.count ?? 0,
isOverlay: false,
subscriptionGated: false
)
}
}
@@ -181,13 +225,45 @@ public struct ModelCatalogService: Sendable {
providerName: p.name ?? prefix,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0
modelCount: p.models?.count ?? 0,
isOverlay: false,
subscriptionGated: false
)
}
}
return nil
}
/// Look up a provider by ID, falling back to overlays when the cache has
/// no entry. Use this when resolving a stored `model.provider` to display
/// metadata `nous` and other overlay-only IDs never appear in the
/// cache, so a plain catalog lookup returns nil for them.
public func providerByID(_ providerID: String) -> HermesProviderInfo? {
if let catalog = loadCatalog(), let p = catalog[providerID] {
return HermesProviderInfo(
providerID: providerID,
providerName: p.name ?? providerID,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0,
isOverlay: false,
subscriptionGated: false
)
}
if let overlay = Self.overlayOnlyProviders[providerID] {
return HermesProviderInfo(
providerID: providerID,
providerName: overlay.displayName,
envVars: [],
docURL: overlay.docURL,
modelCount: 0,
isOverlay: true,
subscriptionGated: overlay.subscriptionGated
)
}
return nil
}
/// Look up a specific model by provider + ID. Returns nil if not in the
/// catalog (e.g., free-typed custom model).
public func model(providerID: String, modelID: String) -> HermesModelInfo? {
@@ -253,4 +329,93 @@ public struct ModelCatalogService: Sendable {
let context: Int?
let output: Int?
}
// MARK: - Hermes overlay providers
/// The six providers Hermes surfaces via `hermes model` that have no
/// entry in `models_dev_cache.json` (models.dev doesn't mirror them).
/// Mirrors the overlay-only subset of `HERMES_OVERLAYS` in
/// `hermes-agent/hermes_cli/providers.py`. The other ~19 overlay entries
/// already ship in the cache and only add augmentation (base-URL
/// override, extra env vars) that Scarf doesn't currently display.
///
/// Keep this in sync with the Python side on Hermes version bumps.
static let overlayOnlyProviders: [String: HermesProviderOverlay] = [
"nous": HermesProviderOverlay(
displayName: "Nous Portal",
baseURL: "https://inference-api.nousresearch.com/v1",
authType: .oauthDeviceCode,
subscriptionGated: true,
docURL: "https://hermes-agent.nousresearch.com/docs/user-guide/setup/nous-portal"
),
"openai-codex": HermesProviderOverlay(
displayName: "OpenAI Codex",
baseURL: "https://chatgpt.com/backend-api/codex",
authType: .oauthExternal,
subscriptionGated: false,
docURL: nil
),
"qwen-oauth": HermesProviderOverlay(
displayName: "Qwen (OAuth)",
baseURL: "https://portal.qwen.ai/v1",
authType: .oauthExternal,
subscriptionGated: false,
docURL: nil
),
"google-gemini-cli": HermesProviderOverlay(
displayName: "Google Gemini CLI",
baseURL: "cloudcode-pa://google",
authType: .oauthExternal,
subscriptionGated: false,
docURL: nil
),
"copilot-acp": HermesProviderOverlay(
displayName: "GitHub Copilot ACP",
baseURL: "acp://copilot",
authType: .externalProcess,
subscriptionGated: false,
docURL: nil
),
"arcee": HermesProviderOverlay(
displayName: "Arcee",
baseURL: "https://api.arcee.ai/api/v1",
authType: .apiKey,
subscriptionGated: false,
docURL: nil
),
]
}
/// Scarf-side mirror of `HermesOverlay` from hermes-agent's
/// `hermes_cli/providers.py`. Describes a provider that isn't in the
/// models.dev catalog.
public struct HermesProviderOverlay: Sendable {
public let displayName: String
public let baseURL: String?
public let authType: AuthType
/// True for providers whose tool access is subscription-gated rather than
/// BYO-API-key. Nous Portal is the only `true` entry today.
public let subscriptionGated: Bool
public let docURL: String?
public init(
displayName: String,
baseURL: String?,
authType: AuthType,
subscriptionGated: Bool,
docURL: String?
) {
self.displayName = displayName
self.baseURL = baseURL
self.authType = authType
self.subscriptionGated = subscriptionGated
self.docURL = docURL
}
public enum AuthType: String, Sendable {
case apiKey
case oauthDeviceCode
case oauthExternal
case externalProcess
}
}
@@ -108,6 +108,11 @@ public struct LocalTransport: ServerTransport {
// MARK: - Processes
public func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
#if os(iOS)
// iOS can't spawn processes. Callers on iOS use `CitadelServerTransport`
// (from the ScarfIOS package) instead; reaching here is a wiring bug.
throw TransportError.other(message: "LocalTransport.runProcess is unavailable on iOS")
#else
let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable)
proc.arguments = args
@@ -148,6 +153,7 @@ public struct LocalTransport: ServerTransport {
try? stderrPipe.fileHandleForReading.close()
try? stdinPipe.fileHandleForWriting.close()
return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err)
#endif
}
#if !os(iOS)
@@ -637,6 +637,11 @@ public struct SSHTransport: ServerTransport {
/// SSH-specific code paths live on this type and we want all Process
/// lifecycle in one place per transport.
nonisolated private func runLocal(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
#if os(iOS)
// iOS uses `CitadelServerTransport` instead of spawning ssh/scp
// binaries. Reaching here from iOS is a wiring bug.
throw TransportError.other(message: "SSHTransport.runLocal is unavailable on iOS")
#else
ensureControlDir()
let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable)
@@ -682,5 +687,6 @@ public struct SSHTransport: ServerTransport {
try? stderrPipe.fileHandleForReading.close()
try? stdinPipe.fileHandleForWriting.close()
return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err)
#endif
}
}
@@ -30,15 +30,15 @@ public final class ConnectionStatusViewModel {
case error(message: String, stderr: String)
}
private(set) var status: Status = .idle
public private(set) var status: Status = .idle
/// Timestamp of the last successful probe. Used by the UI to show how
/// fresh the status indicator is ("just now", "2m ago").
private(set) var lastSuccess: Date?
public private(set) var lastSuccess: Date?
/// Number of consecutive probe failures. Surfaced as a yellow "Reconnecting"
/// state for the first failure (silent retry), then promoted to red after
/// `consecutiveFailureThreshold` failures so flaky connections don't
/// flap the indicator on every dropped packet.
private(set) var consecutiveFailures = 0
public private(set) var consecutiveFailures = 0
private let consecutiveFailureThreshold = 2
public let context: ServerContext
@@ -30,7 +30,7 @@ public final class LogsViewModel {
switch self {
case .agent: return "Agent"
case .errors: return "Errors"
case .gateway: return "Gateway"
case .gateway: return "Messaging Gateway"
}
}
#endif
@@ -58,7 +58,7 @@ public final class LogsViewModel {
public var displayName: LocalizedStringResource {
switch self {
case .all: return "All"
case .gateway: return "Gateway"
case .gateway: return "Messaging Gateway"
case .agent: return "Agent"
case .tools: return "Tools"
case .cli: return "CLI"
@@ -73,6 +73,88 @@ public final class ProjectsViewModel {
}
}
// MARK: - v2.3 registry verbs (folder / archive / rename)
/// Move a project into a folder. `nil` folder returns the project
/// to the top level. No-op when the target already matches.
public func moveProject(_ project: ProjectEntry, toFolder folder: String?) {
mutateEntry(project) { $0.folder = folder }
}
/// Rename a project. `name` is the registry's unique key + the
/// Identifiable id; rejects renames that would collide with an
/// existing project's name. Returns true on success.
@discardableResult
public func renameProject(_ project: ProjectEntry, to newName: String) -> Bool {
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
guard trimmed != project.name else { return true }
var registry = service.loadRegistry()
guard !registry.projects.contains(where: { $0.name == trimmed }) else { return false }
guard let index = registry.projects.firstIndex(where: { $0.name == project.name }) else { return false }
let old = registry.projects[index]
registry.projects[index] = ProjectEntry(
name: trimmed,
path: old.path,
folder: old.folder,
archived: old.archived
)
do {
try service.saveRegistry(registry)
} catch {
logger.error("renameProject couldn't persist registry: \(error.localizedDescription, privacy: .public)")
return false
}
projects = registry.projects
if selectedProject?.name == project.name {
selectedProject = registry.projects[index]
}
return true
}
/// Soft-archive a project. Stays on disk + in the registry; the
/// sidebar just hides it unless `showArchived` is on.
public func archiveProject(_ project: ProjectEntry) {
mutateEntry(project) { $0.archived = true }
if selectedProject?.name == project.name {
selectedProject = nil
dashboard = nil
}
}
/// Restore an archived project to the default view.
public func unarchiveProject(_ project: ProjectEntry) {
mutateEntry(project) { $0.archived = false }
}
/// Distinct folder labels across the current project set, sorted
/// alphabetically. Drives the sidebar's DisclosureGroups + the
/// Move-to-Folder sheet's existing-folder list.
public var folders: [String] {
let set = Set(projects.compactMap(\.folder).filter { !$0.isEmpty })
return set.sorted()
}
// MARK: - Helpers
private func mutateEntry(_ project: ProjectEntry, _ mutation: (inout ProjectEntry) -> Void) {
var registry = service.loadRegistry()
guard let index = registry.projects.firstIndex(where: { $0.name == project.name }) else { return }
var entry = registry.projects[index]
mutation(&entry)
registry.projects[index] = entry
do {
try service.saveRegistry(registry)
} catch {
logger.error("mutateEntry couldn't persist registry for \(project.name, privacy: .public): \(error.localizedDescription, privacy: .public)")
return
}
projects = registry.projects
if selectedProject?.name == project.name {
selectedProject = entry
}
}
public func refreshDashboard() {
guard let project = selectedProject else { return }
loadDashboard(for: project)
@@ -50,15 +50,15 @@ public final class RichChatViewModel {
public var scrollTrigger = UUID()
// Cumulative ACP token tracking (ACP returns tokens per prompt but DB has none)
private(set) var acpInputTokens = 0
private(set) var acpOutputTokens = 0
private(set) var acpThoughtTokens = 0
private(set) var acpCachedReadTokens = 0
public private(set) var acpInputTokens = 0
public private(set) var acpOutputTokens = 0
public private(set) var acpThoughtTokens = 0
public private(set) var acpCachedReadTokens = 0
/// Slash commands advertised by the ACP server via `available_commands_update`.
private(set) var acpCommands: [HermesSlashCommand] = []
public private(set) var acpCommands: [HermesSlashCommand] = []
/// User-defined commands parsed from `config.yaml` `quick_commands`.
private(set) var quickCommands: [HermesSlashCommand] = []
public private(set) var quickCommands: [HermesSlashCommand] = []
/// Merged list, ACP-first, de-duplicated by name.
public var availableCommands: [HermesSlashCommand] {
@@ -81,7 +81,7 @@ public final class RichChatViewModel {
public private(set) var sessionId: String?
/// The original CLI session ID when resuming a CLI session via ACP.
/// Used to combine old CLI messages with new ACP messages.
private(set) var originSessionId: String?
public private(set) var originSessionId: String?
private var nextLocalId = -1
private var streamingAssistantText = ""
private var streamingThinkingText = ""
@@ -253,13 +253,13 @@ public final class RichChatViewModel {
public func loadQuickCommands() {
let ctx = context
Task.detached { [weak self] in
let loaded = QuickCommandsViewModel.loadQuickCommands(context: ctx)
let mapped = loaded.map { qc -> HermesSlashCommand in
let truncated = qc.command.count > 60
? String(qc.command.prefix(60)) + ""
: qc.command
let loaded = Self.loadQuickCommands(context: ctx)
let mapped = loaded.map { (name, command) -> HermesSlashCommand in
let truncated = command.count > 60
? String(command.prefix(60)) + ""
: command
return HermesSlashCommand(
name: qc.name,
name: name,
description: "Run: \(truncated)",
argumentHint: nil,
source: .quickCommand
@@ -271,6 +271,33 @@ public final class RichChatViewModel {
}
}
/// Parse `quick_commands` from `<context>/config.yaml`. Returns
/// `[(name, command)]` for every well-formed `type: exec` entry.
/// Mac-side `QuickCommandsViewModel` uses a richer model + adds
/// an `isDangerous` check; here we only need the slash-menu
/// projection, so we keep the parser minimal and ScarfCore-local.
nonisolated static func loadQuickCommands(context: ServerContext) -> [(name: String, command: String)] {
guard let yaml = context.readText(context.paths.configYAML) else { return [] }
let parsed = HermesYAML.parseNestedYAML(yaml)
var byName: [String: (type: String, command: String)] = [:]
for (key, value) in parsed.values where key.hasPrefix("quick_commands.") {
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
guard parts.count == 3 else { continue }
let name = String(parts[1])
let field = String(parts[2])
var existing = byName[name] ?? (type: "exec", command: "")
let stripped = HermesYAML.stripYAMLQuotes(value)
if field == "type" { existing.type = stripped }
if field == "command" { existing.command = stripped }
byName[name] = existing
}
return byName.compactMap { (name, entry) in
guard entry.type == "exec", !entry.command.isEmpty else { return nil }
return (name: name, command: entry.command)
}
.sorted { $0.name < $1.name }
}
private func appendMessageChunk(text: String) {
streamingAssistantText += text
upsertStreamingMessage()
@@ -302,7 +302,7 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
// Parallel to LocalTransport: no-op if the file doesn't exist.
let exists = try await asyncFileExists(path)
if !exists { return }
try await sftp.remove(atPath: path)
try await sftp.remove(at: path)
}
private func asyncRunProcess(
+53
View File
@@ -56,3 +56,56 @@ Rich usage analytics pulled from the sessions and messages SQLite tables:
### 10. Config Editor
- Structured form editor for config.yaml with validation
---
## Projects System Evolution (post-v2.2.1)
A parallel backlog specific to the Projects feature. Ordered by dependency: organization first, then per-project attribution via sidecar, then observability built on that attribution, then polish, then platform bets.
### Shipping in v2.3 (planned — plan file at `~/.claude/plans/`)
- **Folder hierarchy in the sidebar.** `ProjectEntry` gains optional `folder: String?`. `DisclosureGroup`-based sidebar.
- **Rename + archive + search.** Registry verbs + a fuzzy ⌘F search + soft-archive (`archived: Bool?`) with Show/Hide toggle.
- **⌘1–⌘9 project jumps.**
- **Per-project Sessions tab** alongside Dashboard / Site. Filters the global sessions list by a new `~/.hermes/scarf/session_project_map.json` sidecar that Scarf populates when it starts a chat with a project context.
- **New Chat button** on the Sessions tab — spawns `hermes acp` with `cwd = project.path` and attributes the resulting session in the sidecar.
### v2.4+ — per-project observability
Depends on v2.3's sidecar being stable. All features below are "filter the existing data by the sidecar's project mapping."
- **Per-project activity feed.** Extend `ActivityViewModel` with a `projectPath` filter that maps through the sidecar. Dashboard widget type `recent-activity`.
- **Per-project token / cost rollup.** `InsightsViewModel.computeAggregates()` already sums over sessions; add a project filter. Widget binding `project.tokens` exposes it to agent-driven dashboards.
- **Per-project cron-job filter.** Cron sidebar gains a project dropdown. Template-installed jobs already carry `[tmpl:<id>]` prefixes; match against installed template manifests to attribute.
- **Desktop notifications for cron completion.** When a project-attributed cron job finishes (success or failure), fire a `UNUserNotification`. Per-project mute.
### v2.5+ — platform bets
Bigger investments with longer arcs.
- **Hermes upstream: `sessions.cwd` column.** Propose adding a nullable `cwd` (or `workspace_id`) column to Hermes's sessions table, populated on session create. Scarf would prefer the canonical column when available and fall back to the sidecar for pre-upgrade sessions. Requires coordinated Hermes release; filed under platform bets because it cuts the sidecar's blind spot (CLI-started sessions never enter the sidecar today).
- **Per-project memory slice.** Hermes reads `MEMORY.md` from a known path. Explore whether Scarf can spawn `hermes acp` with an overridden memory path (per-project `<project>/.scarf/MEMORY.md`) so projects get isolated context. Needs a Hermes-side env var or flag.
- **Per-project skills namespace.** Today user-authored skills are flat under `~/.hermes/skills/`. A `~/.hermes/skills/project/<slug>/` namespace parallel to the existing `templates/` namespace would let users install skills *into* a project without a template. Uninstall = drop the folder.
- **Cross-project meta-dashboards.** A portfolio view that aggregates widgets from multiple projects — total token spend, combined activity feed, project-health matrix. Useful at 20+ projects.
- **Project backup / restore.** One-click zip of `<project>/` + sidecar entries + related Keychain secrets, restorable on another machine. Richer than the existing Export flow (which carries the template shape only).
### Continuous — UX polish
Small, shippable at any time. Each is a half-day-to-one-day item.
- **Drag-and-drop to reorder** projects within a folder and between folders. Would be the first use of `.onDrag`/`.onDrop` in the codebase; establishes the pattern.
- **Tags as a secondary axis.** Keep folders as primary, add multi-valued string tags + filter chips at the sidebar top. Decide only if folders feel insufficient after v2.3 lands.
- **Favorites / pin** — bubble a project to the top of its folder.
- **Recent projects collection** — auto-populated "Recents" row at the top of the sidebar.
- **Color labels or SF Symbol icons** per project (Finder-tag-style).
- **Project dashboard starter templates** — "blank", "monitor", "feed", "timeline" shapes when creating a bare project (distinct from `.scarftemplate` sharing flow).
- **Opportunistic session backfill.** When Scarf loads any session that isn't in the sidecar, peek at first tool call's `working_directory` or `cwd` hint; if it matches a registered project path, write a sidecar entry. Heuristic, not perfect — useful as an "it just works" improvement after v2.3 ships.
### Research / verification gaps
Noted during v2.3 planning; chase when relevant:
- `DisclosureGroup` inside `List(.sidebar)` on macOS — occasional animation glitches with many-rows-expanding. Early prototype will confirm before full commit.
- Concurrent sidecar writers from multiple Scarf windows on the same `~/.hermes` — atomic replace handles per-write; reload behavior may lag. Acceptable; revisit if users report stale attribution.
- Do Hermes sessions ever persist `cwd` anywhere in `state.db` today that we've missed? If so, we can skip the sidecar and use it directly. Worth a one-hour investigation before starting v2.4 observability work.
+18 -18
View File
@@ -9,7 +9,7 @@
/* Begin PBXBuildFile section */
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; };
53SCARFCORE0010 /* ScarfCore in Frameworks */ = {isa = PBXBuildFile; productRef = 53SCARFCORE0001 /* ScarfCore */; };
53SCARFCORE0011 /* ScarfCore in Frameworks (iOS) */ = {isa = PBXBuildFile; productRef = 53SCARFCORE0002 /* ScarfCore */; };
53SCARFCORE0011 /* ScarfCore in Frameworks */ = {isa = PBXBuildFile; productRef = 53SCARFCORE0002 /* ScarfCore */; };
53SCARFIOS0010 /* ScarfIOS in Frameworks */ = {isa = PBXBuildFile; productRef = 53SCARFIOS0001 /* ScarfIOS */; };
53SPARKLE00010 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 53SPARKLE00011 /* Sparkle */; };
/* End PBXBuildFile section */
@@ -115,7 +115,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
53SCARFCORE0011 /* ScarfCore in Frameworks (iOS) */,
53SCARFCORE0011 /* ScarfCore in Frameworks */,
53SCARFIOS0010 /* ScarfIOS in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -522,7 +522,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -539,7 +539,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.scarf-mobile.app";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -560,7 +560,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -577,7 +577,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.scarf-mobile.app";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -819,7 +819,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 23;
CURRENT_PROJECT_VERSION = 25;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
@@ -833,7 +833,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.2.0;
MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -855,7 +855,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 23;
CURRENT_PROJECT_VERSION = 25;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
@@ -869,7 +869,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.2.0;
MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -887,12 +887,12 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
CURRENT_PROJECT_VERSION = 25;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.2.0;
MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -909,12 +909,12 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
CURRENT_PROJECT_VERSION = 25;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.2.0;
MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -930,11 +930,11 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
CURRENT_PROJECT_VERSION = 25;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 2.2.0;
MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -950,11 +950,11 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
CURRENT_PROJECT_VERSION = 25;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 2.2.0;
MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 962 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

@@ -1,61 +1,61 @@
{
"images" : [
{
"filename" : "AW Mac OS Applications-iOS-Default-16x16@1x.png",
"filename" : "AW Mac OS Applications-macOS-Default-16x16@1x.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "AW Mac OS Applications-iOS-Default-16x16@2x.png",
"filename" : "AW Mac OS Applications-macOS-Default-16x16@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "AW Mac OS Applications-iOS-Default-32x32@1x.png",
"filename" : "AW Mac OS Applications-macOS-Default-32x32@1x.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "AW Mac OS Applications-iOS-Default-32x32@2x.png",
"filename" : "AW Mac OS Applications-macOS-Default-32x32@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "AW Mac OS Applications-iOS-Default-128x128@1x.png",
"filename" : "AW Mac OS Applications-macOS-Default-128x128@1x.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "AW Mac OS Applications-iOS-Default-128x128@2x.png",
"filename" : "AW Mac OS Applications-macOS-Default-128x128@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "AW Mac OS Applications-iOS-Default-256x256@1x.png",
"filename" : "AW Mac OS Applications-macOS-Default-256x256@1x.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "AW Mac OS Applications-iOS-Default-512x512@1x 1.png",
"filename" : "AW Mac OS Applications-macOS-Default-256x256@2x 1.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "AW Mac OS Applications-iOS-Default-512x512@1x.png",
"filename" : "AW Mac OS Applications-macOS-Default-512x512@1x.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "AW Mac OS Applications-iOS-Default-1024x1024@1x.png",
"filename" : "AW Mac OS Applications-macOS-Default-1024x1024@1x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
@@ -0,0 +1,43 @@
import Foundation
/// Scarf-owned sidecar mapping Hermes session IDs to the Scarf
/// project path a chat was started for. Written on session create
/// when Scarf spawns `hermes acp` with a project-scoped cwd; read
/// by the per-project Sessions tab.
///
/// Hermes's own `state.db` has no `cwd` column on the sessions
/// table the cwd is passed at runtime via ACP but not persisted
/// on its side. This sidecar is how we recover the attribution
/// without requiring an upstream schema change.
///
/// Stored at `~/.hermes/scarf/session_project_map.json`. Forward-
/// compatible: if Hermes ever gains a canonical `cwd` column, Scarf
/// can prefer that and fall back to this file for pre-upgrade
/// sessions. Missing file empty map (nothing attributed yet).
struct SessionProjectMap: Codable, Sendable {
/// session-id absolute-project-path. Both strings are opaque
/// from this file's perspective; the service validates project
/// paths against the live registry when building the reverse
/// lookup used by the Sessions tab, so stale entries for
/// removed projects are ignored at read time without needing a
/// write-side cleanup.
var mappings: [String: String]
/// ISO-8601 timestamp of the most recent write. Informational
/// only not used for any decision logic. Useful when debugging
/// a stale sidecar ("when was this last updated?").
var updatedAt: String?
init(mappings: [String: String] = [:], updatedAt: String? = nil) {
self.mappings = mappings
self.updatedAt = updatedAt
}
/// Current time in ISO-8601 format, suitable for the
/// `updatedAt` field. Matches the format used elsewhere in
/// Scarf (e.g. `TemplateLock.installedAt`) so tooling that
/// greps across .json files sees consistent timestamps.
static func nowISO8601() -> String {
ISO8601DateFormatter().string(from: Date())
}
}
@@ -212,6 +212,16 @@ struct HermesFileService: Sendable {
replyPrefix: str("whatsapp.reply_prefix")
)
// `platform_toolsets.<platform>` is a dict of lists in config.yaml
// parseNestedYAML flattens nested lists into dotted-path keys. Pull
// every key under the prefix and strip it.
var platformToolsets: [String: [String]] = [:]
for (key, items) in lists where key.hasPrefix("platform_toolsets.") {
let platform = String(key.dropFirst("platform_toolsets.".count))
guard !platform.isEmpty else { continue }
platformToolsets[platform] = items
}
// Home Assistant lives under `platforms.homeassistant.extra.*`.
let homeAssistant = HomeAssistantSettings(
watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [],
@@ -259,6 +269,7 @@ struct HermesFileService: Sendable {
cronWrapResponse: bool("cron.wrap_response", default: true),
prefillMessagesFile: str("prefill_messages_file"),
skillsExternalDirs: lists["skills.external_dirs"] ?? [],
platformToolsets: platformToolsets,
display: display,
terminal: terminal,
browser: browser,
@@ -36,6 +36,16 @@ final class HermesFileWatcher {
paths.errorsLog,
paths.gatewayLog,
paths.projectsRegistry,
// v2.3: sidecar attributing Hermes session IDs to Scarf project
// paths. Written by SessionAttributionService when a chat
// starts with a project context; read by
// ProjectSessionsViewModel to filter the session list. Without
// watching this file, the per-project Sessions tab would only
// pick up new sessions when the user re-entered the tab
// (triggering .task(id:) re-fire) switching directly back
// to the project's Sessions tab after a chat left the tab
// stale.
paths.sessionProjectMap,
paths.mcpTokensDir
]
}
@@ -0,0 +1,265 @@
import Foundation
import AppKit
import os
import ScarfCore
/// Drives `hermes auth add nous --no-browser` for Nous Portal sign-in.
///
/// Nous uses OAuth 2.0 device-code flow, not PKCE. Hermes prints the
/// verification URL + user code to stdout, then long-polls the token
/// endpoint every ~1s until the user approves in their browser (or the
/// device code expires, currently 15 minutes).
///
/// The controller:
///
/// 1. Spawns hermes via `context.makeTransport().makeProcess(...)`.
/// 2. Streams stdout, regex-extracts the `verification_uri_complete` and
/// `user_code` from the lines hermes prints (auth.py:3282-3286).
/// 3. Auto-opens the verification URL in the default browser and
/// transitions to `.waitingForApproval` so the sheet can show the code.
/// 4. On subprocess exit, confirms success by re-reading `auth.json` via
/// `NousSubscriptionService` hermes exit 0 alone isn't enough, we want
/// to see `providers.nous.access_token` actually landed.
/// 5. Detects the `subscription_required` failure (auth.py:3347-3356) and
/// surfaces the billing URL so the sheet can offer a Subscribe link.
///
/// The parser functions are `nonisolated static` so tests can feed fixture
/// buffers without standing up a real subprocess.
@Observable
@MainActor
final class NousAuthFlow {
enum State: Equatable {
case idle
case starting
case waitingForApproval(userCode: String, verificationURL: URL)
case success
case failure(reason: String, billingURL: URL?)
}
private(set) var state: State = .idle
/// Accumulated subprocess output. Surfaced in the failure UI so the user
/// can copy the tail for bug reports.
private(set) var output: String = ""
let context: ServerContext
private let subscriptionService: NousSubscriptionService
private let logger = Logger(subsystem: "com.scarf", category: "NousAuthFlow")
private var process: Process?
private var stdoutPipe: Pipe?
init(context: ServerContext = .local) {
self.context = context
self.subscriptionService = NousSubscriptionService(context: context)
}
// MARK: - Lifecycle
/// Start the sign-in flow. Any in-flight subprocess is terminated first.
/// Safe to call repeatedly (e.g. user hits "Try again").
func start() {
cancel()
output = ""
state = .starting
let proc = context.makeTransport().makeProcess(
executable: context.paths.hermesBinary,
args: ["auth", "add", "nous", "--no-browser"]
)
if !context.isRemote {
// Only enrich env locally remote ssh gets the remote login env
// naturally, and exporting our local keys into it would be wrong.
var env = HermesFileService.enrichedEnvironment()
// Python block-buffers stdout when it's a pipe (not a TTY). The
// device-code flow prints the verification URL + user code, then
// enters a ~15-minute polling loop that never hits `input()`
// so nothing flushes and our readability handler never sees the
// output. Users see the sheet spinning forever while hermes is
// actually waiting for approval.
//
// PKCE doesn't have this problem because `input("Authorization
// code: ")` flushes stdout before blocking, which is why
// OAuthFlowController works without this setting.
//
// PYTHONUNBUFFERED forces line-buffered stdout for the whole
// subprocess tiny perf cost, huge UX win for device-code.
env["PYTHONUNBUFFERED"] = "1"
proc.environment = env
}
let outPipe = Pipe()
// Merge stderr into stdout hermes prints the device-code block to
// stdout but may emit diagnostics on stderr; we want them interleaved
// in display order so the failure-tail UI reads naturally.
proc.standardOutput = outPipe
proc.standardError = outPipe
outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
if data.isEmpty {
handle.readabilityHandler = nil
return
}
let chunk = String(data: data, encoding: .utf8) ?? ""
Task { @MainActor [weak self] in
self?.handleOutputChunk(chunk)
}
}
proc.terminationHandler = { [weak self] p in
let code = p.terminationStatus
Task { @MainActor [weak self] in
outPipe.fileHandleForReading.readabilityHandler = nil
self?.handleTermination(exitCode: code)
}
}
do {
try proc.run()
process = proc
stdoutPipe = outPipe
} catch {
logger.error("failed to spawn hermes: \(error.localizedDescription, privacy: .public)")
state = .failure(
reason: "Failed to start hermes: \(error.localizedDescription)",
billingURL: nil
)
}
}
/// Terminate the in-flight subprocess. Idempotent. Does NOT clear state
/// the sheet dismisses on cancel via its own binding, and re-opening
/// calls `start()` which does a fresh reset.
func cancel() {
stdoutPipe?.fileHandleForReading.readabilityHandler = nil
process?.terminate()
process = nil
stdoutPipe = nil
}
// MARK: - Output handling
private func handleOutputChunk(_ chunk: String) {
output += chunk
// Only transition into waiting while we're still in .starting once
// we've already emitted the URL + code, subsequent "Waiting for
// approval..." noise shouldn't re-fire NSWorkspace.open.
guard case .starting = state else { return }
if let result = Self.parseDeviceCode(from: output) {
state = .waitingForApproval(
userCode: result.userCode,
verificationURL: result.verificationURL
)
NSWorkspace.shared.open(result.verificationURL)
}
}
private func handleTermination(exitCode: Int32) {
// Subscription-required is a specific failure path that hermes
// signals both via an exit code and a unique billing-URL message.
// It overrides other checks because we want the Subscribe affordance
// in the UI regardless of exit code.
if let billing = Self.parseSubscriptionRequired(from: output) {
state = .failure(
reason: "Your Nous Portal account does not have an active subscription.",
billingURL: billing
)
return
}
if exitCode == 0 {
// Hermes claims success. Confirm by reading auth.json the
// authoritative signal is that providers.nous has an access token
// AND active_provider flipped to nous. Anything short of that is
// a silent failure on the hermes side.
let sub = subscriptionService.loadState()
if sub.subscribed {
state = .success
} else if sub.present {
state = .failure(
reason: "Signed in, but Nous isn't the active provider yet. Run `hermes model` and pick Nous Portal.",
billingURL: nil
)
} else {
state = .failure(
reason: "Sign-in finished without writing credentials. Try again, or run `hermes auth add nous` in a terminal to see full diagnostics.",
billingURL: nil
)
}
} else {
let tail = Self.lastLines(of: output, count: 8)
state = .failure(
reason: tail.isEmpty
? "hermes exited with code \(exitCode)"
: tail,
billingURL: nil
)
}
}
// MARK: - Parsers (pure, testable)
struct DeviceCodeResult: Equatable {
let verificationURL: URL
let userCode: String
}
/// Extract the device-code verification URL and user code from hermes's
/// output. Anchored on the exact shape hermes prints (auth.py:3282-3286):
///
/// To continue:
/// 1. Open: https://portal.nousresearch.com/device/XXXX-XXXX
/// 2. If prompted, enter code: XXXX-XXXX
///
/// Returns nil when either line is missing the sheet stays on the
/// `.starting` spinner until both are captured.
nonisolated static func parseDeviceCode(from text: String) -> DeviceCodeResult? {
let urlPattern = #"^\s*1\.\s*Open:\s*(https?://\S+)\s*$"#
let codePattern = #"^\s*2\.\s*If prompted, enter code:\s*(\S+)\s*$"#
guard
let urlString = firstCapture(in: text, pattern: urlPattern),
let userCode = firstCapture(in: text, pattern: codePattern),
let url = URL(string: urlString)
else {
return nil
}
return DeviceCodeResult(verificationURL: url, userCode: userCode)
}
/// Detect the subscription-required failure and extract the billing URL
/// hermes prints (auth.py:3347-3356). Scarf shows a "Subscribe" button
/// linking to this URL so the user can resolve the blocker without
/// hunting through logs.
nonisolated static func parseSubscriptionRequired(from text: String) -> URL? {
guard text.contains("Your Nous Portal account does not have an active subscription") else {
return nil
}
guard
let raw = firstCapture(in: text, pattern: #"Subscribe here:\s*(https?://\S+)"#),
let url = URL(string: raw)
else {
return nil
}
return url
}
private nonisolated static func firstCapture(in text: String, pattern: String) -> String? {
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
return nil
}
let range = NSRange(text.startIndex..., in: text)
guard
let match = regex.firstMatch(in: text, range: range),
match.numberOfRanges >= 2,
let r = Range(match.range(at: 1), in: text)
else {
return nil
}
return String(text[r])
}
private nonisolated static func lastLines(of text: String, count: Int) -> String {
let lines = text.split(separator: "\n", omittingEmptySubsequences: false)
return lines.suffix(count).joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@@ -0,0 +1,86 @@
import Foundation
import os
import ScarfCore
/// Snapshot of the user's Nous Portal subscription state, derived from the
/// `providers.nous` entry in `~/.hermes/auth.json`. Read-only Scarf never
/// writes the subscription record; `hermes model` + `hermes auth` own that
/// path.
struct NousSubscriptionState: Sendable, Hashable {
/// True when `providers.nous` exists and has a usable access token.
/// Mirrors the `nous_auth_present` field on
/// `NousSubscriptionFeatures` in `hermes_cli/nous_subscription.py`.
let present: Bool
/// True when the user's **active provider** is `nous`, i.e. they've not
/// just authed but selected it as the primary model provider. The Tool
/// Gateway only routes tools when this is true auth alone isn't enough.
let providerIsNous: Bool
/// Last update time for the auth record, if known. Useful in the Health
/// view to tell the user when their subscription state was last refreshed.
let updatedAt: Date?
static let absent = NousSubscriptionState(present: false, providerIsNous: false, updatedAt: nil)
/// Overall subscription active for Tool Gateway routing. Both halves have
/// to line up: auth record present *and* `nous` is the active provider.
/// Mirrors `NousSubscriptionFeatures.subscribed` on the Python side.
var subscribed: Bool { present && providerIsNous }
}
/// Reads `auth.json` to detect Nous Portal subscription state. Delegates file
/// I/O to the active `ServerTransport`, so remote installations work the same
/// as local ones.
///
/// The auth-record shape is defined by hermes-agent and is load-bearing. This
/// service parses a small, stable subset and tolerates anything new Hermes
/// adds we only rely on `providers.nous` being a dict with `access_token`
/// and `active_provider` being either `"nous"` or not.
struct NousSubscriptionService: Sendable {
private let logger = Logger(subsystem: "com.scarf", category: "NousSubscriptionService")
let authJSONPath: String
let transport: any ServerTransport
nonisolated init(context: ServerContext = .local) {
self.authJSONPath = context.paths.authJSON
self.transport = context.makeTransport()
}
/// Escape hatch for tests point at a fixture `auth.json` without
/// constructing a full `ServerContext`. Uses `LocalTransport` so the
/// fixture must live on the local filesystem.
init(path: String) {
self.authJSONPath = path
self.transport = LocalTransport()
}
/// Load the current subscription state. Returns ``NousSubscriptionState/absent``
/// on any read or parse failure callers treat "absent" and "can't
/// read" the same in UI (show a "not subscribed" CTA).
nonisolated func loadState() -> NousSubscriptionState {
guard let data = try? transport.readFile(authJSONPath) else {
return .absent
}
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
logger.warning("auth.json is not a JSON object; assuming no Nous subscription")
return .absent
}
let providers = root["providers"] as? [String: Any] ?? [:]
let nous = providers["nous"] as? [String: Any]
let token = nous?["access_token"] as? String
let present = (token?.isEmpty == false)
let activeProvider = root["active_provider"] as? String
let providerIsNous = (activeProvider == "nous")
let updatedAt: Date? = {
guard let raw = root["updated_at"] as? String else { return nil }
return ISO8601DateFormatter().date(from: raw)
}()
return NousSubscriptionState(
present: present,
providerIsNous: providerIsNous,
updatedAt: updatedAt
)
}
}
@@ -0,0 +1,294 @@
import Foundation
import os
import ScarfCore
/// Writes a Scarf-managed marker block into `<project>/AGENTS.md` so
/// that Hermes which auto-reads `AGENTS.md` from the session's cwd
/// at startup has consistent project identity and metadata in every
/// project-scoped chat.
///
/// **Why this exists.** Hermes has no native "project" concept and ACP
/// passes only `(cwd, mcpServers)` at session create extra params
/// are silently dropped on Hermes's side. The documented hook for
/// giving the agent context when cwd is set programmatically is the
/// auto-load of `AGENTS.md` (or `.hermes.md` / `CLAUDE.md` /
/// `.cursorrules`, in that priority) from the cwd. Scarf owns a
/// managed region of the project's AGENTS.md; template-author content
/// lives outside that region and is preserved.
///
/// **Marker contract.** The region sits between:
///
/// ```
/// <!-- scarf-project:begin -->
/// Scarf-managed content
/// <!-- scarf-project:end -->
/// ```
///
/// Same pattern as the v2.2 memory-block appendix bounded, self-
/// declaring, safe to re-generate. Everything outside the markers is
/// left byte-identical across refreshes.
///
/// **Secret-safe.** The block surfaces field NAMES from `config.json`
/// (via the cached manifest's schema) but never VALUES. A rendered
/// block contains no secrets even for a project whose config.json
/// has Keychain-ref URIs.
///
/// **Refresh timing.** `ChatViewModel.startACPSession(resume:projectPath:)`
/// calls `refresh(for:)` immediately before Hermes opens the session.
/// Hermes reads AGENTS.md during session boot, so the marker block
/// must have landed on disk first. Non-blocking on failure a
/// failed refresh logs and the chat proceeds without the block.
struct ProjectAgentContextService: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectAgentContextService")
/// Marker strings. Load-bearing: the format must stay stable
/// across releases so existing project AGENTS.md files continue
/// to be recognized and rewritten cleanly.
static let beginMarker = "<!-- scarf-project:begin -->"
static let endMarker = "<!-- scarf-project:end -->"
let context: ServerContext
nonisolated init(context: ServerContext = .local) {
self.context = context
}
// MARK: - Public
/// Refresh (or create) the Scarf-managed block in the project's
/// AGENTS.md. Reads current project state template manifest,
/// config schema, registered cron jobs and produces a block
/// reflecting today's truth. Idempotent: two consecutive calls
/// with no intervening state change produce byte-identical
/// output.
nonisolated func refresh(for project: ProjectEntry) throws {
let block = renderBlock(for: project)
let path = agentsMdPath(for: project)
let transport = context.makeTransport()
// Ensure the project directory exists this service is the
// first thing that touches the project dir when the user
// scaffolds a bare project via `+` + starts a chat. Normally
// the dir exists (registered project = dir exists); belt-
// and-suspenders for edge cases.
if !transport.fileExists(project.path) {
try transport.createDirectory(project.path)
}
if !transport.fileExists(path) {
// Fresh AGENTS.md with just our block + a trailing
// newline so editors render it cleanly.
let data = (block + "\n").data(using: .utf8) ?? Data()
try transport.writeFile(path, data: data)
Self.logger.info("created AGENTS.md with Scarf block for \(project.name, privacy: .public)")
return
}
// Read existing, splice in the new block.
let existingData = try transport.readFile(path)
let existing = String(data: existingData, encoding: .utf8) ?? ""
let rewritten = Self.applyBlock(block: block, to: existing)
guard let outData = rewritten.data(using: .utf8) else {
throw ProjectAgentContextError.encodingFailed
}
// Skip the write when nothing changed avoids unnecessary
// file-watcher churn. Matches what disk snapshot shows.
guard outData != existingData else { return }
try transport.writeFile(path, data: outData)
Self.logger.info("refreshed Scarf block in AGENTS.md for \(project.name, privacy: .public)")
}
// MARK: - Marker splice (testable in isolation)
/// Core text transform: given an existing file and a freshly-
/// rendered block, return the file with the block spliced in.
///
/// Three cases handled:
/// 1. Existing file has both markers replace the inclusive
/// region, preserve everything outside untouched.
/// 2. Existing file has no markers prepend the block followed
/// by a two-newline separator so it reads as its own section.
/// 3. Existing file has a begin marker but no end we DON'T try
/// to be clever; treat as "no markers present" and prepend.
/// User intervention or a later refresh can restore shape.
/// The stray begin-marker is left in the file; we don't
/// truncate to EOF (as the memory-block installer does)
/// because an orphaned begin on this file is more likely
/// hand-typed than a corrupt Scarf write.
nonisolated static func applyBlock(block: String, to existing: String) -> String {
guard let beginRange = existing.range(of: beginMarker),
let endRange = existing.range(
of: endMarker,
range: beginRange.upperBound..<existing.endIndex
)
else {
// No well-formed Scarf block present prepend.
let trimmedExisting = existing.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedExisting.isEmpty {
return block + "\n"
}
return block + "\n\n" + existing
}
// Full span: from the begin marker through the end marker
// (inclusive). Consumes any trailing whitespace/newlines
// immediately following the end marker so a re-render of a
// shorter block doesn't leave a dangling blank line.
var upperBound = endRange.upperBound
while upperBound < existing.endIndex,
existing[upperBound].isNewline {
upperBound = existing.index(after: upperBound)
}
let before = String(existing[existing.startIndex..<beginRange.lowerBound])
let after = String(existing[upperBound..<existing.endIndex])
// Preserve the leading whitespace / content structure of
// `before` but ensure exactly one blank line separates it
// from the new block when there IS prior content.
let prefix = before.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? ""
: before.trimmingRightNewlines() + "\n\n"
// Suffix: a blank line BEFORE the remaining content, ensuring
// the template/user content is visually separated from the
// Scarf block.
let suffix = after.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "\n"
: "\n\n" + after.trimmingLeftNewlines()
return prefix + block + suffix
}
// MARK: - Block rendering
/// Build the Markdown block for a given project. Pure function of
/// project state exposed for tests that want to assert on
/// rendered content without touching disk.
nonisolated func renderBlock(for project: ProjectEntry) -> String {
let templateInfo = readTemplateInfo(for: project)
let configFieldsLine = renderConfigFieldsLine(for: project)
let cronLines = renderCronLines(for: project, templateId: templateInfo?.id)
let lockFilePresent = context.makeTransport().fileExists(
project.path + "/.scarf/template.lock.json"
)
var lines: [String] = []
lines.append(Self.beginMarker)
lines.append("## Scarf project context")
lines.append("")
lines.append("_Auto-generated by Scarf — do not edit between the begin/end markers._")
lines.append("")
lines.append("You are operating inside a Scarf project named **\"\(project.name)\"**. Scarf is a macOS GUI for Hermes; the user is working with this project through it. This chat session's working directory is the project's directory — path-relative tool calls resolve inside the project.")
lines.append("")
lines.append("- **Project directory:** `\(project.path)`")
lines.append("- **Dashboard:** `\(project.path)/.scarf/dashboard.json`")
if let tpl = templateInfo {
lines.append("- **Template:** `\(tpl.id)` v\(tpl.version)")
}
lines.append("- **Configuration fields:** \(configFieldsLine)")
if cronLines.isEmpty {
lines.append("- **Registered cron jobs:** (none attributed to this project)")
} else {
lines.append("- **Registered cron jobs:**")
for line in cronLines {
lines.append(" - \(line)")
}
}
if lockFilePresent {
lines.append("- **Uninstall manifest:** `\(project.path)/.scarf/template.lock.json` (tracks files written by template install)")
}
lines.append("")
lines.append("Any content below this block is template- or user-authored; preserve and defer to it for project-specific behavior. Do NOT modify content inside these markers — Scarf rewrites this block on every project-scoped chat start.")
lines.append(Self.endMarker)
return lines.joined(separator: "\n")
}
// MARK: - Helpers
nonisolated private func agentsMdPath(for project: ProjectEntry) -> String {
project.path + "/AGENTS.md"
}
/// Read `<project>/.scarf/manifest.json` for template id + version.
/// Nil when not present (bare project) or when the file is
/// unparseable the block still renders cleanly without the
/// template line.
nonisolated private func readTemplateInfo(for project: ProjectEntry) -> (id: String, version: String)? {
let manifestPath = project.path + "/.scarf/manifest.json"
let transport = context.makeTransport()
guard transport.fileExists(manifestPath) else { return nil }
guard let data = try? transport.readFile(manifestPath) else { return nil }
guard let manifest = try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data) else { return nil }
return (id: manifest.id, version: manifest.version)
}
/// Build the "Configuration fields" bullet's tail. Returns a
/// comma-joined list of backticked field names with inline type
/// hints (`(secret)`), or the literal string "(none)" when the
/// project has no config schema. **Never** includes values.
nonisolated private func renderConfigFieldsLine(for project: ProjectEntry) -> String {
let manifestPath = project.path + "/.scarf/manifest.json"
let transport = context.makeTransport()
guard transport.fileExists(manifestPath),
let data = try? transport.readFile(manifestPath),
let manifest = try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data),
let schema = manifest.config,
!schema.fields.isEmpty
else {
return "(none)"
}
let fieldList = schema.fields.map { field -> String in
let secretTag = field.type == .secret ? " (secret — name only, value stored in Keychain)" : ""
return "`\(field.key)`\(secretTag)"
}
return fieldList.joined(separator: ", ")
}
/// Return a list of human-readable cron-job descriptions for jobs
/// attributed to this project via the `[tmpl:<id>] ` name prefix.
/// Empty array when no jobs match (either the project has no
/// template or no jobs carry the tag).
nonisolated private func renderCronLines(for project: ProjectEntry, templateId: String?) -> [String] {
guard let templateId else { return [] }
let prefix = "[tmpl:\(templateId)]"
let jobs = HermesFileService(context: context).loadCronJobs()
return jobs
.filter { $0.name.hasPrefix(prefix) }
.map { job in
let scheduleDesc = job.schedule.display
?? job.schedule.expression
?? job.schedule.kind
let pausedDesc = job.enabled ? "enabled" : "paused"
return "`\(job.name)` — schedule `\(scheduleDesc)`, currently \(pausedDesc)"
}
}
}
enum ProjectAgentContextError: Error {
case encodingFailed
}
// MARK: - String helpers (file-scoped)
private extension String {
/// Drop trailing newlines + CRs but preserve other trailing
/// whitespace (tabs, non-breaking spaces) that might be
/// meaningful in some edge case.
func trimmingRightNewlines() -> String {
var result = self
while let last = result.last, last.isNewline {
result.removeLast()
}
return result
}
/// Symmetric counterpart: strip leading newlines / CRs.
func trimmingLeftNewlines() -> String {
var result = self
while let first = result.first, first.isNewline {
result.removeFirst()
}
return result
}
}
@@ -0,0 +1,116 @@
import Foundation
import os
import ScarfCore
/// Owns the sidecar that attributes Hermes session IDs to Scarf
/// project paths. The `cwd` passed to `hermes acp` at session
/// creation is ephemeral from Hermes's perspective (not written to
/// `state.db`), so Scarf keeps this Scarf-owned record parallel to
/// Hermes's session store.
///
/// File: `~/.hermes/scarf/session_project_map.json` (resolved via
/// `HermesPathSet.sessionProjectMap`).
///
/// Thread safety: all public methods are `nonisolated` and each
/// performs a single read-modify-write cycle that's atomic on
/// disk. Concurrent writers (two Scarf windows on the same
/// `~/.hermes`) are safe at the file level last write wins
/// but the in-memory read in one window may lag until that window
/// reloads. Acceptable for v2.3's scale; revisit if multi-window
/// cross-talk becomes a problem.
struct SessionAttributionService: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "SessionAttributionService")
let context: ServerContext
nonisolated init(context: ServerContext = .local) {
self.context = context
}
// MARK: - Read
/// Load the current sidecar contents. Missing file or unparseable
/// JSON returns an empty map the sidecar is a convenience
/// index, not a source of truth for anything load-bearing.
nonisolated func load() -> SessionProjectMap {
let path = context.paths.sessionProjectMap
let transport = context.makeTransport()
guard transport.fileExists(path) else {
return SessionProjectMap()
}
do {
let data = try transport.readFile(path)
return try JSONDecoder().decode(SessionProjectMap.self, from: data)
} catch {
Self.logger.warning("session-project-map parse failed at \(path, privacy: .public): \(error.localizedDescription, privacy: .public); returning empty map")
return SessionProjectMap()
}
}
/// Look up the project path a given session was attributed to.
/// Returns nil for unattributed sessions (CLI-started, or
/// started before v2.3) those surface in the global Sessions
/// sidebar unchanged and don't appear in any project's Sessions
/// tab.
nonisolated func projectPath(for sessionID: String) -> String? {
load().mappings[sessionID]
}
/// Reverse lookup: every session ID attributed to the given
/// project path. Used by the per-project Sessions tab to filter
/// the global session list. Comparison is exact-string; the
/// registry stores absolute paths and we write absolute paths,
/// so no normalisation is needed in practice.
nonisolated func sessionIDs(forProject projectPath: String) -> Set<String> {
let map = load()
return Set(map.mappings.filter { $0.value == projectPath }.keys)
}
// MARK: - Write
/// Record that `sessionID` was created under the given project
/// path. Idempotent repeated calls for the same pair are no-
/// ops. Replacing an existing mapping (session moved to a
/// different project) is legal but expected to be rare; the
/// caller decides when that's correct.
nonisolated func attribute(sessionID: String, toProjectPath projectPath: String) {
var map = load()
if map.mappings[sessionID] == projectPath {
return
}
map.mappings[sessionID] = projectPath
map.updatedAt = SessionProjectMap.nowISO8601()
persist(map)
}
/// Remove a mapping. Called in v2.3's Sessions-tab code path is
/// minimal we don't currently prune on session delete because
/// Hermes owns session lifecycle and we don't observe deletes.
/// Exposed for future roadmap items (e.g. explicit "detach
/// from project" action) and tests.
nonisolated func forget(sessionID: String) {
var map = load()
guard map.mappings.removeValue(forKey: sessionID) != nil else { return }
map.updatedAt = SessionProjectMap.nowISO8601()
persist(map)
}
// MARK: - Private
private func persist(_ map: SessionProjectMap) {
let path = context.paths.sessionProjectMap
let transport = context.makeTransport()
let dir = context.paths.scarfDir
do {
if !transport.fileExists(dir) {
try transport.createDirectory(dir)
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(map)
try transport.writeFile(path, data: data)
} catch {
Self.logger.error("failed to persist session-project-map at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
}
}
}
@@ -42,6 +42,20 @@ final class ChatViewModel {
let richChatViewModel: RichChatViewModel
private var coordinator: Coordinator?
/// Absolute project path for the current session, when the chat is
/// project-scoped (either started via a project's "New Chat" button
/// or resumed from a session that was previously attributed via the
/// v2.3 sidecar). Nil for plain global chats. Drives the project
/// indicator in SessionInfoBar + the `Chat · <Name>` nav title.
private(set) var currentProjectPath: String?
/// Human-readable name of the active project, resolved from the
/// projects registry at session-start time. Stored alongside the
/// path so the view renders without hitting disk on every update.
/// Nil when `currentProjectPath` is nil OR the path isn't in the
/// registry (project was removed after the session was attributed).
private(set) var currentProjectName: String?
// ACP state
private var acpClient: ACPClient?
private var acpEventTask: Task<Void, Never>?
@@ -119,15 +133,20 @@ final class ChatViewModel {
// MARK: - Session Lifecycle
func startNewSession() {
func startNewSession(projectPath: String? = nil) {
voiceEnabled = false
ttsEnabled = false
isRecording = false
richChatViewModel.reset()
if displayMode == .richChat {
startACPSession(resume: nil)
startACPSession(resume: nil, projectPath: projectPath)
} else {
// Terminal mode doesn't surface project attribution today
// `hermes chat` uses the shell's cwd, so starting a terminal
// chat from a project button would require changing the
// shell's cwd too. Out of scope for v2.3 Rich Chat is
// the primary surface for project-scoped sessions.
launchTerminal(arguments: ["chat"])
}
}
@@ -290,13 +309,33 @@ final class ChatViewModel {
// MARK: - ACP Session Management
private func startACPSession(resume sessionId: String?) {
private func startACPSession(resume sessionId: String?, projectPath: String? = nil) {
stopACP()
clearACPErrorState()
acpStatus = "Starting..."
let client = ACPClient.forMacApp(context: context)
self.acpClient = client
let attribution = SessionAttributionService(context: context)
// If the caller passed a project path, refresh the Scarf-
// managed block in the project's AGENTS.md BEFORE starting
// ACP Hermes auto-reads AGENTS.md at session boot, so the
// block has to land on disk first. Non-blocking on failure:
// we log and proceed without the block. Safe on bare
// projects (creates AGENTS.md with just the block); safe on
// template-installed projects (splices the block into
// existing AGENTS.md without touching template content).
if let projectPath {
let registry = ProjectDashboardService(context: context).loadRegistry()
if let project = registry.projects.first(where: { $0.path == projectPath }) {
do {
try ProjectAgentContextService(context: context).refresh(for: project)
} catch {
logger.warning("couldn't refresh project context block for \(project.name): \(error.localizedDescription)")
}
}
}
Task { @MainActor in
do {
@@ -306,7 +345,19 @@ final class ChatViewModel {
startACPEventLoop(client: client)
startHealthMonitor(client: client)
let cwd = await context.resolvedUserHome()
// Project-scoped chats pass the project's absolute path
// as cwd so Hermes tool calls and subsequent ACP ops
// resolve relative paths against the project's files.
// Falls back to the user's home (existing v2.2 behavior)
// when the caller didn't request a project scope.
// `??` can't wrap an async autoclosure, so we
// materialize the fallback with an if-let.
let cwd: String
if let projectPath {
cwd = projectPath
} else {
cwd = await context.resolvedUserHome()
}
// Mark active BEFORE setting session ID so .task(id:) sees isACPMode=true
// and doesn't wipe messages with a DB refresh
@@ -335,6 +386,48 @@ final class ChatViewModel {
richChatViewModel.setSessionId(resolvedSessionId)
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
// Attribute this session to the project it was started
// under, so the per-project Sessions tab can surface it
// without a user action. No-op when projectPath is nil.
// Idempotent: re-attribution of the same pair is free.
if let projectPath {
attribution.attribute(
sessionID: resolvedSessionId,
toProjectPath: projectPath
)
}
// Resolve which project (if any) this session belongs
// to, so SessionInfoBar + nav title can surface it.
// Two inputs use whichever is non-nil:
// * `projectPath` the caller asked for a project
// scope (fresh project chat). Just-attributed;
// definitely in the sidecar.
// * `attribution.projectPath(for: resolvedSessionId)`
// the resumed session was previously attributed.
// Covers "click an old project-attributed session
// from the global Sessions sidebar / Resume menu"
// where projectPath isn't known at the call site.
let attributedPath = projectPath
?? attribution.projectPath(for: resolvedSessionId)
if let path = attributedPath {
// Look up a human-readable name from the projects
// registry. Missing project (path in the sidecar,
// project since removed) show the path as a
// fallback label so the chip still renders and the
// user sees *something* rather than silently losing
// the indicator.
let registry = ProjectDashboardService(context: context).loadRegistry()
let name = registry.projects.first(where: { $0.path == path })?.name
self.currentProjectPath = path
self.currentProjectName = name ?? path
} else {
// Explicit clear on non-project sessions so the
// indicator doesn't leak from a previous chat.
self.currentProjectPath = nil
self.currentProjectName = nil
}
// Refresh session list so the new ACP session appears in the Resume menu
await loadRecentSessions()
+60 -2
View File
@@ -4,25 +4,83 @@ import ScarfCore
struct ChatView: View {
@Environment(ChatViewModel.self) private var viewModel
@Environment(HermesFileWatcher.self) private var fileWatcher
@Environment(AppCoordinator.self) private var coordinator
@State private var showErrorDetails = false
var body: some View {
@Bindable var vm = viewModel
@Bindable var coord = coordinator
VStack(spacing: 0) {
toolbar
Divider()
errorBanner
chatArea
}
.navigationTitle("Chat")
// Clamp the outer VStack to the detail column's offered
// space. Without this, the chat area's intrinsic height (a
// RichChatView whose message list grows with content) can
// bubble up through NavigationSplitView's detail slot and
// push the whole window past the screen. Same pattern as
// the Sessions tab fix in the v2.3 branch.
.frame(maxWidth: .infinity, maxHeight: .infinity)
// v2.3: reflect the active Scarf project in the nav title
// so the user can see at a glance that the chat is scoped
// (complements the folder chip in SessionInfoBar). Falls
// back to the plain "Chat" label for global chats.
.navigationTitle(
viewModel.currentProjectName.map { "Chat · \($0)" } ?? "Chat"
)
.task {
await viewModel.loadRecentSessions()
viewModel.refreshCredentialPreflight()
// Cold-launch handoff: if the user clicked "New Chat" on
// a project before ChatView had a chance to render, the
// coordinator was already populated. Consume the request
// here. The onChange below handles the live case.
if let pending = coordinator.pendingProjectChat {
coordinator.pendingProjectChat = nil
viewModel.startNewSession(projectPath: pending)
}
// Same story for resume-session handoff: the user clicked
// a session in the Projects Sessions tab (routes to `.chat`
// rather than `.sessions` so the chat actually reopens).
// SessionsView consumes `selectedSessionId` for its own
// routing; Chat now consumes it too. Mutually exclusive at
// any given render because only one section is active per
// `coordinator.selectedSection`. `else if` makes precedence
// explicit pendingProjectChat (new) outranks
// selectedSessionId (resume) when both are somehow set.
else if let pendingId = coordinator.selectedSessionId {
coordinator.selectedSessionId = nil
viewModel.resumeSession(pendingId)
}
}
.onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.loadRecentSessions() }
viewModel.refreshCredentialPreflight()
}
// Live handoff from the per-project Sessions tab: the tab
// sets `pendingProjectChat` + flips `selectedSection` to
// `.chat`; this view consumes the path and starts a fresh
// session with cwd=projectPath. Attribution happens inside
// ChatViewModel on successful session creation.
.onChange(of: coord.pendingProjectChat) { _, new in
if let projectPath = new {
coordinator.pendingProjectChat = nil
viewModel.startNewSession(projectPath: projectPath)
}
}
// Live handoff for resume: user clicked an existing session in
// the Projects Sessions tab while already in the Chat section
// (or switched back to Chat after). Project-chip rendering
// happens automatically inside ChatViewModel.resumeSession ->
// startACPSession via the attribution.projectPath(for:) lookup.
.onChange(of: coord.selectedSessionId) { _, new in
if let sessionId = new {
coordinator.selectedSessionId = nil
viewModel.resumeSession(sessionId)
}
}
}
/// Banner rendered between the toolbar and the chat area when either
@@ -361,7 +419,7 @@ struct ChatView: View {
// MARK: - Permission Approval View
extension RichChatViewModel.PendingPermission: Identifiable {
var id: Int { requestId }
public var id: Int { requestId }
}
struct PermissionApprovalView: View {
@@ -18,7 +18,13 @@ struct RichChatView: View {
isWorking: richChat.isAgentWorking,
acpInputTokens: richChat.acpInputTokens,
acpOutputTokens: richChat.acpOutputTokens,
acpThoughtTokens: richChat.acpThoughtTokens
acpThoughtTokens: richChat.acpThoughtTokens,
// v2.3: surface the active Scarf project (if any) as
// a folder chip at the start of the bar. Driven by
// ChatViewModel.currentProjectName which is set in
// startACPSession on both new project chats and
// resumed project-attributed sessions.
projectName: chatViewModel.currentProjectName
)
Divider()
@@ -43,6 +49,19 @@ struct RichChatView: View {
showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu
)
}
// `idealHeight: 500` caps what this subtree REPORTS as its ideal
// height. Load-bearing: RichChatMessageList uses a plain VStack
// (not LazyVStack see RichChatMessageList.swift:13-24 for the
// rationale) inside a ScrollView, so its natural ideal grows
// with message count. Under the WindowGroup's
// `.windowResizability(.contentMinSize)` policy, that uncapped
// ideal would open the window at a height that exceeds the
// screen on long conversations, pushing the input bar below
// the visible desktop. `maxHeight: .infinity` still lets the
// view fill any larger offered space, and `minHeight: 0`
// allows it to shrink freely the ideal cap only affects the
// initial-size hint reported up to the window.
.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)
// DB polling fallback for terminal mode only never overwrite ACP messages
.onChange(of: fileWatcher.lastChangeDate) {
if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil {
@@ -8,10 +8,28 @@ struct SessionInfoBar: View {
var acpInputTokens: Int = 0
var acpOutputTokens: Int = 0
var acpThoughtTokens: Int = 0
/// Name of the Scarf project this session is attributed to, when
/// applicable. Nil for plain global chats. Drives the folder-chip
/// indicator rendered before the session title. Resolved by
/// `ChatViewModel.currentProjectName` the view just passes it
/// through.
var projectName: String? = nil
var body: some View {
HStack(spacing: 16) {
if let session {
// 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
// identical to v2.2.1 behavior.
if let projectName {
Label(projectName, systemImage: "folder.fill")
.font(.caption)
.foregroundStyle(.tint)
.lineLimit(1)
.help("Chat is scoped to Scarf project \"\(projectName)\"")
}
HStack(spacing: 4) {
Circle()
.fill(isWorking ? .green : .secondary)
@@ -0,0 +1,53 @@
import Foundation
import ScarfCore
/// Describes whether Credential Pools' generic OAuth flow
/// (``OAuthFlowController``) can handle a given provider.
///
/// Hermes supports four OAuth styles, and only **PKCE** is driven by the
/// generic controller:
///
/// | Style | Works via `OAuthFlowController`? | Example providers |
/// |---|---|---|
/// | PKCE | Yes | anthropic, github-copilot |
/// | Device-code | No stalls silently | nous |
/// | External OAuth | No needs a terminal | openai-codex, qwen-oauth, google-gemini-cli |
/// | External process | No uses an agent bridge | copilot-acp |
///
/// Routing a non-PKCE provider through the generic controller silently
/// fails: the PKCE URL regex in ``OAuthFlowController/extractAuthURL`` only
/// matches `client_id=&redirect_uri=` -shaped strings, and nothing else
/// hermes prints for the other styles matches that. This gate closes the
/// dead end by steering the user to the right flow for each style.
///
/// `.ok` is the default for unknown providers so existing PKCE-based
/// flows (anthropic, etc.) keep working this gate is strictly additive.
enum CredentialPoolsOAuthGate: Equatable {
/// The standard PKCE flow works for this provider show the normal
/// "Start OAuth" button and let ``OAuthFlowController`` handle it.
case ok
/// User hasn't typed a provider ID yet. Disable the button.
case providerEmpty
/// Route Nous Portal through ``NousSignInSheet`` instead of the
/// generic flow, since Nous uses device-code.
case useNousSignIn
/// Hermes knows how to sign in to this provider but Scarf doesn't yet
/// have a dedicated UI for it. Point the user to `hermes auth add
/// <provider>` in a terminal.
case useCLI(provider: String)
/// Compute the gate for a typed provider ID. Consults the Hermes
/// overlay table via ``ModelCatalogService/overlayMetadata(for:)`` to
/// decide which OAuth style applies.
static func resolve(providerID rawID: String, catalog: ModelCatalogService) -> CredentialPoolsOAuthGate {
let id = rawID.trimmingCharacters(in: .whitespaces).lowercased()
guard !id.isEmpty else { return .providerEmpty }
if id == "nous" { return .useNousSignIn }
switch catalog.overlayMetadata(for: id)?.authType {
case .oauthDeviceCode, .oauthExternal, .externalProcess:
return .useCLI(provider: id)
default:
return .ok
}
}
}
@@ -15,6 +15,33 @@ struct HermesCredential: Identifiable, Sendable, Equatable {
let tokenTail: String // Last 4 chars of the token NEVER store full token in UI state
let lastStatus: String // "ok" | "cooldown" | "exhausted" | ""
let requestCount: Int
/// OAuth access-token expiry. Populated from `expires_at_ms` (epoch ms,
/// preferred) or `expires_at` (ISO8601). Nil for API-key entries and
/// for OAuth providers that haven't yet recorded an expiry.
let expiresAt: Date?
/// When the current Nous agent key was minted surfaced so users can
/// tell whether a recent rotation has gone through. Nil for non-Nous
/// providers and for older Nous entries without the field.
let agentKeyObtainedAt: Date?
/// Display-time badge for expiry. Recomputed against `Date()` on each
/// render so the label stays current without needing a timer.
enum ExpiryBadge: Equatable {
case expired
case expiringSoon(days: Int)
}
/// Returns a badge when expiry is within 7 days or already past. Nil
/// means "not worth flagging" either expiry is unknown or still far
/// enough out that a warning would be noise.
func expiryBadge(now: Date = Date()) -> ExpiryBadge? {
guard let expiresAt else { return nil }
if expiresAt <= now { return .expired }
let seconds = expiresAt.timeIntervalSince(now)
let days = Int(seconds / 86_400)
if days <= 7 { return .expiringSoon(days: max(1, days)) }
return nil
}
}
/// Summary of one provider's pool with its rotation strategy.
@@ -102,7 +129,9 @@ final class CredentialPoolsViewModel {
source: entry.source ?? "",
tokenTail: Self.tail(of: entry.access_token ?? ""),
lastStatus: entry.last_status ?? "",
requestCount: entry.request_count ?? 0
requestCount: entry.request_count ?? 0,
expiresAt: Self.resolveExpiry(msField: entry.expires_at_ms, isoField: entry.expires_at),
agentKeyObtainedAt: Self.parseISO8601(entry.agent_key_obtained_at)
)
}
return HermesCredentialPool(
@@ -113,6 +142,30 @@ final class CredentialPoolsViewModel {
}
}
/// Prefer `expires_at_ms` (integer epoch ms unambiguous) over
/// `expires_at` (ISO8601 string). Hermes writes whichever format the
/// upstream provider returned; new entries almost always carry the ms
/// form, older Nous entries may only have the ISO form.
nonisolated private static func resolveExpiry(msField: Double?, isoField: String?) -> Date? {
if let ms = msField, ms > 0 {
return Date(timeIntervalSince1970: ms / 1000.0)
}
return parseISO8601(isoField)
}
nonisolated private static func parseISO8601(_ str: String?) -> Date? {
guard let s = str, !s.isEmpty else { return nil }
// Fractional seconds are present on Nous tokens; plain seconds on
// most OAuth providers. Try the fractional parser first, fall back
// to the strict one.
let withFractional = ISO8601DateFormatter()
withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = withFractional.date(from: s) { return d }
let plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
return plain.date(from: s)
}
/// Return last 4 chars prefixed with "", or "" if the token is too short.
/// Callers MUST NOT pass the full token anywhere user-visible beyond this.
nonisolated private static func tail(of token: String) -> String {
@@ -251,9 +304,20 @@ private struct AuthEntry: Decodable, Sendable {
nonisolated let access_token: String?
nonisolated let last_status: String?
nonisolated let request_count: Int?
/// Epoch milliseconds. Double (not Int64) because some Nous entries
/// round-trip through JS and end up as `1780339200000.0`. Decoding as
/// Int would throw on the fractional zero.
nonisolated let expires_at_ms: Double?
/// ISO8601 fallback when `expires_at_ms` isn't present.
nonisolated let expires_at: String?
/// Nous-specific when the current agent key was issued. Surfaced as
/// "Agent key rotated Nh ago" so the user can tell if a recent manual
/// rotation has taken effect.
nonisolated let agent_key_obtained_at: String?
enum CodingKeys: String, CodingKey {
case id, label, auth_type, source, access_token, last_status, request_count
case expires_at_ms, expires_at, agent_key_obtained_at
}
nonisolated init(from decoder: any Decoder) throws {
@@ -265,5 +329,8 @@ private struct AuthEntry: Decodable, Sendable {
self.access_token = try c.decodeIfPresent(String.self, forKey: .access_token)
self.last_status = try c.decodeIfPresent(String.self, forKey: .last_status)
self.request_count = try c.decodeIfPresent(Int.self, forKey: .request_count)
self.expires_at_ms = try c.decodeIfPresent(Double.self, forKey: .expires_at_ms)
self.expires_at = try c.decodeIfPresent(String.self, forKey: .expires_at)
self.agent_key_obtained_at = try c.decodeIfPresent(String.self, forKey: .agent_key_obtained_at)
}
}
@@ -136,6 +136,7 @@ struct CredentialPoolsView: View {
.font(.caption2)
.foregroundStyle(statusColor(cred.lastStatus))
}
expiryBadge(cred)
}
HStack(spacing: 8) {
Text(cred.tokenTail.isEmpty ? "" : cred.tokenTail)
@@ -151,6 +152,11 @@ struct CredentialPoolsView: View {
.font(.caption2)
.foregroundStyle(.tertiary)
}
if let rotated = cred.agentKeyObtainedAt {
Text("agent key · \(Self.relativeAge(rotated))")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
Spacer()
@@ -180,6 +186,45 @@ struct CredentialPoolsView: View {
default: return .secondary
}
}
/// Red "expired" / orange "expires in Nd" pill shown inline with the
/// credential's auth-type chip. Hidden when the credential has no
/// expiry or is more than 7 days out no point pulling attention to a
/// token the user doesn't need to think about yet.
@ViewBuilder
private func expiryBadge(_ cred: HermesCredential) -> some View {
if let badge = cred.expiryBadge() {
switch badge {
case .expired:
Text("expired")
.font(.caption2.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(.red)
.clipShape(Capsule())
case .expiringSoon(let days):
Text("expires in \(days)d")
.font(.caption2.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(.orange)
.clipShape(Capsule())
}
}
}
/// "2h ago" / "3d ago" / "just now". Kept terse for the one-line
/// credential row. `RelativeDateTimeFormatter` isn't used because its
/// output ("2 hours ago") is too long for the slot.
private static func relativeAge(_ date: Date, now: Date = Date()) -> String {
let seconds = Int(now.timeIntervalSince(date))
if seconds < 60 { return "just now" }
if seconds < 3600 { return "\(seconds / 60)m ago" }
if seconds < 86_400 { return "\(seconds / 3600)h ago" }
return "\(seconds / 86_400)d ago"
}
}
/// Two-step sheet for adding a credential:
@@ -211,9 +256,18 @@ private struct AddCredentialSheet: View {
@State private var providers: [HermesProviderInfo] = []
@State private var oauthStarted: Bool = false
@State private var authCode: String = ""
/// Drives presentation of the dedicated Nous sign-in sheet from inside
/// this add-credential sheet. Nous uses device-code, not PKCE the
/// regular `OAuthFlowController` silently stalls, so we route Nous
/// through ``NousSignInSheet`` instead.
@State private var showNousSignIn: Bool = false
private var catalog: ModelCatalogService { ModelCatalogService(context: viewModel.context) }
private func oauthGate(for rawID: String) -> CredentialPoolsOAuthGate {
CredentialPoolsOAuthGate.resolve(providerID: rawID, catalog: catalog)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Add Credential")
@@ -241,6 +295,17 @@ private struct AddCredentialSheet: View {
onDismiss()
}
}
// Nous sign-in is a parallel flow that bypasses OAuthFlowController.
// When it completes, the parent list refreshes from auth.json just
// like it does after a regular OAuth add so we dismiss the
// AddCredentialSheet after a short delay.
.sheet(isPresented: $showNousSignIn) {
NousSignInSheet {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
onDismiss()
}
}
}
}
// MARK: - Step 1: provider + type + label + optional API key
@@ -291,11 +356,57 @@ private struct AddCredentialSheet: View {
.font(.system(.caption, design: .monospaced))
}
} else {
oauthPreamble
oauthGuidance
}
}
}
/// Renders either the standard PKCE preamble, the Nous-specific
/// "sign in with the dedicated sheet" affordance, or a CLI fallback
/// whichever matches the provider the user has typed.
@ViewBuilder
private var oauthGuidance: some View {
switch oauthGate(for: providerID) {
case .ok, .providerEmpty:
oauthPreamble
case .useNousSignIn:
nousSignInPreamble
case .useCLI(let provider):
cliFallbackPreamble(for: provider)
}
}
private var nousSignInPreamble: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "sparkles")
.foregroundStyle(.tint)
Text("Nous Portal uses a dedicated sign-in flow.")
.font(.caption)
}
Text("We'll open the Nous Portal approval page in your browser and show the device code here. No code-paste step.")
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private func cliFallbackPreamble(for provider: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: "terminal")
.foregroundStyle(.secondary)
Text("`\(provider)` uses a different sign-in flow.")
.font(.caption)
}
Text("Run `hermes auth add \(provider)` in a terminal to finish sign-in. In-app support for this provider is coming in a follow-up.")
.font(.caption2)
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
}
/// Brief explanation shown before the user clicks "Start OAuth". Sets
/// expectations about the embedded-terminal flow so the browser window
/// and code-paste step aren't surprises.
@@ -477,14 +588,38 @@ private struct AddCredentialSheet: View {
.buttonStyle(.borderedProminent)
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty || apiKey.trimmingCharacters(in: .whitespaces).isEmpty)
} else {
Button("Start OAuth") {
viewModel.startOAuth(provider: providerID, label: label)
oauthStarted = true
}
.buttonStyle(.borderedProminent)
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty)
oauthActionButton
}
}
}
}
/// Gate-aware OAuth primary action. For PKCE providers it's the
/// unchanged "Start OAuth" button; for Nous it's "Sign in to Nous
/// Portal" (opens ``NousSignInSheet``); for other device-code /
/// external providers it's a disabled button with a CLI hint inline.
@ViewBuilder
private var oauthActionButton: some View {
switch oauthGate(for: providerID) {
case .providerEmpty:
Button("Start OAuth") {}
.buttonStyle(.borderedProminent)
.disabled(true)
case .ok:
Button("Start OAuth") {
viewModel.startOAuth(provider: providerID, label: label)
oauthStarted = true
}
.buttonStyle(.borderedProminent)
case .useNousSignIn:
Button("Sign in to Nous Portal") {
showNousSignIn = true
}
.buttonStyle(.borderedProminent)
case .useCLI:
Button("Start OAuth") {}
.buttonStyle(.borderedProminent)
.disabled(true)
}
}
}
@@ -96,7 +96,7 @@ struct DashboardView: View {
color: .purple
)
StatusCard(
title: "Gateway",
title: "Messaging Gateway",
value: viewModel.gatewayState?.statusText ?? "unknown",
icon: "network",
color: viewModel.gatewayState?.isRunning == true ? .green : .secondary
@@ -20,7 +20,7 @@ struct GatewayView: View {
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.navigationTitle("Gateway")
.navigationTitle("Messaging Gateway")
.onAppear { viewModel.load() }
.onChange(of: fileWatcher.lastChangeDate) { viewModel.load() }
}
@@ -1,5 +1,23 @@
import Foundation
import ScarfCore
#if canImport(AppKit)
import AppKit
#endif
import os
/// Observed state of the local `hermes dashboard` web UI (introduced in
/// Hermes v0.10.x). `port` defaults to 9119 the CLI's default and the only
/// value Scarf launches with today.
struct WebDashboardStatus: Sendable, Equatable {
var running: Bool
var port: Int
/// True while a start/stop transition is in flight so the UI can disable
/// buttons and show a spinner.
var busy: Bool
static let defaultPort = 9119
static let unknown = WebDashboardStatus(running: false, port: defaultPort, busy: false)
}
struct HealthCheck: Identifiable {
let id = UUID()
@@ -25,10 +43,12 @@ struct HealthSection: Identifiable {
final class HealthViewModel {
let context: ServerContext
private let fileService: HermesFileService
private let subscriptionService: NousSubscriptionService
init(context: ServerContext = .local) {
self.context = context
self.fileService = HermesFileService(context: context)
self.subscriptionService = NousSubscriptionService(context: context)
}
@@ -49,10 +69,24 @@ final class HealthViewModel {
var diagnosticsOutput: String = ""
var isSharingDebug = false
/// Liveness + control state for `hermes dashboard` (local web UI). The
/// section in `HealthView` is hidden for remote contexts the dashboard
/// binds 127.0.0.1 by default and remote probing / tunneling is out of
/// scope for v1.
var dashboardStatus: WebDashboardStatus = .unknown
/// Our own spawned subprocess, if the user hit "Launch Dashboard" from
/// Scarf. Nil when the dashboard was started externally (we still detect
/// it via the probe but can't terminate it cleanly via `Process.terminate`).
private var dashboardProcess: Process?
/// Background polling loop; started in `startDashboardMonitoring()` and
/// cancelled on view disappear.
private var dashboardProbeTask: Task<Void, Never>?
func load() {
isLoading = true
let ctx = context
let svc = fileService
let subSvc = subscriptionService
// Health runs four sync transport-mediated commands plus a process
// probe that's 4-5 ssh round-trips on remote, easily 1-2s. Detach
// the whole load.
@@ -61,6 +95,8 @@ final class HealthViewModel {
let versionOutput = ctx.runHermes(["version"]).output
let statusOutput = ctx.runHermes(["status"]).output
let doctorOutput = ctx.runHermes(["doctor"]).output
let subscription = subSvc.loadState()
let config = svc.loadConfig()
let lines = versionOutput.components(separatedBy: "\n")
let version = lines.first ?? ""
@@ -69,6 +105,7 @@ final class HealthViewModel {
let updateInfo = updateLine?.trimmingCharacters(in: .whitespaces) ?? ""
let statusSections = Self.parseOutputStatic(statusOutput)
+ [Self.toolGatewaySection(subscription: subscription, config: config)]
let doctorSections = Self.parseOutputStatic(doctorOutput)
await MainActor.run { [weak self] in
@@ -86,6 +123,80 @@ final class HealthViewModel {
}
}
/// Synthesize a Tool Gateway health section from the subscription state +
/// `platform_toolsets` table. Runs alongside the other status sections so
/// the user sees at a glance whether their Nous Portal subscription is
/// wired up.
///
/// This is distinct from the "Messaging Gateway" (inbound Slack/Discord/
/// requests) the two are unrelated systems that unfortunately share the
/// "gateway" name in Hermes's CLI output.
///
/// `nonisolated` so `load()` can call it from `Task.detached` alongside
/// `parseOutputStatic` without hopping back to MainActor.
nonisolated private static func toolGatewaySection(subscription: NousSubscriptionState, config: HermesConfig) -> HealthSection {
var checks: [HealthCheck] = []
let subscriptionCheck: HealthCheck = {
if subscription.subscribed {
return HealthCheck(
label: "Nous Portal subscription active",
status: .ok,
detail: "Tool requests route through the Nous Portal gateway."
)
}
if subscription.present {
return HealthCheck(
label: "Signed in, but Nous isn't the active provider",
status: .warning,
detail: "Open Settings → General and pick Nous Portal to route tools through the gateway."
)
}
return HealthCheck(
label: "Not subscribed",
status: .warning,
detail: "Run `hermes auth` and pick Nous Portal to enable subscription-gated tools."
)
}()
checks.append(subscriptionCheck)
if !config.platformToolsets.isEmpty {
let platforms = config.platformToolsets.keys.sorted()
for platform in platforms {
let toolsets = config.platformToolsets[platform] ?? []
checks.append(HealthCheck(
label: "\(platform): \(toolsets.count) toolset\(toolsets.count == 1 ? "" : "s")",
status: .ok,
detail: toolsets.joined(separator: ", ")
))
}
}
let auxOnNous = [
("vision", config.auxiliary.vision.provider),
("web_extract", config.auxiliary.webExtract.provider),
("compression", config.auxiliary.compression.provider),
("session_search", config.auxiliary.sessionSearch.provider),
("skills_hub", config.auxiliary.skillsHub.provider),
("approval", config.auxiliary.approval.provider),
("mcp", config.auxiliary.mcp.provider),
("flush_memories", config.auxiliary.flushMemories.provider),
].filter { $0.1 == "nous" }.map(\.0)
if !auxOnNous.isEmpty {
checks.append(HealthCheck(
label: "Auxiliary tasks routed through Nous",
status: subscription.subscribed ? .ok : .warning,
detail: auxOnNous.joined(separator: ", ")
))
}
return HealthSection(
title: "Tool Gateway",
icon: "arrow.triangle.branch",
checks: checks
)
}
func refreshProcessStatus() {
let svc = fileService
Task.detached { [weak self] in
@@ -368,4 +479,191 @@ final class HealthViewModel {
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
context.runHermes(arguments)
}
// MARK: - Web Dashboard (`hermes dashboard`)
/// Called from `HealthView.onAppear`. Starts a background loop that
/// probes `http://127.0.0.1:<port>/api/status` every 3s and keeps
/// `dashboardStatus.running` in sync with reality whether we launched
/// the dashboard or the user did via terminal. No-op on remote contexts.
func startDashboardMonitoring() {
guard !context.isRemote else { return }
dashboardProbeTask?.cancel()
let port = dashboardStatus.port
dashboardProbeTask = Task { [weak self] in
while !Task.isCancelled {
let running = await Self.probeDashboard(port: port)
await MainActor.run { [weak self] in
guard let self else { return }
// Preserve `busy` so the button stays disabled during an
// in-flight start/stop; only toggle the `running` bit.
self.dashboardStatus = WebDashboardStatus(
running: running,
port: self.dashboardStatus.port,
busy: self.dashboardStatus.busy
)
// Reap our spawned process if it exited externally.
if !running, let p = self.dashboardProcess, !p.isRunning {
self.dashboardProcess = nil
}
}
try? await Task.sleep(nanoseconds: 3_000_000_000)
}
}
}
func stopDashboardMonitoring() {
dashboardProbeTask?.cancel()
dashboardProbeTask = nil
}
/// Launch `hermes dashboard --no-open --port 9119` detached. We pass
/// `--no-open` so Hermes doesn't try to open its own browser tab Scarf
/// opens the URL after the probe confirms the server is listening, which
/// avoids the "Safari tab loads faster than uvicorn binds the port" race.
func launchDashboard() {
guard !context.isRemote else { return }
guard !dashboardStatus.running, !dashboardStatus.busy else { return }
guard let binary = fileService.hermesBinaryPath() else {
actionMessage = "hermes binary not found"
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.actionMessage = nil
}
return
}
dashboardStatus = WebDashboardStatus(
running: dashboardStatus.running,
port: dashboardStatus.port,
busy: true
)
actionMessage = "Starting dashboard…"
let port = dashboardStatus.port
let proc = Process()
proc.executableURL = URL(fileURLWithPath: binary)
proc.arguments = ["dashboard", "--no-open", "--port", String(port)]
proc.environment = HermesFileService.enrichedEnvironment()
// Discard stdout/stderr we rely on the HTTP probe for liveness and
// don't want a growing pipe buffer to block the subprocess.
proc.standardOutput = FileHandle.nullDevice
proc.standardError = FileHandle.nullDevice
do {
try proc.run()
dashboardProcess = proc
Task { [weak self] in
// Give uvicorn up to ~6 seconds to bind the port, probing
// every 300ms. First 200 response opens the browser.
for _ in 0..<20 {
if await Self.probeDashboard(port: port) {
if let url = URL(string: "http://127.0.0.1:\(port)") {
await MainActor.run {
NSWorkspace.shared.open(url)
}
}
break
}
try? await Task.sleep(nanoseconds: 300_000_000)
}
await MainActor.run { [weak self] in
guard let self else { return }
self.dashboardStatus = WebDashboardStatus(
running: self.dashboardStatus.running,
port: self.dashboardStatus.port,
busy: false
)
self.actionMessage = nil
}
}
} catch {
Self.dashboardLogger.error("Failed to spawn hermes dashboard: \(error.localizedDescription, privacy: .public)")
dashboardProcess = nil
dashboardStatus = WebDashboardStatus(
running: dashboardStatus.running,
port: dashboardStatus.port,
busy: false
)
actionMessage = "Failed to start: \(error.localizedDescription)"
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.actionMessage = nil
}
}
}
/// Stop the dashboard. If Scarf spawned it, send SIGTERM directly. If an
/// external instance is running, fall back to `pkill -f "hermes dashboard"`
/// so the Stop button works regardless of who launched it.
func stopDashboard() {
guard !context.isRemote else { return }
dashboardStatus = WebDashboardStatus(
running: dashboardStatus.running,
port: dashboardStatus.port,
busy: true
)
actionMessage = "Stopping dashboard…"
if let proc = dashboardProcess, proc.isRunning {
proc.terminate()
dashboardProcess = nil
} else {
// External instance best-effort pkill.
let kill = Process()
kill.executableURL = URL(fileURLWithPath: "/usr/bin/pkill")
kill.arguments = ["-f", "hermes dashboard"]
_ = try? kill.run()
kill.waitUntilExit()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
guard let self else { return }
Task {
let running = await Self.probeDashboard(port: self.dashboardStatus.port)
await MainActor.run {
self.dashboardStatus = WebDashboardStatus(
running: running,
port: self.dashboardStatus.port,
busy: false
)
self.actionMessage = nil
}
}
}
}
/// Open the dashboard in the default browser. Safe to call only when the
/// probe reports `running: true` UI gates the button on that.
func openDashboardInBrowser() {
guard let url = URL(string: "http://127.0.0.1:\(dashboardStatus.port)") else { return }
NSWorkspace.shared.open(url)
}
/// HEAD-shaped GET against `/api/status`. Returns true on any 2xx response.
/// `/api/status` is whitelisted in `_PUBLIC_API_PATHS` in Hermes's
/// `web_server.py` no token required, so a bare GET works.
///
/// `nonisolated` + `async` so the polling loop can call it without
/// bouncing through MainActor on every tick.
nonisolated private static func probeDashboard(port: Int) async -> Bool {
guard let url = URL(string: "http://127.0.0.1:\(port)/api/status") else { return false }
var request = URLRequest(url: url)
request.timeoutInterval = 0.5
request.httpMethod = "GET"
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 0.5
config.timeoutIntervalForResource = 1.0
let session = URLSession(configuration: config)
defer { session.invalidateAndCancel() }
do {
let (_, response) = try await session.data(for: request)
if let http = response as? HTTPURLResponse {
return (200..<300).contains(http.statusCode)
}
return false
} catch {
return false
}
}
nonisolated private static let dashboardLogger = Logger(subsystem: "com.scarf", category: "WebDashboard")
}
@@ -54,7 +54,11 @@ struct HealthView: View {
label: "Running health checks…",
isEmpty: viewModel.statusSections.isEmpty && viewModel.doctorSections.isEmpty
)
.onAppear { viewModel.load() }
.onAppear {
viewModel.load()
viewModel.startDashboardMonitoring()
}
.onDisappear { viewModel.stopDashboardMonitoring() }
.confirmationDialog("Upload debug report?", isPresented: $showShareConfirm) {
Button("Upload", role: .destructive) {
viewModel.runDebugShare()
@@ -162,9 +166,56 @@ struct HealthView: View {
}
.padding(.horizontal)
.padding(.vertical, 8)
if !viewModel.context.isRemote {
Divider()
webDashboardRow
}
}
}
/// Status + controls for `hermes dashboard` (the web UI introduced in
/// v0.10.x). Hidden for remote contexts the dashboard binds 127.0.0.1
/// and remote tunneling is deferred.
private var webDashboardRow: some View {
HStack(spacing: 16) {
HStack(spacing: 6) {
Image(systemName: "safari")
.foregroundStyle(viewModel.dashboardStatus.running ? .green : .secondary)
.font(.caption)
if viewModel.dashboardStatus.running {
Text("Web Dashboard on :\(viewModel.dashboardStatus.port)")
.font(.caption.bold())
} else {
Text("Web Dashboard")
.font(.caption.bold())
Text("not running")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
HStack(spacing: 8) {
if viewModel.dashboardStatus.running {
Button("Open in Browser") { viewModel.openDashboardInBrowser() }
Button("Stop") { viewModel.stopDashboard() }
.disabled(viewModel.dashboardStatus.busy)
} else {
Button("Launch Dashboard") { viewModel.launchDashboard() }
.disabled(viewModel.dashboardStatus.busy)
}
if viewModel.dashboardStatus.busy {
ProgressView().controlSize(.small)
}
}
.controlSize(.small)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
// MARK: - Grid
private func sectionGrid(_ sections: [HealthSection]) -> some View {
@@ -1,4 +1,5 @@
import SwiftUI
import ScarfCore
struct MCPServerEditorView: View {
@State var viewModel: MCPServerEditorViewModel
@@ -0,0 +1,97 @@
import Foundation
import os
import ScarfCore
/// Drives the per-project Sessions tab introduced in v2.3. Pulls the
/// global session list from `HermesDataService`, filters by the
/// attribution sidecar, and exposes a minimal surface for the view:
/// the filtered sessions array, loading state, and a refresh entry
/// point that the view can call on appearance + on file-watcher
/// change.
@Observable
@MainActor
final class ProjectSessionsViewModel {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectSessionsViewModel")
private let dataService: HermesDataService
private let attribution: SessionAttributionService
private let project: ProjectEntry
init(context: ServerContext, project: ProjectEntry) {
self.dataService = HermesDataService(context: context)
self.attribution = SessionAttributionService(context: context)
self.project = project
}
/// Sessions attributed to the owning project, in the order
/// `HermesDataService.fetchSessions` returns them (newest first).
var sessions: [HermesSession] = []
/// True from `load()` start to its completion. The view renders
/// a ProgressView during the first fetch; afterwards, re-fetches
/// triggered by file-watcher changes happen silently.
var isLoading: Bool = false
/// Short diagnostic string for an empty list nil when sessions
/// are loaded and populated, otherwise explains the empty state
/// (no sessions ever created in this project, vs. no sessions
/// matched the project's attribution map).
var emptyStateHint: String?
/// Refresh the session list. Safe to call repeatedly; the data
/// service reconnects to state.db on demand and the attribution
/// service reads the sidecar afresh each call.
func load() async {
isLoading = true
defer { isLoading = false }
let attributed = attribution.sessionIDs(forProject: project.path)
if attributed.isEmpty {
sessions = []
emptyStateHint = "No chats have been started in this project yet. Click New Chat to begin."
return
}
// Open (or re-open for remote) the DB handle before querying.
// `HermesDataService` is an actor with a lazily-initialised
// SQLite pointer; every query method short-circuits to `[]`
// when `db == nil`. This VM constructs its own service
// instance (separate from ChatViewModel / InsightsVM /
// ActivityVM), so we have to open it ourselves. Same
// pattern used by those other VMs (`refresh()` rather than
// `open()` because refresh also re-pulls the remote-server
// snapshot on each call local is a cheap no-op).
_ = await dataService.refresh()
// Fetch a generous page; we filter client-side by attribution
// map membership. The 200 ceiling matches other feature VMs
// (ActivityViewModel, InsightsViewModel). HermesDataService
// is an actor so this crosses the isolation boundary the
// SQLite read happens off the MainActor. If a single project
// accumulates more than 200 attributed sessions, we'll need
// a paged query; roadmap item, not a v2.3 problem.
let all = await dataService.fetchSessions(limit: 200)
let filtered = all.filter { attributed.contains($0.id) }
sessions = filtered
if filtered.isEmpty {
// Attribution map has entries but none appear in the
// recent session fetch likely stale sidecar entries
// for sessions Hermes has since deleted. The view shows
// an informational empty state; pruning stale entries
// is a roadmap follow-up, not a blocker.
emptyStateHint = "This project has \(attributed.count) attributed session\(attributed.count == 1 ? "" : "s"), but none are in the recent history. They may have been deleted from Hermes."
} else {
emptyStateHint = nil
}
}
/// Release the underlying DB handle. Safe to call repeatedly; the
/// service re-opens on the next `load()`. Mirrors the pattern in
/// ActivityViewModel.swift:80 view calls this on `.onDisappear`
/// so file descriptors and the SQLite cache don't dangle once
/// the tab isn't visible.
func close() async {
await dataService.close()
}
}
@@ -0,0 +1,114 @@
import SwiftUI
import ScarfCore
/// Sheet for assigning a project to a folder in the sidebar. Folders
/// are implicit they exist because at least one project references
/// them via its `folder` field. The "create" action here just seeds
/// a new label the user types; it becomes real once any project is
/// assigned to it.
struct MoveToFolderSheet: View {
@Environment(\.dismiss) private var dismiss
let project: ProjectEntry
/// Existing folder labels in the registry, sorted. Computed by
/// the caller via `ProjectsViewModel.folders`.
let existingFolders: [String]
/// Called with the chosen folder. `nil` means "move back to top
/// level". Caller wires this through
/// `ProjectsViewModel.moveProject(_:toFolder:)`.
let onMove: (String?) -> Void
@State private var mode: Mode
@State private var newFolderName: String = ""
private enum Mode: Hashable {
case topLevel
case existing(String)
case new
}
init(
project: ProjectEntry,
existingFolders: [String],
onMove: @escaping (String?) -> Void
) {
self.project = project
self.existingFolders = existingFolders
self.onMove = onMove
// Start selection on the project's current folder if any,
// otherwise "Top Level". Feels right Move sheet should
// reflect where the project currently lives.
if let current = project.folder, existingFolders.contains(current) {
_mode = State(initialValue: .existing(current))
} else {
_mode = State(initialValue: .topLevel)
}
}
private var canMove: Bool {
switch mode {
case .topLevel, .existing:
return true
case .new:
return !newFolderName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Move \"\(project.name)\" to folder").font(.headline)
Text("Folders only affect how projects are grouped in Scarf's sidebar. Nothing on disk changes.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Picker("Destination", selection: $mode) {
Text("Top Level").tag(Mode.topLevel)
if !existingFolders.isEmpty {
Section {
ForEach(existingFolders, id: \.self) { folder in
Text(folder).tag(Mode.existing(folder))
}
}
}
Text("New folder…").tag(Mode.new)
}
.labelsHidden()
.pickerStyle(.inline)
if case .new = mode {
TextField("New folder name", text: $newFolderName)
.textFieldStyle(.roundedBorder)
.onSubmit {
if canMove { commit() }
}
}
HStack {
Button("Cancel") { dismiss() }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Move") { commit() }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(!canMove)
}
}
.padding()
.frame(minWidth: 420, minHeight: 320)
}
private func commit() {
switch mode {
case .topLevel:
onMove(nil)
case .existing(let folder):
onMove(folder)
case .new:
let trimmed = newFolderName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
onMove(trimmed)
}
dismiss()
}
}
@@ -0,0 +1,199 @@
import SwiftUI
import ScarfCore
/// Per-project Sessions tab (v2.3). Lives beside the Dashboard and
/// Site tabs in the project view; populated from the session
/// attribution sidecar maintained by ChatViewModel. A "New Chat"
/// button spawns a fresh ACP session at cwd = project.path and
/// routes the user into the Chat feature via AppCoordinator.
struct ProjectSessionsView: View {
let project: ProjectEntry
@Environment(AppCoordinator.self) private var coordinator
@Environment(HermesFileWatcher.self) private var fileWatcher
@Environment(\.serverContext) private var serverContext
@State private var viewModel: ProjectSessionsViewModel?
var body: some View {
VStack(spacing: 0) {
header
Divider()
content
}
// `idealHeight: 400` caps what this subtree reports as its
// ideal height. Without it, the inner List's row-materialised
// intrinsic height bubbles up through NavigationSplitView's
// detail slot and, under `.windowResizability(.contentMinSize)`,
// opens the window at a height that exceeds the screen on
// busy projects the Sessions tab header + "New Chat" button
// end up below the visible desktop edge. `maxHeight: .infinity`
// still lets the List fill any taller offered space, and
// `minHeight: 0` allows it to shrink. Mirrors the same pattern
// applied in RichChatView.
.frame(minHeight: 0, idealHeight: 400, maxHeight: .infinity)
.task(id: project.id) {
// Rebuild the VM when the project changes so stale state
// from a previously-selected project doesn't bleed
// through.
viewModel = ProjectSessionsViewModel(
context: serverContext,
project: project
)
await viewModel?.load()
}
.onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel?.load() }
}
.onDisappear {
// Release the SQLite handle so it doesn't dangle once
// the user leaves this tab. `load()` will re-open next
// time. Mirrors ActivityView's disappear cleanup.
Task { await viewModel?.close() }
}
}
// MARK: - Header
private var header: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text("Sessions in this project")
.font(.headline)
Text("Chats you start here get attributed automatically. Older CLI-started sessions live in the global Sessions sidebar.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
Button {
// Route into the Chat feature with a cwd override.
// ChatView observes this via its onChange and starts
// a fresh session with projectPath = our project.
coordinator.pendingProjectChat = project.path
coordinator.selectedSection = .chat
} label: {
Label("New Chat", systemImage: "message.badge.filled.fill")
}
.buttonStyle(.borderedProminent)
}
.padding()
}
// MARK: - Content
@ViewBuilder
private var content: some View {
if let vm = viewModel {
if vm.isLoading && vm.sessions.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if vm.sessions.isEmpty {
emptyState(hint: vm.emptyStateHint)
} else {
sessionList(vm.sessions)
}
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func emptyState(hint: String?) -> some View {
VStack(spacing: 10) {
Image(systemName: "bubble.left.and.bubble.right")
.font(.system(size: 36))
.foregroundStyle(.tertiary)
Text(hint ?? "No sessions yet.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func sessionList(_ sessions: [HermesSession]) -> some View {
List(sessions) { session in
ProjectSessionRow(session: session)
.contentShape(Rectangle())
.onTapGesture {
// Route into the Chat feature with this session
// as a resume target. Existing ChatView logic
// handles ACP reconnect.
coordinator.selectedSessionId = session.id
coordinator.selectedSection = .chat
}
}
.listStyle(.plain)
}
}
/// Single row in the per-project Sessions list. Intentionally small
/// and self-contained so it can evolve independently of the global
/// Sessions sidebar's row UI if the two visualisations diverge
/// (e.g. the project tab wants to hide the `source` badge that's
/// useful in the global list), they don't pull each other along.
private struct ProjectSessionRow: View {
let session: HermesSession
var body: some View {
HStack(spacing: 10) {
Image(systemName: iconForSource(session.source))
.foregroundStyle(.secondary)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(displayTitle)
.font(.callout)
.lineLimit(1)
HStack(spacing: 6) {
Text(session.id.prefix(12))
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
if let started = formattedStart {
Text("·")
.foregroundStyle(.tertiary)
Text(started)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
Spacer(minLength: 12)
VStack(alignment: .trailing, spacing: 2) {
Text("\(session.messageCount)")
.font(.caption.monospaced())
Text("msgs")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
private var displayTitle: String {
if let t = session.title, !t.isEmpty { return t }
return "Untitled session"
}
private var formattedStart: String? {
// `startedAt` is `Date?` the DB column can be null for
// sessions in unusual states. Locale-aware short form keeps
// us consistent with Insights + Activity.
guard let date = session.startedAt else { return nil }
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter.string(from: date)
}
private func iconForSource(_ source: String) -> String {
switch source.lowercased() {
case "cli", "acp": return "terminal"
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
default: return "message"
}
}
}
@@ -0,0 +1,275 @@
import SwiftUI
import ScarfCore
/// Sidebar view for the Projects feature. Renders the registry as:
/// - A search field at the top (F focus).
/// - Top-level (folder-less) projects.
/// - Collapsible DisclosureGroups, one per folder.
/// - An "Archived" DisclosureGroup at the bottom, hidden unless the
/// Show Archived toggle is on.
///
/// Selection is bound to `viewModel.selectedProject` so the
/// dashboard area stays in sync with clicks anywhere in the hierarchy.
/// Context-menu actions delegate back to the parent view via closures
/// so the sheets / confirmation dialogs stay co-located with the rest
/// of ProjectsView's state.
struct ProjectsSidebar: View {
@Bindable var viewModel: ProjectsViewModel
// Predicates hoisted from the parent avoid reaching down into
// service objects from this view.
let canConfigureProject: (ProjectEntry) -> Bool
let isTemplateInstalled: (ProjectEntry) -> Bool
// Context-menu + bottom-bar callbacks. Parent owns sheet state
// (install, uninstall, rename, move-to-folder, remove-from-list
// confirmation dialog) this view just routes user intent.
let onConfigure: (ProjectEntry) -> Void
let onUninstallTemplate: (ProjectEntry) -> Void
let onRemoveFromList: (ProjectEntry) -> Void
let onRename: (ProjectEntry) -> Void
let onMoveToFolder: (ProjectEntry) -> Void
let onAddProject: () -> Void
/// Per-view UI state filter text, show-archived toggle, and
/// which folders are expanded. Folder expansion defaults to all
/// open so a new user sees everything; they can collapse what
/// they don't want.
@State private var filterText: String = ""
@State private var showArchived: Bool = false
@State private var expandedFolders: Set<String> = []
@FocusState private var searchFocused: Bool
var body: some View {
VStack(spacing: 0) {
searchField
Divider()
list
Divider()
bottomBar
}
.onAppear {
// Start with every folder expanded on first render. If
// users collapse, that choice persists for the lifetime
// of the view instance (window open).
expandedFolders = Set(viewModel.folders)
}
.onChange(of: viewModel.folders) { _, newFolders in
// When a new folder appears (user just moved a project
// into one), start it expanded so the move is visibly
// reflected.
expandedFolders.formUnion(newFolders)
}
}
// MARK: - Search
private var searchField: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
.font(.caption)
TextField("Filter projects", text: $filterText)
.textFieldStyle(.plain)
.focused($searchFocused)
.font(.caption)
if !filterText.isEmpty {
Button {
filterText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
.font(.caption)
}
.buttonStyle(.borderless)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
}
// MARK: - List
private var list: some View {
List(selection: Binding(
get: { viewModel.selectedProject },
set: { if let p = $0 { viewModel.selectProject(p) } }
)) {
// Top-level projects first matches the Finder-like
// mental model where top-level items sit above folders.
ForEach(topLevelVisible) { project in
projectRow(project)
}
// Per-folder collapsible sections.
ForEach(visibleFolders, id: \.self) { folder in
let children = folderProjects(folder)
if !children.isEmpty {
DisclosureGroup(
isExpanded: Binding(
get: { expandedFolders.contains(folder) },
set: { expanded in
if expanded {
expandedFolders.insert(folder)
} else {
expandedFolders.remove(folder)
}
}
)
) {
ForEach(children) { project in
projectRow(project)
}
} label: {
Label(folder, systemImage: "folder")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// Archived section only surfaces under the toggle.
if showArchived, !archivedVisible.isEmpty {
DisclosureGroup {
ForEach(archivedVisible) { project in
projectRow(project)
.opacity(0.7)
}
} label: {
Label("Archived (\(archivedVisible.count))", systemImage: "archivebox")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.listStyle(.sidebar)
}
@ViewBuilder
private func projectRow(_ project: ProjectEntry) -> some View {
HStack {
Image(
systemName: viewModel.dashboard != nil
&& viewModel.selectedProject == project
? "square.grid.2x2.fill"
: "square.grid.2x2"
)
.foregroundStyle(.secondary)
Text(project.name)
.lineLimit(1)
.truncationMode(.tail)
}
.tag(project)
.contextMenu {
projectContextMenu(project)
}
}
@ViewBuilder
private func projectContextMenu(_ project: ProjectEntry) -> some View {
if canConfigureProject(project) {
Button("Configuration…", systemImage: "slider.horizontal.3") {
onConfigure(project)
}
Divider()
}
Button("Rename…", systemImage: "pencil") { onRename(project) }
Button("Move to Folder…", systemImage: "folder") { onMoveToFolder(project) }
if project.archived {
Button("Unarchive", systemImage: "tray.and.arrow.up") {
viewModel.unarchiveProject(project)
}
} else {
Button("Archive", systemImage: "archivebox") {
viewModel.archiveProject(project)
}
}
Divider()
if isTemplateInstalled(project) {
Button("Uninstall Template (remove installed files)…", systemImage: "trash") {
onUninstallTemplate(project)
}
Divider()
}
Button("Remove from List (keep files)…", systemImage: "minus.circle") {
onRemoveFromList(project)
}
}
// MARK: - Bottom bar
private var bottomBar: some View {
HStack {
Button(action: onAddProject) {
Image(systemName: "plus")
}
.buttonStyle(.borderless)
.help("Add a project")
Toggle(isOn: $showArchived) {
Image(systemName: showArchived ? "archivebox.fill" : "archivebox")
.font(.caption)
}
.toggleStyle(.button)
.buttonStyle(.borderless)
.help(showArchived ? "Hide archived projects" : "Show archived projects")
Spacer()
if let selected = viewModel.selectedProject {
Button(action: { onRemoveFromList(selected) }) {
Image(systemName: "minus")
}
.buttonStyle(.borderless)
.help("Remove \(selected.name) from Scarf's project list (files are kept on disk)")
}
}
.padding(8)
}
// MARK: - Derived data
/// Fuzzy-match on name + path + folder label. Case-insensitive,
/// substring not a true fuzzy search, but matches the project
/// count scale (tens, not thousands). Upgradable to a Levenshtein
/// scorer later without changing the call sites.
private func matches(_ project: ProjectEntry) -> Bool {
let needle = filterText
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
guard !needle.isEmpty else { return true }
if project.name.lowercased().contains(needle) { return true }
if project.path.lowercased().contains(needle) { return true }
if let folder = project.folder, folder.lowercased().contains(needle) { return true }
return false
}
/// Visible top-level projects (no folder, not archived, passes
/// the current filter). Sort is stable by name the registry
/// already preserves insertion order, but showing a sorted list
/// of homogeneous top-level entries feels cleaner.
private var topLevelVisible: [ProjectEntry] {
viewModel.projects
.filter { ($0.folder ?? "").isEmpty && !$0.archived && matches($0) }
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
/// Folders that currently have at least one matching, non-
/// archived project. Folders with only archived projects move
/// into the Archived section's items; empty folders disappear.
private var visibleFolders: [String] {
viewModel.folders.filter { !folderProjects($0).isEmpty }
}
private func folderProjects(_ folder: String) -> [ProjectEntry] {
viewModel.projects
.filter { $0.folder == folder && !$0.archived && matches($0) }
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
private var archivedVisible: [ProjectEntry] {
viewModel.projects
.filter { $0.archived && matches($0) }
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
}
@@ -5,11 +5,21 @@ import UniformTypeIdentifiers
private enum DashboardTab: String, CaseIterable {
case dashboard = "Dashboard"
case site = "Site"
case sessions = "Sessions"
var displayName: LocalizedStringResource {
switch self {
case .dashboard: return "Dashboard"
case .site: return "Site"
case .sessions: return "Sessions"
}
}
var systemImage: String {
switch self {
case .dashboard: return "square.grid.2x2"
case .site: return "globe"
case .sessions: return "bubble.left.and.bubble.right"
}
}
}
@@ -35,6 +45,16 @@ struct ProjectsView: View {
/// drop from the registry.
@State private var pendingRemoveFromList: ProjectEntry?
/// Project queued for the rename sheet (v2.3). Sheet state lives
/// on the parent view so the sidebar stays a pure presentation
/// layer; rename logic routes through `ProjectsViewModel.renameProject`.
@State private var renameTarget: ProjectEntry?
/// Project queued for the move-to-folder sheet (v2.3). Same
/// pattern as renameTarget: parent owns sheet state, sidebar
/// delegates up.
@State private var moveTarget: ProjectEntry?
private let uninstaller: ProjectTemplateUninstaller
init(context: ServerContext) {
@@ -264,79 +284,47 @@ struct ProjectsView: View {
// MARK: - Project List
private var projectList: some View {
VStack(spacing: 0) {
List(viewModel.projects, selection: Binding(
get: { viewModel.selectedProject },
set: { project in
if let project {
viewModel.selectProject(project)
}
}
)) { project in
HStack {
Image(systemName: viewModel.dashboard != nil && viewModel.selectedProject == project
? "square.grid.2x2.fill" : "square.grid.2x2")
.foregroundStyle(.secondary)
Text(project.name)
}
.tag(project)
.contextMenu {
if isConfigurable(project) {
Button("Configuration…", systemImage: "slider.horizontal.3") {
configEditorProject = project
}
}
if uninstaller.isTemplateInstalled(project: project) {
// "Uninstall Template" only appears for projects
// installed from a `.scarftemplate`. Trailing
// ellipsis signals a confirmation sheet follows
// (macOS HIG convention); the sheet itself lists
// every file/cron/skill that will be removed.
Button("Uninstall Template (remove installed files)…", systemImage: "trash") {
uninstallerViewModel.begin(project: project)
showingUninstallSheet = true
}
Divider()
}
// "Remove from List" used to be "Remove from Scarf",
// which users read as a full delete. Clarified label +
// ellipsis + confirmation dialog all spell out that
// this is registry-only; nothing on disk is touched.
Button("Remove from List (keep files)…", systemImage: "minus.circle") {
pendingRemoveFromList = project
}
}
}
.listStyle(.sidebar)
Divider()
HStack {
Button(action: { showingAddSheet = true }) {
Image(systemName: "plus")
}
.buttonStyle(.borderless)
Spacer()
if let selected = viewModel.selectedProject {
// Route through the same confirmation dialog as the
// context-menu "Remove from List" entry. The minus
// icon is a drive-by click target right next to "+"
// confirming before mutating the registry stops the
// "I clicked by accident and my project's gone" case.
Button(action: { pendingRemoveFromList = selected }) {
Image(systemName: "minus")
}
.buttonStyle(.borderless)
.help("Remove \(selected.name) from Scarf's project list (files are kept on disk)")
}
}
.padding(8)
}
// Sidebar is an extracted view; this view stays the owner of
// sheet state (add / rename / move / uninstall / remove-from-
// list confirmation) and routes intents down as closures.
ProjectsSidebar(
viewModel: viewModel,
canConfigureProject: { isConfigurable($0) },
isTemplateInstalled: { uninstaller.isTemplateInstalled(project: $0) },
onConfigure: { configEditorProject = $0 },
onUninstallTemplate: { project in
uninstallerViewModel.begin(project: project)
showingUninstallSheet = true
},
onRemoveFromList: { pendingRemoveFromList = $0 },
onRename: { renameTarget = $0 },
onMoveToFolder: { moveTarget = $0 },
onAddProject: { showingAddSheet = true }
)
.sheet(isPresented: $showingAddSheet) {
AddProjectSheet { name, path in
viewModel.addProject(name: name, path: path)
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
}
}
.sheet(item: $renameTarget) { target in
RenameProjectSheet(
project: target,
existingNames: viewModel.projects
.filter { $0.name != target.name }
.map(\.name)
) { newName in
viewModel.renameProject(target, to: newName)
}
}
.sheet(item: $moveTarget) { target in
MoveToFolderSheet(
project: target,
existingFolders: viewModel.folders
) { newFolder in
viewModel.moveProject(target, toFolder: newFolder)
}
}
}
// MARK: - Dashboard Area
@@ -356,11 +344,13 @@ struct ProjectsView: View {
.padding(.horizontal)
.padding(.top)
.padding(.bottom, 8)
if siteWidget != nil {
tabBar
.padding(.horizontal)
.padding(.bottom, 8)
}
// Sessions tab is always present in v2.3, so the tab
// bar always renders when a dashboard is loaded.
// Site tab filters out when there's no webview widget
// (existing v2.2 behavior preserved).
tabBar
.padding(.horizontal)
.padding(.bottom, 8)
switch selectedTab {
case .dashboard:
widgetsTab(dashboard)
@@ -370,8 +360,24 @@ struct ProjectsView: View {
} else {
widgetsTab(dashboard)
}
case .sessions:
if let project = viewModel.selectedProject {
ProjectSessionsView(project: project)
} else {
ContentUnavailableView("No project selected", systemImage: "bubble.left.and.bubble.right")
}
}
}
// Clamp the container VStack to the detail column's
// offered space. Without it, any tab whose content is
// taller than the window (long Sessions list, tall
// README block in a dashboard's text widget, etc.) can
// bubble its intrinsic height up through
// NavigationSplitView's detail slot and push the whole
// window past the screen. widgetsTab's own ScrollView
// and siteTab's explicit maxHeight both cooperate; the
// sessions tab needs this as well.
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = viewModel.dashboardError {
ContentUnavailableView {
Label("No Dashboard", systemImage: "square.grid.2x2")
@@ -395,14 +401,23 @@ struct ProjectsView: View {
}
}
/// Tabs that should appear for the current project. `.site` is
/// gated on the dashboard actually containing a webview widget,
/// per v2.2 behavior the Site tab is meaningless without one.
private var visibleTabs: [DashboardTab] {
DashboardTab.allCases.filter { tab in
tab != .site || siteWidget != nil
}
}
private var tabBar: some View {
HStack(spacing: 0) {
ForEach(DashboardTab.allCases, id: \.self) { tab in
ForEach(visibleTabs, id: \.self) { tab in
Button {
selectedTab = tab
} label: {
HStack(spacing: 4) {
Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe")
Image(systemName: tab.systemImage)
.font(.caption)
Text(tab.displayName)
.font(.subheadline)
@@ -0,0 +1,88 @@
import SwiftUI
import ScarfCore
/// Sheet for renaming a project in the registry. Preserves the
/// project's `path`, `folder`, and `archived` fields the rename
/// only changes the user-visible name (and therefore the Identifiable
/// id). Duplicate-name / empty-name rejection lives in the VM.
struct RenameProjectSheet: View {
@Environment(\.dismiss) private var dismiss
let project: ProjectEntry
/// Current set of project names in the registry, used to flag
/// duplicates before the user tries to Save. Excludes the
/// project being renamed so same-name is a no-op (accepted).
let existingNames: [String]
/// Called with the trimmed new name. Caller is responsible for
/// calling `ProjectsViewModel.renameProject(_:to:)`; this sheet
/// just gathers input + validates inline.
let onSave: (String) -> Void
@State private var newName: String
init(
project: ProjectEntry,
existingNames: [String],
onSave: @escaping (String) -> Void
) {
self.project = project
self.existingNames = existingNames
self.onSave = onSave
_newName = State(initialValue: project.name)
}
/// Validation for the live input. Empty / whitespace-only / a
/// collision with another project's name all disable Save.
private var validation: (isValid: Bool, message: String?) {
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return (false, nil) // no error message just disabled
}
if trimmed != project.name && existingNames.contains(trimmed) {
return (false, String(localized: "A project named \"\(trimmed)\" already exists."))
}
return (true, nil)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Rename project").font(.headline)
Text("The project directory on disk isn't changed — only the label Scarf shows in the sidebar.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
TextField("Project name", text: $newName)
.textFieldStyle(.roundedBorder)
.onSubmit {
if validation.isValid {
save()
}
}
if let message = validation.message {
Label(message, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.red)
}
HStack {
Button("Cancel") { dismiss() }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Save") { save() }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(!validation.isValid)
}
}
.padding()
.frame(minWidth: 420)
}
private func save() {
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
onSave(trimmed)
dismiss()
}
}
@@ -4,6 +4,12 @@ import ScarfCore
/// Two-column model browser sheet. Left column lists providers, right column
/// lists models for the selected provider. Supports filtering and a "Custom"
/// option for free-form model IDs not in the catalog.
///
/// Overlay-only providers (Nous Portal, OpenAI Codex, Qwen OAuth, ) have no
/// models.dev catalog entry, so their right column renders an overlay detail
/// view: subscription state for Nous, plus a free-form model-ID field for
/// users who know what they want. This is how the picker keeps parity with
/// `hermes model` on the CLI, which can reach these providers natively.
struct ModelPickerSheet: View {
let initialProvider: String
let initialModel: String
@@ -22,8 +28,21 @@ struct ModelPickerSheet: View {
@State private var customModelID: String = ""
@State private var customProviderID: String = ""
// Overlay-provider model entry distinct from `customMode` because the
// provider is pinned; only the model ID is user-editable.
@State private var overlayModelID: String = ""
// Subscription state for the Nous Portal row / detail view. Loaded on
// appear; stays in-memory for the life of the sheet.
@State private var subscription: NousSubscriptionState = .absent
/// Drives presentation of the Nous sign-in sheet. Bound to the
/// "Sign in to Nous Portal" button in the subscription summary.
@State private var showNousSignIn: Bool = false
@Environment(\.serverContext) private var serverContext
private var catalog: ModelCatalogService { ModelCatalogService(context: serverContext) }
private var subscriptionService: NousSubscriptionService { NousSubscriptionService(context: serverContext) }
var body: some View {
VStack(spacing: 0) {
@@ -45,8 +64,18 @@ struct ModelPickerSheet: View {
providers = catalog.loadProviders()
selectedProviderID = initialProvider.isEmpty ? (providers.first?.providerID ?? "") : initialProvider
selectedModelID = initialModel
overlayModelID = initialModel
subscription = subscriptionService.loadState()
loadModelsForSelection()
}
.sheet(isPresented: $showNousSignIn) {
NousSignInSheet {
// Refresh subscription immediately so the right-column
// status row flips to "active" without waiting for the
// picker to be re-opened.
subscription = subscriptionService.loadState()
}
}
}
private var header: some View {
@@ -81,20 +110,39 @@ struct ModelPickerSheet: View {
}
)) {
ForEach(filteredProviders) { provider in
HStack {
Text(provider.providerName)
Spacer()
Text("\(provider.modelCount)")
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
.tag(provider.providerID)
providerRow(provider)
.tag(provider.providerID)
}
}
.listStyle(.inset)
}
@ViewBuilder
private func providerRow(_ provider: HermesProviderInfo) -> some View {
HStack(spacing: 6) {
Text(provider.providerName)
if provider.subscriptionGated {
capsuleTag("Subscription", tint: .accentColor)
}
Spacer()
if !provider.isOverlay {
Text("\(provider.modelCount)")
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private var modelColumn: some View {
if let selected = providers.first(where: { $0.providerID == selectedProviderID }), selected.isOverlay {
overlayProviderDetail(selected)
} else {
cachedModelList
}
}
private var cachedModelList: some View {
List(selection: $selectedModelID) {
ForEach(filteredModels) { model in
VStack(alignment: .leading, spacing: 2) {
@@ -139,6 +187,114 @@ struct ModelPickerSheet: View {
}
}
/// Right-column detail for overlay-only providers (Nous Portal, OpenAI
/// Codex, Qwen OAuth, ). models.dev has no catalog for them, so the user
/// either trusts Hermes's default (subscription providers) or types a
/// model ID they know is valid for the provider's API.
@ViewBuilder
private func overlayProviderDetail(_ provider: HermesProviderInfo) -> some View {
let overlay = catalog.overlayMetadata(for: provider.providerID)
ScrollView {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(provider.providerName).font(.title3.bold())
if provider.subscriptionGated {
capsuleTag("Subscription", tint: .accentColor)
}
}
if provider.subscriptionGated {
subscriptionSummary(provider: provider, overlay: overlay)
} else {
Text(overlayInstruction(for: overlay?.authType))
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Divider()
VStack(alignment: .leading, spacing: 4) {
Text("Model ID").font(.caption).foregroundStyle(.secondary)
TextField(modelIDPlaceholder(for: provider), text: $overlayModelID)
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))
if provider.subscriptionGated {
Text("Leave blank to use Hermes's default Nous model.")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
if let docURL = overlay?.docURL, let url = URL(string: docURL) {
Link(destination: url) {
Label("Setup documentation", systemImage: "book")
.font(.caption)
}
}
Spacer(minLength: 0)
}
.padding()
}
}
@ViewBuilder
private func subscriptionSummary(provider: HermesProviderInfo, overlay: HermesProviderOverlay?) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text("Paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription — no separate API keys needed.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 6) {
Image(systemName: subscription.subscribed ? "checkmark.circle.fill" : "exclamationmark.circle")
.foregroundStyle(subscription.subscribed ? Color.green : Color.secondary)
if subscription.subscribed {
Text("Subscription active — active provider is Nous.")
} else if subscription.present {
Text("Signed in to Nous, but another provider is active.")
.foregroundStyle(.secondary)
} else {
Text("Not signed in yet.")
.foregroundStyle(.secondary)
}
}
.font(.callout)
if !subscription.subscribed {
Button {
showNousSignIn = true
} label: {
Label("Sign in to Nous Portal", systemImage: "person.badge.key.fill")
}
.buttonStyle(.borderedProminent)
.controlSize(.regular)
}
}
}
private func overlayInstruction(for authType: HermesProviderOverlay.AuthType?) -> String {
switch authType {
case .oauthExternal:
return "Sign in through the provider's OAuth flow — run `hermes auth` from a terminal, then pick the provider to complete sign-in. Back here, set the model ID you want to use."
case .externalProcess:
return "Uses an external process (e.g. a local agent bridge). Run `hermes auth` from a terminal to complete the link, then set the model ID you want to use."
case .oauthDeviceCode:
return "Sign in via device-code flow — run `hermes auth` from a terminal and follow the printed URL."
default:
return "This provider isn't in the models.dev catalog. Enter the model ID you want to use — Hermes will pass it through to the provider verbatim."
}
}
private func modelIDPlaceholder(for provider: HermesProviderInfo) -> String {
switch provider.providerID {
case "nous": return "e.g. hermes-3"
case "openai-codex": return "e.g. gpt-5-codex"
case "qwen-oauth": return "e.g. qwen3-coder-plus"
default: return "e.g. model-name"
}
}
private var customEntry: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Use a model not in the catalog. Hermes accepts any string the provider recognizes, including provider-prefixed forms like \"openrouter/anthropic/claude-opus-4.6\".")
@@ -202,14 +358,35 @@ struct ModelPickerSheet: View {
}
}
private var isSelectedProviderOverlay: Bool {
providers.first(where: { $0.providerID == selectedProviderID })?.isOverlay ?? false
}
private var isSelectedProviderSubscriptionGated: Bool {
providers.first(where: { $0.providerID == selectedProviderID })?.subscriptionGated ?? false
}
private var canSubmit: Bool {
if customMode {
return !customModelID.trimmingCharacters(in: .whitespaces).isEmpty
}
if isSelectedProviderOverlay {
// Subscription-gated providers can submit with an empty model ID
// (Hermes picks its default). Other overlays require a model ID.
if isSelectedProviderSubscriptionGated { return true }
return !overlayModelID.trimmingCharacters(in: .whitespaces).isEmpty
}
return !selectedModelID.isEmpty
}
private var selectedPreview: String? {
if isSelectedProviderOverlay {
let trimmed = overlayModelID.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
return selectedProviderID.isEmpty ? nil : "\(selectedProviderID) / (default)"
}
return "\(selectedProviderID) / \(trimmed)"
}
guard !selectedModelID.isEmpty, !selectedProviderID.isEmpty else { return nil }
return "\(selectedProviderID) / \(selectedModelID)"
}
@@ -250,18 +427,21 @@ struct ModelPickerSheet: View {
let model = customModelID.trimmingCharacters(in: .whitespaces)
let provider = resolvedCustomProvider()
onSelect(model, provider)
} else if isSelectedProviderOverlay {
let model = overlayModelID.trimmingCharacters(in: .whitespaces)
onSelect(model, selectedProviderID)
} else {
onSelect(selectedModelID, selectedProviderID)
}
}
private func capsuleTag(_ text: String) -> some View {
private func capsuleTag(_ text: String, tint: Color = .secondary) -> some View {
Text(text)
.font(.caption2)
.foregroundStyle(.secondary)
.foregroundStyle(tint == .secondary ? AnyShapeStyle(.secondary) : AnyShapeStyle(tint))
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(.quaternary)
.background(tint == .secondary ? AnyShapeStyle(.quaternary) : AnyShapeStyle(tint.opacity(0.15)))
.clipShape(Capsule())
}
}
@@ -0,0 +1,237 @@
import SwiftUI
import AppKit
/// In-app sign-in sheet for Nous Portal hosts a ``NousAuthFlow`` and
/// renders one of four sub-views keyed on `flow.state`. Reached from the
/// model picker's Nous Portal row, the Auxiliary tab's per-task toggle,
/// and Credential Pools when the selected provider is `nous`.
///
/// UX contract with the caller:
///
/// - Sheet is presented via `.sheet(isPresented:)` from the caller.
/// - Parent owns the `isPresented` binding and a `@State var` for the
/// dismiss trigger.
/// - `onSignedIn` fires on success so the caller can refresh subscription
/// state (e.g. re-query ``NousSubscriptionService``) before the sheet
/// auto-dismisses ~1.2s later.
struct NousSignInSheet: View {
@Environment(\.serverContext) private var serverContext
@Environment(\.dismiss) private var dismiss
/// Fires on `.success`. Callers use this to refresh their cached
/// ``NousSubscriptionState`` so the new "Subscription active" chip
/// shows immediately without waiting for a full view reload.
var onSignedIn: () -> Void = {}
@State private var flow: NousAuthFlow?
@State private var successDismissTask: Task<Void, Never>?
var body: some View {
VStack(spacing: 16) {
header
Divider()
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.padding(20)
.frame(minWidth: 440, idealWidth: 440, minHeight: 340)
.onAppear {
if flow == nil {
let f = NousAuthFlow(context: serverContext)
flow = f
f.start()
}
}
.onDisappear {
successDismissTask?.cancel()
flow?.cancel()
}
.onChange(of: flowState) { _, newValue in
if case .success = newValue {
onSignedIn()
successDismissTask?.cancel()
successDismissTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 1_200_000_000)
if !Task.isCancelled { dismiss() }
}
}
}
}
private var flowState: NousAuthFlow.State {
flow?.state ?? .idle
}
// MARK: - Header
private var header: some View {
HStack(spacing: 8) {
Image(systemName: "person.badge.key.fill")
.foregroundStyle(.tint)
Text("Sign in to Nous Portal")
.font(.headline)
Spacer()
if case .waitingForApproval = flowState {
Button("Cancel") { dismiss() }
.controlSize(.small)
} else if case .starting = flowState {
Button("Cancel") { dismiss() }
.controlSize(.small)
} else {
Button("Close") { dismiss() }
.controlSize(.small)
}
}
}
// MARK: - State-keyed content
@ViewBuilder
private var content: some View {
switch flowState {
case .idle, .starting:
startingView
case .waitingForApproval(let code, let url):
waitingView(userCode: code, verificationURL: url)
case .success:
successView
case .failure(let reason, let billingURL):
failureView(reason: reason, billingURL: billingURL)
}
}
// MARK: - .starting
private var startingView: some View {
VStack(spacing: 12) {
ProgressView()
.controlSize(.large)
Text("Contacting Nous Portal…")
.font(.callout)
.foregroundStyle(.secondary)
Text("This may take a few seconds.")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - .waitingForApproval
@ViewBuilder
private func waitingView(userCode: String, verificationURL: URL) -> some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
Text("Approve in your browser")
.font(.headline)
Text("We opened the Nous Portal approval page. Confirm this code matches what it shows, then approve.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
userCodeBadge(userCode)
HStack(spacing: 12) {
Button {
NSWorkspace.shared.open(verificationURL)
} label: {
Label("Open approval page again", systemImage: "safari")
}
.controlSize(.small)
Spacer()
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Waiting for approval…")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 4)
}
private func userCodeBadge(_ code: String) -> some View {
HStack(spacing: 10) {
Text(code)
.font(.system(size: 28, weight: .semibold, design: .monospaced))
.textSelection(.enabled)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
Button {
copyToPasteboard(code)
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
.controlSize(.small)
}
}
// MARK: - .success
private var successView: some View {
VStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.system(size: 48))
Text("Signed in to Nous Portal")
.font(.headline)
Text("Your tools will now route through your subscription.")
.font(.callout)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - .failure
@ViewBuilder
private func failureView(reason: String, billingURL: URL?) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text(billingURL == nil ? "Sign-in didn't complete" : "Subscription required")
.font(.headline)
}
Text(reason)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
if let billingURL {
Button {
NSWorkspace.shared.open(billingURL)
} label: {
Label("Subscribe", systemImage: "creditcard")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
HStack(spacing: 10) {
Button("Try again") { flow?.start() }
.buttonStyle(.bordered)
Button("Copy error") {
let payload = (flow?.output.isEmpty == false) ? flow!.output : reason
copyToPasteboard(payload)
}
.buttonStyle(.bordered)
Spacer()
Button("Close") { dismiss() }
}
}
}
// MARK: - Helpers
private func copyToPasteboard(_ value: String) {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(value, forType: .string)
}
}
@@ -1,4 +1,5 @@
import SwiftUI
import ScarfCore
/// Advanced tab network, compression, checkpoints, logging, delegation, file read cap,
/// cron wrap, config diagnostics, backup/restore, paths, raw config.
@@ -1,4 +1,5 @@
import SwiftUI
import ScarfCore
/// Agent tab turns, reasoning effort, tool use enforcement, approvals, gateway timing, service tier.
struct AgentTab: View {
@@ -16,7 +17,7 @@ struct AgentTab: View {
StepperRow(label: "Approval Timeout (s)", value: viewModel.config.approvalTimeout, range: 5...600, step: 5) { viewModel.setApprovalTimeout($0) }
}
SettingsSection(title: "Gateway", icon: "antenna.radiowaves.left.and.right") {
SettingsSection(title: "Messaging Gateway", icon: "antenna.radiowaves.left.and.right") {
ToggleRow(label: "Fast Mode", isOn: viewModel.config.serviceTier == "fast") { on in
viewModel.setServiceTier(on ? "fast" : "normal")
}
@@ -3,9 +3,19 @@ import ScarfCore
/// Auxiliary tab the 8 sub-model tasks hermes delegates to cheaper models.
/// Each follows the same provider/model/base_url/api_key/timeout pattern.
///
/// Adds a per-task **Route through Nous Portal** toggle for Hermes v0.10.0+
/// subscribers. The toggle flips `auxiliary.<task>.provider` between `nous`
/// (subscription-routed) and `auto` (inherit main provider) Hermes derives
/// the gateway routing from that single field; there is no separate
/// `use_gateway` key to write.
struct AuxiliaryTab: View {
@Bindable var viewModel: SettingsViewModel
@Environment(\.serverContext) private var serverContext
@State private var subscription: NousSubscriptionState = .absent
@State private var showNousSignIn: Bool = false
// Keyed by the config path name matches `auxiliary.<task>.*` in config.yaml.
private let tasks: [(key: String, title: LocalizedStringKey, icon: String)] = [
("vision", "Vision", "eye"),
@@ -29,11 +39,21 @@ struct AuxiliaryTab: View {
auxRows(for: task.key)
}
}
Color.clear.frame(height: 0)
.onAppear {
subscription = NousSubscriptionService(context: serverContext).loadState()
}
.sheet(isPresented: $showNousSignIn) {
NousSignInSheet {
subscription = NousSubscriptionService(context: serverContext).loadState()
}
}
}
@ViewBuilder
private func auxRows(for key: String) -> some View {
let model = auxModel(for: key)
nousGatewayToggle(for: key, currentProvider: model.provider)
EditableTextField(label: "Provider", value: model.provider) { viewModel.setAuxiliary(key, field: "provider", value: $0) }
EditableTextField(label: "Model", value: model.model) { viewModel.setAuxiliary(key, field: "model", value: $0) }
EditableTextField(label: "Base URL", value: model.baseURL) { viewModel.setAuxiliary(key, field: "base_url", value: $0) }
@@ -41,6 +61,30 @@ struct AuxiliaryTab: View {
StepperRow(label: "Timeout (s)", value: model.timeout, range: 5...3600, step: 5) { viewModel.setAuxiliaryTimeout(key, value: $0) }
}
@ViewBuilder
private func nousGatewayToggle(for key: String, currentProvider: String) -> some View {
let isOn = (currentProvider == "nous")
ToggleRow(label: "Nous Portal", isOn: isOn) { wantsOn in
// "nous" enables subscription routing; "auto" reverts to the
// inherit-main-provider default. We never touch model/base/key
// fields here Hermes reuses them if the user switches back.
viewModel.setAuxiliary(key, field: "provider", value: wantsOn ? "nous" : "auto")
}
if !subscription.present && !isOn {
HStack(spacing: 8) {
Text("Requires an active Nous Portal subscription.")
.font(.caption2)
.foregroundStyle(.tertiary)
Button("Sign in first") { showNousSignIn = true }
.controlSize(.mini)
.buttonStyle(.borderedProminent)
Spacer()
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
}
}
private func auxModel(for key: String) -> AuxiliaryModel {
switch key {
case "vision": return viewModel.config.auxiliary.vision
@@ -1,4 +1,5 @@
import SwiftUI
import ScarfCore
/// Browser tab browser backend + automation timeouts + camofox.
struct BrowserTab: View {
@@ -1,4 +1,5 @@
import SwiftUI
import ScarfCore
/// Display tab streaming, reasoning, cost, skin, compact mode, inline diffs, bell, etc.
struct DisplayTab: View {
@@ -1,4 +1,5 @@
import SwiftUI
import ScarfCore
/// General tab model picker (provider auto-follows), personality, locale.
/// Credential management lives in the Credential Pools sidebar item; a hint
@@ -1,4 +1,5 @@
import SwiftUI
import ScarfCore
/// Memory tab built-in memory settings + external provider picker.
struct MemoryTab: View {
@@ -1,4 +1,5 @@
import SwiftUI
import ScarfCore
/// Security tab redaction, command allowlist (read-only), Tirith sandbox, website blocklist, human delay.
struct SecurityTab: View {
@@ -1,4 +1,5 @@
import SwiftUI
import ScarfCore
/// Terminal tab backend plus docker/container options.
/// Heavy docker/container settings are hidden unless a container backend is selected.
@@ -1,4 +1,5 @@
import SwiftUI
import ScarfCore
/// Voice tab push-to-talk + TTS + STT provider settings.
struct VoiceTab: View {
@@ -24,6 +24,20 @@ struct TemplateConfigSheet: View {
header
Divider()
ScrollView {
// `.frame(maxWidth: .infinity, alignment: .leading)` is
// load-bearing: without it, SwiftUI resolves width
// bottom-up and an unbreakable token in a child (e.g. a
// raw URL inside a field description rendered via
// AttributedString markdown) sets the whole VStack's
// ideal width to that token's length. ScrollView's
// content then exceeds the sheet's viewport, the outer
// `.frame(minWidth: 560)` grows to content width, and
// the window clips the result with labels cut off on
// the left + URL spilling off the right. With the
// explicit maxWidth, the ScrollView's offered width
// propagates down and the description Text's
// `.fixedSize(horizontal: false, vertical: true)`
// wraps at whitespace boundaries as intended.
VStack(alignment: .leading, spacing: 18) {
if viewModel.schema.fields.isEmpty {
ContentUnavailableView(
@@ -41,6 +55,7 @@ struct TemplateConfigSheet: View {
modelRecommendation(rec)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(20)
}
Divider()
@@ -117,7 +132,11 @@ struct TemplateConfigSheet: View {
// Inline markdown so descriptions can include
// `[Create one](https://)`-style links to token
// generation pages, **bold** emphasis on important
// prerequisites, etc.
// prerequisites, etc. Raw URLs (not wrapped in
// markdown link syntax) will still render but can't
// word-break mid-token keep the parent maxWidth
// constraint below so a rogue raw URL wraps cleanly
// instead of expanding the entire sheet.
TemplateMarkdown.inlineText(description)
.font(.caption)
.foregroundStyle(.secondary)
@@ -130,6 +149,12 @@ struct TemplateConfigSheet: View {
.foregroundStyle(.red)
}
}
// maxWidth: .infinity forces this row to span the column's
// full width so its internal description Text wraps instead
// of expanding the outer VStack when a description contains
// a long unbreakable token (raw URL, path, etc.). See the
// comment on the parent ScrollView's inner VStack.
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(
RoundedRectangle(cornerRadius: 8)
@@ -288,24 +313,23 @@ private struct EnumControl: View {
let options: [TemplateConfigField.EnumOption]
@Binding var value: String
var body: some View {
// Segmented for 4 options, dropdown otherwise fits Scarf's
// existing settings UI.
if options.count <= 4 {
Picker("", selection: $value) {
ForEach(options) { opt in
Text(opt.label).tag(opt.value)
}
// Always use the default Menu picker (dropdown). An earlier
// version switched to `.pickerStyle(.segmented)` when
// `options.count 4` for a more compact look, but on macOS
// segmented pickers size to the intrinsic width of all their
// labels concatenated they refuse offered width constraints
// and refuse to wrap. A schema with three long labels like
// "Claude Opus 4 (Recommended - Most Capable)" produced a
// ~650pt picker that overflowed the 560pt sheet viewport,
// clipping the entire form. Menu pickers respect the fieldRow's
// offered width and show long labels in the popup list, so the
// sheet can't overflow regardless of label length.
Picker("", selection: $value) {
ForEach(options) { opt in
Text(opt.label).tag(opt.value)
}
.pickerStyle(.segmented)
.labelsHidden()
} else {
Picker("", selection: $value) {
ForEach(options) { opt in
Text(opt.label).tag(opt.value)
}
}
.labelsHidden()
}
.labelsHidden()
}
}
@@ -1,6 +1,7 @@
import SwiftUI
import AppKit
import UniformTypeIdentifiers
import ScarfCore
/// Author-facing sheet for exporting an existing project as a
/// `.scarftemplate`. Mirrors the profile-export flow: fill in a few fields,
@@ -127,6 +127,16 @@ struct TemplateInstallSheet: View {
.padding(.bottom, 8)
Divider()
ScrollView {
// `.frame(maxWidth: .infinity, alignment: .leading)`
// without it, a subsection containing an unbreakable
// token (raw URL in a cron prompt or README block, a
// long file path in the project-files list, a schema
// description with a bare URL, etc.) sets the VStack's
// ideal width to that token's length; the sheet grows
// past its `.frame(minWidth: 620)` and gets clipped by
// the window. Same fix as `TemplateConfigSheet`'s
// inner VStack propagate the ScrollView's width down
// so inner Text wraps instead of expanding outward.
VStack(alignment: .leading, spacing: 16) {
projectFilesSection(plan: plan)
if plan.skillsNamespaceDir != nil {
@@ -143,6 +153,7 @@ struct TemplateInstallSheet: View {
}
readmeSection
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical)
}
Divider()
+211
View File
@@ -889,6 +889,10 @@
},
"••••••••••" : {
},
"`%@` uses a different sign-in flow." : {
"comment" : "A description of the sign-in flow for a given provider.",
"isCommentAutoGenerated" : true
},
"+ %lld more…" : {
"comment" : "A button that shows the number of files that were left behind by the template uninstaller.",
@@ -1028,6 +1032,10 @@
"comment" : "A message that appears when a memory block is no longer present in MEMORY.md.",
"isCommentAutoGenerated" : true
},
"A project named \"%@\" already exists." : {
"comment" : "A warning message that appears in a Rename Project sheet if the user-provided name is a duplicate of an existing project. The argument is the name of the duplicate project.",
"isCommentAutoGenerated" : true
},
"A QR code will appear below. Scan it with WhatsApp on your phone. The session is saved to ~/.hermes/platforms/whatsapp/ so you won't need to scan again after restarts." : {
"localizations" : {
"de" : {
@@ -1391,6 +1399,10 @@
}
}
},
"Add a project" : {
"comment" : "A button that adds a new project.",
"isCommentAutoGenerated" : true
},
"Add a project folder to get started. Create a .scarf/dashboard.json file in your project to define widgets." : {
"localizations" : {
"de" : {
@@ -2480,6 +2492,9 @@
}
}
}
},
"Approve in your browser" : {
},
"Archive" : {
"localizations" : {
@@ -2521,6 +2536,10 @@
}
}
},
"Archived (%lld)" : {
"comment" : "A label that opens a group of archived projects.",
"isCommentAutoGenerated" : true
},
"Args (one per line)" : {
"localizations" : {
"de" : {
@@ -3745,6 +3764,14 @@
}
}
},
"Chat · %@" : {
"comment" : "A label that shows the name of the active Scarf project, followed by \"Chat\".",
"isCommentAutoGenerated" : true
},
"Chat is scoped to Scarf project \"%@\"" : {
"comment" : "Tooltip for the folder-chip indicator.",
"isCommentAutoGenerated" : true
},
"Chat Messages" : {
"localizations" : {
"de" : {
@@ -3785,6 +3812,10 @@
}
}
},
"Chats you start here get attributed automatically. Older CLI-started sessions live in the global Sessions sidebar." : {
"comment" : "A description of the purpose of the Sessions tab.",
"isCommentAutoGenerated" : true
},
"Check" : {
"localizations" : {
"de" : {
@@ -5246,6 +5277,9 @@
}
}
}
},
"Contacting Nous Portal…" : {
},
"Container Limits" : {
"localizations" : {
@@ -5530,6 +5564,9 @@
}
}
}
},
"Copy error" : {
},
"Copy error details" : {
"localizations" : {
@@ -6874,6 +6911,10 @@
},
"Description" : {
},
"Destination" : {
"comment" : "A label for the folder picker in the move-to-folder sheet.",
"isCommentAutoGenerated" : true
},
"Details" : {
"localizations" : {
@@ -8802,6 +8843,10 @@
}
}
},
"Filter projects" : {
"comment" : "A label for a search field in the sidebar.",
"isCommentAutoGenerated" : true
},
"Filter servers..." : {
"localizations" : {
"de" : {
@@ -9006,6 +9051,10 @@
"comment" : "A placeholder for a comma-separated list of tags.",
"isCommentAutoGenerated" : true
},
"Folders only affect how projects are grouped in Scarf's sidebar. Nothing on disk changes." : {
"comment" : "A description of how folders affect project grouping.",
"isCommentAutoGenerated" : true
},
"Full copy of active profile (all state)" : {
"localizations" : {
"de" : {
@@ -9047,6 +9096,7 @@
}
},
"Gateway" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -9127,6 +9177,7 @@
}
},
"Gateway Running" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -9167,6 +9218,7 @@
}
},
"Gateway Stopped" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -9698,6 +9750,10 @@
}
}
},
"Hide archived projects" : {
"comment" : "A toggle that hides archived projects.",
"isCommentAutoGenerated" : true
},
"Hide details" : {
"localizations" : {
"de" : {
@@ -10958,6 +11014,10 @@
}
}
},
"Leave blank to use Hermes's default Nous model." : {
"comment" : "A description of the default Nous model.",
"isCommentAutoGenerated" : true
},
"Leave blank unless Hermes is installed at a non-default path (systemd services often live at /var/lib/hermes/.hermes; Docker sidecars vary). Test Connection auto-suggests a value when it detects one of the known alternates." : {
"localizations" : {
"de" : {
@@ -11902,6 +11962,18 @@
}
}
},
"Messaging Gateway" : {
"comment" : "The title of the messaging gateway view.",
"isCommentAutoGenerated" : true
},
"Messaging Gateway Running" : {
"comment" : "A label that indicates that the messaging gateway is running.",
"isCommentAutoGenerated" : true
},
"Messaging Gateway Stopped" : {
"comment" : "A label that describes the messaging gateway as stopped.",
"isCommentAutoGenerated" : true
},
"Metadata" : {
"comment" : "A heading for the metadata section of the template export sheet.",
"isCommentAutoGenerated" : true
@@ -12186,6 +12258,22 @@
}
}
},
"Move" : {
"comment" : "A button that moves a project to a folder.",
"isCommentAutoGenerated" : true
},
"Move \"%@\" to folder" : {
"comment" : "A heading for a dialog that lets the user move a project to a folder.",
"isCommentAutoGenerated" : true
},
"Move to Folder…" : {
"comment" : "A context menu action that moves a project to a folder.",
"isCommentAutoGenerated" : true
},
"msgs" : {
"comment" : "A label for the number of messages in a session.",
"isCommentAutoGenerated" : true
},
"my_server" : {
},
@@ -12309,6 +12397,17 @@
}
}
},
"New Chat" : {
"comment" : "A button that starts a new chat session.",
"isCommentAutoGenerated" : true
},
"New folder name" : {
},
"New folder…" : {
"comment" : "A label for a new folder name.",
"isCommentAutoGenerated" : true
},
"New name for '%@'" : {
"localizations" : {
"de" : {
@@ -13327,6 +13426,10 @@
}
}
},
"No project selected" : {
"comment" : "A label that indicates that no project is selected.",
"isCommentAutoGenerated" : true
},
"No Projects" : {
"localizations" : {
"de" : {
@@ -13734,6 +13837,10 @@
}
}
},
"Not signed in yet." : {
"comment" : "A description of a model picker sheet's subscription",
"isCommentAutoGenerated" : true
},
"Notable Sessions" : {
"localizations" : {
"de" : {
@@ -13774,6 +13881,10 @@
}
}
},
"Nous Portal uses a dedicated sign-in flow." : {
"comment" : "A description of the process of using the Nous Portal.",
"isCommentAutoGenerated" : true
},
"npx" : {
},
@@ -13867,6 +13978,10 @@
"comment" : "A placeholder for a template's description.",
"isCommentAutoGenerated" : true
},
"Open approval page again" : {
"comment" : "A button that opens a web page.",
"isCommentAutoGenerated" : true
},
"Open BotFather" : {
"localizations" : {
"de" : {
@@ -14559,6 +14674,10 @@
"comment" : "A label for the template's owner and name.",
"isCommentAutoGenerated" : true
},
"Paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription — no separate API keys needed." : {
"comment" : "A description of the benefits of using a Nous",
"isCommentAutoGenerated" : true
},
"Pair Device" : {
"localizations" : {
"de" : {
@@ -15458,6 +15577,10 @@
},
"Project folder kept" : {
},
"Project name" : {
"comment" : "A label for a text field that lets the user enter a project name.",
"isCommentAutoGenerated" : true
},
"Project Name" : {
"localizations" : {
@@ -16870,6 +16993,10 @@
}
}
},
"Rename project" : {
"comment" : "A title for a sheet that renames a project.",
"isCommentAutoGenerated" : true
},
"Rename Session" : {
"localizations" : {
"de" : {
@@ -16949,6 +17076,9 @@
}
}
}
},
"Rename…" : {
},
"required" : {
@@ -17036,6 +17166,10 @@
}
}
},
"Requires an active Nous Portal subscription." : {
"comment" : "A message that appears when the Nous Portal subscription is not active.",
"isCommentAutoGenerated" : true
},
"Requires: %@" : {
"localizations" : {
"de" : {
@@ -17716,6 +17850,10 @@
}
}
},
"Run `hermes auth add %@` in a terminal to finish sign-in. In-app support for this provider is coming in a follow-up." : {
"comment" : "A description of the CLI fallback for a given provider.",
"isCommentAutoGenerated" : true
},
"Run `hermes memory setup` in Terminal for full provider configuration." : {
"localizations" : {
"de" : {
@@ -19379,6 +19517,10 @@
}
}
},
"Sessions in this project" : {
"comment" : "A heading for the list of sessions in a project.",
"isCommentAutoGenerated" : true
},
"Set as default — open this server when Scarf launches." : {
"comment" : "A tooltip for the star button in the Manage Servers view.",
"isCommentAutoGenerated" : true
@@ -19463,6 +19605,10 @@
}
}
},
"Setup documentation" : {
"comment" : "A link to a documentation page for setting up a",
"isCommentAutoGenerated" : true
},
"Share Debug Report…" : {
"localizations" : {
"de" : {
@@ -19623,6 +19769,10 @@
}
}
},
"Show archived projects" : {
"comment" : "A toggle that shows/hides archived projects.",
"isCommentAutoGenerated" : true
},
"Show details" : {
"localizations" : {
"de" : {
@@ -19790,6 +19940,17 @@
"comment" : "A hint for the user on how to show/hide the secret.",
"isCommentAutoGenerated" : true
},
"Sign in first" : {
"comment" : "A button that opens a sheet for signing in to a Nous Portal subscription.",
"isCommentAutoGenerated" : true
},
"Sign in to Nous Portal" : {
},
"Sign-in didn't complete" : {
"comment" : "A title for a failed sign-in attempt.",
"isCommentAutoGenerated" : true
},
"Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages." : {
"localizations" : {
"de" : {
@@ -19915,6 +20076,13 @@
},
"signal-cli Terminal" : {
},
"Signed in to Nous Portal" : {
},
"Signed in to Nous, but another provider is active." : {
"comment" : "A description of a user's subscription to Nous, but",
"isCommentAutoGenerated" : true
},
"SILENT" : {
@@ -20980,6 +21148,13 @@
}
}
}
},
"Subscription active — active provider is Nous." : {
"comment" : "A description of a user's active subscription to",
"isCommentAutoGenerated" : true
},
"Subscription required" : {
},
"Succeeded" : {
"localizations" : {
@@ -21435,6 +21610,10 @@
}
}
},
"The project directory on disk isn't changed — only the label Scarf shows in the sidebar." : {
"comment" : "A description of the project name field.",
"isCommentAutoGenerated" : true
},
"The remote's SSH fingerprint no longer matches what your `~/.ssh/known_hosts` file expected. This usually means the remote was reinstalled — or, less commonly, that someone is intercepting the connection." : {
"localizations" : {
"de" : {
@@ -21643,6 +21822,10 @@
"comment" : "A description of the local machine.",
"isCommentAutoGenerated" : true
},
"This may take a few seconds." : {
"comment" : "A description of the time it takes to connect to Nous Portal.",
"isCommentAutoGenerated" : true
},
"This project wasn't installed from a schemaful template." : {
},
@@ -22422,6 +22605,10 @@
}
}
},
"Top Level" : {
"comment" : "A folder in the sidebar.",
"isCommentAutoGenerated" : true
},
"Top Tools" : {
"localizations" : {
"de" : {
@@ -22462,6 +22649,10 @@
}
}
},
"Try again" : {
"comment" : "A button that triggers a re-attempt to sign in to Nous Portal.",
"isCommentAutoGenerated" : true
},
"TTS Off" : {
"localizations" : {
"de" : {
@@ -22582,6 +22773,10 @@
}
}
},
"Unarchive" : {
"comment" : "A button that unarchives a project.",
"isCommentAutoGenerated" : true
},
"Uninstall" : {
"localizations" : {
"de" : {
@@ -23409,6 +23604,10 @@
}
}
},
"Waiting for approval…" : {
"comment" : "A label displayed in the `.waitingForApproval` state of the",
"isCommentAutoGenerated" : true
},
"Waiting for authorization URL…" : {
"localizations" : {
"de" : {
@@ -23529,6 +23728,14 @@
}
}
},
"We opened the Nous Portal approval page. Confirm this code matches what it shows, then approve." : {
"comment" : "A description of the user's task to approve a",
"isCommentAutoGenerated" : true
},
"We'll open the Nous Portal approval page in your browser and show the device code here. No code-paste step." : {
"comment" : "A description of the process of adding a credential via the Nous Portal.",
"isCommentAutoGenerated" : true
},
"Web Extract" : {
"localizations" : {
"de" : {
@@ -23951,6 +24158,10 @@
"Your name" : {
"comment" : "A label for the user's name.",
"isCommentAutoGenerated" : true
},
"Your tools will now route through your subscription." : {
"comment" : "A description of the success state of the",
"isCommentAutoGenerated" : true
}
},
"version" : "1.1"
+12 -1
View File
@@ -50,7 +50,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case .profiles: return "Profiles"
case .tools: return "Tools"
case .mcpServers: return "MCP Servers"
case .gateway: return "Gateway"
case .gateway: return "Messaging Gateway"
case .cron: return "Cron"
case .health: return "Health"
case .logs: return "Logs"
@@ -91,4 +91,15 @@ final class AppCoordinator {
var selectedSection: SidebarSection = .dashboard
var selectedSessionId: String?
var selectedProjectName: String?
/// When non-nil, ChatView should start a fresh ACP session with
/// this absolute project path as cwd and then clear the value.
/// Wired from the per-project Sessions tab's "New Chat" button
/// (v2.3): the tab sets this, switches `selectedSection` to
/// `.chat`, and ChatView reacts on its next render.
///
/// Separate from `selectedSessionId` (which resumes an existing
/// session) a new session needs a cwd override Scarf doesn't
/// yet have an id for.
var pendingProjectChat: String?
}
+1
View File
@@ -1,4 +1,5 @@
import SwiftUI
import ScarfCore
struct SidebarView: View {
@Environment(AppCoordinator.self) private var coordinator
+14 -1
View File
@@ -95,6 +95,19 @@ struct ScarfApp: App {
registry.defaultServerID
}
.defaultSize(width: 1100, height: 700)
// Without an explicit resizability, `WindowGroup` defaults to
// `.automatic` which on macOS evaluates to `.contentSize`
// meaning the window is BOUND to its content's ideal size
// rather than bounded-below by it. Any section whose content's
// intrinsic height changes (Chat's message list, the v2.3
// per-project Sessions tab, Insights charts) would resize the
// window on every section switch, snap back against user
// resize, and sometimes push the whole window past the
// screen. `.contentMinSize` turns the content's ideal height
// into a minimum floor: user resize works freely, the window
// stays put across section switches, and it still can't shrink
// smaller than a section's minimum render.
.windowResizability(.contentMinSize)
.commands {
CommandGroup(after: .appInfo) {
Button("Check for Updates…") { updater.checkForUpdates() }
@@ -374,7 +387,7 @@ struct MenuBarMenu: View {
systemImage: status.hermesRunning ? "circle.fill" : "circle"
)
Label(
status.gatewayRunning ? "Gateway Running" : "Gateway Stopped",
status.gatewayRunning ? "Messaging Gateway Running" : "Messaging Gateway Stopped",
systemImage: status.gatewayRunning ? "circle.fill" : "circle"
)
Button("Start Hermes") { status.startHermes() }
@@ -0,0 +1,84 @@
import Testing
import Foundation
@testable import scarf
/// Tests that ``CredentialPoolsOAuthGate`` steers each known provider to
/// the right OAuth flow. The regression this prevents: a user hitting the
/// "Start OAuth" button for nous / openai-codex / qwen-oauth /
/// google-gemini-cli / copilot-acp and watching the UI stall silently.
@Suite struct CredentialPoolsGatingTests {
/// Synthesize a ModelCatalogService over a minimal fixture cache so
/// tests don't depend on the live `~/.hermes/models_dev_cache.json`.
private func makeCatalog() throws -> ModelCatalogService {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-cpgate-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let path = dir.appendingPathComponent("models_dev_cache.json").path
// Include anthropic so the .ok path has a recognizable provider.
let json = """
{
"anthropic": {
"name": "Anthropic",
"models": { "claude-sonnet-4-5": { "name": "Claude Sonnet 4.5" } }
}
}
"""
try json.write(toFile: path, atomically: true, encoding: .utf8)
return ModelCatalogService(path: path)
}
@Test func nousRoutesToDedicatedSignInFlow() throws {
let catalog = try makeCatalog()
#expect(CredentialPoolsOAuthGate.resolve(providerID: "nous", catalog: catalog) == .useNousSignIn)
// Whitespace + case insensitivity should also work users who type
// "Nous " shouldn't fall through to the generic flow.
#expect(CredentialPoolsOAuthGate.resolve(providerID: " Nous ", catalog: catalog) == .useNousSignIn)
}
@Test func deviceCodeAndExternalProvidersRouteToCLI() throws {
let catalog = try makeCatalog()
// `openai-codex` is .oauthExternal in the overlay table.
if case .useCLI(let provider) = CredentialPoolsOAuthGate.resolve(providerID: "openai-codex", catalog: catalog) {
#expect(provider == "openai-codex")
} else {
Issue.record("openai-codex should route to .useCLI")
}
// `qwen-oauth` is .oauthExternal.
if case .useCLI = CredentialPoolsOAuthGate.resolve(providerID: "qwen-oauth", catalog: catalog) {
// ok
} else {
Issue.record("qwen-oauth should route to .useCLI")
}
// `google-gemini-cli` is .oauthExternal.
if case .useCLI = CredentialPoolsOAuthGate.resolve(providerID: "google-gemini-cli", catalog: catalog) {
// ok
} else {
Issue.record("google-gemini-cli should route to .useCLI")
}
// `copilot-acp` is .externalProcess.
if case .useCLI = CredentialPoolsOAuthGate.resolve(providerID: "copilot-acp", catalog: catalog) {
// ok
} else {
Issue.record("copilot-acp should route to .useCLI")
}
}
@Test func pkceProvidersPassThroughAsOK() throws {
let catalog = try makeCatalog()
// Anthropic is a standard PKCE provider in Hermes must not be gated.
#expect(CredentialPoolsOAuthGate.resolve(providerID: "anthropic", catalog: catalog) == .ok)
}
@Test func unknownProvidersDefaultToOK() throws {
let catalog = try makeCatalog()
// Providers we don't know about shouldn't be blocked users with
// custom setups need the escape hatch.
#expect(CredentialPoolsOAuthGate.resolve(providerID: "custom-provider-xyz", catalog: catalog) == .ok)
}
@Test func emptyProviderReturnsProviderEmpty() throws {
let catalog = try makeCatalog()
#expect(CredentialPoolsOAuthGate.resolve(providerID: "", catalog: catalog) == .providerEmpty)
#expect(CredentialPoolsOAuthGate.resolve(providerID: " ", catalog: catalog) == .providerEmpty)
}
}
+109
View File
@@ -0,0 +1,109 @@
import Testing
import Foundation
@testable import scarf
/// Unit tests for the pure parsers in ``NousAuthFlow``. The subprocess side
/// of the flow is covered by manual end-to-end testing against a live
/// hermes install parser behavior is what we can pin here.
@Suite struct NousAuthFlowParserTests {
// MARK: - Device-code block
@Test func parsesVerificationURLAndUserCode() throws {
let text = """
Requesting device code from Nous Portal...
To continue:
1. Open: https://portal.nousresearch.com/device/ABCD-EFGH
2. If prompted, enter code: ABCD-EFGH
Waiting for approval (polling every 1s)...
"""
let result = try #require(NousAuthFlow.parseDeviceCode(from: text))
#expect(result.verificationURL.absoluteString == "https://portal.nousresearch.com/device/ABCD-EFGH")
#expect(result.userCode == "ABCD-EFGH")
}
@Test func ignoresNoiseBetweenExpectedLines() throws {
// Hermes may log unrelated diagnostics between or after the two
// expected lines. The parser anchors on line-start regex so noise
// above, below, or even intermixed shouldn't block it.
let text = """
[DEBUG] some internal log line
To continue:
1. Open: https://portal.nousresearch.com/device/WXYZ-1234
[DEBUG] another log line
2. If prompted, enter code: WXYZ-1234
extra trailing noise
"""
let result = try #require(NousAuthFlow.parseDeviceCode(from: text))
#expect(result.userCode == "WXYZ-1234")
}
@Test func returnsNilWhenUserCodeLineMissing() {
let text = """
To continue:
1. Open: https://portal.nousresearch.com/device/AAAA-AAAA
Waiting for approval...
"""
#expect(NousAuthFlow.parseDeviceCode(from: text) == nil)
}
@Test func returnsNilWhenURLLineMissing() {
let text = """
To continue:
2. If prompted, enter code: BBBB-BBBB
"""
#expect(NousAuthFlow.parseDeviceCode(from: text) == nil)
}
@Test func returnsNilOnEmptyInput() {
#expect(NousAuthFlow.parseDeviceCode(from: "") == nil)
}
// MARK: - Subscription-required failure
@Test func parsesSubscriptionRequiredBillingURL() throws {
let text = """
Login successful!
Your Nous Portal account does not have an active subscription.
Subscribe here: https://portal.nousresearch.com/billing
After subscribing, run `hermes model` again to finish setup.
"""
let url = try #require(NousAuthFlow.parseSubscriptionRequired(from: text))
#expect(url.absoluteString == "https://portal.nousresearch.com/billing")
}
@Test func subscriptionRequiredReturnsNilWithoutMarker() {
let text = """
hermes: something else went wrong
Subscribe here: https://example.com/billing
"""
// The "Subscribe here:" URL alone isn't enough we require the
// specific subscription-required sentinel so we don't misclassify
// unrelated errors as subscription failures.
#expect(NousAuthFlow.parseSubscriptionRequired(from: text) == nil)
}
@Test func subscriptionRequiredReturnsNilWhenBillingURLMissing() {
let text = """
Your Nous Portal account does not have an active subscription.
(no subscribe here line)
"""
#expect(NousAuthFlow.parseSubscriptionRequired(from: text) == nil)
}
// MARK: - State equality
@Test func stateEnumEquatableDistinguishesCases() {
let u = URL(string: "https://example.com")!
let a: NousAuthFlow.State = .waitingForApproval(userCode: "X", verificationURL: u)
let b: NousAuthFlow.State = .waitingForApproval(userCode: "X", verificationURL: u)
let c: NousAuthFlow.State = .waitingForApproval(userCode: "Y", verificationURL: u)
#expect(a == b)
#expect(a != c)
#expect(NousAuthFlow.State.idle != NousAuthFlow.State.starting)
#expect(NousAuthFlow.State.success != NousAuthFlow.State.failure(reason: "", billingURL: nil))
}
}
@@ -0,0 +1,261 @@
import Testing
import Foundation
import ScarfCore
@testable import scarf
/// Exercises the Scarf-managed AGENTS.md marker block logic added in
/// v2.3. Tests operate on isolated temp directories no dependency
/// on ~/.hermes contents, no cross-suite lock needed.
@Suite struct ProjectAgentContextServiceTests {
// MARK: - applyBlock pure-text transform
@Test func applyBlockPrependsWhenNoMarkersPresent() {
let existing = "# My Template\n\nSome instructions.\n"
let block = "<!-- scarf-project:begin -->\nhello\n<!-- scarf-project:end -->"
let result = ProjectAgentContextService.applyBlock(block: block, to: existing)
#expect(result.hasPrefix("<!-- scarf-project:begin -->"))
#expect(result.contains("<!-- scarf-project:end -->"))
#expect(result.contains("# My Template"))
#expect(result.contains("Some instructions."))
// Exactly one blank line between block and original content.
#expect(result.contains("<!-- scarf-project:end -->\n\n# My Template"))
}
@Test func applyBlockWritesFreshFileWhenEmpty() {
let block = "<!-- scarf-project:begin -->\nhello\n<!-- scarf-project:end -->"
let result = ProjectAgentContextService.applyBlock(block: block, to: "")
// Empty input just the block + trailing newline; no weird
// leading whitespace.
#expect(result == block + "\n")
}
@Test func applyBlockReplacesExistingMarkerRegion() {
let existing = """
<!-- scarf-project:begin -->
old content line 1
old content line 2
<!-- scarf-project:end -->
# Template docs preserved
Template behavior.
"""
let newBlock = "<!-- scarf-project:begin -->\nfresh content\n<!-- scarf-project:end -->"
let result = ProjectAgentContextService.applyBlock(block: newBlock, to: existing)
#expect(result.contains("fresh content"))
// Old content is gone.
#expect(!result.contains("old content line 1"))
#expect(!result.contains("old content line 2"))
// Template content outside markers is preserved.
#expect(result.contains("# Template docs preserved"))
#expect(result.contains("Template behavior."))
}
@Test func applyBlockIsIdempotent() {
let existing = "# Project\n\nContent.\n"
let block = "<!-- scarf-project:begin -->\nv1\n<!-- scarf-project:end -->"
let once = ProjectAgentContextService.applyBlock(block: block, to: existing)
let twice = ProjectAgentContextService.applyBlock(block: block, to: once)
#expect(once == twice)
}
@Test func applyBlockOrphanedBeginMarkerFallsBackToPrepend() {
// Stray begin with no end: treat as "no well-formed block,"
// prepend. Leaves the orphan in place it was probably
// hand-typed, not a corrupt Scarf write. Conservative.
let existing = "<!-- scarf-project:begin -->\nstray text with no end marker\n"
let block = "<!-- scarf-project:begin -->\nnew\n<!-- scarf-project:end -->"
let result = ProjectAgentContextService.applyBlock(block: block, to: existing)
#expect(result.hasPrefix("<!-- scarf-project:begin -->\nnew\n<!-- scarf-project:end -->"))
#expect(result.contains("stray text with no end marker"))
}
// MARK: - renderBlock content
@Test func renderBlockIncludesProjectIdentity() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let project = ProjectEntry(name: "My Project", path: dir)
let svc = ProjectAgentContextService(context: .local)
let block = svc.renderBlock(for: project)
#expect(block.contains(ProjectAgentContextService.beginMarker))
#expect(block.contains(ProjectAgentContextService.endMarker))
#expect(block.contains("\"My Project\""))
#expect(block.contains(dir))
#expect(block.contains("dashboard.json"))
}
@Test func renderBlockOmitsTemplateSectionForBareProject() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let project = ProjectEntry(name: "Bare", path: dir)
let svc = ProjectAgentContextService(context: .local)
let block = svc.renderBlock(for: project)
#expect(!block.contains("**Template:**"))
#expect(block.contains("**Configuration fields:** (none)"))
}
@Test func renderBlockIncludesTemplateWhenManifestPresent() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let scarfDir = dir + "/.scarf"
try FileManager.default.createDirectory(atPath: scarfDir, withIntermediateDirectories: true)
// Minimal valid v1 manifest no config schema.
let manifest = """
{
"schemaVersion": 1,
"id": "author/example",
"name": "Example",
"version": "1.2.3",
"description": "",
"contents": { "dashboard": true, "agentsMd": true }
}
"""
try manifest.data(using: .utf8)!.write(to: URL(fileURLWithPath: scarfDir + "/manifest.json"))
let project = ProjectEntry(name: "Example", path: dir)
let svc = ProjectAgentContextService(context: .local)
let block = svc.renderBlock(for: project)
#expect(block.contains("**Template:** `author/example` v1.2.3"))
}
@Test func renderBlockListsConfigFieldNamesNotValues() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let scarfDir = dir + "/.scarf"
try FileManager.default.createDirectory(atPath: scarfDir, withIntermediateDirectories: true)
// Schema-bearing manifest with one string field and one secret.
let manifest = """
{
"schemaVersion": 2,
"id": "x/y",
"name": "Y",
"version": "1.0.0",
"description": "",
"contents": { "dashboard": true, "agentsMd": true, "config": 2 },
"config": {
"schema": [
{ "key": "site_url", "type": "string", "label": "Site URL", "required": true },
{ "key": "api_token", "type": "secret", "label": "API Token", "required": true }
]
}
}
"""
try manifest.data(using: .utf8)!.write(to: URL(fileURLWithPath: scarfDir + "/manifest.json"))
// A config.json with a "secret" VALUE the block must NOT
// echo this value. If it does, secrets leak into an agent-
// readable file, which is exactly the thing to avoid.
let configJSON = """
{
"schemaVersion": 2,
"templateId": "x/y",
"values": {
"site_url": { "type": "string", "value": "https://example.com" },
"api_token": { "type": "keychainRef", "uri": "keychain://com.scarf.template.x-y/api_token:abc123" }
},
"updatedAt": "2026-04-24T00:00:00Z"
}
"""
try configJSON.data(using: .utf8)!.write(to: URL(fileURLWithPath: scarfDir + "/config.json"))
let project = ProjectEntry(name: "Y", path: dir)
let svc = ProjectAgentContextService(context: .local)
let block = svc.renderBlock(for: project)
// Field names present with type hints.
#expect(block.contains("`site_url`"))
#expect(block.contains("`api_token`"))
#expect(block.contains("(secret — name only, value stored in Keychain)"))
// CRITICAL: no VALUES appear not the site URL, not the
// keychain ref. The block is safe to drop into an agent
// context.
#expect(!block.contains("https://example.com"))
#expect(!block.contains("keychain://"))
#expect(!block.contains("abc123"))
}
// MARK: - refresh end-to-end (temp dir on local filesystem)
@Test func refreshCreatesAGENTSMdWhenMissing() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let project = ProjectEntry(name: "Fresh", path: dir)
try ProjectAgentContextService(context: .local).refresh(for: project)
let agentsMd = dir + "/AGENTS.md"
#expect(FileManager.default.fileExists(atPath: agentsMd))
let contents = try String(contentsOf: URL(fileURLWithPath: agentsMd))
#expect(contents.contains(ProjectAgentContextService.beginMarker))
#expect(contents.contains(ProjectAgentContextService.endMarker))
#expect(contents.contains("\"Fresh\""))
}
@Test func refreshPreservesUserContentBelow() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let agentsMd = dir + "/AGENTS.md"
let userContent = "# Template\n\nDo the thing.\n"
try userContent.data(using: .utf8)!.write(to: URL(fileURLWithPath: agentsMd))
let project = ProjectEntry(name: "Preserved", path: dir)
try ProjectAgentContextService(context: .local).refresh(for: project)
let after = try String(contentsOf: URL(fileURLWithPath: agentsMd))
#expect(after.contains(ProjectAgentContextService.beginMarker))
#expect(after.contains("# Template"))
#expect(after.contains("Do the thing."))
// Block goes FIRST; user content follows.
let beginIdx = after.range(of: ProjectAgentContextService.beginMarker)!.lowerBound
let userIdx = after.range(of: "# Template")!.lowerBound
#expect(beginIdx < userIdx)
}
@Test func refreshIsFullyIdempotent() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let project = ProjectEntry(name: "Twice", path: dir)
let svc = ProjectAgentContextService(context: .local)
try svc.refresh(for: project)
let first = try Data(contentsOf: URL(fileURLWithPath: dir + "/AGENTS.md"))
try svc.refresh(for: project)
let second = try Data(contentsOf: URL(fileURLWithPath: dir + "/AGENTS.md"))
#expect(first == second)
}
@Test func refreshRewritesStaleBlock() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let agentsMd = dir + "/AGENTS.md"
// Pre-seed a stale Scarf block with a different project name
// and a user section below.
let seed = """
<!-- scarf-project:begin -->
Old stale content project was called "Something Else".
<!-- scarf-project:end -->
# Template
"""
try seed.data(using: .utf8)!.write(to: URL(fileURLWithPath: agentsMd))
let project = ProjectEntry(name: "Current Name", path: dir)
try ProjectAgentContextService(context: .local).refresh(for: project)
let after = try String(contentsOf: URL(fileURLWithPath: agentsMd))
#expect(after.contains("\"Current Name\""))
#expect(!after.contains("Something Else"))
#expect(after.contains("# Template"))
}
// MARK: - Helpers
nonisolated static func makeTempDir() throws -> String {
let dir = NSTemporaryDirectory() + "scarf-project-context-test-" + UUID().uuidString
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
return dir
}
}
@@ -0,0 +1,130 @@
import Testing
import Foundation
import ScarfCore
@testable import scarf
/// v2.3 grew `ProjectEntry` with `folder` and `archived` fields.
/// Both are optional/defaulted at the decoder so v2.2-era
/// `~/.hermes/scarf/projects.json` files still parse cleanly, and
/// v2.3-written files are forward-compatible with v2.2 readers
/// (which ignore unknown keys). These tests lock in both ends of
/// that contract.
///
/// No disk or Hermes dependency we work entirely with in-memory
/// `Data`, so the `TestRegistryLock` from `ProjectTemplateTests` isn't
/// needed. Safe to run in parallel with every other test suite.
@Suite struct ProjectRegistryMigrationTests {
@Test func decodesV22RegistryWithoutNewFields() throws {
// v2.2-era file: just name + path. No folder, no archived.
let json = """
{
"projects": [
{ "name": "Legacy", "path": "/Users/x/legacy" },
{ "name": "Another", "path": "/Users/x/another" }
]
}
""".data(using: .utf8)!
let registry = try JSONDecoder().decode(ProjectRegistry.self, from: json)
#expect(registry.projects.count == 2)
#expect(registry.projects[0].name == "Legacy")
#expect(registry.projects[0].path == "/Users/x/legacy")
// Defaults hydrate for absent v2.3 fields.
#expect(registry.projects[0].folder == nil)
#expect(registry.projects[0].archived == false)
}
@Test func decodesV23RegistryWithFolderAndArchived() throws {
let json = """
{
"projects": [
{ "name": "Client A", "path": "/Users/x/a", "folder": "Clients" },
{ "name": "Client B", "path": "/Users/x/b", "folder": "Clients", "archived": true },
{ "name": "Personal", "path": "/Users/x/p" }
]
}
""".data(using: .utf8)!
let registry = try JSONDecoder().decode(ProjectRegistry.self, from: json)
#expect(registry.projects.count == 3)
#expect(registry.projects[0].folder == "Clients")
#expect(registry.projects[0].archived == false)
#expect(registry.projects[1].folder == "Clients")
#expect(registry.projects[1].archived == true)
#expect(registry.projects[2].folder == nil)
#expect(registry.projects[2].archived == false)
}
@Test func encodeOmitsDefaultedFields() throws {
// A top-level, non-archived project should encode with ONLY
// name + path keys. This keeps v2.3-written registries
// loadable by v2.2 Scarf (which ignores unknown keys), and
// keeps the file clean for the common case.
let entry = ProjectEntry(name: "Plain", path: "/Users/x/plain")
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(entry)
let s = try #require(String(data: data, encoding: .utf8))
#expect(s == #"{"name":"Plain","path":"\/Users\/x\/plain"}"#)
}
@Test func encodeIncludesFolderWhenPresent() throws {
let entry = ProjectEntry(name: "Acme", path: "/a", folder: "Clients")
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(entry)
let s = try #require(String(data: data, encoding: .utf8))
#expect(s.contains(#""folder":"Clients""#))
// archived still omitted when false cleanliness matters.
#expect(!s.contains(#""archived""#))
}
@Test func encodeIncludesArchivedOnlyWhenTrue() throws {
let archived = ProjectEntry(name: "Old", path: "/o", archived: true)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(archived)
let s = try #require(String(data: data, encoding: .utf8))
#expect(s.contains(#""archived":true"#))
let active = ProjectEntry(name: "New", path: "/n", archived: false)
let data2 = try encoder.encode(active)
let s2 = try #require(String(data: data2, encoding: .utf8))
#expect(!s2.contains(#""archived""#))
}
@Test func roundTripPreservesAllFields() throws {
let original = ProjectRegistry(projects: [
ProjectEntry(name: "Top", path: "/t"),
ProjectEntry(name: "InFolder", path: "/f", folder: "Work"),
ProjectEntry(name: "ArchivedTop", path: "/a", archived: true),
ProjectEntry(name: "ArchivedInFolder", path: "/af", folder: "Work", archived: true)
])
let encoded = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(ProjectRegistry.self, from: encoded)
#expect(decoded.projects.count == 4)
#expect(decoded.projects[0].folder == nil && decoded.projects[0].archived == false)
#expect(decoded.projects[1].folder == "Work" && decoded.projects[1].archived == false)
#expect(decoded.projects[2].folder == nil && decoded.projects[2].archived == true)
#expect(decoded.projects[3].folder == "Work" && decoded.projects[3].archived == true)
}
@Test func identityStaysKeyedOnName() throws {
// ProjectEntry.id should remain `name`, so selecting by id
// across a folder-move or archive-flip still works without
// a reselection step.
let a = ProjectEntry(name: "Foo", path: "/p")
let b = ProjectEntry(name: "Foo", path: "/p", folder: "Clients")
let c = ProjectEntry(name: "Foo", path: "/p", archived: true)
#expect(a.id == "Foo")
#expect(b.id == "Foo")
#expect(c.id == "Foo")
#expect(a.id == b.id)
#expect(a.id == c.id)
}
}
@@ -1064,6 +1064,68 @@ final class TestRegistryLock: @unchecked Sendable {
#expect(cronPrompt.contains("{{PROJECT_DIR}}"))
}
/// Exercises the second shipped template `awizemann/template-author`
/// which is a skill-only bundle (no config, no cron, no memory). The
/// shape is deliberately different from site-status-checker so a
/// regression in the installer's "no config, no cron" path can't hide
/// behind the richer example template. Also asserts the skill lands
/// under the expected namespaced path so Hermes's recursive skill
/// discovery finds it.
@Test func templateAuthorParsesAndPlans() throws {
let bundle = try Self.locateExample(author: "awizemann", name: "template-author")
let service = ProjectTemplateService(context: .local)
let inspection = try service.inspect(zipPath: bundle)
defer { service.cleanupTempDir(inspection.unpackedDir) }
// Manifest shape: schemaVersion 2 (contains `skills` claim, which
// wasn't part of v1), no config, no cron, one skill.
#expect(inspection.manifest.id == "awizemann/template-author")
#expect(inspection.manifest.name == "Scarf Template Author")
#expect(inspection.manifest.version == "1.0.0")
#expect(inspection.manifest.schemaVersion == 2)
#expect(inspection.manifest.contents.dashboard)
#expect(inspection.manifest.contents.agentsMd)
#expect(inspection.manifest.contents.cron == nil)
#expect(inspection.manifest.contents.config == nil)
#expect(inspection.manifest.contents.memory == nil)
#expect(inspection.manifest.contents.skills == ["scarf-template-author"])
#expect(inspection.manifest.config == nil)
#expect(inspection.cronJobs.isEmpty)
// Plan: empty config, empty cron, but one skill queued for install
// under the template's namespaced dir. The namespace path has to
// match what the uninstaller wipes `skills/templates/<slug>`
// or uninstall leaves orphan skill files.
let scratch = try ProjectTemplateServiceTests.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: scratch) }
let plan = try service.buildPlan(inspection: inspection, parentDir: scratch)
#expect(plan.projectDir.hasSuffix("awizemann-template-author"))
#expect(plan.cronJobs.isEmpty)
#expect(plan.configSchema == nil)
#expect(plan.configValues.isEmpty)
#expect(plan.memoryAppendix == nil)
// The skill should land at
// `~/.hermes/skills/templates/awizemann-template-author/scarf-template-author/SKILL.md`
// namespace dir + skill folder + SKILL.md. Anything else
// breaks Hermes's recursive discovery or the uninstaller's
// `rm -rf` on the namespace dir.
let namespaceDir = try #require(plan.skillsNamespaceDir)
#expect(namespaceDir.hasSuffix("/skills/templates/awizemann-template-author"))
#expect(plan.skillsFiles.count == 1)
let skillDest = try #require(plan.skillsFiles.first?.destinationPath)
#expect(skillDest.hasSuffix("/scarf-template-author/SKILL.md"))
#expect(skillDest.hasPrefix(namespaceDir))
// No-config templates deliberately skip the manifest cache
// the dashboard's Configuration button only shows up when
// `.scarf/manifest.json` exists, so a skill-only template
// like this one correctly doesn't surface that button.
// (See ProjectTemplateService.buildPlan lines 198227.)
#expect(plan.manifestCachePath == nil)
}
/// Resolve the example bundle path robustly. Unit-test working dirs
/// differ between `xcodebuild test` (project root) and an Xcode IDE
/// run (build-output dir), so we walk up from this source file until
@@ -0,0 +1,174 @@
import Testing
import Foundation
import ScarfCore
@testable import scarf
/// Exercises the v2.3 registry verbs added to ProjectsViewModel:
/// moveProject, renameProject, archiveProject, unarchiveProject,
/// + the derived `folders` list. All verbs write through to
/// `~/.hermes/scarf/projects.json` via ProjectDashboardService, so
/// each test uses TestRegistryLock to snapshot + restore the real
/// file. Cross-suite serialization ensures we don't race with other
/// registry-touching tests.
@MainActor @Suite(.serialized) struct ProjectsViewModelTests {
@Test func moveProjectSetsFolder() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "Alpha", path: "/a"),
ProjectEntry(name: "Beta", path: "/b")
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
#expect(vm.projects.count == 2)
vm.moveProject(vm.projects[0], toFolder: "Clients")
#expect(vm.projects.count == 2)
#expect(vm.projects.first(where: { $0.name == "Alpha" })?.folder == "Clients")
#expect(vm.projects.first(where: { $0.name == "Beta" })?.folder == nil)
// Round-trip: reload from disk and confirm the move persisted.
let fresh = ProjectDashboardService(context: .local).loadRegistry()
#expect(fresh.projects.first(where: { $0.name == "Alpha" })?.folder == "Clients")
}
@Test func moveProjectToNilReturnsToTopLevel() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "Nested", path: "/n", folder: "Clients")
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
vm.moveProject(vm.projects[0], toFolder: nil)
#expect(vm.projects[0].folder == nil)
let fresh = ProjectDashboardService(context: .local).loadRegistry()
#expect(fresh.projects[0].folder == nil)
}
@Test func renameProjectUpdatesNameAndPreservesOtherFields() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "OldName", path: "/p", folder: "Work", archived: false)
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
vm.selectProject(vm.projects[0])
let ok = vm.renameProject(vm.projects[0], to: "NewName")
#expect(ok == true)
#expect(vm.projects.count == 1)
#expect(vm.projects[0].name == "NewName")
#expect(vm.projects[0].folder == "Work")
#expect(vm.projects[0].archived == false)
// Selection follows the rename the user stays on the same
// project they were on.
#expect(vm.selectedProject?.name == "NewName")
}
@Test func renameProjectRejectsDuplicateName() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "A", path: "/a"),
ProjectEntry(name: "B", path: "/b")
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
// Renaming A to B should be refused B already exists.
let ok = vm.renameProject(vm.projects[0], to: "B")
#expect(ok == false)
// Registry unchanged.
#expect(vm.projects.map(\.name) == ["A", "B"])
}
@Test func renameProjectRejectsEmptyName() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "Foo", path: "/f")
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
#expect(vm.renameProject(vm.projects[0], to: "") == false)
#expect(vm.renameProject(vm.projects[0], to: " ") == false)
#expect(vm.projects[0].name == "Foo")
}
@Test func renameProjectToSameNameIsNoOpSuccess() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "Foo", path: "/f")
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
#expect(vm.renameProject(vm.projects[0], to: "Foo") == true)
// Whitespace around matching name also no-ops.
#expect(vm.renameProject(vm.projects[0], to: " Foo ") == true)
#expect(vm.projects[0].name == "Foo")
}
@Test func archiveAndUnarchiveProject() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "Target", path: "/t")
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
vm.selectProject(vm.projects[0])
#expect(vm.projects[0].archived == false)
#expect(vm.selectedProject != nil)
vm.archiveProject(vm.projects[0])
#expect(vm.projects[0].archived == true)
// Archiving clears the selection so the dashboard doesn't
// linger on a project the sidebar will hide.
#expect(vm.selectedProject == nil)
vm.unarchiveProject(vm.projects[0])
#expect(vm.projects[0].archived == false)
// Unarchive doesn't re-select the user chose to hide it,
// surfacing it doesn't mean they want focus back.
#expect(vm.selectedProject == nil)
}
@Test func foldersListIsSortedAndDeduped() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "A", path: "/a", folder: "Work"),
ProjectEntry(name: "B", path: "/b", folder: "Personal"),
ProjectEntry(name: "C", path: "/c", folder: "Work"),
ProjectEntry(name: "D", path: "/d"), // top-level
ProjectEntry(name: "E", path: "/e", folder: "") // empty string treated as nil
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
#expect(vm.folders == ["Personal", "Work"])
}
// MARK: - Helpers
@MainActor
private func seedRegistry(_ registry: ProjectRegistry) throws {
try ProjectDashboardService(context: .local).saveRegistry(registry)
}
}
@@ -0,0 +1,154 @@
import Testing
import Foundation
import ScarfCore
@testable import scarf
/// Exercises the v2.3 sidecar at `~/.hermes/scarf/session_project_map.json`
/// via the real `ServerContext.local`. Each test snapshots + restores
/// the file through `TestRegistryLock` (reused the sidecar lives
/// in the same scarf/ dir as projects.json, so serialising on one
/// lock prevents both cross-suite races).
///
/// We scope the shared lock to this file's registry helper so tests
/// here don't step on the real registry either.
@Suite(.serialized) struct SessionAttributionServiceTests {
@Test func loadOnMissingFileReturnsEmptyMap() throws {
let snapshot = Self.snapshot()
defer { Self.restore(snapshot) }
Self.deleteSidecar()
let svc = SessionAttributionService(context: .local)
let map = svc.load()
#expect(map.mappings.isEmpty)
#expect(svc.projectPath(for: "anything") == nil)
#expect(svc.sessionIDs(forProject: "/anything").isEmpty)
}
@Test func attributeWritesMappingAndPersists() throws {
let snapshot = Self.snapshot()
defer { Self.restore(snapshot) }
Self.deleteSidecar()
let svc = SessionAttributionService(context: .local)
svc.attribute(sessionID: "sess-1", toProjectPath: "/proj/a")
// Read back via a fresh service instance confirms the
// write actually landed on disk, not just the in-memory map.
let fresh = SessionAttributionService(context: .local)
#expect(fresh.projectPath(for: "sess-1") == "/proj/a")
// updatedAt populated on write.
let map = fresh.load()
let ts = try #require(map.updatedAt)
#expect(!ts.isEmpty)
}
@Test func attributeIsIdempotent() throws {
let snapshot = Self.snapshot()
defer { Self.restore(snapshot) }
Self.deleteSidecar()
let svc = SessionAttributionService(context: .local)
svc.attribute(sessionID: "s", toProjectPath: "/p")
let firstStamp = svc.load().updatedAt
// Call again with the same pair should short-circuit, NOT
// bump updatedAt. We check that the timestamp didn't change
// even if the file would have been rewritten.
svc.attribute(sessionID: "s", toProjectPath: "/p")
let secondStamp = svc.load().updatedAt
#expect(firstStamp == secondStamp)
}
@Test func reattributeChangesMapping() throws {
let snapshot = Self.snapshot()
defer { Self.restore(snapshot) }
Self.deleteSidecar()
let svc = SessionAttributionService(context: .local)
svc.attribute(sessionID: "s", toProjectPath: "/a")
svc.attribute(sessionID: "s", toProjectPath: "/b")
#expect(svc.projectPath(for: "s") == "/b")
#expect(svc.sessionIDs(forProject: "/a").isEmpty)
#expect(svc.sessionIDs(forProject: "/b") == ["s"])
}
@Test func reverseLookupReturnsAllAttributedSessions() throws {
let snapshot = Self.snapshot()
defer { Self.restore(snapshot) }
Self.deleteSidecar()
let svc = SessionAttributionService(context: .local)
svc.attribute(sessionID: "s1", toProjectPath: "/proj")
svc.attribute(sessionID: "s2", toProjectPath: "/proj")
svc.attribute(sessionID: "s3", toProjectPath: "/other")
#expect(svc.sessionIDs(forProject: "/proj") == ["s1", "s2"])
#expect(svc.sessionIDs(forProject: "/other") == ["s3"])
#expect(svc.sessionIDs(forProject: "/nobody").isEmpty)
}
@Test func forgetRemovesMapping() throws {
let snapshot = Self.snapshot()
defer { Self.restore(snapshot) }
Self.deleteSidecar()
let svc = SessionAttributionService(context: .local)
svc.attribute(sessionID: "s", toProjectPath: "/p")
#expect(svc.projectPath(for: "s") == "/p")
svc.forget(sessionID: "s")
#expect(svc.projectPath(for: "s") == nil)
// Forget on a missing session is a no-op, not an error.
svc.forget(sessionID: "s")
#expect(svc.projectPath(for: "s") == nil)
}
@Test func corruptedFileReturnsEmptyMap() throws {
let snapshot = Self.snapshot()
defer { Self.restore(snapshot) }
// Write garbage to the sidecar path and confirm the service
// treats it as "no attributions" rather than crashing. Users
// hand-editing the JSON shouldn't soft-brick the Sessions tab.
let path = ServerContext.local.paths.sessionProjectMap
try FileManager.default.createDirectory(
atPath: (path as NSString).deletingLastPathComponent,
withIntermediateDirectories: true
)
try "not json at all".data(using: .utf8)!.write(to: URL(fileURLWithPath: path))
let svc = SessionAttributionService(context: .local)
let map = svc.load()
#expect(map.mappings.isEmpty)
}
// MARK: - Helpers
/// Snapshot + restore the sidecar file (and delete if missing).
/// Uses the shared TestRegistryLock so this suite serialises
/// with any other registry-writing suite both touch scarfDir.
static func snapshot() -> (lockToken: Any, data: Data?) {
// Re-use the ProjectTemplateTests lock implementation
// same NSLock gates all scarfDir writes across suites.
let projectSnapshot = TestRegistryLock.acquireAndSnapshot()
let path = ServerContext.local.paths.sessionProjectMap
let sidecarData = try? Data(contentsOf: URL(fileURLWithPath: path))
return (lockToken: projectSnapshot as Any, data: sidecarData)
}
static func restore(_ snapshot: (lockToken: Any, data: Data?)) {
let path = ServerContext.local.paths.sessionProjectMap
if let data = snapshot.data {
try? data.write(to: URL(fileURLWithPath: path))
} else {
try? FileManager.default.removeItem(atPath: path)
}
// Release the shared lock via the existing helper.
TestRegistryLock.restore(snapshot.lockToken as? Data)
}
static func deleteSidecar() {
let path = ServerContext.local.paths.sessionProjectMap
try? FileManager.default.removeItem(atPath: path)
}
}
+200
View File
@@ -0,0 +1,200 @@
import Testing
import Foundation
import ScarfCore
@testable import scarf
/// Invariants around Hermes v0.10.0 Tool Gateway integration:
/// overlay-provider merge, Nous Portal subscription detection, and
/// `platform_toolsets` YAML parsing.
@Suite struct ToolGatewayTests {
// MARK: - Fixtures
/// Minimal models.dev cache with exactly two providers so the overlay
/// merge is easy to reason about none of them are overlays.
private func writeCacheFixture() throws -> String {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-catalog-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let path = dir.appendingPathComponent("models_dev_cache.json").path
let json = """
{
"anthropic": {
"name": "Anthropic",
"models": {
"claude-sonnet-4-5-20250929": { "name": "Claude Sonnet 4.5" }
}
},
"openai": {
"name": "OpenAI",
"models": {
"gpt-4o": { "name": "GPT-4o" }
}
}
}
"""
try json.write(toFile: path, atomically: true, encoding: .utf8)
return path
}
private func writeAuthFixture(_ body: String) throws -> String {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-auth-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let path = dir.appendingPathComponent("auth.json").path
try body.write(toFile: path, atomically: true, encoding: .utf8)
return path
}
// MARK: - ModelCatalogService overlay merge
@Test func overlayOnlyProvidersAppearInPicker() throws {
let path = try writeCacheFixture()
let service = ModelCatalogService(path: path)
let providers = service.loadProviders()
let ids = providers.map(\.providerID)
#expect(ids.contains("nous"), "Nous Portal must appear after overlay merge")
#expect(ids.contains("openai-codex"), "OpenAI Codex overlay must appear")
#expect(ids.contains("qwen-oauth"), "Qwen OAuth overlay must appear")
// Cached providers still present.
#expect(ids.contains("anthropic"))
#expect(ids.contains("openai"))
}
@Test func nousPortalSortsFirst() throws {
let path = try writeCacheFixture()
let service = ModelCatalogService(path: path)
let providers = service.loadProviders()
#expect(providers.first?.providerID == "nous",
"Subscription-gated providers must sort before the alphabetical block")
}
@Test func overlayProvidersCarryMetadata() throws {
let path = try writeCacheFixture()
let service = ModelCatalogService(path: path)
let providers = service.loadProviders()
let nous = providers.first { $0.providerID == "nous" }
#expect(nous?.isOverlay == true)
#expect(nous?.subscriptionGated == true)
#expect(nous?.providerName == "Nous Portal")
#expect(nous?.modelCount == 0, "Overlay-only providers have no models in the cache")
let codex = providers.first { $0.providerID == "openai-codex" }
#expect(codex?.isOverlay == true)
#expect(codex?.subscriptionGated == false,
"Only Nous is subscription-gated today")
}
@Test func cachedProvidersAreNotMarkedOverlay() throws {
let path = try writeCacheFixture()
let service = ModelCatalogService(path: path)
let providers = service.loadProviders()
let anthropic = providers.first { $0.providerID == "anthropic" }
#expect(anthropic?.isOverlay == false)
#expect(anthropic?.subscriptionGated == false)
}
@Test func providerByIDReturnsOverlayWhenCacheMisses() throws {
let path = try writeCacheFixture()
let service = ModelCatalogService(path: path)
let nous = service.providerByID("nous")
#expect(nous?.providerName == "Nous Portal")
#expect(nous?.isOverlay == true)
let missing = service.providerByID("definitely-not-a-provider")
#expect(missing == nil)
}
// MARK: - NousSubscriptionService
@Test func subscriptionAbsentWhenAuthFileMissing() throws {
let path = "/tmp/this-file-should-not-exist-\(UUID().uuidString).json"
let service = NousSubscriptionService(path: path)
let state = service.loadState()
#expect(state == .absent)
}
@Test func subscriptionAbsentWhenProvidersEmpty() throws {
let path = try writeAuthFixture("""
{ "version": 1, "providers": {}, "active_provider": null }
""")
let state = NousSubscriptionService(path: path).loadState()
#expect(state.present == false)
#expect(state.subscribed == false)
}
@Test func subscriptionPresentButInactiveWhenOtherProviderActive() throws {
let path = try writeAuthFixture("""
{
"version": 1,
"providers": { "nous": { "access_token": "tok-12345" } },
"active_provider": "anthropic"
}
""")
let state = NousSubscriptionService(path: path).loadState()
#expect(state.present == true)
#expect(state.providerIsNous == false)
#expect(state.subscribed == false,
"Auth alone isn't enough — the Tool Gateway only routes when Nous is the active provider")
}
@Test func subscriptionActiveWhenAuthAndActiveProviderLineUp() throws {
let path = try writeAuthFixture("""
{
"version": 1,
"providers": { "nous": { "access_token": "tok-12345" } },
"active_provider": "nous"
}
""")
let state = NousSubscriptionService(path: path).loadState()
#expect(state.present == true)
#expect(state.providerIsNous == true)
#expect(state.subscribed == true)
}
@Test func subscriptionAbsentWhenTokenEmpty() throws {
let path = try writeAuthFixture("""
{
"version": 1,
"providers": { "nous": { "access_token": "" } },
"active_provider": "nous"
}
""")
let state = NousSubscriptionService(path: path).loadState()
#expect(state.present == false,
"Empty token is as good as no token — don't claim subscription")
}
@Test func subscriptionAbsentOnMalformedJSON() throws {
let path = try writeAuthFixture("{ this is not valid json")
let state = NousSubscriptionService(path: path).loadState()
#expect(state == .absent)
}
// MARK: - platform_toolsets YAML parse
@Test func platformToolsetsParsed() throws {
let yaml = """
model:
default: claude-sonnet-4.5
provider: anthropic
platform_toolsets:
cli:
- browser
- messaging
slack:
- messaging
"""
let parsed = HermesFileService.parseNestedYAML(yaml)
#expect(parsed.lists["platform_toolsets.cli"] == ["browser", "messaging"])
#expect(parsed.lists["platform_toolsets.slack"] == ["messaging"])
}
@Test func platformToolsetsEmptyWhenMissing() throws {
// HermesConfig.empty should have no platform toolsets.
let config = HermesConfig.empty
#expect(config.platformToolsets.isEmpty)
}
}