iOS port M0a: extract 13 leaf Models to new ScarfCore local SPM package

First of four M0 sub-PRs that carve a platform-neutral ScarfCore package
out of the Mac app, in preparation for an iOS target. This PR is
Mac-only — no iOS target yet, no behavior changes expected.

What moves to ScarfCore:
  - 13 leaf model files (HermesSession, HermesMessage, HermesConfig and
    its 19 nested Settings structs, HermesCronJob, HermesMCPServer,
    HermesSkill, HermesSlashCommand, HermesTool + KnownPlatforms,
    HermesPathSet, MCPServerPreset, ProjectDashboard family, ACPMessages).
  - Portable half of HermesConstants.swift (sqliteTransient, QueryDefaults,
    FileSizeUnit). The deprecated HermesPaths enum stays in main target
    as HermesPaths+Deprecated.swift since it references ServerContext.

What stays in the Mac target:
  - ServerContext.swift (moves in M0b alongside Transport — depends on
    LocalTransport/SSHTransport + HermesFileService).
  - HermesPaths+Deprecated.swift (dead forwarders, zero callers in-tree;
    kept for safety until M0b can clean them up).

Mechanics:
  - New Packages/ScarfCore/Package.swift targeting macOS 14 / iOS 18,
    Swift 6 language mode.
  - Every moved type and member marked public; explicit public memberwise
    init added to every struct (Swift's synthesized memberwise init is
    internal and would break cross-module construction).
  - Xcode project references the package via XCLocalSwiftPackageReference
    and links ScarfCore into the scarf target.
  - 49 consumer files get `import ScarfCore` added.

See scarf/docs/IOS_PORT_PLAN.md for the full multi-phase plan, locked
decisions (iOS 18, iPhone only, no APNs v1), and the M0b–M6 roadmap.

