feat: Add Hermes v0.8.0 compatibility and fix Tools tab toggling (#9)

Hermes v0.8.0 support:
- Filter subagent sessions from main list with parent session drill-down
- Add agent.log support (new default log file)
- Add Feishu and Mattermost platforms
- Add Google AI Studio, xAI, Ollama Cloud providers
- Expand cron job model (pre-run scripts, delivery tracking, timeouts, SILENT)
- Add Docker env, command allowlist, and memory profile to config
- Add profile-scoped memory with profile picker
- Add browser backend picker and credential removal to Settings
- Add skills required config warnings
- Consolidate platform icon resolution to single source of truth
- Filter Insights queries to exclude subagent sessions

Bug fix:
- Fix Tools tab phantom toggling when switching platforms (#9)
  - Add .id() to tool list for proper SwiftUI view identity
  - Replace ambiguous plain buttons with segmented Picker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-08 22:12:13 -04:00
parent ae2872e08f
commit 3acf95a824
25 changed files with 420 additions and 84 deletions
+10 -9
View File
@@ -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.
+6 -6
View File
@@ -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;
+7 -1
View File
@@ -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: ""
)
}
@@ -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"
+12 -1
View File
@@ -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 {
@@ -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 }
@@ -12,4 +12,5 @@ struct HermesSkill: Identifiable, Sendable {
let category: String
let path: String
let files: [String]
let requiredConfig: [String]
}
+4
View File
@@ -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"
}
}
@@ -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 [:] }
@@ -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..<colonIdx]).trimmingCharacters(in: .whitespaces)
let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
dockerEnv[key] = val
continue
}
// Collect allowlist items
if inAllowlist, indent >= 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..<colonIdx]).trimmingCharacters(in: .whitespaces)
let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
if key == "docker_env" && val.isEmpty {
inDockerEnv = true
continue
}
if key == "permanent_allowlist" && val.isEmpty {
inAllowlist = true
continue
}
values[currentSection + "." + key] = val
}
}
@@ -49,7 +84,10 @@ struct HermesFileService: Sendable {
showCost: values["display.show_cost"] == "true",
approvalMode: values["approvals.mode"] ?? "manual",
browserBackend: values["browser.backend"] ?? "",
memoryProvider: values["memory.provider"] ?? ""
memoryProvider: values["memory.provider"] ?? "",
dockerEnv: dockerEnv,
commandAllowlist: commandAllowlist,
memoryProfile: values["memory.profile"] ?? ""
)
}
@@ -67,20 +105,41 @@ struct HermesFileService: Sendable {
// MARK: - Memory
func loadMemory() -> 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 {
@@ -16,6 +16,7 @@ final class HermesFileWatcher {
HermesPaths.userMD,
HermesPaths.cronJobsJSON,
HermesPaths.gatewayStateJSON,
HermesPaths.agentLog,
HermesPaths.errorsLog,
HermesPaths.gatewayLog,
HermesPaths.projectsRegistry
@@ -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) {
@@ -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 {
@@ -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)
}
}
@@ -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
}
@@ -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
@@ -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")
@@ -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 {
@@ -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) {
@@ -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)
}
}
@@ -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))
@@ -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
@@ -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) {
@@ -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) {
@@ -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 {