diff --git a/README.md b/README.md index 3e577b6..76e2c50 100644 --- a/README.md +++ b/README.md @@ -21,24 +21,24 @@ - **Dashboard** — System health, token usage, cost tracking, recent sessions with live refresh - **Insights** — Usage analytics with token breakdown (including reasoning tokens), cost tracking, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time) -- **Sessions Browser** — Full conversation history with message rendering, model reasoning/thinking display, tool call inspection, full-text search, rename, delete, and JSONL export +- **Sessions Browser** — Full conversation history with message rendering, model reasoning/thinking display, tool call inspection, full-text search, rename, delete, and JSONL export. Subagent sessions are filtered from the main list and accessible via parent session drill-down - **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments - **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm), session persistence across navigation, resume/continue previous sessions, and voice mode controls -- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, etc.) -- **Skills Browser** — Browse all installed skills by category with file content viewer and file switcher -- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, Webhook, Matrix) with toggle switches, MCP server status +- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, Supermemory, etc.), and profile-scoped memory support with profile picker +- **Skills Browser** — Browse all installed skills by category with file content viewer, file switcher, and required config warnings for skills that need specific settings +- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, Webhook, Matrix, Feishu, Mattermost) with toggle switches and segmented platform picker, MCP server status - **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke) -- **Cron Manager** — View scheduled jobs, their status, prompts, and output -- **Log Viewer** — Real-time log tailing with level filtering and text search +- **Cron Manager** — View scheduled jobs with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators +- **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering and text search - **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically -- **Settings** — Structured config editor for all Hermes settings including reasoning effort, approval mode, cost display, and more +- **Settings** — Structured config editor for all Hermes settings including model/provider selection, browser backend, reasoning effort, approval mode, cost display, Docker environment, command allowlist, credential management, and more - **Menu Bar** — Status icon showing Hermes running state with quick actions ## Requirements - macOS 26.2+ - Xcode 26.3+ -- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` +- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` (v0.8.0 recommended for full feature support) ### Compatibility @@ -47,7 +47,8 @@ Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`, | Hermes Version | Status | |----------------|--------| | v0.6.0 (2026-03-30) | Verified | -| v0.7.0 (2026-04-03, latest) | Verified | +| v0.7.0 (2026-04-03) | Verified | +| v0.8.0 (2026-04-08, latest) | Verified | If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings. diff --git a/scarf/scarf.xcodeproj/project.pbxproj b/scarf/scarf.xcodeproj/project.pbxproj index d435013..b4b69a3 100644 --- a/scarf/scarf.xcodeproj/project.pbxproj +++ b/scarf/scarf.xcodeproj/project.pbxproj @@ -421,7 +421,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = com.scarf; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -457,7 +457,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = com.scarf; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -479,7 +479,7 @@ DEVELOPMENT_TEAM = 3Q6X2L86C4; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -500,7 +500,7 @@ DEVELOPMENT_TEAM = 3Q6X2L86C4; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -519,7 +519,7 @@ CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 3Q6X2L86C4; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -538,7 +538,7 @@ CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 3Q6X2L86C4; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/scarf/scarf/Core/Models/HermesConfig.swift b/scarf/scarf/Core/Models/HermesConfig.swift index 87b6782..7101171 100644 --- a/scarf/scarf/Core/Models/HermesConfig.swift +++ b/scarf/scarf/Core/Models/HermesConfig.swift @@ -20,6 +20,9 @@ struct HermesConfig: Sendable { var approvalMode: String var browserBackend: String var memoryProvider: String + var dockerEnv: [String: String] + var commandAllowlist: [String] + var memoryProfile: String static let empty = HermesConfig( model: "unknown", @@ -40,7 +43,10 @@ struct HermesConfig: Sendable { showCost: false, approvalMode: "manual", browserBackend: "", - memoryProvider: "" + memoryProvider: "", + dockerEnv: [:], + commandAllowlist: [], + memoryProfile: "" ) } diff --git a/scarf/scarf/Core/Models/HermesConstants.swift b/scarf/scarf/Core/Models/HermesConstants.swift index 3d733e2..5b74ab4 100644 --- a/scarf/scarf/Core/Models/HermesConstants.swift +++ b/scarf/scarf/Core/Models/HermesConstants.swift @@ -17,6 +17,7 @@ enum HermesPaths: Sendable { nonisolated static let gatewayStateJSON: String = home + "/gateway_state.json" nonisolated static let skillsDir: String = home + "/skills" nonisolated static let errorsLog: String = home + "/logs/errors.log" + nonisolated static let agentLog: String = home + "/logs/agent.log" nonisolated static let gatewayLog: String = home + "/logs/gateway.log" nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes" nonisolated static let scarfDir: String = home + "/scarf" diff --git a/scarf/scarf/Core/Models/HermesCronJob.swift b/scarf/scarf/Core/Models/HermesCronJob.swift index ca6c5a1..9fc3af9 100644 --- a/scarf/scarf/Core/Models/HermesCronJob.swift +++ b/scarf/scarf/Core/Models/HermesCronJob.swift @@ -13,12 +13,23 @@ struct HermesCronJob: Identifiable, Sendable, Codable { let nextRunAt: String? let lastRunAt: String? let lastError: String? + let preRunScript: String? + let deliveryFailures: Int? + let lastDeliveryError: String? + let timeoutType: String? + let timeoutSeconds: Int? + let silent: Bool? enum CodingKeys: String, CodingKey { - case id, name, prompt, skills, model, schedule, enabled, state, deliver + case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent case nextRunAt = "next_run_at" case lastRunAt = "last_run_at" case lastError = "last_error" + case preRunScript = "pre_run_script" + case deliveryFailures = "delivery_failures" + case lastDeliveryError = "last_delivery_error" + case timeoutType = "timeout_type" + case timeoutSeconds = "timeout_seconds" } var stateIcon: String { diff --git a/scarf/scarf/Core/Models/HermesSession.swift b/scarf/scarf/Core/Models/HermesSession.swift index f293c06..bc57522 100644 --- a/scarf/scarf/Core/Models/HermesSession.swift +++ b/scarf/scarf/Core/Models/HermesSession.swift @@ -22,6 +22,8 @@ struct HermesSession: Identifiable, Sendable { let costStatus: String? let billingProvider: String? + var isSubagent: Bool { parentSessionId != nil } + var totalTokens: Int { inputTokens + outputTokens + reasoningTokens } var displayCostUSD: Double? { actualCostUSD ?? estimatedCostUSD } diff --git a/scarf/scarf/Core/Models/HermesSkill.swift b/scarf/scarf/Core/Models/HermesSkill.swift index 351b248..4907668 100644 --- a/scarf/scarf/Core/Models/HermesSkill.swift +++ b/scarf/scarf/Core/Models/HermesSkill.swift @@ -12,4 +12,5 @@ struct HermesSkill: Identifiable, Sendable { let category: String let path: String let files: [String] + let requiredConfig: [String] } diff --git a/scarf/scarf/Core/Models/HermesTool.swift b/scarf/scarf/Core/Models/HermesTool.swift index 8037b42..acd3b10 100644 --- a/scarf/scarf/Core/Models/HermesTool.swift +++ b/scarf/scarf/Core/Models/HermesTool.swift @@ -28,6 +28,8 @@ enum KnownPlatforms { HermesToolPlatform(name: "homeassistant", displayName: "Home Assistant", icon: "house"), HermesToolPlatform(name: "webhook", displayName: "Webhook", icon: "arrow.up.right.square"), HermesToolPlatform(name: "matrix", displayName: "Matrix", icon: "lock.rectangle.stack"), + HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"), + HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"), ] static func icon(for platform: String) -> String { @@ -42,6 +44,8 @@ enum KnownPlatforms { case "homeassistant": return "house" case "webhook": return "arrow.up.right.square" case "matrix": return "lock.rectangle.stack" + case "feishu": return "message.badge.circle" + case "mattermost": return "bubble.left.and.exclamationmark.bubble.right" default: return "bubble.left" } } diff --git a/scarf/scarf/Core/Services/HermesDataService.swift b/scarf/scarf/Core/Services/HermesDataService.swift index 30ffe5c..ad384c7 100644 --- a/scarf/scarf/Core/Services/HermesDataService.swift +++ b/scarf/scarf/Core/Services/HermesDataService.swift @@ -58,7 +58,7 @@ actor HermesDataService { func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] { guard let db else { return [] } - let sql = "SELECT \(sessionColumns) FROM sessions ORDER BY started_at DESC LIMIT ?" + let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL ORDER BY started_at DESC LIMIT ?" var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } defer { sqlite3_finalize(stmt) } @@ -73,7 +73,7 @@ actor HermesDataService { func fetchSessionsInPeriod(since: Date) -> [HermesSession] { guard let db else { return [] } - let sql = "SELECT \(sessionColumns) FROM sessions WHERE started_at >= ? ORDER BY started_at DESC" + let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL AND started_at >= ? ORDER BY started_at DESC" var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } defer { sqlite3_finalize(stmt) } @@ -86,6 +86,21 @@ actor HermesDataService { return sessions } + func fetchSubagentSessions(parentId: String) -> [HermesSession] { + guard let db else { return [] } + let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id = ? ORDER BY started_at ASC" + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_text(stmt, 1, parentId, -1, sqliteTransient) + + var sessions: [HermesSession] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + sessions.append(sessionFromRow(stmt!)) + } + return sessions + } + // MARK: - Message Queries private var messageColumns: String { @@ -252,7 +267,7 @@ actor HermesDataService { let sql = """ SELECT COUNT(*) FROM messages m JOIN sessions s ON m.session_id = s.id - WHERE m.role = 'user' AND s.started_at >= ? + WHERE m.role = 'user' AND s.parent_session_id IS NULL AND s.started_at >= ? """ var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 } @@ -268,7 +283,7 @@ actor HermesDataService { SELECT m.tool_name, COUNT(*) as cnt FROM messages m JOIN sessions s ON m.session_id = s.id - WHERE m.tool_name IS NOT NULL AND m.tool_name <> '' AND s.started_at >= ? + WHERE m.tool_name IS NOT NULL AND m.tool_name <> '' AND s.parent_session_id IS NULL AND s.started_at >= ? GROUP BY m.tool_name ORDER BY cnt DESC """ @@ -289,7 +304,7 @@ actor HermesDataService { func fetchSessionStartHours(since: Date) -> [Int: Int] { guard let db else { return [:] } let sql = """ - SELECT started_at FROM sessions WHERE started_at >= ? + SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ? """ var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] } @@ -310,7 +325,7 @@ actor HermesDataService { func fetchSessionDaysOfWeek(since: Date) -> [Int: Int] { guard let db else { return [:] } let sql = """ - SELECT started_at FROM sessions WHERE started_at >= ? + SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ? """ var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] } diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index a51a4a5..62a2d4c 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -12,12 +12,37 @@ struct HermesFileService: Sendable { private func parseConfig(_ yaml: String) -> HermesConfig { var values: [String: String] = [:] var currentSection = "" + var dockerEnv: [String: String] = [:] + var commandAllowlist: [String] = [] + var inDockerEnv = false + var inAllowlist = false for line in yaml.components(separatedBy: "\n") { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } let indent = line.prefix(while: { $0 == " " }).count + + // Detect end of nested blocks when indent returns to section level + if indent <= 2 && (inDockerEnv || inAllowlist) { + inDockerEnv = false + inAllowlist = false + } + + // Collect docker_env nested key-value pairs + if inDockerEnv, indent >= 4, let colonIdx = trimmed.firstIndex(of: ":") { + let key = String(trimmed[trimmed.startIndex..= 4, trimmed.hasPrefix("- ") { + commandAllowlist.append(String(trimmed.dropFirst(2))) + continue + } + if indent == 0 && trimmed.hasSuffix(":") { currentSection = String(trimmed.dropLast()) continue @@ -26,6 +51,16 @@ struct HermesFileService: Sendable { if let colonIdx = trimmed.firstIndex(of: ":") { let key = String(trimmed[trimmed.startIndex.. String { - readFile(HermesPaths.memoryMD) ?? "" + func loadMemoryProfiles() -> [String] { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory(atPath: HermesPaths.memoriesDir) else { return [] } + return entries.filter { name in + var isDir: ObjCBool = false + let path = HermesPaths.memoriesDir + "/" + name + return fm.fileExists(atPath: path, isDirectory: &isDir) && isDir.boolValue + }.sorted() } - func loadUserProfile() -> String { - readFile(HermesPaths.userMD) ?? "" + func loadMemory(profile: String = "") -> String { + let path = memoryPath(profile: profile, file: "MEMORY.md") + return readFile(path) ?? "" } - func saveMemory(_ content: String) { - writeFile(HermesPaths.memoryMD, content: content) + func loadUserProfile(profile: String = "") -> String { + let path = memoryPath(profile: profile, file: "USER.md") + return readFile(path) ?? "" } - func saveUserProfile(_ content: String) { - writeFile(HermesPaths.userMD, content: content) + func saveMemory(_ content: String, profile: String = "") { + let path = memoryPath(profile: profile, file: "MEMORY.md") + writeFile(path, content: content) + } + + func saveUserProfile(_ content: String, profile: String = "") { + let path = memoryPath(profile: profile, file: "USER.md") + writeFile(path, content: content) + } + + private func memoryPath(profile: String, file: String) -> String { + if profile.isEmpty { + return HermesPaths.memoriesDir + "/" + file + } + return HermesPaths.memoriesDir + "/" + profile + "/" + file } // MARK: - Cron @@ -123,12 +182,14 @@ struct HermesFileService: Sendable { var isSkillDir: ObjCBool = false guard fm.fileExists(atPath: skillPath, isDirectory: &isSkillDir), isSkillDir.boolValue else { return nil } let files = (try? fm.contentsOfDirectory(atPath: skillPath)) ?? [] + let requiredConfig = parseSkillRequiredConfig(skillPath + "/skill.yaml") return HermesSkill( id: categoryName + "/" + skillName, name: skillName, category: categoryName, path: skillPath, - files: files.sorted() + files: files.sorted(), + requiredConfig: requiredConfig ) } @@ -147,6 +208,30 @@ struct HermesFileService: Sendable { return readFile(path) ?? "" } + private func parseSkillRequiredConfig(_ path: String) -> [String] { + guard let content = readFile(path) else { return [] } + var result: [String] = [] + var inRequiredConfig = false + for line in content.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + let indent = line.prefix(while: { $0 == " " }).count + if trimmed == "required_config:" || trimmed.hasPrefix("required_config:") { + inRequiredConfig = true + continue + } + if inRequiredConfig { + if indent < 2 && !trimmed.isEmpty { + break + } + if trimmed.hasPrefix("- ") { + result.append(String(trimmed.dropFirst(2))) + } + } + } + return result + } + // MARK: - Hermes Process func isHermesRunning() -> Bool { diff --git a/scarf/scarf/Core/Services/HermesFileWatcher.swift b/scarf/scarf/Core/Services/HermesFileWatcher.swift index 050fa69..313147c 100644 --- a/scarf/scarf/Core/Services/HermesFileWatcher.swift +++ b/scarf/scarf/Core/Services/HermesFileWatcher.swift @@ -16,6 +16,7 @@ final class HermesFileWatcher { HermesPaths.userMD, HermesPaths.cronJobsJSON, HermesPaths.gatewayStateJSON, + HermesPaths.agentLog, HermesPaths.errorsLog, HermesPaths.gatewayLog, HermesPaths.projectsRegistry diff --git a/scarf/scarf/Features/Cron/Views/CronView.swift b/scarf/scarf/Features/Cron/Views/CronView.swift index c73086e..51f9011 100644 --- a/scarf/scarf/Features/Cron/Views/CronView.swift +++ b/scarf/scarf/Features/Cron/Views/CronView.swift @@ -38,6 +38,11 @@ struct CronView: View { .foregroundStyle(.secondary) } Spacer() + if job.silent == true { + Text("SILENT") + .font(.caption2.bold()) + .foregroundStyle(.purple) + } if !job.enabled { Text("Disabled") .font(.caption2) @@ -86,6 +91,20 @@ struct CronView: View { .background(.quaternary.opacity(0.5)) .clipShape(RoundedRectangle(cornerRadius: 6)) } + if let script = job.preRunScript, !script.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Pre-Run Script") + .font(.caption.bold()) + .foregroundStyle(.secondary) + Text(script) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } if let skills = job.skills, !skills.isEmpty { VStack(alignment: .leading, spacing: 4) { Text("Skills") @@ -118,6 +137,21 @@ struct CronView: View { .font(.caption) .foregroundStyle(.red) } + if let timeout = job.timeoutSeconds { + Label("Timeout: \(timeout)s (\(job.timeoutType ?? "wall_clock"))", systemImage: "timer") + .font(.caption) + .foregroundStyle(.secondary) + } + if let failures = job.deliveryFailures, failures > 0 { + Label("\(failures) delivery failure\(failures == 1 ? "" : "s")", systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.orange) + } + if let deliveryError = job.lastDeliveryError { + Label(deliveryError, systemImage: "paperplane.circle") + .font(.caption) + .foregroundStyle(.orange) + } if let output = viewModel.jobOutput { Divider() VStack(alignment: .leading, spacing: 4) { diff --git a/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift b/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift index 7eac5a2..702736a 100644 --- a/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift +++ b/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift @@ -19,17 +19,7 @@ struct PlatformInfo: Identifiable { var isConnected: Bool { state == "connected" } - var icon: String { - switch name { - case "telegram": return "paperplane" - case "discord": return "bubble.left.and.bubble.right" - case "slack": return "number" - case "whatsapp": return "phone.bubble" - case "signal": return "lock.shield" - case "email": return "envelope" - default: return "bubble.left" - } - } + var icon: String { KnownPlatforms.icon(for: name) } } struct PairedUser: Identifiable { diff --git a/scarf/scarf/Features/Gateway/Views/GatewayView.swift b/scarf/scarf/Features/Gateway/Views/GatewayView.swift index f4bfc6a..12034b5 100644 --- a/scarf/scarf/Features/Gateway/Views/GatewayView.swift +++ b/scarf/scarf/Features/Gateway/Views/GatewayView.swift @@ -177,15 +177,7 @@ struct GatewayView: View { } private func platformIcon(_ platform: String) -> String { - switch platform { - case "telegram": return "paperplane" - case "discord": return "bubble.left.and.bubble.right" - case "slack": return "number" - case "whatsapp": return "phone.bubble" - case "signal": return "lock.shield" - case "email": return "envelope" - default: return "bubble.left" - } + KnownPlatforms.icon(for: platform) } } diff --git a/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift b/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift index a587156..cc6d96c 100644 --- a/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift +++ b/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift @@ -5,12 +5,13 @@ final class LogsViewModel { private let logService = HermesLogService() var entries: [LogEntry] = [] - var selectedLogFile: LogFile = .errors + var selectedLogFile: LogFile = .agent var filterLevel: LogEntry.LogLevel? var searchText = "" private var pollTimer: Timer? enum LogFile: String, CaseIterable, Identifiable { + case agent = "agent.log" case errors = "errors.log" case gateway = "gateway.log" @@ -18,6 +19,7 @@ final class LogsViewModel { var path: String { switch self { + case .agent: return HermesPaths.agentLog case .errors: return HermesPaths.errorsLog case .gateway: return HermesPaths.gatewayLog } diff --git a/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift b/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift index 3451b4d..c8e841a 100644 --- a/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift +++ b/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift @@ -10,6 +10,8 @@ final class MemoryViewModel { var isEditing = false var editingFile: EditTarget = .memory var editText = "" + var profiles: [String] = [] + var activeProfile = "" enum EditTarget { case memory, user @@ -22,10 +24,23 @@ final class MemoryViewModel { !memoryProvider.isEmpty && memoryProvider != "file" } + var hasMultipleProfiles: Bool { !profiles.isEmpty } + func load() { - memoryContent = fileService.loadMemory() - userContent = fileService.loadUserProfile() - memoryProvider = fileService.loadConfig().memoryProvider + let config = fileService.loadConfig() + memoryProvider = config.memoryProvider + profiles = fileService.loadMemoryProfiles() + if activeProfile.isEmpty { + activeProfile = config.memoryProfile + } + memoryContent = fileService.loadMemory(profile: activeProfile) + userContent = fileService.loadUserProfile(profile: activeProfile) + } + + func switchProfile(_ profile: String) { + activeProfile = profile + memoryContent = fileService.loadMemory(profile: profile) + userContent = fileService.loadUserProfile(profile: profile) } func startEditing(_ target: EditTarget) { @@ -37,10 +52,10 @@ final class MemoryViewModel { func save() { switch editingFile { case .memory: - fileService.saveMemory(editText) + fileService.saveMemory(editText, profile: activeProfile) memoryContent = editText case .user: - fileService.saveUserProfile(editText) + fileService.saveUserProfile(editText, profile: activeProfile) userContent = editText } isEditing = false diff --git a/scarf/scarf/Features/Memory/Views/MemoryView.swift b/scarf/scarf/Features/Memory/Views/MemoryView.swift index 287116d..ac29e8a 100644 --- a/scarf/scarf/Features/Memory/Views/MemoryView.swift +++ b/scarf/scarf/Features/Memory/Views/MemoryView.swift @@ -7,6 +7,23 @@ struct MemoryView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { + if viewModel.hasMultipleProfiles { + HStack(spacing: 8) { + Text("Profile") + .font(.caption.bold()) + .foregroundStyle(.secondary) + Picker("", selection: Binding( + get: { viewModel.activeProfile }, + set: { viewModel.switchProfile($0) } + )) { + Text("Default").tag("") + ForEach(viewModel.profiles, id: \.self) { profile in + Text(profile).tag(profile) + } + } + .frame(maxWidth: 200) + } + } if viewModel.hasExternalProvider { HStack(spacing: 8) { Image(systemName: "info.circle") diff --git a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift index 1f16090..d4fecd8 100644 --- a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift +++ b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift @@ -21,6 +21,7 @@ final class SessionsViewModel { var searchResults: [HermesMessage] = [] var isSearching = false var storeStats: SessionStoreStats? + var subagentSessions: [HermesSession] = [] var renameSessionId: String? var renameText = "" @@ -45,6 +46,7 @@ final class SessionsViewModel { func selectSession(_ session: HermesSession) async { selectedSession = session messages = await dataService.fetchMessages(sessionId: session.id) + subagentSessions = await dataService.fetchSubagentSessions(parentId: session.id) } func selectSessionById(_ id: String) async { diff --git a/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift b/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift index bb4f0ea..5c22858 100644 --- a/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift +++ b/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift @@ -3,14 +3,19 @@ import SwiftUI struct SessionDetailView: View { let session: HermesSession let messages: [HermesMessage] + var subagentSessions: [HermesSession] = [] var preview: String? var onRename: (() -> Void)? var onExport: (() -> Void)? var onDelete: (() -> Void)? + var onSelectSubagent: ((HermesSession) -> Void)? var body: some View { VStack(alignment: .leading, spacing: 0) { sessionHeader + if !subagentSessions.isEmpty { + subagentSection + } Divider() messagesList } @@ -41,6 +46,13 @@ struct SessionDetailView: View { } HStack(spacing: 16) { Label(session.source, systemImage: session.sourceIcon) + if session.isSubagent { + Label("Subagent", systemImage: "arrow.triangle.branch") + .foregroundStyle(.orange) + } + if let userId = session.userId, !userId.isEmpty, session.source != "cli" { + Label(userId, systemImage: "person") + } Label(session.model ?? "unknown", systemImage: "cpu") Label("\(session.messageCount) msgs", systemImage: "bubble.left") Label("\(session.toolCallCount) tools", systemImage: "wrench") @@ -64,6 +76,38 @@ struct SessionDetailView: View { .padding() } + private var subagentSection: some View { + VStack(alignment: .leading, spacing: 6) { + Divider() + Text("Subagent Sessions (\(subagentSessions.count))") + .font(.caption.bold()) + .foregroundStyle(.secondary) + ForEach(subagentSessions) { sub in + Button { + onSelectSubagent?(sub) + } label: { + HStack(spacing: 8) { + Image(systemName: "arrow.triangle.branch") + .foregroundStyle(.orange) + Text(sub.displayTitle) + .lineLimit(1) + Spacer() + Text(sub.model ?? "") + .font(.caption2) + .foregroundStyle(.tertiary) + Text("\(sub.messageCount) msgs") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .font(.caption) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + .padding(.bottom, 8) + } + private var messagesList: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 12) { diff --git a/scarf/scarf/Features/Sessions/Views/SessionsView.swift b/scarf/scarf/Features/Sessions/Views/SessionsView.swift index 0dae57b..1815f58 100644 --- a/scarf/scarf/Features/Sessions/Views/SessionsView.swift +++ b/scarf/scarf/Features/Sessions/Views/SessionsView.swift @@ -115,10 +115,14 @@ struct SessionsView: View { SessionDetailView( session: session, messages: viewModel.messages, + subagentSessions: viewModel.subagentSessions, preview: viewModel.previewFor(session), onRename: { viewModel.beginRename(session) }, onExport: { viewModel.exportSession(session) }, - onDelete: { viewModel.beginDelete(session) } + onDelete: { viewModel.beginDelete(session) }, + onSelectSubagent: { sub in + Task { await viewModel.selectSession(sub) } + } ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } else { @@ -149,13 +153,6 @@ struct SessionsView: View { } private func platformIcon(_ platform: String) -> String { - switch platform { - case "cli": return "terminal" - case "telegram": return "paperplane" - case "discord": return "bubble.left.and.bubble.right" - case "slack": return "number" - case "email": return "envelope" - default: return "bubble.left" - } + KnownPlatforms.icon(for: platform) } } diff --git a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift index b1278f7..8c578c1 100644 --- a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift +++ b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift @@ -10,9 +10,11 @@ final class SettingsViewModel { var hermesRunning = false var rawConfigYAML = "" var personalities: [String] = [] - var providers = ["anthropic", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax"] + var providers = ["anthropic", "openrouter", "nous", "openai-codex", "google-ai-studio", "xai", "ollama-cloud", "zai", "kimi-coding", "minimax"] var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"] + var browserBackends = ["browseruse", "firecrawl", "local"] var saveMessage: String? + var showAuthRemoveConfirmation = false func load() { config = fileService.loadConfig() @@ -55,6 +57,19 @@ final class SettingsViewModel { func setReasoningEffort(_ value: String) { setSetting("agent.reasoning_effort", value: value) } func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") } func setApprovalMode(_ value: String) { setSetting("approvals.mode", value: value) } + func setBrowserBackend(_ value: String) { setSetting("browser.backend", value: value) } + + func removeAuth() { + let result = runHermes(["auth", "remove"]) + if result.exitCode == 0 { + saveMessage = "Credentials removed" + } else { + saveMessage = "Failed to remove credentials" + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + self?.saveMessage = nil + } + } func openConfigInEditor() { NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML)) diff --git a/scarf/scarf/Features/Settings/Views/SettingsView.swift b/scarf/scarf/Features/Settings/Views/SettingsView.swift index 2815251..bcf5e9b 100644 --- a/scarf/scarf/Features/Settings/Views/SettingsView.swift +++ b/scarf/scarf/Features/Settings/Views/SettingsView.swift @@ -11,6 +11,12 @@ struct SettingsView: View { modelSection displaySection terminalSection + if !viewModel.config.dockerEnv.isEmpty { + dockerEnvSection + } + if !viewModel.config.commandAllowlist.isEmpty { + allowlistSection + } voiceSection memorySection pathsSection @@ -21,6 +27,12 @@ struct SettingsView: View { } .navigationTitle("Settings") .onAppear { viewModel.load() } + .confirmationDialog("Remove Credentials?", isPresented: $viewModel.showAuthRemoveConfirmation) { + Button("Remove", role: .destructive) { viewModel.removeAuth() } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will permanently clear all stored provider credentials.") + } } private var headerBar: some View { @@ -44,6 +56,20 @@ struct SettingsView: View { SettingsSection(title: "Model", icon: "cpu") { EditableTextField(label: "Model", value: viewModel.config.model) { viewModel.setModel($0) } PickerRow(label: "Provider", selection: viewModel.config.provider, options: viewModel.providers) { viewModel.setProvider($0) } + HStack { + Text("Credentials") + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 130, alignment: .trailing) + Button("Remove Credentials", role: .destructive) { + viewModel.showAuthRemoveConfirmation = true + } + .controlSize(.small) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.quaternary.opacity(0.3)) } } @@ -71,6 +97,25 @@ struct SettingsView: View { StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) } PickerRow(label: "Reasoning Effort", selection: viewModel.config.reasoningEffort, options: ["low", "medium", "high"]) { viewModel.setReasoningEffort($0) } PickerRow(label: "Approval Mode", selection: viewModel.config.approvalMode, options: ["auto", "manual", "smart"]) { viewModel.setApprovalMode($0) } + PickerRow(label: "Browser Backend", selection: viewModel.config.browserBackend, options: viewModel.browserBackends) { viewModel.setBrowserBackend($0) } + } + } + + // MARK: - Docker Environment + + private var dockerEnvSection: some View { + SettingsSection(title: "Docker Environment", icon: "shippingbox") { + ForEach(viewModel.config.dockerEnv.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in + ReadOnlyRow(label: key, value: value) + } + } + } + + // MARK: - Command Allowlist + + private var allowlistSection: some View { + SettingsSection(title: "Command Allowlist", icon: "checkmark.shield") { + ReadOnlyRow(label: "Commands", value: viewModel.config.commandAllowlist.joined(separator: ", ")) } } @@ -88,6 +133,9 @@ struct SettingsView: View { private var memorySection: some View { SettingsSection(title: "Memory", icon: "brain") { ToggleRow(label: "Memory Enabled", isOn: viewModel.config.memoryEnabled) { viewModel.setMemoryEnabled($0) } + if !viewModel.config.memoryProfile.isEmpty { + ReadOnlyRow(label: "Profile", value: viewModel.config.memoryProfile) + } StepperRow(label: "Memory Char Limit", value: viewModel.config.memoryCharLimit, range: 500...10000) { viewModel.setMemoryCharLimit($0) } StepperRow(label: "User Char Limit", value: viewModel.config.userCharLimit, range: 500...10000) { viewModel.setUserCharLimit($0) } StepperRow(label: "Nudge Interval", value: viewModel.config.nudgeInterval, range: 1...50) { viewModel.setNudgeInterval($0) } @@ -104,7 +152,8 @@ struct SettingsView: View { PathRow(label: "Memory", path: HermesPaths.memoriesDir) PathRow(label: "Sessions", path: HermesPaths.sessionsDir) PathRow(label: "Skills", path: HermesPaths.skillsDir) - PathRow(label: "Logs", path: HermesPaths.errorsLog) + PathRow(label: "Agent Log", path: HermesPaths.agentLog) + PathRow(label: "Error Log", path: HermesPaths.errorsLog) } } @@ -273,6 +322,27 @@ struct StepperRow: View { } } +struct ReadOnlyRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 130, alignment: .trailing) + Text(value) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.quaternary.opacity(0.3)) + } +} + struct PathRow: View { let label: String let path: String diff --git a/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift b/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift index 5d8820b..6dc63b5 100644 --- a/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift +++ b/scarf/scarf/Features/Skills/ViewModels/SkillsViewModel.swift @@ -9,6 +9,8 @@ final class SkillsViewModel { var skillContent = "" var selectedFileName: String? var searchText = "" + var missingConfig: [String] = [] + private var currentConfig = HermesConfig.empty var filteredCategories: [HermesSkillCategory] { guard !searchText.isEmpty else { return categories } @@ -28,6 +30,7 @@ final class SkillsViewModel { func load() { categories = fileService.loadSkills() + currentConfig = fileService.loadConfig() } func selectSkill(_ skill: HermesSkill) { @@ -40,6 +43,17 @@ final class SkillsViewModel { selectedFileName = nil skillContent = "" } + missingConfig = computeMissingConfig(for: skill) + } + + private func computeMissingConfig(for skill: HermesSkill) -> [String] { + guard !skill.requiredConfig.isEmpty else { return [] } + guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else { + return skill.requiredConfig + } + return skill.requiredConfig.filter { key in + !yaml.contains(key) + } } func selectFile(_ file: String) { diff --git a/scarf/scarf/Features/Skills/Views/SkillsView.swift b/scarf/scarf/Features/Skills/Views/SkillsView.swift index 112d9bb..27cea3d 100644 --- a/scarf/scarf/Features/Skills/Views/SkillsView.swift +++ b/scarf/scarf/Features/Skills/Views/SkillsView.swift @@ -53,9 +53,28 @@ struct SkillsView: View { HStack { Label(skill.category, systemImage: "folder") Label("\(skill.files.count) files", systemImage: "doc") + if !skill.requiredConfig.isEmpty { + Label("\(skill.requiredConfig.count) required config", systemImage: "gearshape") + } } .font(.caption) .foregroundStyle(.secondary) + if !viewModel.missingConfig.isEmpty { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + VStack(alignment: .leading, spacing: 2) { + Text("Missing required config:") + .font(.caption.bold()) + Text(viewModel.missingConfig.joined(separator: ", ")) + .font(.caption.monospaced()) + } + } + .foregroundStyle(.orange) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.orange.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } Divider() if !skill.files.isEmpty { VStack(alignment: .leading, spacing: 4) { diff --git a/scarf/scarf/Features/Tools/Views/ToolsView.swift b/scarf/scarf/Features/Tools/Views/ToolsView.swift index 749ed69..85657aa 100644 --- a/scarf/scarf/Features/Tools/Views/ToolsView.swift +++ b/scarf/scarf/Features/Tools/Views/ToolsView.swift @@ -18,23 +18,20 @@ struct ToolsView: View { } private var platformPicker: some View { - HStack(spacing: 16) { - ForEach(viewModel.availablePlatforms) { platform in - Button { - viewModel.switchPlatform(platform) - } label: { - HStack(spacing: 4) { - Image(systemName: platform.icon) - Text(platform.displayName) - .font(.caption) + HStack(spacing: 12) { + Picker("Platform", selection: Binding( + get: { viewModel.selectedPlatform.name }, + set: { name in + if let platform = viewModel.availablePlatforms.first(where: { $0.name == name }) { + viewModel.switchPlatform(platform) } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(viewModel.selectedPlatform.name == platform.name ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 6)) } - .buttonStyle(.plain) + )) { + ForEach(viewModel.availablePlatforms) { platform in + Text(platform.displayName).tag(platform.name) + } } + .pickerStyle(.segmented) Spacer() Text("\(viewModel.toolsets.filter(\.enabled).count) of \(viewModel.toolsets.count) enabled") .font(.caption) @@ -56,6 +53,7 @@ struct ToolsView: View { .padding(.horizontal) .padding(.vertical, 8) } + .id(viewModel.selectedPlatform.name) } private var mcpSection: some View {