Manual verification checklist:
  - Open scarf.xcodeproj in Xcode and build the scarf scheme — should
    resolve the local package and compile with no new errors.
  - Run scarfTests — should pass (tests don't touch moved types).
  - Smoke-run the app: Dashboard, Sessions, Chat, Memory should render
    with identical data to pre-PR.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
This commit is contained in:
Claude
2026-04-22 12:19:17 +00:00
parent 3e0d2db4c7
commit bb5045c10f
73 changed files with 2029 additions and 990 deletions
@@ -8,15 +8,25 @@ import Foundation
// decoded inside `ACPClient`'s actor context (the JSON-RPC read/write loop). // decoded inside `ACPClient`'s actor context (the JSON-RPC read/write loop).
// The member list must stay in sync with the stored properties above. // The member list must stay in sync with the stored properties above.
struct ACPRequest: Encodable, Sendable { public struct ACPRequest: Encodable, Sendable {
nonisolated let jsonrpc = "2.0" public nonisolated let jsonrpc = "2.0"
nonisolated let id: Int public nonisolated let id: Int
nonisolated let method: String public nonisolated let method: String
nonisolated let params: [String: AnyCodable] public nonisolated let params: [String: AnyCodable]
enum CodingKeys: String, CodingKey { case jsonrpc, id, method, params }
nonisolated func encode(to encoder: any Encoder) throws { public init(
id: Int,
method: String,
params: [String: AnyCodable]
) {
self.id = id
self.method = method
self.params = params
}
public enum CodingKeys: String, CodingKey { case jsonrpc, id, method, params }
public nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self) var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(jsonrpc, forKey: .jsonrpc) try c.encode(jsonrpc, forKey: .jsonrpc)
try c.encode(id, forKey: .id) try c.encode(id, forKey: .id)
@@ -25,21 +35,21 @@ struct ACPRequest: Encodable, Sendable {
} }
} }
struct ACPRawMessage: Decodable, Sendable { public struct ACPRawMessage: Decodable, Sendable {
nonisolated let jsonrpc: String? public nonisolated let jsonrpc: String?
nonisolated let id: Int? public nonisolated let id: Int?
nonisolated let method: String? public nonisolated let method: String?
nonisolated let result: AnyCodable? public nonisolated let result: AnyCodable?
nonisolated let error: ACPError? public nonisolated let error: ACPError?
nonisolated let params: AnyCodable? public nonisolated let params: AnyCodable?
nonisolated var isResponse: Bool { id != nil && method == nil } public nonisolated var isResponse: Bool { id != nil && method == nil }
nonisolated var isNotification: Bool { method != nil && id == nil } public nonisolated var isNotification: Bool { method != nil && id == nil }
nonisolated var isRequest: Bool { method != nil && id != nil } public nonisolated var isRequest: Bool { method != nil && id != nil }
enum CodingKeys: String, CodingKey { case jsonrpc, id, method, result, error, params } public enum CodingKeys: String, CodingKey { case jsonrpc, id, method, result, error, params }
nonisolated init(from decoder: any Decoder) throws { public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
self.jsonrpc = try c.decodeIfPresent(String.self, forKey: .jsonrpc) self.jsonrpc = try c.decodeIfPresent(String.self, forKey: .jsonrpc)
self.id = try c.decodeIfPresent(Int.self, forKey: .id) self.id = try c.decodeIfPresent(Int.self, forKey: .id)
@@ -50,13 +60,13 @@ struct ACPRawMessage: Decodable, Sendable {
} }
} }
struct ACPError: Decodable, Sendable { public struct ACPError: Decodable, Sendable {
nonisolated let code: Int public nonisolated let code: Int
nonisolated let message: String public nonisolated let message: String
enum CodingKeys: String, CodingKey { case code, message } public enum CodingKeys: String, CodingKey { case code, message }
nonisolated init(from decoder: any Decoder) throws { public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
self.code = try c.decode(Int.self, forKey: .code) self.code = try c.decode(Int.self, forKey: .code)
self.message = try c.decode(String.self, forKey: .message) self.message = try c.decode(String.self, forKey: .message)
@@ -65,10 +75,10 @@ struct ACPError: Decodable, Sendable {
// MARK: - AnyCodable (for dynamic JSON) // MARK: - AnyCodable (for dynamic JSON)
struct AnyCodable: Codable, @unchecked Sendable { public struct AnyCodable: Codable, @unchecked Sendable {
nonisolated let value: Any public nonisolated let value: Any
nonisolated init(_ value: Any) { self.value = value } public nonisolated init(_ value: Any) { self.value = value }
// NOT marked `nonisolated`: Swift's default-isolation treats writes to a // NOT marked `nonisolated`: Swift's default-isolation treats writes to a
// `let value: Any` stored property as MainActor-isolated even when the // `let value: Any` stored property as MainActor-isolated even when the
@@ -78,7 +88,7 @@ struct AnyCodable: Codable, @unchecked Sendable {
// conformance is still usable from ACPClient's nonisolated read loop // conformance is still usable from ACPClient's nonisolated read loop
// because all callers are already @preconcurrency with respect to // because all callers are already @preconcurrency with respect to
// `AnyCodable` (it's @unchecked Sendable). // `AnyCodable` (it's @unchecked Sendable).
init(from decoder: any Decoder) throws { public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer() let container = try decoder.singleValueContainer()
if container.decodeNil() { if container.decodeNil() {
value = NSNull() value = NSNull()
@@ -99,7 +109,7 @@ struct AnyCodable: Codable, @unchecked Sendable {
} }
} }
func encode(to encoder: any Encoder) throws { public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer() var container = encoder.singleValueContainer()
switch value { switch value {
case is NSNull: case is NSNull:
@@ -123,15 +133,15 @@ struct AnyCodable: Codable, @unchecked Sendable {
// MARK: - Accessors // MARK: - Accessors
nonisolated var stringValue: String? { value as? String } public nonisolated var stringValue: String? { value as? String }
nonisolated var intValue: Int? { value as? Int } public nonisolated var intValue: Int? { value as? Int }
nonisolated var dictValue: [String: Any]? { value as? [String: Any] } public nonisolated var dictValue: [String: Any]? { value as? [String: Any] }
nonisolated var arrayValue: [Any]? { value as? [Any] } public nonisolated var arrayValue: [Any]? { value as? [Any] }
} }
// MARK: - ACP Events (parsed from session/update notifications) // MARK: - ACP Events (parsed from session/update notifications)
enum ACPEvent: Sendable { public enum ACPEvent: Sendable {
case messageChunk(sessionId: String, text: String) case messageChunk(sessionId: String, text: String)
case thoughtChunk(sessionId: String, text: String) case thoughtChunk(sessionId: String, text: String)
case toolCallStart(sessionId: String, call: ACPToolCallEvent) case toolCallStart(sessionId: String, call: ACPToolCallEvent)
@@ -143,21 +153,37 @@ enum ACPEvent: Sendable {
case unknown(sessionId: String, type: String) case unknown(sessionId: String, type: String)
} }
struct ACPToolCallEvent: Sendable { public struct ACPToolCallEvent: Sendable {
let toolCallId: String public let toolCallId: String
let title: String public let title: String
let kind: String public let kind: String
let status: String public let status: String
let content: String public let content: String
let rawInput: [String: Any]? public let rawInput: [String: Any]?
var functionName: String {
public init(
toolCallId: String,
title: String,
kind: String,
status: String,
content: String,
rawInput: [String: Any]?
) {
self.toolCallId = toolCallId
self.title = title
self.kind = kind
self.status = status
self.content = content
self.rawInput = rawInput
}
public var functionName: String {
// title format is "functionName: summary" or just "functionName" // title format is "functionName: summary" or just "functionName"
let parts = title.split(separator: ":", maxSplits: 1) let parts = title.split(separator: ":", maxSplits: 1)
return String(parts.first ?? Substring(title)).trimmingCharacters(in: .whitespaces) return String(parts.first ?? Substring(title)).trimmingCharacters(in: .whitespaces)
} }
var argumentsSummary: String { public var argumentsSummary: String {
let parts = title.split(separator: ":", maxSplits: 1) let parts = title.split(separator: ":", maxSplits: 1)
if parts.count > 1 { if parts.count > 1 {
return String(parts[1]).trimmingCharacters(in: .whitespaces) return String(parts[1]).trimmingCharacters(in: .whitespaces)
@@ -165,7 +191,7 @@ struct ACPToolCallEvent: Sendable {
return "" return ""
} }
var argumentsJSON: String { public var argumentsJSON: String {
guard let input = rawInput, guard let input = rawInput,
let data = try? JSONSerialization.data(withJSONObject: input), let data = try? JSONSerialization.data(withJSONObject: input),
let str = String(data: data, encoding: .utf8) else { return "{}" } let str = String(data: data, encoding: .utf8) else { return "{}" }
@@ -173,32 +199,70 @@ struct ACPToolCallEvent: Sendable {
} }
} }
struct ACPToolCallUpdateEvent: Sendable { public struct ACPToolCallUpdateEvent: Sendable {
let toolCallId: String public let toolCallId: String
let kind: String public let kind: String
let status: String public let status: String
let content: String public let content: String
let rawOutput: String? public let rawOutput: String?
public init(
toolCallId: String,
kind: String,
status: String,
content: String,
rawOutput: String?
) {
self.toolCallId = toolCallId
self.kind = kind
self.status = status
self.content = content
self.rawOutput = rawOutput
}
} }
struct ACPPermissionRequestEvent: Sendable { public struct ACPPermissionRequestEvent: Sendable {
let toolCallTitle: String public let toolCallTitle: String
let toolCallKind: String public let toolCallKind: String
let options: [(optionId: String, name: String)] public let options: [(optionId: String, name: String)]
public init(
toolCallTitle: String,
toolCallKind: String,
options: [(optionId: String, name: String)]
) {
self.toolCallTitle = toolCallTitle
self.toolCallKind = toolCallKind
self.options = options
}
} }
struct ACPPromptResult: Sendable { public struct ACPPromptResult: Sendable {
let stopReason: String public let stopReason: String
let inputTokens: Int public let inputTokens: Int
let outputTokens: Int public let outputTokens: Int
let thoughtTokens: Int public let thoughtTokens: Int
let cachedReadTokens: Int public let cachedReadTokens: Int
public init(
stopReason: String,
inputTokens: Int,
outputTokens: Int,
thoughtTokens: Int,
cachedReadTokens: Int
) {
self.stopReason = stopReason
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.thoughtTokens = thoughtTokens
self.cachedReadTokens = cachedReadTokens
}
} }
// MARK: - Event Parsing // MARK: - Event Parsing
enum ACPEventParser { public enum ACPEventParser {
nonisolated static func parse(notification: ACPRawMessage) -> ACPEvent? { public nonisolated static func parse(notification: ACPRawMessage) -> ACPEvent? {
guard notification.method == "session/update", guard notification.method == "session/update",
let params = notification.params?.dictValue, let params = notification.params?.dictValue,
let sessionId = params["sessionId"] as? String, let sessionId = params["sessionId"] as? String,
@@ -246,7 +310,7 @@ enum ACPEventParser {
} }
} }
nonisolated static func parsePermissionRequest(_ message: ACPRawMessage) -> ACPEvent? { public nonisolated static func parsePermissionRequest(_ message: ACPRawMessage) -> ACPEvent? {
guard message.method == "session/request_permission", guard message.method == "session/request_permission",
let params = message.params?.dictValue, let params = message.params?.dictValue,
let sessionId = params["sessionId"] as? String, let sessionId = params["sessionId"] as? String,
@@ -0,0 +1,888 @@
import Foundation
/// Settings for one of hermes's auxiliary model tasks (vision, compression, approvals, etc.).
/// Every auxiliary task follows the same provider/model/base_url/api_key/timeout pattern.
public struct AuxiliaryModel: Sendable, Equatable {
public var provider: String
public var model: String
public var baseURL: String
public var apiKey: String
public var timeout: Int
public init(
provider: String,
model: String,
baseURL: String,
apiKey: String,
timeout: Int
) {
self.provider = provider
self.model = model
self.baseURL = baseURL
self.apiKey = apiKey
self.timeout = timeout
}
public nonisolated static let empty = AuxiliaryModel(provider: "auto", model: "", baseURL: "", apiKey: "", timeout: 30)
}
/// Group of display-related settings mirroring the `display:` block in config.yaml.
public struct DisplaySettings: Sendable, Equatable {
public var skin: String
public var compact: Bool
public var resumeDisplay: String // "full" | "minimal"
public var bellOnComplete: Bool
public var inlineDiffs: Bool
public var toolProgressCommand: Bool
public var toolPreviewLength: Int
public var busyInputMode: String // e.g. "interrupt"
public init(
skin: String,
compact: Bool,
resumeDisplay: String,
bellOnComplete: Bool,
inlineDiffs: Bool,
toolProgressCommand: Bool,
toolPreviewLength: Int,
busyInputMode: String
) {
self.skin = skin
self.compact = compact
self.resumeDisplay = resumeDisplay
self.bellOnComplete = bellOnComplete
self.inlineDiffs = inlineDiffs
self.toolProgressCommand = toolProgressCommand
self.toolPreviewLength = toolPreviewLength
self.busyInputMode = busyInputMode
}
public nonisolated static let empty = DisplaySettings(
skin: "default",
compact: false,
resumeDisplay: "full",
bellOnComplete: false,
inlineDiffs: true,
toolProgressCommand: false,
toolPreviewLength: 0,
busyInputMode: "interrupt"
)
}
/// Container/terminal backend options. These map to `terminal.*` keys in config.yaml.
public struct TerminalSettings: Sendable, Equatable {
public var cwd: String
public var timeout: Int
public var envPassthrough: [String]
public var persistentShell: Bool
public var dockerImage: String
public var dockerMountCwdToWorkspace: Bool
public var dockerForwardEnv: [String]
public var dockerVolumes: [String]
public var containerCPU: Int // 0 = unlimited
public var containerMemory: Int // MB, 0 = unlimited
public var containerDisk: Int // MB, 0 = unlimited
public var containerPersistent: Bool
public var modalImage: String
public var modalMode: String // "auto" | other
public var daytonaImage: String
public var singularityImage: String
public init(
cwd: String,
timeout: Int,
envPassthrough: [String],
persistentShell: Bool,
dockerImage: String,
dockerMountCwdToWorkspace: Bool,
dockerForwardEnv: [String],
dockerVolumes: [String],
containerCPU: Int,
containerMemory: Int,
containerDisk: Int,
containerPersistent: Bool,
modalImage: String,
modalMode: String,
daytonaImage: String,
singularityImage: String
) {
self.cwd = cwd
self.timeout = timeout
self.envPassthrough = envPassthrough
self.persistentShell = persistentShell
self.dockerImage = dockerImage
self.dockerMountCwdToWorkspace = dockerMountCwdToWorkspace
self.dockerForwardEnv = dockerForwardEnv
self.dockerVolumes = dockerVolumes
self.containerCPU = containerCPU
self.containerMemory = containerMemory
self.containerDisk = containerDisk
self.containerPersistent = containerPersistent
self.modalImage = modalImage
self.modalMode = modalMode
self.daytonaImage = daytonaImage
self.singularityImage = singularityImage
}
public nonisolated static let empty = TerminalSettings(
cwd: ".",
timeout: 180,
envPassthrough: [],
persistentShell: true,
dockerImage: "",
dockerMountCwdToWorkspace: false,
dockerForwardEnv: [],
dockerVolumes: [],
containerCPU: 0,
containerMemory: 0,
containerDisk: 0,
containerPersistent: false,
modalImage: "",
modalMode: "auto",
daytonaImage: "",
singularityImage: ""
)
}
/// Browser automation tuning (`browser.*`).
public struct BrowserSettings: Sendable, Equatable {
public var inactivityTimeout: Int
public var commandTimeout: Int
public var recordSessions: Bool
public var allowPrivateURLs: Bool
public var camofoxManagedPersistence: Bool
public init(
inactivityTimeout: Int,
commandTimeout: Int,
recordSessions: Bool,
allowPrivateURLs: Bool,
camofoxManagedPersistence: Bool
) {
self.inactivityTimeout = inactivityTimeout
self.commandTimeout = commandTimeout
self.recordSessions = recordSessions
self.allowPrivateURLs = allowPrivateURLs
self.camofoxManagedPersistence = camofoxManagedPersistence
}
public nonisolated static let empty = BrowserSettings(
inactivityTimeout: 120,
commandTimeout: 30,
recordSessions: false,
allowPrivateURLs: false,
camofoxManagedPersistence: false
)
}
/// Voice push-to-talk plus TTS/STT provider settings.
public struct VoiceSettings: Sendable, Equatable {
public var recordKey: String
public var maxRecordingSeconds: Int
public var silenceDuration: Double
// TTS
public var ttsProvider: String
public var ttsEdgeVoice: String
public var ttsElevenLabsVoiceID: String
public var ttsElevenLabsModelID: String
public var ttsOpenAIModel: String
public var ttsOpenAIVoice: String
public var ttsNeuTTSModel: String
public var ttsNeuTTSDevice: String
// STT
public var sttEnabled: Bool
public var sttProvider: String
public var sttLocalModel: String
public var sttLocalLanguage: String
public var sttOpenAIModel: String
public var sttMistralModel: String
public init(
recordKey: String,
maxRecordingSeconds: Int,
silenceDuration: Double,
ttsProvider: String,
ttsEdgeVoice: String,
ttsElevenLabsVoiceID: String,
ttsElevenLabsModelID: String,
ttsOpenAIModel: String,
ttsOpenAIVoice: String,
ttsNeuTTSModel: String,
ttsNeuTTSDevice: String,
sttEnabled: Bool,
sttProvider: String,
sttLocalModel: String,
sttLocalLanguage: String,
sttOpenAIModel: String,
sttMistralModel: String
) {
self.recordKey = recordKey
self.maxRecordingSeconds = maxRecordingSeconds
self.silenceDuration = silenceDuration
self.ttsProvider = ttsProvider
self.ttsEdgeVoice = ttsEdgeVoice
self.ttsElevenLabsVoiceID = ttsElevenLabsVoiceID
self.ttsElevenLabsModelID = ttsElevenLabsModelID
self.ttsOpenAIModel = ttsOpenAIModel
self.ttsOpenAIVoice = ttsOpenAIVoice
self.ttsNeuTTSModel = ttsNeuTTSModel
self.ttsNeuTTSDevice = ttsNeuTTSDevice
self.sttEnabled = sttEnabled
self.sttProvider = sttProvider
self.sttLocalModel = sttLocalModel
self.sttLocalLanguage = sttLocalLanguage
self.sttOpenAIModel = sttOpenAIModel
self.sttMistralModel = sttMistralModel
}
public nonisolated static let empty = VoiceSettings(
recordKey: "ctrl+b",
maxRecordingSeconds: 120,
silenceDuration: 3.0,
ttsProvider: "edge",
ttsEdgeVoice: "en-US-AriaNeural",
ttsElevenLabsVoiceID: "",
ttsElevenLabsModelID: "eleven_multilingual_v2",
ttsOpenAIModel: "gpt-4o-mini-tts",
ttsOpenAIVoice: "alloy",
ttsNeuTTSModel: "neuphonic/neutts-air-q4-gguf",
ttsNeuTTSDevice: "cpu",
sttEnabled: true,
sttProvider: "local",
sttLocalModel: "base",
sttLocalLanguage: "",
sttOpenAIModel: "whisper-1",
sttMistralModel: "voxtral-mini-latest"
)
}
/// Eight sub-models that share the same provider/model/base_url/api_key/timeout shape.
public struct AuxiliarySettings: Sendable, Equatable {
public var vision: AuxiliaryModel
public var webExtract: AuxiliaryModel
public var compression: AuxiliaryModel
public var sessionSearch: AuxiliaryModel
public var skillsHub: AuxiliaryModel
public var approval: AuxiliaryModel
public var mcp: AuxiliaryModel
public var flushMemories: AuxiliaryModel
public init(
vision: AuxiliaryModel,
webExtract: AuxiliaryModel,
compression: AuxiliaryModel,
sessionSearch: AuxiliaryModel,
skillsHub: AuxiliaryModel,
approval: AuxiliaryModel,
mcp: AuxiliaryModel,
flushMemories: AuxiliaryModel
) {
self.vision = vision
self.webExtract = webExtract
self.compression = compression
self.sessionSearch = sessionSearch
self.skillsHub = skillsHub
self.approval = approval
self.mcp = mcp
self.flushMemories = flushMemories
}
public nonisolated static let empty = AuxiliarySettings(
vision: .empty,
webExtract: .empty,
compression: .empty,
sessionSearch: .empty,
skillsHub: .empty,
approval: .empty,
mcp: .empty,
flushMemories: .empty
)
}
/// Security/redaction/firewall config. Website blocklist is nested in YAML.
public struct SecuritySettings: Sendable, Equatable {
public var redactSecrets: Bool
public var redactPII: Bool // from privacy.redact_pii
public var tirithEnabled: Bool
public var tirithPath: String
public var tirithTimeout: Int
public var tirithFailOpen: Bool
public var blocklistEnabled: Bool
public var blocklistDomains: [String]
public init(
redactSecrets: Bool,
redactPII: Bool,
tirithEnabled: Bool,
tirithPath: String,
tirithTimeout: Int,
tirithFailOpen: Bool,
blocklistEnabled: Bool,
blocklistDomains: [String]
) {
self.redactSecrets = redactSecrets
self.redactPII = redactPII
self.tirithEnabled = tirithEnabled
self.tirithPath = tirithPath
self.tirithTimeout = tirithTimeout
self.tirithFailOpen = tirithFailOpen
self.blocklistEnabled = blocklistEnabled
self.blocklistDomains = blocklistDomains
}
public nonisolated static let empty = SecuritySettings(
redactSecrets: true,
redactPII: false,
tirithEnabled: true,
tirithPath: "tirith",
tirithTimeout: 5,
tirithFailOpen: true,
blocklistEnabled: false,
blocklistDomains: []
)
}
/// Human-delay simulates realistic typing pace (`human_delay.*`).
public struct HumanDelaySettings: Sendable, Equatable {
public var mode: String // "off" | "natural" | "custom"
public var minMS: Int
public var maxMS: Int
public init(
mode: String,
minMS: Int,
maxMS: Int
) {
self.mode = mode
self.minMS = minMS
self.maxMS = maxMS
}
public nonisolated static let empty = HumanDelaySettings(mode: "off", minMS: 800, maxMS: 2500)
}
/// Compression / context routing.
public struct CompressionSettings: Sendable, Equatable {
public var enabled: Bool
public var threshold: Double
public var targetRatio: Double
public var protectLastN: Int
public init(
enabled: Bool,
threshold: Double,
targetRatio: Double,
protectLastN: Int
) {
self.enabled = enabled
self.threshold = threshold
self.targetRatio = targetRatio
self.protectLastN = protectLastN
}
public nonisolated static let empty = CompressionSettings(enabled: true, threshold: 0.5, targetRatio: 0.2, protectLastN: 20)
}
public struct CheckpointSettings: Sendable, Equatable {
public var enabled: Bool
public var maxSnapshots: Int
public init(
enabled: Bool,
maxSnapshots: Int
) {
self.enabled = enabled
self.maxSnapshots = maxSnapshots
}
public nonisolated static let empty = CheckpointSettings(enabled: true, maxSnapshots: 50)
}
public struct LoggingSettings: Sendable, Equatable {
public var level: String // DEBUG | INFO | WARNING | ERROR
public var maxSizeMB: Int
public var backupCount: Int
public init(
level: String,
maxSizeMB: Int,
backupCount: Int
) {
self.level = level
self.maxSizeMB = maxSizeMB
self.backupCount = backupCount
}
public nonisolated static let empty = LoggingSettings(level: "INFO", maxSizeMB: 5, backupCount: 3)
}
public struct DelegationSettings: Sendable, Equatable {
public var model: String
public var provider: String
public var baseURL: String
public var apiKey: String
public var maxIterations: Int
public init(
model: String,
provider: String,
baseURL: String,
apiKey: String,
maxIterations: Int
) {
self.model = model
self.provider = provider
self.baseURL = baseURL
self.apiKey = apiKey
self.maxIterations = maxIterations
}
public nonisolated static let empty = DelegationSettings(model: "", provider: "", baseURL: "", apiKey: "", maxIterations: 50)
}
/// Discord-specific platform settings (`discord.*`). Other platforms currently have thinner schemas.
public struct DiscordSettings: Sendable, Equatable {
public var requireMention: Bool
public var freeResponseChannels: String
public var autoThread: Bool
public var reactions: Bool
public init(
requireMention: Bool,
freeResponseChannels: String,
autoThread: Bool,
reactions: Bool
) {
self.requireMention = requireMention
self.freeResponseChannels = freeResponseChannels
self.autoThread = autoThread
self.reactions = reactions
}
public nonisolated static let empty = DiscordSettings(requireMention: true, freeResponseChannels: "", autoThread: true, reactions: true)
}
/// Telegram settings under `telegram.*` in config.yaml. Most Telegram tuning is
/// done via environment variables (`TELEGRAM_*`) this is the subset that lives
/// in the YAML.
public struct TelegramSettings: Sendable, Equatable {
public var requireMention: Bool
public var reactions: Bool
public init(
requireMention: Bool,
reactions: Bool
) {
self.requireMention = requireMention
self.reactions = reactions
}
public nonisolated static let empty = TelegramSettings(requireMention: true, reactions: false)
}
/// Slack settings under `platforms.slack.*` (and a couple of top-level keys).
public struct SlackSettings: Sendable, Equatable {
public var replyToMode: String // "off" | "first" | "all"
public var requireMention: Bool
public var replyInThread: Bool
public var replyBroadcast: Bool
public init(
replyToMode: String,
requireMention: Bool,
replyInThread: Bool,
replyBroadcast: Bool
) {
self.replyToMode = replyToMode
self.requireMention = requireMention
self.replyInThread = replyInThread
self.replyBroadcast = replyBroadcast
}
public nonisolated static let empty = SlackSettings(replyToMode: "first", requireMention: true, replyInThread: true, replyBroadcast: false)
}
/// Matrix settings under `matrix.*`.
public struct MatrixSettings: Sendable, Equatable {
public var requireMention: Bool
public var autoThread: Bool
public var dmMentionThreads: Bool
public init(
requireMention: Bool,
autoThread: Bool,
dmMentionThreads: Bool
) {
self.requireMention = requireMention
self.autoThread = autoThread
self.dmMentionThreads = dmMentionThreads
}
public nonisolated static let empty = MatrixSettings(requireMention: true, autoThread: true, dmMentionThreads: false)
}
/// Mattermost settings. Mattermost is mostly driven by env vars; config.yaml
/// currently just exposes `group_sessions_per_user` at the top level, but we
/// reserve this struct for future expansion so the form has a stable type.
public struct MattermostSettings: Sendable, Equatable {
public var requireMention: Bool
public var replyMode: String // "thread" | "off"
public init(
requireMention: Bool,
replyMode: String
) {
self.requireMention = requireMention
self.replyMode = replyMode
}
public nonisolated static let empty = MattermostSettings(requireMention: true, replyMode: "off")
}
/// WhatsApp settings under `whatsapp.*`.
public struct WhatsAppSettings: Sendable, Equatable {
public var unauthorizedDMBehavior: String // "pair" | "ignore"
public var replyPrefix: String
public init(
unauthorizedDMBehavior: String,
replyPrefix: String
) {
self.unauthorizedDMBehavior = unauthorizedDMBehavior
self.replyPrefix = replyPrefix
}
public nonisolated static let empty = WhatsAppSettings(unauthorizedDMBehavior: "pair", replyPrefix: "")
}
/// Home Assistant filters under `platforms.homeassistant.extra`. Hermes ignores
/// every state change by default; users must opt-in via at least one filter.
public struct HomeAssistantSettings: Sendable, Equatable {
public var watchDomains: [String]
public var watchEntities: [String]
public var watchAll: Bool
public var ignoreEntities: [String]
public var cooldownSeconds: Int
public init(
watchDomains: [String],
watchEntities: [String],
watchAll: Bool,
ignoreEntities: [String],
cooldownSeconds: Int
) {
self.watchDomains = watchDomains
self.watchEntities = watchEntities
self.watchAll = watchAll
self.ignoreEntities = ignoreEntities
self.cooldownSeconds = cooldownSeconds
}
public nonisolated static let empty = HomeAssistantSettings(watchDomains: [], watchEntities: [], watchAll: false, ignoreEntities: [], cooldownSeconds: 30)
}
// MARK: - Root Config
public struct HermesConfig: Sendable {
// Original fields preserved for zero breakage with existing call sites.
public var model: String
public var provider: String
public var maxTurns: Int
public var personality: String
public var terminalBackend: String
public var memoryEnabled: Bool
public var memoryCharLimit: Int
public var userCharLimit: Int
public var nudgeInterval: Int
public var streaming: Bool
public var showReasoning: Bool
public var verbose: Bool
public var autoTTS: Bool
public var silenceThreshold: Int
public var reasoningEffort: String
public var showCost: Bool
public var approvalMode: String
public var browserBackend: String
public var memoryProvider: String
public var dockerEnv: [String: String]
public var commandAllowlist: [String]
public var memoryProfile: String
public var serviceTier: String
public var gatewayNotifyInterval: Int
public var forceIPv4: Bool
public var contextEngine: String
public var interimAssistantMessages: Bool
public var honchoInitOnSessionStart: Bool
// Phase 1 additions
public var timezone: String
public var userProfileEnabled: Bool
public var toolUseEnforcement: String // "auto" | "true" | "false" | comma list
public var gatewayTimeout: Int
public var approvalTimeout: Int
public var fileReadMaxChars: Int
public var cronWrapResponse: Bool
public var prefillMessagesFile: String
public var skillsExternalDirs: [String]
// Grouped blocks
public var display: DisplaySettings
public var terminal: TerminalSettings
public var browser: BrowserSettings
public var voice: VoiceSettings
public var auxiliary: AuxiliarySettings
public var security: SecuritySettings
public var humanDelay: HumanDelaySettings
public var compression: CompressionSettings
public var checkpoints: CheckpointSettings
public var logging: LoggingSettings
public var delegation: DelegationSettings
public var discord: DiscordSettings
public var telegram: TelegramSettings
public var slack: SlackSettings
public var matrix: MatrixSettings
public var mattermost: MattermostSettings
public var whatsapp: WhatsAppSettings
public var homeAssistant: HomeAssistantSettings
public init(
model: String,
provider: String,
maxTurns: Int,
personality: String,
terminalBackend: String,
memoryEnabled: Bool,
memoryCharLimit: Int,
userCharLimit: Int,
nudgeInterval: Int,
streaming: Bool,
showReasoning: Bool,
verbose: Bool,
autoTTS: Bool,
silenceThreshold: Int,
reasoningEffort: String,
showCost: Bool,
approvalMode: String,
browserBackend: String,
memoryProvider: String,
dockerEnv: [String: String],
commandAllowlist: [String],
memoryProfile: String,
serviceTier: String,
gatewayNotifyInterval: Int,
forceIPv4: Bool,
contextEngine: String,
interimAssistantMessages: Bool,
honchoInitOnSessionStart: Bool,
timezone: String,
userProfileEnabled: Bool,
toolUseEnforcement: String,
gatewayTimeout: Int,
approvalTimeout: Int,
fileReadMaxChars: Int,
cronWrapResponse: Bool,
prefillMessagesFile: String,
skillsExternalDirs: [String],
display: DisplaySettings,
terminal: TerminalSettings,
browser: BrowserSettings,
voice: VoiceSettings,
auxiliary: AuxiliarySettings,
security: SecuritySettings,
humanDelay: HumanDelaySettings,
compression: CompressionSettings,
checkpoints: CheckpointSettings,
logging: LoggingSettings,
delegation: DelegationSettings,
discord: DiscordSettings,
telegram: TelegramSettings,
slack: SlackSettings,
matrix: MatrixSettings,
mattermost: MattermostSettings,
whatsapp: WhatsAppSettings,
homeAssistant: HomeAssistantSettings
) {
self.model = model
self.provider = provider
self.maxTurns = maxTurns
self.personality = personality
self.terminalBackend = terminalBackend
self.memoryEnabled = memoryEnabled
self.memoryCharLimit = memoryCharLimit
self.userCharLimit = userCharLimit
self.nudgeInterval = nudgeInterval
self.streaming = streaming
self.showReasoning = showReasoning
self.verbose = verbose
self.autoTTS = autoTTS
self.silenceThreshold = silenceThreshold
self.reasoningEffort = reasoningEffort
self.showCost = showCost
self.approvalMode = approvalMode
self.browserBackend = browserBackend
self.memoryProvider = memoryProvider
self.dockerEnv = dockerEnv
self.commandAllowlist = commandAllowlist
self.memoryProfile = memoryProfile
self.serviceTier = serviceTier
self.gatewayNotifyInterval = gatewayNotifyInterval
self.forceIPv4 = forceIPv4
self.contextEngine = contextEngine
self.interimAssistantMessages = interimAssistantMessages
self.honchoInitOnSessionStart = honchoInitOnSessionStart
self.timezone = timezone
self.userProfileEnabled = userProfileEnabled
self.toolUseEnforcement = toolUseEnforcement
self.gatewayTimeout = gatewayTimeout
self.approvalTimeout = approvalTimeout
self.fileReadMaxChars = fileReadMaxChars
self.cronWrapResponse = cronWrapResponse
self.prefillMessagesFile = prefillMessagesFile
self.skillsExternalDirs = skillsExternalDirs
self.display = display
self.terminal = terminal
self.browser = browser
self.voice = voice
self.auxiliary = auxiliary
self.security = security
self.humanDelay = humanDelay
self.compression = compression
self.checkpoints = checkpoints
self.logging = logging
self.delegation = delegation
self.discord = discord
self.telegram = telegram
self.slack = slack
self.matrix = matrix
self.mattermost = mattermost
self.whatsapp = whatsapp
self.homeAssistant = homeAssistant
}
public nonisolated static let empty = HermesConfig(
model: "unknown",
provider: "unknown",
maxTurns: 0,
personality: "default",
terminalBackend: "local",
memoryEnabled: false,
memoryCharLimit: 0,
userCharLimit: 0,
nudgeInterval: 0,
streaming: true,
showReasoning: false,
verbose: false,
autoTTS: true,
silenceThreshold: 200,
reasoningEffort: "medium",
showCost: false,
approvalMode: "manual",
browserBackend: "",
memoryProvider: "",
dockerEnv: [:],
commandAllowlist: [],
memoryProfile: "",
serviceTier: "normal",
gatewayNotifyInterval: 600,
forceIPv4: false,
contextEngine: "compressor",
interimAssistantMessages: true,
honchoInitOnSessionStart: false,
timezone: "",
userProfileEnabled: true,
toolUseEnforcement: "auto",
gatewayTimeout: 1800,
approvalTimeout: 60,
fileReadMaxChars: 100_000,
cronWrapResponse: true,
prefillMessagesFile: "",
skillsExternalDirs: [],
display: .empty,
terminal: .empty,
browser: .empty,
voice: .empty,
auxiliary: .empty,
security: .empty,
humanDelay: .empty,
compression: .empty,
checkpoints: .empty,
logging: .empty,
delegation: .empty,
discord: .empty,
telegram: .empty,
slack: .empty,
matrix: .empty,
mattermost: .empty,
whatsapp: .empty,
homeAssistant: .empty
)
}
// Hand-written `init(from:)` so Swift 6 doesn't synthesize a
// MainActor-isolated Decodable conformance (which would fail to be used from
// `HermesFileService.loadGatewayState()`, a nonisolated method).
public struct GatewayState: Sendable, Codable {
public nonisolated let pid: Int?
public nonisolated let kind: String?
public nonisolated let gatewayState: String?
public nonisolated let exitReason: String?
public nonisolated let platforms: [String: PlatformState]?
public nonisolated let updatedAt: String?
public enum CodingKeys: String, CodingKey {
case pid, kind
case gatewayState = "gateway_state"
case exitReason = "exit_reason"
case platforms
case updatedAt = "updated_at"
}
public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.pid = try c.decodeIfPresent(Int.self, forKey: .pid)
self.kind = try c.decodeIfPresent(String.self, forKey: .kind)
self.gatewayState = try c.decodeIfPresent(String.self, forKey: .gatewayState)
self.exitReason = try c.decodeIfPresent(String.self, forKey: .exitReason)
self.platforms = try c.decodeIfPresent([String: PlatformState].self, forKey: .platforms)
self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
}
public nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encodeIfPresent(pid, forKey: .pid)
try c.encodeIfPresent(kind, forKey: .kind)
try c.encodeIfPresent(gatewayState, forKey: .gatewayState)
try c.encodeIfPresent(exitReason, forKey: .exitReason)
try c.encodeIfPresent(platforms, forKey: .platforms)
try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
}
public nonisolated var isRunning: Bool {
gatewayState == "running"
}
public nonisolated var statusText: String {
gatewayState ?? "unknown"
}
}
public struct PlatformState: Sendable, Codable {
public nonisolated let connected: Bool?
public nonisolated let error: String?
public enum CodingKeys: String, CodingKey { case connected, error }
public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.connected = try c.decodeIfPresent(Bool.self, forKey: .connected)
self.error = try c.decodeIfPresent(String.self, forKey: .error)
}
public nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encodeIfPresent(connected, forKey: .connected)
try c.encodeIfPresent(error, forKey: .error)
}
}
@@ -1,26 +1,26 @@
import Foundation import Foundation
struct HermesCronJob: Identifiable, Sendable, Codable { public struct HermesCronJob: Identifiable, Sendable, Codable {
nonisolated let id: String public nonisolated let id: String
nonisolated let name: String public nonisolated let name: String
nonisolated let prompt: String public nonisolated let prompt: String
nonisolated let skills: [String]? public nonisolated let skills: [String]?
nonisolated let model: String? public nonisolated let model: String?
nonisolated let schedule: CronSchedule public nonisolated let schedule: CronSchedule
nonisolated let enabled: Bool public nonisolated let enabled: Bool
nonisolated let state: String public nonisolated let state: String
nonisolated let deliver: String? public nonisolated let deliver: String?
nonisolated let nextRunAt: String? public nonisolated let nextRunAt: String?
nonisolated let lastRunAt: String? public nonisolated let lastRunAt: String?
nonisolated let lastError: String? public nonisolated let lastError: String?
nonisolated let preRunScript: String? public nonisolated let preRunScript: String?
nonisolated let deliveryFailures: Int? public nonisolated let deliveryFailures: Int?
nonisolated let lastDeliveryError: String? public nonisolated let lastDeliveryError: String?
nonisolated let timeoutType: String? public nonisolated let timeoutType: String?
nonisolated let timeoutSeconds: Int? public nonisolated let timeoutSeconds: Int?
nonisolated let silent: Bool? public nonisolated let silent: Bool?
enum CodingKeys: String, CodingKey { public enum CodingKeys: String, CodingKey {
case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
case nextRunAt = "next_run_at" case nextRunAt = "next_run_at"
case lastRunAt = "last_run_at" case lastRunAt = "last_run_at"
@@ -32,7 +32,7 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
case timeoutSeconds = "timeout_seconds" case timeoutSeconds = "timeout_seconds"
} }
nonisolated init(from decoder: any Decoder) throws { public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decode(String.self, forKey: .id) self.id = try c.decode(String.self, forKey: .id)
self.name = try c.decode(String.self, forKey: .name) self.name = try c.decode(String.self, forKey: .name)
@@ -54,7 +54,7 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent) self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent)
} }
nonisolated func encode(to encoder: any Encoder) throws { public nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self) var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(id, forKey: .id) try c.encode(id, forKey: .id)
try c.encode(name, forKey: .name) try c.encode(name, forKey: .name)
@@ -76,7 +76,7 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
try c.encodeIfPresent(silent, forKey: .silent) try c.encodeIfPresent(silent, forKey: .silent)
} }
nonisolated var stateIcon: String { public nonisolated var stateIcon: String {
switch state { switch state {
case "scheduled": return "clock" case "scheduled": return "clock"
case "running": return "play.circle" case "running": return "play.circle"
@@ -86,7 +86,7 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
} }
} }
nonisolated var deliveryDisplay: String? { public nonisolated var deliveryDisplay: String? {
guard let deliver, !deliver.isEmpty else { return nil } guard let deliver, !deliver.isEmpty else { return nil }
// v0.9.0 extends Discord routing to threads: `discord:<chat>:<thread>`. // v0.9.0 extends Discord routing to threads: `discord:<chat>:<thread>`.
if deliver.hasPrefix("discord:") { if deliver.hasPrefix("discord:") {
@@ -102,20 +102,20 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
} }
} }
struct CronSchedule: Sendable, Codable { public struct CronSchedule: Sendable, Codable {
nonisolated let kind: String public nonisolated let kind: String
nonisolated let runAt: String? public nonisolated let runAt: String?
nonisolated let display: String? public nonisolated let display: String?
nonisolated let expression: String? public nonisolated let expression: String?
enum CodingKeys: String, CodingKey { public enum CodingKeys: String, CodingKey {
case kind case kind
case runAt = "run_at" case runAt = "run_at"
case display case display
case expression case expression
} }
nonisolated init(from decoder: any Decoder) throws { public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
self.kind = try c.decode(String.self, forKey: .kind) self.kind = try c.decode(String.self, forKey: .kind)
self.runAt = try c.decodeIfPresent(String.self, forKey: .runAt) self.runAt = try c.decodeIfPresent(String.self, forKey: .runAt)
@@ -123,7 +123,7 @@ struct CronSchedule: Sendable, Codable {
self.expression = try c.decodeIfPresent(String.self, forKey: .expression) self.expression = try c.decodeIfPresent(String.self, forKey: .expression)
} }
nonisolated func encode(to encoder: any Encoder) throws { public nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self) var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(kind, forKey: .kind) try c.encode(kind, forKey: .kind)
try c.encodeIfPresent(runAt, forKey: .runAt) try c.encodeIfPresent(runAt, forKey: .runAt)
@@ -135,22 +135,22 @@ struct CronSchedule: Sendable, Codable {
// Hand-written `init(from:)` / `encode(to:)` so Swift 6 doesn't synthesize a // Hand-written `init(from:)` / `encode(to:)` so Swift 6 doesn't synthesize a
// MainActor-isolated Codable conformance `HermesFileService.loadCronJobs` // MainActor-isolated Codable conformance `HermesFileService.loadCronJobs`
// is nonisolated and needs to decode this from a background task. // is nonisolated and needs to decode this from a background task.
struct CronJobsFile: Sendable, Codable { public struct CronJobsFile: Sendable, Codable {
nonisolated let jobs: [HermesCronJob] public nonisolated let jobs: [HermesCronJob]
nonisolated let updatedAt: String? public nonisolated let updatedAt: String?
enum CodingKeys: String, CodingKey { public enum CodingKeys: String, CodingKey {
case jobs case jobs
case updatedAt = "updated_at" case updatedAt = "updated_at"
} }
nonisolated init(from decoder: any Decoder) throws { public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
self.jobs = try c.decode([HermesCronJob].self, forKey: .jobs) self.jobs = try c.decode([HermesCronJob].self, forKey: .jobs)
self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt) self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
} }
nonisolated func encode(to encoder: any Encoder) throws { public nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self) var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(jobs, forKey: .jobs) try c.encode(jobs, forKey: .jobs)
try c.encodeIfPresent(updatedAt, forKey: .updatedAt) try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
@@ -0,0 +1,104 @@
import Foundation
public enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable {
case stdio
case http
public var id: String { rawValue }
public var displayName: LocalizedStringResource {
switch self {
case .stdio: return "Local (stdio)"
case .http: return "Remote (HTTP)"
}
}
}
public struct HermesMCPServer: Identifiable, Sendable, Equatable {
public let name: String
public let transport: MCPTransport
public let command: String?
public let args: [String]
public let url: String?
public let auth: String?
public let env: [String: String]
public let headers: [String: String]
public let timeout: Int?
public let connectTimeout: Int?
public let enabled: Bool
public let toolsInclude: [String]
public let toolsExclude: [String]
public let resourcesEnabled: Bool
public let promptsEnabled: Bool
public let hasOAuthToken: Bool
public init(
name: String,
transport: MCPTransport,
command: String?,
args: [String],
url: String?,
auth: String?,
env: [String: String],
headers: [String: String],
timeout: Int?,
connectTimeout: Int?,
enabled: Bool,
toolsInclude: [String],
toolsExclude: [String],
resourcesEnabled: Bool,
promptsEnabled: Bool,
hasOAuthToken: Bool
) {
self.name = name
self.transport = transport
self.command = command
self.args = args
self.url = url
self.auth = auth
self.env = env
self.headers = headers
self.timeout = timeout
self.connectTimeout = connectTimeout
self.enabled = enabled
self.toolsInclude = toolsInclude
self.toolsExclude = toolsExclude
self.resourcesEnabled = resourcesEnabled
self.promptsEnabled = promptsEnabled
self.hasOAuthToken = hasOAuthToken
}
public var id: String { name }
public var summary: String {
switch transport {
case .stdio:
let argString = args.isEmpty ? "" : " " + args.joined(separator: " ")
return (command ?? "") + argString
case .http:
return url ?? ""
}
}
}
public struct MCPTestResult: Sendable, Equatable {
public let serverName: String
public let succeeded: Bool
public let output: String
public let tools: [String]
public let elapsed: TimeInterval
public init(
serverName: String,
succeeded: Bool,
output: String,
tools: [String],
elapsed: TimeInterval
) {
self.serverName = serverName
self.succeeded = succeeded
self.output = output
self.tools = tools
self.elapsed = elapsed
}
}
@@ -1,48 +1,74 @@
import Foundation import Foundation
struct HermesMessage: Identifiable, Sendable { public struct HermesMessage: Identifiable, Sendable {
let id: Int public let id: Int
let sessionId: String public let sessionId: String
let role: String public let role: String
let content: String public let content: String
let toolCallId: String? public let toolCallId: String?
let toolCalls: [HermesToolCall] public let toolCalls: [HermesToolCall]
let toolName: String? public let toolName: String?
let timestamp: Date? public let timestamp: Date?
let tokenCount: Int? public let tokenCount: Int?
let finishReason: String? public let finishReason: String?
let reasoning: String? public let reasoning: String?
var isUser: Bool { role == "user" }
var isAssistant: Bool { role == "assistant" } public init(
var isToolResult: Bool { role == "tool" } id: Int,
var hasReasoning: Bool { reasoning != nil && !(reasoning?.isEmpty ?? true) } sessionId: String,
role: String,
content: String,
toolCallId: String?,
toolCalls: [HermesToolCall],
toolName: String?,
timestamp: Date?,
tokenCount: Int?,
finishReason: String?,
reasoning: String?
) {
self.id = id
self.sessionId = sessionId
self.role = role
self.content = content
self.toolCallId = toolCallId
self.toolCalls = toolCalls
self.toolName = toolName
self.timestamp = timestamp
self.tokenCount = tokenCount
self.finishReason = finishReason
self.reasoning = reasoning
}
public var isUser: Bool { role == "user" }
public var isAssistant: Bool { role == "assistant" }
public var isToolResult: Bool { role == "tool" }
public var hasReasoning: Bool { reasoning != nil && !(reasoning?.isEmpty ?? true) }
} }
struct HermesToolCall: Identifiable, Sendable, Codable { public struct HermesToolCall: Identifiable, Sendable, Codable {
var id: String { callId } public var id: String { callId }
let callId: String public let callId: String
let functionName: String public let functionName: String
let arguments: String public let arguments: String
enum CodingKeys: String, CodingKey { public enum CodingKeys: String, CodingKey {
case callId = "id" case callId = "id"
case type case type
case function case function
} }
enum FunctionKeys: String, CodingKey { public enum FunctionKeys: String, CodingKey {
case name case name
case arguments case arguments
} }
init(callId: String, functionName: String, arguments: String) { public init(callId: String, functionName: String, arguments: String) {
self.callId = callId self.callId = callId
self.functionName = functionName self.functionName = functionName
self.arguments = arguments self.arguments = arguments
} }
init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
callId = try container.decode(String.self, forKey: .callId) callId = try container.decode(String.self, forKey: .callId)
let funcContainer = try container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function) let funcContainer = try container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function)
@@ -50,7 +76,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable {
arguments = try funcContainer.decode(String.self, forKey: .arguments) arguments = try funcContainer.decode(String.self, forKey: .arguments)
} }
func encode(to encoder: Encoder) throws { public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(callId, forKey: .callId) try container.encode(callId, forKey: .callId)
try container.encode("function", forKey: .type) try container.encode("function", forKey: .type)
@@ -59,7 +85,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable {
try funcContainer.encode(arguments, forKey: .arguments) try funcContainer.encode(arguments, forKey: .arguments)
} }
var toolKind: ToolKind { public var toolKind: ToolKind {
switch functionName { switch functionName {
case "read_file", "search_files", "vision_analyze": return .read case "read_file", "search_files", "vision_analyze": return .read
case "write_file", "patch": return .edit case "write_file", "patch": return .edit
@@ -70,7 +96,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable {
} }
} }
var argumentsSummary: String { public var argumentsSummary: String {
guard let data = arguments.data(using: .utf8), guard let data = arguments.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return arguments return arguments
@@ -91,7 +117,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable {
} }
} }
enum ToolKind: String, Sendable, CaseIterable { public enum ToolKind: String, Sendable, CaseIterable {
case read case read
case edit case edit
case execute case execute
@@ -99,7 +125,7 @@ enum ToolKind: String, Sendable, CaseIterable {
case browser case browser
case other case other
var displayName: LocalizedStringResource { public var displayName: LocalizedStringResource {
switch self { switch self {
case .read: return "Read" case .read: return "Read"
case .edit: return "Edit" case .edit: return "Edit"
@@ -110,7 +136,7 @@ enum ToolKind: String, Sendable, CaseIterable {
} }
} }
var icon: String { public var icon: String {
switch self { switch self {
case .read: return "doc.text.magnifyingglass" case .read: return "doc.text.magnifyingglass"
case .edit: return "pencil" case .edit: return "pencil"
@@ -121,7 +147,7 @@ enum ToolKind: String, Sendable, CaseIterable {
} }
} }
var color: String { public var color: String {
switch self { switch self {
case .read: return "green" case .read: return "green"
case .edit: return "blue" case .edit: return "blue"
@@ -11,58 +11,68 @@ import Foundation
/// an instance property here. `ServerContext.paths` is the canonical way to /// an instance property here. `ServerContext.paths` is the canonical way to
/// reach these values; the old `HermesPaths` statics are preserved as /// reach these values; the old `HermesPaths` statics are preserved as
/// deprecated forwarders so Phase 1 can migrate call sites incrementally. /// deprecated forwarders so Phase 1 can migrate call sites incrementally.
struct HermesPathSet: Sendable, Hashable { public struct HermesPathSet: Sendable, Hashable {
let home: String public let home: String
/// `true` when this path set belongs to a remote installation. Affects /// `true` when this path set belongs to a remote installation. Affects
/// only `hermesBinary` resolution every other path is identical in /// only `hermesBinary` resolution every other path is identical in
/// shape between local and remote. /// shape between local and remote.
let isRemote: Bool public let isRemote: Bool
/// Pre-resolved remote binary path (e.g. `/home/deploy/.local/bin/hermes`). /// Pre-resolved remote binary path (e.g. `/home/deploy/.local/bin/hermes`).
/// Populated by `SSHTransport` once `command -v hermes` has run on the /// Populated by `SSHTransport` once `command -v hermes` has run on the
/// target host. Unused when `isRemote == false`. /// target host. Unused when `isRemote == false`.
let binaryHint: String? public let binaryHint: String?
// MARK: - Defaults // MARK: - Defaults
/// Absolute path to the local user's `~/.hermes` directory. /// Absolute path to the local user's `~/.hermes` directory.
nonisolated static let defaultLocalHome: String = {
public init(
home: String,
isRemote: Bool,
binaryHint: String?
) {
self.home = home
self.isRemote = isRemote
self.binaryHint = binaryHint
}
public nonisolated static let defaultLocalHome: String = {
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
return user + "/.hermes" return user + "/.hermes"
}() }()
/// Default remote home when the user doesn't override it in `SSHConfig`. /// Default remote home when the user doesn't override it in `SSHConfig`.
/// We leave `~` unexpanded on purpose the remote shell resolves it. /// We leave `~` unexpanded on purpose the remote shell resolves it.
nonisolated static let defaultRemoteHome: String = "~/.hermes" public nonisolated static let defaultRemoteHome: String = "~/.hermes"
// MARK: - Paths (mirror of the old HermesPaths layout) // MARK: - Paths (mirror of the old HermesPaths layout)
nonisolated var stateDB: String { home + "/state.db" } public nonisolated var stateDB: String { home + "/state.db" }
nonisolated var configYAML: String { home + "/config.yaml" } public nonisolated var configYAML: String { home + "/config.yaml" }
nonisolated var envFile: String { home + "/.env" } public nonisolated var envFile: String { home + "/.env" }
nonisolated var authJSON: String { home + "/auth.json" } public nonisolated var authJSON: String { home + "/auth.json" }
nonisolated var soulMD: String { home + "/SOUL.md" } public nonisolated var soulMD: String { home + "/SOUL.md" }
nonisolated var pluginsDir: String { home + "/plugins" } public nonisolated var pluginsDir: String { home + "/plugins" }
nonisolated var memoriesDir: String { home + "/memories" } public nonisolated var memoriesDir: String { home + "/memories" }
nonisolated var memoryMD: String { memoriesDir + "/MEMORY.md" } public nonisolated var memoryMD: String { memoriesDir + "/MEMORY.md" }
nonisolated var userMD: String { memoriesDir + "/USER.md" } public nonisolated var userMD: String { memoriesDir + "/USER.md" }
nonisolated var sessionsDir: String { home + "/sessions" } public nonisolated var sessionsDir: String { home + "/sessions" }
nonisolated var cronJobsJSON: String { home + "/cron/jobs.json" } public nonisolated var cronJobsJSON: String { home + "/cron/jobs.json" }
nonisolated var cronOutputDir: String { home + "/cron/output" } public nonisolated var cronOutputDir: String { home + "/cron/output" }
nonisolated var gatewayStateJSON: String { home + "/gateway_state.json" } public nonisolated var gatewayStateJSON: String { home + "/gateway_state.json" }
nonisolated var skillsDir: String { home + "/skills" } public nonisolated var skillsDir: String { home + "/skills" }
nonisolated var errorsLog: String { home + "/logs/errors.log" } public nonisolated var errorsLog: String { home + "/logs/errors.log" }
nonisolated var agentLog: String { home + "/logs/agent.log" } public nonisolated var agentLog: String { home + "/logs/agent.log" }
nonisolated var gatewayLog: String { home + "/logs/gateway.log" } public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
nonisolated var scarfDir: String { home + "/scarf" } public nonisolated var scarfDir: String { home + "/scarf" }
nonisolated var projectsRegistry: String { scarfDir + "/projects.json" } public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
nonisolated var mcpTokensDir: String { home + "/mcp-tokens" } public nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
// MARK: - Binary resolution // MARK: - Binary resolution
/// Install locations we probe for the local `hermes` binary, in priority /// Install locations we probe for the local `hermes` binary, in priority
/// order. Checked on every access so a user installing via a different /// order. Checked on every access so a user installing via a different
/// method doesn't need to relaunch Scarf. /// method doesn't need to relaunch Scarf.
nonisolated static let hermesBinaryCandidates: [String] = { public nonisolated static let hermesBinaryCandidates: [String] = {
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
return [ return [
user + "/.local/bin/hermes", // pipx / pip --user (default) user + "/.local/bin/hermes", // pipx / pip --user (default)
@@ -79,7 +89,7 @@ struct HermesPathSet: Sendable, Hashable {
/// ///
/// Remote: returns `binaryHint` (populated at connect time) or bare /// Remote: returns `binaryHint` (populated at connect time) or bare
/// `"hermes"` as a last-resort default that relies on the remote `$PATH`. /// `"hermes"` as a last-resort default that relies on the remote `$PATH`.
nonisolated var hermesBinary: String { public nonisolated var hermesBinary: String {
if isRemote { if isRemote {
return binaryHint ?? "hermes" return binaryHint ?? "hermes"
} }
@@ -0,0 +1,103 @@
import Foundation
public struct HermesSession: Identifiable, Sendable {
public let id: String
public let source: String
public let userId: String?
public let model: String?
public let title: String?
public let parentSessionId: String?
public let startedAt: Date?
public let endedAt: Date?
public let endReason: String?
public let messageCount: Int
public let toolCallCount: Int
public let inputTokens: Int
public let outputTokens: Int
public let cacheReadTokens: Int
public let cacheWriteTokens: Int
public let estimatedCostUSD: Double?
public let reasoningTokens: Int
public let actualCostUSD: Double?
public let costStatus: String?
public let billingProvider: String?
public init(
id: String,
source: String,
userId: String?,
model: String?,
title: String?,
parentSessionId: String?,
startedAt: Date?,
endedAt: Date?,
endReason: String?,
messageCount: Int,
toolCallCount: Int,
inputTokens: Int,
outputTokens: Int,
cacheReadTokens: Int,
cacheWriteTokens: Int,
estimatedCostUSD: Double?,
reasoningTokens: Int,
actualCostUSD: Double?,
costStatus: String?,
billingProvider: String?
) {
self.id = id
self.source = source
self.userId = userId
self.model = model
self.title = title
self.parentSessionId = parentSessionId
self.startedAt = startedAt
self.endedAt = endedAt
self.endReason = endReason
self.messageCount = messageCount
self.toolCallCount = toolCallCount
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.cacheReadTokens = cacheReadTokens
self.cacheWriteTokens = cacheWriteTokens
self.estimatedCostUSD = estimatedCostUSD
self.reasoningTokens = reasoningTokens
self.actualCostUSD = actualCostUSD
self.costStatus = costStatus
self.billingProvider = billingProvider
}
public var isSubagent: Bool { parentSessionId != nil }
public var totalTokens: Int { inputTokens + outputTokens + reasoningTokens }
public var displayCostUSD: Double? { actualCostUSD ?? estimatedCostUSD }
public var costIsActual: Bool { actualCostUSD != nil }
public var duration: TimeInterval? {
guard let start = startedAt, let end = endedAt else { return nil }
return end.timeIntervalSince(start)
}
public var displayTitle: String {
title ?? id
}
public var sourceIcon: String {
KnownPlatforms.icon(for: source)
}
public func withTitle(_ newTitle: String) -> HermesSession {
HermesSession(
id: id, source: source, userId: userId, model: model,
title: newTitle, parentSessionId: parentSessionId,
startedAt: startedAt, endedAt: endedAt, endReason: endReason,
messageCount: messageCount, toolCallCount: toolCallCount,
inputTokens: inputTokens, outputTokens: outputTokens,
cacheReadTokens: cacheReadTokens, cacheWriteTokens: cacheWriteTokens,
estimatedCostUSD: estimatedCostUSD, reasoningTokens: reasoningTokens,
actualCostUSD: actualCostUSD, costStatus: costStatus,
billingProvider: billingProvider
)
}
}
@@ -0,0 +1,42 @@
import Foundation
public struct HermesSkillCategory: Identifiable, Sendable {
public let id: String
public let name: String
public let skills: [HermesSkill]
public init(
id: String,
name: String,
skills: [HermesSkill]
) {
self.id = id
self.name = name
self.skills = skills
}
}
public struct HermesSkill: Identifiable, Sendable {
public let id: String
public let name: String
public let category: String
public let path: String
public let files: [String]
public let requiredConfig: [String]
public init(
id: String,
name: String,
category: String,
path: String,
files: [String],
requiredConfig: [String]
) {
self.id = id
self.name = name
self.category = category
self.path = path
self.files = files
self.requiredConfig = requiredConfig
}
}
@@ -0,0 +1,29 @@
import Foundation
/// A slash command available in chat. Sourced either from the ACP server
/// (`available_commands_update`) or from user-defined `quick_commands` in
/// `config.yaml`.
public struct HermesSlashCommand: Identifiable, Sendable, Equatable {
public enum Source: Sendable, Equatable {
case acp
case quickCommand
}
public var id: String { name }
public let name: String
public let description: String
public let argumentHint: String?
public let source: Source
public init(
name: String,
description: String,
argumentHint: String?,
source: Source
) {
self.name = name
self.description = description
self.argumentHint = argumentHint
self.source = source
}
}
@@ -1,23 +1,45 @@
import Foundation import Foundation
struct HermesToolset: Identifiable, Sendable { public struct HermesToolset: Identifiable, Sendable {
var id: String { name } public var id: String { name }
let name: String public let name: String
let description: String public let description: String
let icon: String public let icon: String
var enabled: Bool public var enabled: Bool
public init(
name: String,
description: String,
icon: String,
enabled: Bool
) {
self.name = name
self.description = description
self.icon = icon
self.enabled = enabled
}
} }
struct HermesToolPlatform: Identifiable, Sendable { public struct HermesToolPlatform: Identifiable, Sendable {
var id: String { name } public var id: String { name }
let name: String public let name: String
let displayName: String public let displayName: String
let icon: String public let icon: String
public init(
name: String,
displayName: String,
icon: String
) {
self.name = name
self.displayName = displayName
self.icon = icon
}
} }
enum KnownPlatforms { public enum KnownPlatforms {
static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal") public static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
static let all: [HermesToolPlatform] = [ public static let all: [HermesToolPlatform] = [
cli, cli,
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"), HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"), HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
@@ -33,7 +55,7 @@ enum KnownPlatforms {
HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"), HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"),
] ]
static func icon(for platform: String) -> String { public static func icon(for platform: String) -> String {
switch platform { switch platform {
case "cli": return "terminal" case "cli": return "terminal"
case "telegram": return "paperplane" case "telegram": return "paperplane"
@@ -1,22 +1,54 @@
import Foundation import Foundation
struct MCPServerPreset: Identifiable, Sendable, Equatable { public struct MCPServerPreset: Identifiable, Sendable, Equatable {
let id: String public let id: String
let displayName: String public let displayName: String
let description: String public let description: String
let category: String public let category: String
let iconSystemName: String public let iconSystemName: String
let transport: MCPTransport public let transport: MCPTransport
let command: String? public let command: String?
let args: [String] public let args: [String]
let url: String? public let url: String?
let auth: String? public let auth: String?
let requiredEnvKeys: [String] public let requiredEnvKeys: [String]
let optionalEnvKeys: [String] public let optionalEnvKeys: [String]
let pathArgPrompt: String? public let pathArgPrompt: String?
let docsURL: String public let docsURL: String
static let gallery: [MCPServerPreset] = [
public init(
id: String,
displayName: String,
description: String,
category: String,
iconSystemName: String,
transport: MCPTransport,
command: String?,
args: [String],
url: String?,
auth: String?,
requiredEnvKeys: [String],
optionalEnvKeys: [String],
pathArgPrompt: String?,
docsURL: String
) {
self.id = id
self.displayName = displayName
self.description = description
self.category = category
self.iconSystemName = iconSystemName
self.transport = transport
self.command = command
self.args = args
self.url = url
self.auth = auth
self.requiredEnvKeys = requiredEnvKeys
self.optionalEnvKeys = optionalEnvKeys
self.pathArgPrompt = pathArgPrompt
self.docsURL = docsURL
}
public static let gallery: [MCPServerPreset] = [
MCPServerPreset( MCPServerPreset(
id: "filesystem", id: "filesystem",
displayName: "Filesystem", displayName: "Filesystem",
@@ -163,12 +195,12 @@ struct MCPServerPreset: Identifiable, Sendable, Equatable {
) )
] ]
static var categories: [String] { public static var categories: [String] {
var seen = Set<String>() var seen = Set<String>()
return gallery.compactMap { p in seen.insert(p.category).inserted ? p.category : nil } return gallery.compactMap { p in seen.insert(p.category).inserted ? p.category : nil }
} }
static func byCategory(_ category: String) -> [MCPServerPreset] { public static func byCategory(_ category: String) -> [MCPServerPreset] {
gallery.filter { $0.category == category } gallery.filter { $0.category == category }
} }
} }
@@ -0,0 +1,250 @@
import Foundation
// MARK: - Registry
public struct ProjectRegistry: Codable, Sendable {
public var projects: [ProjectEntry]
public init(
projects: [ProjectEntry]
) {
self.projects = projects
}
}
public struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
public var id: String { name }
public let name: String
public let path: String
public init(
name: String,
path: String
) {
self.name = name
self.path = path
}
public var dashboardPath: String { path + "/.scarf/dashboard.json" }
}
// MARK: - Dashboard
public struct ProjectDashboard: Codable, Sendable {
public let version: Int
public let title: String
public let description: String?
public let updatedAt: String?
public let theme: DashboardTheme?
public let sections: [DashboardSection]
public init(
version: Int,
title: String,
description: String?,
updatedAt: String?,
theme: DashboardTheme?,
sections: [DashboardSection]
) {
self.version = version
self.title = title
self.description = description
self.updatedAt = updatedAt
self.theme = theme
self.sections = sections
}
}
public struct DashboardTheme: Codable, Sendable {
public let accent: String?
public init(
accent: String?
) {
self.accent = accent
}
}
public struct DashboardSection: Codable, Sendable, Identifiable {
public var id: String { title }
public let title: String
public let columns: Int?
public let widgets: [DashboardWidget]
public init(
title: String,
columns: Int?,
widgets: [DashboardWidget]
) {
self.title = title
self.columns = columns
self.widgets = widgets
}
public var columnCount: Int { columns ?? 3 }
}
public struct DashboardWidget: Codable, Sendable, Identifiable {
public var id: String { type + ":" + title }
public let type: String
public let title: String
// Stat
public let value: WidgetValue?
public let icon: String?
public let color: String?
public let subtitle: String?
// Progress
public let label: String?
// Text
public let content: String?
public let format: String?
// Table
public let columns: [String]?
public let rows: [[String]]?
// Chart
public let chartType: String?
public let xLabel: String?
public let yLabel: String?
public let series: [ChartSeries]?
// List
public let items: [ListItem]?
// Webview
public let url: String?
public let height: Double?
public init(
type: String,
title: String,
value: WidgetValue?,
icon: String?,
color: String?,
subtitle: String?,
label: String?,
content: String?,
format: String?,
columns: [String]?,
rows: [[String]]?,
chartType: String?,
xLabel: String?,
yLabel: String?,
series: [ChartSeries]?,
items: [ListItem]?,
url: String?,
height: Double?
) {
self.type = type
self.title = title
self.value = value
self.icon = icon
self.color = color
self.subtitle = subtitle
self.label = label
self.content = content
self.format = format
self.columns = columns
self.rows = rows
self.chartType = chartType
self.xLabel = xLabel
self.yLabel = yLabel
self.series = series
self.items = items
self.url = url
self.height = height
}
}
// MARK: - Widget Value (String or Number)
public enum WidgetValue: Codable, Sendable, Hashable {
case string(String)
case number(Double)
public var displayString: String {
switch self {
case .string(let s): return s
case .number(let n):
return n.truncatingRemainder(dividingBy: 1) == 0
? Int(n).formatted(.number)
: n.formatted(.number.precision(.fractionLength(1)))
}
}
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let d = try? container.decode(Double.self) {
self = .number(d)
} else if let s = try? container.decode(String.self) {
self = .string(s)
} else {
throw DecodingError.typeMismatch(
WidgetValue.self,
.init(codingPath: decoder.codingPath, debugDescription: "Expected String or Number")
)
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let s): try container.encode(s)
case .number(let n): try container.encode(n)
}
}
}
// MARK: - Chart Data
public struct ChartSeries: Codable, Sendable, Identifiable {
public var id: String { name }
public let name: String
public let color: String?
public let data: [ChartDataPoint]
public init(
name: String,
color: String?,
data: [ChartDataPoint]
) {
self.name = name
self.color = color
self.data = data
}
}
public struct ChartDataPoint: Codable, Sendable, Identifiable {
public var id: String { x }
public let x: String
public let y: Double
public init(
x: String,
y: Double
) {
self.x = x
self.y = y
}
}
// MARK: - List Data
public struct ListItem: Codable, Sendable, Identifiable {
public var id: String { text }
public let text: String
public let status: String?
public init(
text: String,
status: String?
) {
self.text = text
self.status = status
}
}
+191
View File
@@ -0,0 +1,191 @@
# Scarf iOS Port — Plan & Progress Log
> Living document. Updated at the end of each phase. Read this before starting
> any phase so you know what the prior phase did, what shipped, and what the
> next phase is allowed to assume.
## Locked Decisions
- **iOS 18 minimum.** Matches the Mac app's `@Observable` / `NavigationStack`
APIs so ViewModels can move into `ScarfCore` without `#if os(iOS)` gymnastics
on the navigation layer.
- **iPhone only for v1.** iPad Universal deferred (+1 week to add later).
- **No APNs push for v1.** Requires a Hermes-side server component. Deferred.
- **Remote-only on iOS.** No local Hermes mode — iOS sandbox can't read
`~/.hermes/` and can't spawn subprocesses. SSH to a user-owned Hermes
install (Mac, home server, VPS) is the only connection model. This is by
design, not a regression.
- **SSH library: Citadel** (pure Swift, SwiftNIO, MIT licensed).
- **Distribution: TestFlight → App Store.** No Sparkle on iOS. Apple
Developer team `3Q6X2L86C4` is reused.
- **Shared-code strategy: local Swift Package (`ScarfCore`).** Not a
multiplatform target. `PBXFileSystemSynchronizedRootGroup` makes
per-file target membership impractical, so the Mac and iOS apps each
consume a separate SPM package and provide their own platform shells.
## Target Architecture
```
scarf/ (repo root)
scarf/ (Xcode project folder)
scarf.xcodeproj/
Packages/
ScarfCore/ (local SPM — platform-neutral)
Package.swift
Sources/ScarfCore/
Models/ (added in M0a)
Transport/ (added in M0b)
Services/ (added in M0c — portable subset)
ViewModels/ (added in M0d — portable subset)
Views/ (added in M0d — portable subset)
Tests/ScarfCoreTests/
scarf/ (macOS app — PBXFileSystemSynchronizedRootGroup)
MacApp/ (Mac-only glue: Sparkle, SwiftTerm, NSWorkspace shims)
Core/Services/ (Mac-only services remain here)
Features/ (Mac-only features remain here)
Navigation/
scarf-ios/ (iOS app — added in M2)
iOSApp/ (iOS-only glue: CitadelTransport, tab/stack nav)
```
## What We Give Up On iOS (Intentional)
| Dropped | Reason |
|---|---|
| Local Hermes mode | Sandbox + no subprocess on iOS |
| Sparkle auto-updates | App Store handles updates |
| Terminal mode in Chat (SwiftTerm) | Mac-only in v1; SwiftTerm does support iOS, defer to v1.1 |
| Embedded terminal platform-setup (Signal/WhatsApp pairing) | Same SwiftTerm dependency |
| `NSWorkspace.open(_:)` "open in editor" / "reveal" | No equivalent; use `UIApplication.open(_:)` for URLs |
| Multi-window (one window per server) | iPhone-only v1; iPad scenes may come later |
| Menu bar, global shortcuts, drag-and-drop from Finder | Not applicable on iOS |
## What Ships In The v1 iOS App
Dashboard, Sessions Browser, Sessions Detail, Activity Feed, Insights,
Memory viewer/editor, Skills, Cron, Logs, Health, Rich Chat, Settings
(read-mostly). ~70% of the current Mac feature surface.
## The One Real Refactor: Decouple ACP from `Process`
`Core/Services/ACPClient.swift` currently pokes at `Process.isRunning`,
`Process.terminationHandler`, and `Darwin.write()` on raw pipe file
descriptors. Those APIs don't exist on iOS. We introduce:
```swift
protocol ACPChannel: Sendable {
var isOpen: Bool { get }
func send(_ line: String) async throws // JSON line + "\n"
var incoming: AsyncThrowingStream<String, Error> { get }
func close() async
}
```
- Mac: `ProcessACPChannel` wraps today's `Process` + `Pipe` code.
- iOS: `SSHExecACPChannel` wraps a Citadel exec session.
This lands in **M1**.
## SSH on iOS: Citadel
[`orlandos-nl/Citadel`](https://github.com/orlandos-nl/Citadel) is pure-Swift
SSH on SwiftNIO. What we use:
- Public-key auth, keys imported from Files.app or generated on-device and
exported as public key for `authorized_keys`.
- Long-lived exec channel for ACP JSON-RPC over stdio.
- SFTP for `state.db` snapshot pulls (same flow as Mac's `scp`).
- One-shot exec for `stat`/`cat`/`sqlite3 .backup` used by existing services.
What we lose vs. system `ssh`: no `~/.ssh/config`, no `ProxyJump`, no
ControlMaster, no ssh-agent. We run a per-app in-memory session pool (one
session per server, reused across calls) to recover the perf benefit.
## Distribution, Testing, CI
- **TestFlight** primary beta channel.
- **App Store** production distribution.
- **CI** (GitHub Actions, `macos-latest`):
- `swift test` against `Packages/ScarfCore` — fast, no simulator.
- `xcodebuild test -scheme scarf-ios -destination 'platform=iOS Simulator,...'`
for iOS UI tests (added in M2+).
- `xcodebuild test -scheme scarf` for the Mac target (unchanged).
- **Release script** `scripts/release-ios.sh` added in M6: `xcodebuild archive`
`-exportArchive` with App Store profile → `xcrun notarytool`-free path
(App Store review replaces notarization for iOS). The existing
`scripts/release.sh` keeps its Mac-specific Sparkle flow.
## Milestones
| ID | Scope | Size |
|---|---|---|
| **M0** | Extract `ScarfCore` package (Mac-only, no iOS yet) | 12 weeks |
| **M1** | Decouple ACP from `Process` via `ACPChannel` protocol | 23 days |
| **M2** | iOS app skeleton — Citadel, onboarding, Dashboard only | ~1 week |
| **M3** | iOS monitor surface — Sessions, Activity, Insights, Logs, Health | 12 weeks |
| **M4** | iOS Rich Chat — `SSHExecACPChannel` + ACPClient wiring | ~1 week |
| **M5** | iOS writes — Memory, Cron, Skills, Settings | 35 days |
| **M6** | Polish, TestFlight public beta, App Store submission | ~1 week |
Total: **69 weeks.**
### M0 Sub-Phases (each is its own PR)
Because M0 is too large for a single safe PR (no ability to run builds
between commits), it's split into 4 self-contained sub-PRs that each leave
the Mac app in a working state:
- **M0a** — Package scaffolding + move 13 leaf Models to `ScarfCore`
- **M0b** — Move Transport + `ServerContext` to `ScarfCore`
- **M0c** — Move portable Services (`HermesDataService`, `HermesLogService`,
`ModelCatalogService`, `ProjectDashboardService`) to `ScarfCore`
- **M0d** — Move portable ViewModels + Views to `ScarfCore`
## Rules For Future Phases
1. **Any new feature lands in `ScarfCore` by default.** macOS-only is allowed
for features that need `Process`, `NSWorkspace`, embedded `SwiftTerm`, or
menu-bar integration — document why in the feature's header comment.
2. **Every PR leaves the Mac app building and passing tests.** If the PR's
own changes can't be verified in the sandbox agent environment, the PR
description must list a manual verification checklist for Alan to run
before merging.
3. **Wiki updates follow the CLAUDE.md rules** — if the feature was moved,
the wiki page for that feature should note whether it's available on
macOS, iOS, or both.
4. **Version numbers stay in lockstep.** Mac and iOS bump to the same
`MARKETING_VERSION` in one commit.
---
## Progress Log
### M0a — in progress
**Goal:** Create `Packages/ScarfCore` scaffolding and migrate the 13 leaf
Model files to it. `ServerContext.swift` stays in the Mac target (it
depends on Transport + HermesFileService and is not a leaf).
Expected artifacts when done:
- `Packages/ScarfCore/Package.swift` exists.
- 13 model files moved under `Sources/ScarfCore/Models/`.
- `HermesConstants.swift` split: portable parts (`sqliteTransient`,
`QueryDefaults`, `FileSizeUnit`) move to ScarfCore; the deprecated
`HermesPaths` enum stays behind (it references `ServerContext.local`).
- Every moved type annotated `public` with explicit `public init`.
- `scarf.xcodeproj/project.pbxproj` gains one `XCLocalSwiftPackageReference`
for `Packages/ScarfCore` and one `XCSwiftPackageProductDependency` on the
`scarf` target (and `scarfTests` + `scarfUITests` inherit via the target).
- 35 main-target files get `import ScarfCore` added.
- Mac app builds and runs identically to before.
(This section will be finalized when M0a is merged.)
### M0b — pending
### M0c — pending
### M0d — pending
### M1 — pending
### M2 — pending
### M3 — pending
### M4 — pending
### M5 — pending
### M6 — pending
+15
View File
@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; }; 53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; };
53SCARFCORE0010 /* ScarfCore in Frameworks */ = {isa = PBXBuildFile; productRef = 53SCARFCORE0001 /* ScarfCore */; };
53SPARKLE00010 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 53SPARKLE00011 /* Sparkle */; }; 53SPARKLE00010 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 53SPARKLE00011 /* Sparkle */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -71,6 +72,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */, 53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */,
53SCARFCORE0010 /* ScarfCore in Frameworks */,
53SPARKLE00010 /* Sparkle in Frameworks */, 53SPARKLE00010 /* Sparkle in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -133,6 +135,7 @@
name = scarf; name = scarf;
packageProductDependencies = ( packageProductDependencies = (
53SWIFTTERM0001 /* SwiftTerm */, 53SWIFTTERM0001 /* SwiftTerm */,
53SCARFCORE0001 /* ScarfCore */,
53SPARKLE00011 /* Sparkle */, 53SPARKLE00011 /* Sparkle */,
); );
productName = scarf; productName = scarf;
@@ -225,6 +228,7 @@
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */, 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
53SCARFCORE0020 /* XCLocalSwiftPackageReference "Packages/ScarfCore" */,
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */, 53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
@@ -622,6 +626,13 @@
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
53SCARFCORE0020 /* XCLocalSwiftPackageReference "Packages/ScarfCore" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Packages/ScarfCore;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */ = { 53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
@@ -642,6 +653,10 @@
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
53SCARFCORE0001 /* ScarfCore */ = {
isa = XCSwiftPackageProductDependency;
productName = ScarfCore;
};
53SPARKLE00011 /* Sparkle */ = { 53SPARKLE00011 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */; package = 53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */;
-486
View File
@@ -1,486 +0,0 @@
import Foundation
/// Settings for one of hermes's auxiliary model tasks (vision, compression, approvals, etc.).
/// Every auxiliary task follows the same provider/model/base_url/api_key/timeout pattern.
struct AuxiliaryModel: Sendable, Equatable {
var provider: String
var model: String
var baseURL: String
var apiKey: String
var timeout: Int
nonisolated static let empty = AuxiliaryModel(provider: "auto", model: "", baseURL: "", apiKey: "", timeout: 30)
}
/// Group of display-related settings mirroring the `display:` block in config.yaml.
struct DisplaySettings: Sendable, Equatable {
var skin: String
var compact: Bool
var resumeDisplay: String // "full" | "minimal"
var bellOnComplete: Bool
var inlineDiffs: Bool
var toolProgressCommand: Bool
var toolPreviewLength: Int
var busyInputMode: String // e.g. "interrupt"
nonisolated static let empty = DisplaySettings(
skin: "default",
compact: false,
resumeDisplay: "full",
bellOnComplete: false,
inlineDiffs: true,
toolProgressCommand: false,
toolPreviewLength: 0,
busyInputMode: "interrupt"
)
}
/// Container/terminal backend options. These map to `terminal.*` keys in config.yaml.
struct TerminalSettings: Sendable, Equatable {
var cwd: String
var timeout: Int
var envPassthrough: [String]
var persistentShell: Bool
var dockerImage: String
var dockerMountCwdToWorkspace: Bool
var dockerForwardEnv: [String]
var dockerVolumes: [String]
var containerCPU: Int // 0 = unlimited
var containerMemory: Int // MB, 0 = unlimited
var containerDisk: Int // MB, 0 = unlimited
var containerPersistent: Bool
var modalImage: String
var modalMode: String // "auto" | other
var daytonaImage: String
var singularityImage: String
nonisolated static let empty = TerminalSettings(
cwd: ".",
timeout: 180,
envPassthrough: [],
persistentShell: true,
dockerImage: "",
dockerMountCwdToWorkspace: false,
dockerForwardEnv: [],
dockerVolumes: [],
containerCPU: 0,
containerMemory: 0,
containerDisk: 0,
containerPersistent: false,
modalImage: "",
modalMode: "auto",
daytonaImage: "",
singularityImage: ""
)
}
/// Browser automation tuning (`browser.*`).
struct BrowserSettings: Sendable, Equatable {
var inactivityTimeout: Int
var commandTimeout: Int
var recordSessions: Bool
var allowPrivateURLs: Bool
var camofoxManagedPersistence: Bool
nonisolated static let empty = BrowserSettings(
inactivityTimeout: 120,
commandTimeout: 30,
recordSessions: false,
allowPrivateURLs: false,
camofoxManagedPersistence: false
)
}
/// Voice push-to-talk plus TTS/STT provider settings.
struct VoiceSettings: Sendable, Equatable {
var recordKey: String
var maxRecordingSeconds: Int
var silenceDuration: Double
// TTS
var ttsProvider: String
var ttsEdgeVoice: String
var ttsElevenLabsVoiceID: String
var ttsElevenLabsModelID: String
var ttsOpenAIModel: String
var ttsOpenAIVoice: String
var ttsNeuTTSModel: String
var ttsNeuTTSDevice: String
// STT
var sttEnabled: Bool
var sttProvider: String
var sttLocalModel: String
var sttLocalLanguage: String
var sttOpenAIModel: String
var sttMistralModel: String
nonisolated static let empty = VoiceSettings(
recordKey: "ctrl+b",
maxRecordingSeconds: 120,
silenceDuration: 3.0,
ttsProvider: "edge",
ttsEdgeVoice: "en-US-AriaNeural",
ttsElevenLabsVoiceID: "",
ttsElevenLabsModelID: "eleven_multilingual_v2",
ttsOpenAIModel: "gpt-4o-mini-tts",
ttsOpenAIVoice: "alloy",
ttsNeuTTSModel: "neuphonic/neutts-air-q4-gguf",
ttsNeuTTSDevice: "cpu",
sttEnabled: true,
sttProvider: "local",
sttLocalModel: "base",
sttLocalLanguage: "",
sttOpenAIModel: "whisper-1",
sttMistralModel: "voxtral-mini-latest"
)
}
/// Eight sub-models that share the same provider/model/base_url/api_key/timeout shape.
struct AuxiliarySettings: Sendable, Equatable {
var vision: AuxiliaryModel
var webExtract: AuxiliaryModel
var compression: AuxiliaryModel
var sessionSearch: AuxiliaryModel
var skillsHub: AuxiliaryModel
var approval: AuxiliaryModel
var mcp: AuxiliaryModel
var flushMemories: AuxiliaryModel
nonisolated static let empty = AuxiliarySettings(
vision: .empty,
webExtract: .empty,
compression: .empty,
sessionSearch: .empty,
skillsHub: .empty,
approval: .empty,
mcp: .empty,
flushMemories: .empty
)
}
/// Security/redaction/firewall config. Website blocklist is nested in YAML.
struct SecuritySettings: Sendable, Equatable {
var redactSecrets: Bool
var redactPII: Bool // from privacy.redact_pii
var tirithEnabled: Bool
var tirithPath: String
var tirithTimeout: Int
var tirithFailOpen: Bool
var blocklistEnabled: Bool
var blocklistDomains: [String]
nonisolated static let empty = SecuritySettings(
redactSecrets: true,
redactPII: false,
tirithEnabled: true,
tirithPath: "tirith",
tirithTimeout: 5,
tirithFailOpen: true,
blocklistEnabled: false,
blocklistDomains: []
)
}
/// Human-delay simulates realistic typing pace (`human_delay.*`).
struct HumanDelaySettings: Sendable, Equatable {
var mode: String // "off" | "natural" | "custom"
var minMS: Int
var maxMS: Int
nonisolated static let empty = HumanDelaySettings(mode: "off", minMS: 800, maxMS: 2500)
}
/// Compression / context routing.
struct CompressionSettings: Sendable, Equatable {
var enabled: Bool
var threshold: Double
var targetRatio: Double
var protectLastN: Int
nonisolated static let empty = CompressionSettings(enabled: true, threshold: 0.5, targetRatio: 0.2, protectLastN: 20)
}
struct CheckpointSettings: Sendable, Equatable {
var enabled: Bool
var maxSnapshots: Int
nonisolated static let empty = CheckpointSettings(enabled: true, maxSnapshots: 50)
}
struct LoggingSettings: Sendable, Equatable {
var level: String // DEBUG | INFO | WARNING | ERROR
var maxSizeMB: Int
var backupCount: Int
nonisolated static let empty = LoggingSettings(level: "INFO", maxSizeMB: 5, backupCount: 3)
}
struct DelegationSettings: Sendable, Equatable {
var model: String
var provider: String
var baseURL: String
var apiKey: String
var maxIterations: Int
nonisolated static let empty = DelegationSettings(model: "", provider: "", baseURL: "", apiKey: "", maxIterations: 50)
}
/// Discord-specific platform settings (`discord.*`). Other platforms currently have thinner schemas.
struct DiscordSettings: Sendable, Equatable {
var requireMention: Bool
var freeResponseChannels: String
var autoThread: Bool
var reactions: Bool
nonisolated static let empty = DiscordSettings(requireMention: true, freeResponseChannels: "", autoThread: true, reactions: true)
}
/// Telegram settings under `telegram.*` in config.yaml. Most Telegram tuning is
/// done via environment variables (`TELEGRAM_*`) this is the subset that lives
/// in the YAML.
struct TelegramSettings: Sendable, Equatable {
var requireMention: Bool
var reactions: Bool
nonisolated static let empty = TelegramSettings(requireMention: true, reactions: false)
}
/// Slack settings under `platforms.slack.*` (and a couple of top-level keys).
struct SlackSettings: Sendable, Equatable {
var replyToMode: String // "off" | "first" | "all"
var requireMention: Bool
var replyInThread: Bool
var replyBroadcast: Bool
nonisolated static let empty = SlackSettings(replyToMode: "first", requireMention: true, replyInThread: true, replyBroadcast: false)
}
/// Matrix settings under `matrix.*`.
struct MatrixSettings: Sendable, Equatable {
var requireMention: Bool
var autoThread: Bool
var dmMentionThreads: Bool
nonisolated static let empty = MatrixSettings(requireMention: true, autoThread: true, dmMentionThreads: false)
}
/// Mattermost settings. Mattermost is mostly driven by env vars; config.yaml
/// currently just exposes `group_sessions_per_user` at the top level, but we
/// reserve this struct for future expansion so the form has a stable type.
struct MattermostSettings: Sendable, Equatable {
var requireMention: Bool
var replyMode: String // "thread" | "off"
nonisolated static let empty = MattermostSettings(requireMention: true, replyMode: "off")
}
/// WhatsApp settings under `whatsapp.*`.
struct WhatsAppSettings: Sendable, Equatable {
var unauthorizedDMBehavior: String // "pair" | "ignore"
var replyPrefix: String
nonisolated static let empty = WhatsAppSettings(unauthorizedDMBehavior: "pair", replyPrefix: "")
}
/// Home Assistant filters under `platforms.homeassistant.extra`. Hermes ignores
/// every state change by default; users must opt-in via at least one filter.
struct HomeAssistantSettings: Sendable, Equatable {
var watchDomains: [String]
var watchEntities: [String]
var watchAll: Bool
var ignoreEntities: [String]
var cooldownSeconds: Int
nonisolated static let empty = HomeAssistantSettings(watchDomains: [], watchEntities: [], watchAll: false, ignoreEntities: [], cooldownSeconds: 30)
}
// MARK: - Root Config
struct HermesConfig: Sendable {
// Original fields preserved for zero breakage with existing call sites.
var model: String
var provider: String
var maxTurns: Int
var personality: String
var terminalBackend: String
var memoryEnabled: Bool
var memoryCharLimit: Int
var userCharLimit: Int
var nudgeInterval: Int
var streaming: Bool
var showReasoning: Bool
var verbose: Bool
var autoTTS: Bool
var silenceThreshold: Int
var reasoningEffort: String
var showCost: Bool
var approvalMode: String
var browserBackend: String
var memoryProvider: String
var dockerEnv: [String: String]
var commandAllowlist: [String]
var memoryProfile: String
var serviceTier: String
var gatewayNotifyInterval: Int
var forceIPv4: Bool
var contextEngine: String
var interimAssistantMessages: Bool
var honchoInitOnSessionStart: Bool
// Phase 1 additions
var timezone: String
var userProfileEnabled: Bool
var toolUseEnforcement: String // "auto" | "true" | "false" | comma list
var gatewayTimeout: Int
var approvalTimeout: Int
var fileReadMaxChars: Int
var cronWrapResponse: Bool
var prefillMessagesFile: String
var skillsExternalDirs: [String]
// Grouped blocks
var display: DisplaySettings
var terminal: TerminalSettings
var browser: BrowserSettings
var voice: VoiceSettings
var auxiliary: AuxiliarySettings
var security: SecuritySettings
var humanDelay: HumanDelaySettings
var compression: CompressionSettings
var checkpoints: CheckpointSettings
var logging: LoggingSettings
var delegation: DelegationSettings
var discord: DiscordSettings
var telegram: TelegramSettings
var slack: SlackSettings
var matrix: MatrixSettings
var mattermost: MattermostSettings
var whatsapp: WhatsAppSettings
var homeAssistant: HomeAssistantSettings
nonisolated static let empty = HermesConfig(
model: "unknown",
provider: "unknown",
maxTurns: 0,
personality: "default",
terminalBackend: "local",
memoryEnabled: false,
memoryCharLimit: 0,
userCharLimit: 0,
nudgeInterval: 0,
streaming: true,
showReasoning: false,
verbose: false,
autoTTS: true,
silenceThreshold: 200,
reasoningEffort: "medium",
showCost: false,
approvalMode: "manual",
browserBackend: "",
memoryProvider: "",
dockerEnv: [:],
commandAllowlist: [],
memoryProfile: "",
serviceTier: "normal",
gatewayNotifyInterval: 600,
forceIPv4: false,
contextEngine: "compressor",
interimAssistantMessages: true,
honchoInitOnSessionStart: false,
timezone: "",
userProfileEnabled: true,
toolUseEnforcement: "auto",
gatewayTimeout: 1800,
approvalTimeout: 60,
fileReadMaxChars: 100_000,
cronWrapResponse: true,
prefillMessagesFile: "",
skillsExternalDirs: [],
display: .empty,
terminal: .empty,
browser: .empty,
voice: .empty,
auxiliary: .empty,
security: .empty,
humanDelay: .empty,
compression: .empty,
checkpoints: .empty,
logging: .empty,
delegation: .empty,
discord: .empty,
telegram: .empty,
slack: .empty,
matrix: .empty,
mattermost: .empty,
whatsapp: .empty,
homeAssistant: .empty
)
}
// Hand-written `init(from:)` so Swift 6 doesn't synthesize a
// MainActor-isolated Decodable conformance (which would fail to be used from
// `HermesFileService.loadGatewayState()`, a nonisolated method).
struct GatewayState: Sendable, Codable {
nonisolated let pid: Int?
nonisolated let kind: String?
nonisolated let gatewayState: String?
nonisolated let exitReason: String?
nonisolated let platforms: [String: PlatformState]?
nonisolated let updatedAt: String?
enum CodingKeys: String, CodingKey {
case pid, kind
case gatewayState = "gateway_state"
case exitReason = "exit_reason"
case platforms
case updatedAt = "updated_at"
}
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.pid = try c.decodeIfPresent(Int.self, forKey: .pid)
self.kind = try c.decodeIfPresent(String.self, forKey: .kind)
self.gatewayState = try c.decodeIfPresent(String.self, forKey: .gatewayState)
self.exitReason = try c.decodeIfPresent(String.self, forKey: .exitReason)
self.platforms = try c.decodeIfPresent([String: PlatformState].self, forKey: .platforms)
self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
}
nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encodeIfPresent(pid, forKey: .pid)
try c.encodeIfPresent(kind, forKey: .kind)
try c.encodeIfPresent(gatewayState, forKey: .gatewayState)
try c.encodeIfPresent(exitReason, forKey: .exitReason)
try c.encodeIfPresent(platforms, forKey: .platforms)
try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
}
nonisolated var isRunning: Bool {
gatewayState == "running"
}
nonisolated var statusText: String {
gatewayState ?? "unknown"
}
}
struct PlatformState: Sendable, Codable {
nonisolated let connected: Bool?
nonisolated let error: String?
enum CodingKeys: String, CodingKey { case connected, error }
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.connected = try c.decodeIfPresent(Bool.self, forKey: .connected)
self.error = try c.decodeIfPresent(String.self, forKey: .error)
}
nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encodeIfPresent(connected, forKey: .connected)
try c.encodeIfPresent(error, forKey: .error)
}
}
@@ -1,54 +0,0 @@
import Foundation
enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable {
case stdio
case http
var id: String { rawValue }
var displayName: LocalizedStringResource {
switch self {
case .stdio: return "Local (stdio)"
case .http: return "Remote (HTTP)"
}
}
}
struct HermesMCPServer: Identifiable, Sendable, Equatable {
let name: String
let transport: MCPTransport
let command: String?
let args: [String]
let url: String?
let auth: String?
let env: [String: String]
let headers: [String: String]
let timeout: Int?
let connectTimeout: Int?
let enabled: Bool
let toolsInclude: [String]
let toolsExclude: [String]
let resourcesEnabled: Bool
let promptsEnabled: Bool
let hasOAuthToken: Bool
var id: String { name }
var summary: String {
switch transport {
case .stdio:
let argString = args.isEmpty ? "" : " " + args.joined(separator: " ")
return (command ?? "") + argString
case .http:
return url ?? ""
}
}
}
struct MCPTestResult: Sendable, Equatable {
let serverName: String
let succeeded: Bool
let output: String
let tools: [String]
let elapsed: TimeInterval
}
@@ -1,11 +1,17 @@
import Foundation import Foundation
import SQLite3 import ScarfCore
/// Deprecated module-level path statics. Preserved as thin forwarders to /// Deprecated module-level path statics. Preserved as thin forwarders to
/// `ServerContext.local.paths` so existing call sites continue to compile /// `ServerContext.local.paths` so existing call sites continue to compile
/// while Phase 1 migrates them to a per-server `ServerContext`. /// while Phase 1 migrates them to a per-server `ServerContext`.
/// ///
/// New code should accept a `ServerContext` and read `context.paths.<field>`. /// New code should accept a `ServerContext` and read `context.paths.<field>`.
///
/// **Staying behind in the Mac target**: this enum references
/// `ServerContext.local`, which currently lives in the Mac target (not yet
/// extracted to `ScarfCore` that move is part of M0b). Once `ServerContext`
/// moves, this file can be deleted or moved alongside it. Until then, leaving
/// it here keeps the Mac build behavior unchanged.
enum HermesPaths: Sendable { enum HermesPaths: Sendable {
@available(*, deprecated, message: "use ServerContext.paths.home") @available(*, deprecated, message: "use ServerContext.paths.home")
nonisolated static var home: String { ServerContext.local.paths.home } nonisolated static var home: String { ServerContext.local.paths.home }
@@ -66,28 +72,3 @@ enum HermesPaths: Sendable {
@available(*, deprecated, message: "use ServerContext.paths.hermesBinary") @available(*, deprecated, message: "use ServerContext.paths.hermesBinary")
nonisolated static var hermesBinary: String { ServerContext.local.paths.hermesBinary } nonisolated static var hermesBinary: String { ServerContext.local.paths.hermesBinary }
} }
// MARK: - SQLite Constants
/// SQLITE_TRANSIENT tells SQLite to make its own copy of bound string data.
/// The C macro is defined as ((sqlite3_destructor_type)-1) which can't be imported directly into Swift.
nonisolated let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
// MARK: - Query Defaults
enum QueryDefaults: Sendable {
nonisolated static let sessionLimit = 100
nonisolated static let messageSearchLimit = 50
nonisolated static let toolCallLimit = 50
nonisolated static let sessionPreviewLimit = 10
nonisolated static let previewContentLength = 100
nonisolated static let logLineLimit = 200
nonisolated static let defaultSilenceThreshold = 200
}
// MARK: - File Size Formatting
enum FileSizeUnit: Sendable {
nonisolated static let kilobyte = 1_024.0
nonisolated static let megabyte = 1_048_576.0
}
@@ -1,59 +0,0 @@
import Foundation
struct HermesSession: Identifiable, Sendable {
let id: String
let source: String
let userId: String?
let model: String?
let title: String?
let parentSessionId: String?
let startedAt: Date?
let endedAt: Date?
let endReason: String?
let messageCount: Int
let toolCallCount: Int
let inputTokens: Int
let outputTokens: Int
let cacheReadTokens: Int
let cacheWriteTokens: Int
let estimatedCostUSD: Double?
let reasoningTokens: Int
let actualCostUSD: Double?
let costStatus: String?
let billingProvider: String?
var isSubagent: Bool { parentSessionId != nil }
var totalTokens: Int { inputTokens + outputTokens + reasoningTokens }
var displayCostUSD: Double? { actualCostUSD ?? estimatedCostUSD }
var costIsActual: Bool { actualCostUSD != nil }
var duration: TimeInterval? {
guard let start = startedAt, let end = endedAt else { return nil }
return end.timeIntervalSince(start)
}
var displayTitle: String {
title ?? id
}
var sourceIcon: String {
KnownPlatforms.icon(for: source)
}
func withTitle(_ newTitle: String) -> HermesSession {
HermesSession(
id: id, source: source, userId: userId, model: model,
title: newTitle, parentSessionId: parentSessionId,
startedAt: startedAt, endedAt: endedAt, endReason: endReason,
messageCount: messageCount, toolCallCount: toolCallCount,
inputTokens: inputTokens, outputTokens: outputTokens,
cacheReadTokens: cacheReadTokens, cacheWriteTokens: cacheWriteTokens,
estimatedCostUSD: estimatedCostUSD, reasoningTokens: reasoningTokens,
actualCostUSD: actualCostUSD, costStatus: costStatus,
billingProvider: billingProvider
)
}
}
-16
View File
@@ -1,16 +0,0 @@
import Foundation
struct HermesSkillCategory: Identifiable, Sendable {
let id: String
let name: String
let skills: [HermesSkill]
}
struct HermesSkill: Identifiable, Sendable {
let id: String
let name: String
let category: String
let path: String
let files: [String]
let requiredConfig: [String]
}
@@ -1,17 +0,0 @@
import Foundation
/// A slash command available in chat. Sourced either from the ACP server
/// (`available_commands_update`) or from user-defined `quick_commands` in
/// `config.yaml`.
struct HermesSlashCommand: Identifiable, Sendable, Equatable {
enum Source: Sendable, Equatable {
case acp
case quickCommand
}
var id: String { name }
let name: String
let description: String
let argumentHint: String?
let source: Source
}
@@ -1,138 +0,0 @@
import Foundation
// MARK: - Registry
struct ProjectRegistry: Codable, Sendable {
var projects: [ProjectEntry]
}
struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
var id: String { name }
let name: String
let path: String
var dashboardPath: String { path + "/.scarf/dashboard.json" }
}
// MARK: - Dashboard
struct ProjectDashboard: Codable, Sendable {
let version: Int
let title: String
let description: String?
let updatedAt: String?
let theme: DashboardTheme?
let sections: [DashboardSection]
}
struct DashboardTheme: Codable, Sendable {
let accent: String?
}
struct DashboardSection: Codable, Sendable, Identifiable {
var id: String { title }
let title: String
let columns: Int?
let widgets: [DashboardWidget]
var columnCount: Int { columns ?? 3 }
}
struct DashboardWidget: Codable, Sendable, Identifiable {
var id: String { type + ":" + title }
let type: String
let title: String
// Stat
let value: WidgetValue?
let icon: String?
let color: String?
let subtitle: String?
// Progress
let label: String?
// Text
let content: String?
let format: String?
// Table
let columns: [String]?
let rows: [[String]]?
// Chart
let chartType: String?
let xLabel: String?
let yLabel: String?
let series: [ChartSeries]?
// List
let items: [ListItem]?
// Webview
let url: String?
let height: Double?
}
// MARK: - Widget Value (String or Number)
enum WidgetValue: Codable, Sendable, Hashable {
case string(String)
case number(Double)
var displayString: String {
switch self {
case .string(let s): return s
case .number(let n):
return n.truncatingRemainder(dividingBy: 1) == 0
? Int(n).formatted(.number)
: n.formatted(.number.precision(.fractionLength(1)))
}
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let d = try? container.decode(Double.self) {
self = .number(d)
} else if let s = try? container.decode(String.self) {
self = .string(s)
} else {
throw DecodingError.typeMismatch(
WidgetValue.self,
.init(codingPath: decoder.codingPath, debugDescription: "Expected String or Number")
)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let s): try container.encode(s)
case .number(let n): try container.encode(n)
}
}
}
// MARK: - Chart Data
struct ChartSeries: Codable, Sendable, Identifiable {
var id: String { name }
let name: String
let color: String?
let data: [ChartDataPoint]
}
struct ChartDataPoint: Codable, Sendable, Identifiable {
var id: String { x }
let x: String
let y: Double
}
// MARK: - List Data
struct ListItem: Codable, Sendable, Identifiable {
var id: String { text }
let text: String
let status: String?
}
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import SwiftUI import SwiftUI
import AppKit import AppKit
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import os import os
/// Manages a `hermes acp` subprocess and communicates via JSON-RPC over stdio. /// Manages a `hermes acp` subprocess and communicates via JSON-RPC over stdio.
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import SQLite3 import SQLite3
import os import os
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import os import os
struct HermesFileService: Sendable { struct HermesFileService: Sendable {
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
struct LogEntry: Identifiable, Sendable { struct LogEntry: Identifiable, Sendable {
let id: Int let id: Int
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import os import os
struct ProjectDashboardService: Sendable { struct ProjectDashboardService: Sendable {
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
@Observable @Observable
final class ActivityViewModel { final class ActivityViewModel {
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct ActivityView: View { struct ActivityView: View {
@State private var viewModel: ActivityViewModel @State private var viewModel: ActivityViewModel
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import AppKit import AppKit
import SwiftTerm import SwiftTerm
import os import os
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
enum ChatDisplayMode: String, CaseIterable { enum ChatDisplayMode: String, CaseIterable {
case terminal case terminal
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct RichChatInputBar: View { struct RichChatInputBar: View {
let onSend: (String) -> Void let onSend: (String) -> Void
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct RichChatMessageList: View { struct RichChatMessageList: View {
let groups: [MessageGroup] let groups: [MessageGroup]
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct RichMessageBubble: View { struct RichMessageBubble: View {
let message: HermesMessage let message: HermesMessage
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct SessionInfoBar: View { struct SessionInfoBar: View {
let session: HermesSession? let session: HermesSession?
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
/// Floating menu of available slash commands shown above the chat input when /// Floating menu of available slash commands shown above the chat input when
/// the user types `/` as the first character. Purely presentational the /// the user types `/` as the first character. Purely presentational the
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct ToolCallCard: View { struct ToolCallCard: View {
let call: HermesToolCall let call: HermesToolCall
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import AppKit import AppKit
import os import os
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct CronView: View { struct CronView: View {
@State private var viewModel: CronViewModel @State private var viewModel: CronViewModel
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
@Observable @Observable
final class DashboardViewModel { final class DashboardViewModel {
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct DashboardView: View { struct DashboardView: View {
@State private var viewModel: DashboardViewModel @State private var viewModel: DashboardViewModel
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
struct GatewayInfo { struct GatewayInfo {
let pid: Int? let pid: Int?
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct GatewayView: View { struct GatewayView: View {
@State private var viewModel: GatewayViewModel @State private var viewModel: GatewayViewModel
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
enum InsightsPeriod: String, CaseIterable, Identifiable { enum InsightsPeriod: String, CaseIterable, Identifiable {
case week = "7 Days" case week = "7 Days"
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct InsightsView: View { struct InsightsView: View {
@State private var viewModel: InsightsViewModel @State private var viewModel: InsightsViewModel
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
@Observable @Observable
final class MCPServerEditorViewModel { final class MCPServerEditorViewModel {
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
@Observable @Observable
final class MCPServersViewModel { final class MCPServersViewModel {
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct MCPServerAddCustomView: View { struct MCPServerAddCustomView: View {
let viewModel: MCPServersViewModel let viewModel: MCPServersViewModel
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct MCPServerDetailView: View { struct MCPServerDetailView: View {
let server: HermesMCPServer let server: HermesMCPServer
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct MCPServerPresetPickerView: View { struct MCPServerPresetPickerView: View {
let viewModel: MCPServersViewModel let viewModel: MCPServersViewModel
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct MCPServerTestResultView: View { struct MCPServerTestResultView: View {
let result: MCPTestResult let result: MCPTestResult
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct MCPServersView: View { struct MCPServersView: View {
@State private var viewModel: MCPServersViewModel @State private var viewModel: MCPServersViewModel
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import os import os
/// Discord setup. Bot token + user IDs in `.env`, behavior knobs in `discord.*`. /// Discord setup. Bot token + user IDs in `.env`, behavior knobs in `discord.*`.
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import os import os
/// Platform list/selection coordinator. Per-platform configuration now lives in /// Platform list/selection coordinator. Per-platform configuration now lives in
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct PlatformsView: View { struct PlatformsView: View {
@State private var viewModel: PlatformsViewModel @State private var viewModel: PlatformsViewModel
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import os import os
@Observable @Observable
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
import UniformTypeIdentifiers import UniformTypeIdentifiers
private enum DashboardTab: String, CaseIterable { private enum DashboardTab: String, CaseIterable {
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
import Charts import Charts
// Flattened data point for Charts to avoid complex nested generic inference // Flattened data point for Charts to avoid complex nested generic inference
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct ListWidgetView: View { struct ListWidgetView: View {
let widget: DashboardWidget let widget: DashboardWidget
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct ProgressWidgetView: View { struct ProgressWidgetView: View {
let widget: DashboardWidget let widget: DashboardWidget
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct StatWidgetView: View { struct StatWidgetView: View {
let widget: DashboardWidget let widget: DashboardWidget
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct TableWidgetView: View { struct TableWidgetView: View {
let widget: DashboardWidget let widget: DashboardWidget
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct TextWidgetView: View { struct TextWidgetView: View {
let widget: DashboardWidget let widget: DashboardWidget
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
import WebKit import WebKit
struct WebviewWidgetView: View { struct WebviewWidgetView: View {
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
/// Bypasses `SSHTransport`'s normal terse-error path so the Add Server sheet /// Bypasses `SSHTransport`'s normal terse-error path so the Add Server sheet
/// can show the user a full diagnostic on failure: the exact ssh command we /// can show the user a full diagnostic on failure: the exact ssh command we
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import AppKit import AppKit
import UniformTypeIdentifiers import UniformTypeIdentifiers
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct SessionDetailView: View { struct SessionDetailView: View {
let session: HermesSession let session: HermesSession
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct SessionsView: View { struct SessionsView: View {
@State private var viewModel: SessionsViewModel @State private var viewModel: SessionsViewModel
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import AppKit import AppKit
import UniformTypeIdentifiers import UniformTypeIdentifiers
import os import os
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
/// Auxiliary tab the 8 sub-model tasks hermes delegates to cheaper models. /// Auxiliary tab the 8 sub-model tasks hermes delegates to cheaper models.
/// Each follows the same provider/model/base_url/api_key/timeout pattern. /// Each follows the same provider/model/base_url/api_key/timeout pattern.
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import os import os
/// A single search/browse result from a skill registry. /// A single search/browse result from a skill registry.
@@ -1,4 +1,5 @@
import Foundation import Foundation
import ScarfCore
import os import os
/// Connection/configuration status for a messaging platform, used for indicator dots in the picker. /// Connection/configuration status for a messaging platform, used for indicator dots in the picker.
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import ScarfCore
struct ToolsView: View { struct ToolsView: View {
@State private var viewModel: ToolsViewModel @State private var viewModel: ToolsViewModel