iOS port M6: YAML parser port, Settings view, Cron editing

Ports the Mac app's YAML parser into ScarfCore, unlocking iOS
Settings. Adds Cron editing (add / delete / toggle / edit). Settings
stays read-only this phase (writes need a round-trip-preserving YAML
writer — out of scope). App Store submission deferred to a later
task per the brief.

## ScarfCore — YAML infrastructure

Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesYAML.swift:
  - ParsedYAML struct (values / lists / maps)
  - HermesYAML.parseNestedYAML(_:) — indent-based block parser
  - HermesYAML.stripYAMLQuotes(_:) — single-layer quote stripping

Lifted verbatim from HermesFileService.parseNestedYAML/stripYAMLQuotes
and hoisted into a standalone namespace. Scope unchanged: the subset
Hermes's config.yaml actually uses (block nesting, scalars, bullet
lists, nested maps). NOT full YAML-spec compliance.

Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift:
  - HermesConfig.init(yaml:) — ports HermesFileService.parseConfig
    one-for-one. Every default, every key, every legacy fallback
    (platforms.slack.* vs slack.*, command_allowlist vs permanent_
    allowlist, etc.) matches the Mac implementation.
  - Forgiving: malformed YAML produces partial state + defaults
    rather than throwing. Callers surface the raw text so users can
    diagnose parse failures on their own.

## ScarfCore — Cron editing (write paths)

Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSCronViewModel.swift:
  - toggleEnabled(id:)
  - delete(id:)
  - upsert(_:)
All funnel through private saveJobs(_:) which encodes the full
CronJobsFile (.prettyPrinted + .sortedKeys), writes atomically via
transport.writeFile (Data.write-atomic from M5). Creates the cron/
directory on fresh installs.

Models/HermesCronJob.swift — both HermesCronJob and CronJobsFile
gained real public memberwise inits (Swift's synthesis was
suppressed by the hand-written Codable; first draft hacked around
this with JSON round-trips). Also HermesCronJob.withEnabled(_:)
does clean field passthrough instead of encode→mutate→decode.

## ScarfCore — iOS Settings VM

Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSettingsViewModel.swift:
  - Reads ~/.hermes/config.yaml via ServerContext.readText
  - Parses with HermesConfig(yaml:)
  - Surfaces both parsed config and rawYAML
  - M6 read-only by design — config.yaml needs round-trip-preserving
    YAML serialization (comments, key order, whitespace) for safe
    edits; option (a) hand-write one, (b) YAML library dep, (c)
    delegate to `hermes config set` via ACP. Defer.

## iOS app

Scarf iOS/Settings/SettingsView.swift:
  - Read-only browser grouped into 10 sections matching the Mac
    app's tabs. DisclosureGroup at the bottom reveals raw YAML
    source for diagnostics.

Scarf iOS/Cron/CronListView.swift rewritten:
  - Toggle-enabled circle (tap to flip, saves atomically)
  - Swipe-to-delete
  - "+" toolbar for new job → editor sheet
  - Row-tap opens editor with existing fields populated

New CronEditorView form:
  - Name, Prompt, Enabled toggle
  - Schedule: kind picker (cron/interval/once), display, expression
    (for cron), run_at (for once)
  - Optional model + comma-separated skills + delivery route
  - Preserves runtime fields (nextRunAt, lastRunAt,
    deliveryFailures, etc.) when editing existing jobs — no reset

Dashboard's Surfaces section gains a 5th row: Settings.

## Test-suite reorganization (real bug caught)

swift-testing's `.serialized` trait serializes WITHIN one @Suite, not
across suites. Shipping M6 revealed a 3-way race on
`ServerContext.sshTransportFactory`:
  - M5's `.serialized` suite sets factory, runs, restores.
  - M6's `.serialized` suite did the same in parallel — clobbered.
  - M0b's non-serialized `serverContextMakeTransportDispatches`
    asserted the DEFAULT factory (nil) returned SSHTransport —
    saw whichever factory was temporarily installed.

Fix: one serialization domain for everything that touches the
factory. Move cron-editing + settings-load M6 tests into M5's
serialized suite. M0b's factory-dependent assertion (SSHTransport
fallback) also moves to the M5 serialized suite with an explicit
`factory = nil` reset for race-freedom. Pure YAML/config/memberwise
tests stay in the new plain (non-serialized) M6ConfigCronTests
suite — they never touch globals.

## Test results: 108 → 134 passing on Linux

19 new in M6ConfigCronTests:
  - YAML parser: scalars, bullets, nested maps, comments, quotes,
    inline {} / []
  - HermesConfig.init(yaml:): empty → defaults, model + agent,
    display, security + blocklist domains, slack legacy fallback,
    auxiliary (3 populated + 2 defaulted), permanent_allowlist vs
    command_allowlist, quoted strings
  - Memberwise inits for HermesCronJob, withEnabled(_:),
    CronJobsFile, CronSchedule

7 new in M5FeatureVMTests (.serialized):
  - defaultFactoryProducesSSHTransportForRemoteContext (moved +
    hardened with explicit factory reset)
  - cronUpsertCreatesFileFromScratch, cronToggleEnabledPersists,
    cronDeleteRemovesJob, cronUpsertReplacesMatchingId,
    cronPreservesRuntimeFieldsAcrossReloads
  - settingsLoadsFromConfigYAML, settingsSurfacesMissingFile

## Manual validation needed on Mac

1. Xcode compile clean.
2. Settings: confirm every section populates from your real
   ~/.hermes/config.yaml. Tap "View source" disclosure, verify raw
   text matches the remote file.
3. Cron: toggle-enabled survives refresh + relaunch. Swipe-delete
   works. "+" creates jobs; round-trip name/prompt/schedule/skills.
   Edit preserves runtime state.
4. Skills: unchanged from M5 (still browse-only, deferred).

Updated scarf/docs/IOS_PORT_PLAN.md with M6's shipped state, the
YAML-parser scope ceiling, the Settings-edit deferral rationale, and
the cross-suite serialization rule for future test authors.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
This commit is contained in:
Claude
2026-04-23 00:31:17 +00:00
parent 6b731ddfb8
commit 44d2d6d6c6
12 changed files with 1625 additions and 108 deletions
@@ -32,6 +32,49 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
case timeoutSeconds = "timeout_seconds"
}
/// Memberwise init. Swift doesn't synthesize one for us because
/// of the hand-written Codable conformance. The iOS Cron editor
/// uses this to rebuild jobs from user-edited fields.
public nonisolated init(
id: String,
name: String,
prompt: String,
skills: [String]? = nil,
model: String? = nil,
schedule: CronSchedule,
enabled: Bool,
state: String,
deliver: String? = nil,
nextRunAt: String? = nil,
lastRunAt: String? = nil,
lastError: String? = nil,
preRunScript: String? = nil,
deliveryFailures: Int? = nil,
lastDeliveryError: String? = nil,
timeoutType: String? = nil,
timeoutSeconds: Int? = nil,
silent: Bool? = nil
) {
self.id = id
self.name = name
self.prompt = prompt
self.skills = skills
self.model = model
self.schedule = schedule
self.enabled = enabled
self.state = state
self.deliver = deliver
self.nextRunAt = nextRunAt
self.lastRunAt = lastRunAt
self.lastError = lastError
self.preRunScript = preRunScript
self.deliveryFailures = deliveryFailures
self.lastDeliveryError = lastDeliveryError
self.timeoutType = timeoutType
self.timeoutSeconds = timeoutSeconds
self.silent = silent
}
public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decode(String.self, forKey: .id)
@@ -115,6 +158,18 @@ public struct CronSchedule: Sendable, Codable {
case expression
}
public nonisolated init(
kind: String,
runAt: String? = nil,
display: String? = nil,
expression: String? = nil
) {
self.kind = kind
self.runAt = runAt
self.display = display
self.expression = expression
}
public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.kind = try c.decode(String.self, forKey: .kind)
@@ -144,6 +199,11 @@ public struct CronJobsFile: Sendable, Codable {
case updatedAt = "updated_at"
}
public nonisolated init(jobs: [HermesCronJob], updatedAt: String?) {
self.jobs = jobs
self.updatedAt = updatedAt
}
public nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.jobs = try c.decode([HermesCronJob].self, forKey: .jobs)
@@ -0,0 +1,275 @@
import Foundation
/// YAML-driven `HermesConfig` constructor. Lifted verbatim (with
/// trivial adjustments to access the ScarfCore-public types) from
/// `HermesFileService.parseConfig` so the same key struct-field
/// mapping feeds both the Mac app and iOS.
///
/// **Behaviour parity.** Every default value, every key, and every
/// fallback path in this file tracks the Mac implementation
/// one-for-one. If the Mac parser learns to recognise a new key,
/// this one should too (and vice versa). The M6 test suite freezes
/// the defaults + a few recognition paths, so behaviour drift
/// surfaces on Linux CI without needing Xcode.
public extension HermesConfig {
/// Parse a `config.yaml` string into a fully-populated
/// `HermesConfig`. Missing keys fall back to `HermesConfig.empty`-
/// compatible defaults. Unknown keys are ignored Hermes is
/// forward-compatible, i.e. a config file with newer keys than
/// scarf knows still loads.
///
/// The parse is deliberately forgiving: malformed YAML produces
/// whatever partial state the parser could recover + defaults
/// for everything else, not a throw. The iOS Settings view
/// surfaces the raw file on top of this so users can spot a
/// broken key even when the struct came back defaulted.
init(yaml: String) {
let parsed = HermesYAML.parseNestedYAML(yaml)
let values = parsed.values
let lists = parsed.lists
let maps = parsed.maps
func bool(_ key: String, default def: Bool) -> Bool {
guard let v = values[key] else { return def }
return v == "true"
}
func int(_ key: String, default def: Int) -> Int {
Int(values[key] ?? "") ?? def
}
func double(_ key: String, default def: Double) -> Double {
Double(values[key] ?? "") ?? def
}
func str(_ key: String, default def: String = "") -> String {
let raw = values[key] ?? def
return HermesYAML.stripYAMLQuotes(raw)
}
let dockerEnv = maps["terminal.docker_env"] ?? [:]
let commandAllowlist = lists["permanent_allowlist"] ?? lists["command_allowlist"] ?? []
let display = DisplaySettings(
skin: str("display.skin", default: "default"),
compact: bool("display.compact", default: false),
resumeDisplay: str("display.resume_display", default: "full"),
bellOnComplete: bool("display.bell_on_complete", default: false),
inlineDiffs: bool("display.inline_diffs", default: true),
toolProgressCommand: bool("display.tool_progress_command", default: false),
toolPreviewLength: int("display.tool_preview_length", default: 0),
busyInputMode: str("display.busy_input_mode", default: "interrupt")
)
let terminal = TerminalSettings(
cwd: str("terminal.cwd", default: "."),
timeout: int("terminal.timeout", default: 180),
envPassthrough: lists["terminal.env_passthrough"] ?? [],
persistentShell: bool("terminal.persistent_shell", default: true),
dockerImage: str("terminal.docker_image"),
dockerMountCwdToWorkspace: bool("terminal.docker_mount_cwd_to_workspace", default: false),
dockerForwardEnv: lists["terminal.docker_forward_env"] ?? [],
dockerVolumes: lists["terminal.docker_volumes"] ?? [],
containerCPU: int("terminal.container_cpu", default: 0),
containerMemory: int("terminal.container_memory", default: 0),
containerDisk: int("terminal.container_disk", default: 0),
containerPersistent: bool("terminal.container_persistent", default: false),
modalImage: str("terminal.modal_image"),
modalMode: str("terminal.modal_mode", default: "auto"),
daytonaImage: str("terminal.daytona_image"),
singularityImage: str("terminal.singularity_image")
)
let browser = BrowserSettings(
inactivityTimeout: int("browser.inactivity_timeout", default: 120),
commandTimeout: int("browser.command_timeout", default: 30),
recordSessions: bool("browser.record_sessions", default: false),
allowPrivateURLs: bool("browser.allow_private_urls", default: false),
camofoxManagedPersistence: bool("browser.camofox.managed_persistence", default: false)
)
let voice = VoiceSettings(
recordKey: str("voice.record_key", default: "ctrl+b"),
maxRecordingSeconds: int("voice.max_recording_seconds", default: 120),
silenceDuration: double("voice.silence_duration", default: 3.0),
ttsProvider: str("tts.provider", default: "edge"),
ttsEdgeVoice: str("tts.edge.voice", default: "en-US-AriaNeural"),
ttsElevenLabsVoiceID: str("tts.elevenlabs.voice_id"),
ttsElevenLabsModelID: str("tts.elevenlabs.model_id", default: "eleven_multilingual_v2"),
ttsOpenAIModel: str("tts.openai.model", default: "gpt-4o-mini-tts"),
ttsOpenAIVoice: str("tts.openai.voice", default: "alloy"),
ttsNeuTTSModel: str("tts.neutts.model"),
ttsNeuTTSDevice: str("tts.neutts.device", default: "cpu"),
sttEnabled: bool("stt.enabled", default: true),
sttProvider: str("stt.provider", default: "local"),
sttLocalModel: str("stt.local.model", default: "base"),
sttLocalLanguage: str("stt.local.language"),
sttOpenAIModel: str("stt.openai.model", default: "whisper-1"),
sttMistralModel: str("stt.mistral.model", default: "voxtral-mini-latest")
)
func aux(_ name: String) -> AuxiliaryModel {
AuxiliaryModel(
provider: str("auxiliary.\(name).provider", default: "auto"),
model: str("auxiliary.\(name).model"),
baseURL: str("auxiliary.\(name).base_url"),
apiKey: str("auxiliary.\(name).api_key"),
timeout: int("auxiliary.\(name).timeout", default: 30)
)
}
let auxiliary = AuxiliarySettings(
vision: aux("vision"),
webExtract: aux("web_extract"),
compression: aux("compression"),
sessionSearch: aux("session_search"),
skillsHub: aux("skills_hub"),
approval: aux("approval"),
mcp: aux("mcp"),
flushMemories: aux("flush_memories")
)
let security = SecuritySettings(
redactSecrets: bool("security.redact_secrets", default: true),
redactPII: bool("privacy.redact_pii", default: false),
tirithEnabled: bool("security.tirith_enabled", default: true),
tirithPath: str("security.tirith_path", default: "tirith"),
tirithTimeout: int("security.tirith_timeout", default: 5),
tirithFailOpen: bool("security.tirith_fail_open", default: true),
blocklistEnabled: bool("security.website_blocklist.enabled", default: false),
blocklistDomains: lists["security.website_blocklist.domains"] ?? []
)
let humanDelay = HumanDelaySettings(
mode: str("human_delay.mode", default: "off"),
minMS: int("human_delay.min_ms", default: 800),
maxMS: int("human_delay.max_ms", default: 2500)
)
let compression = CompressionSettings(
enabled: bool("compression.enabled", default: true),
threshold: double("compression.threshold", default: 0.5),
targetRatio: double("compression.target_ratio", default: 0.2),
protectLastN: int("compression.protect_last_n", default: 20)
)
let checkpoints = CheckpointSettings(
enabled: bool("checkpoints.enabled", default: true),
maxSnapshots: int("checkpoints.max_snapshots", default: 50)
)
let logging = LoggingSettings(
level: str("logging.level", default: "INFO"),
maxSizeMB: int("logging.max_size_mb", default: 5),
backupCount: int("logging.backup_count", default: 3)
)
let delegation = DelegationSettings(
model: str("delegation.model"),
provider: str("delegation.provider"),
baseURL: str("delegation.base_url"),
apiKey: str("delegation.api_key"),
maxIterations: int("delegation.max_iterations", default: 50)
)
let discord = DiscordSettings(
requireMention: bool("discord.require_mention", default: true),
freeResponseChannels: str("discord.free_response_channels"),
autoThread: bool("discord.auto_thread", default: true),
reactions: bool("discord.reactions", default: true)
)
let telegram = TelegramSettings(
requireMention: bool("telegram.require_mention", default: true),
reactions: bool("telegram.reactions", default: false)
)
// Slack fields live under both `platforms.slack.*` (newer) and `slack.*`
// (legacy). Prefer the newer path but fall back.
let slack = SlackSettings(
replyToMode: values["platforms.slack.reply_to_mode"] ?? values["slack.reply_to_mode"] ?? "first",
requireMention: (values["platforms.slack.require_mention"] ?? values["slack.require_mention"]) != "false",
replyInThread: (values["platforms.slack.extra.reply_in_thread"] ?? "true") != "false",
replyBroadcast: (values["platforms.slack.extra.reply_broadcast"] ?? "false") == "true"
)
let matrix = MatrixSettings(
requireMention: bool("matrix.require_mention", default: true),
autoThread: bool("matrix.auto_thread", default: true),
dmMentionThreads: bool("matrix.dm_mention_threads", default: false)
)
let mattermost = MattermostSettings(
requireMention: bool("mattermost.require_mention", default: true),
replyMode: str("mattermost.reply_mode", default: "off")
)
let whatsapp = WhatsAppSettings(
unauthorizedDMBehavior: str("whatsapp.unauthorized_dm_behavior", default: "pair"),
replyPrefix: str("whatsapp.reply_prefix")
)
// Home Assistant lives under `platforms.homeassistant.extra.*`.
let homeAssistant = HomeAssistantSettings(
watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [],
watchEntities: lists["platforms.homeassistant.extra.watch_entities"] ?? [],
watchAll: bool("platforms.homeassistant.extra.watch_all", default: false),
ignoreEntities: lists["platforms.homeassistant.extra.ignore_entities"] ?? [],
cooldownSeconds: int("platforms.homeassistant.extra.cooldown_seconds", default: 30)
)
self.init(
model: str("model.default", default: "unknown"),
provider: str("model.provider", default: "unknown"),
maxTurns: int("agent.max_turns", default: 0),
personality: str("display.personality", default: "default"),
terminalBackend: str("terminal.backend", default: "local"),
memoryEnabled: bool("memory.memory_enabled", default: false),
memoryCharLimit: int("memory.memory_char_limit", default: 0),
userCharLimit: int("memory.user_char_limit", default: 0),
nudgeInterval: int("memory.nudge_interval", default: 0),
streaming: values["display.streaming"] != "false",
showReasoning: bool("display.show_reasoning", default: false),
verbose: bool("agent.verbose", default: false),
autoTTS: values["voice.auto_tts"] != "false",
silenceThreshold: int("voice.silence_threshold", default: QueryDefaults.defaultSilenceThreshold),
reasoningEffort: str("agent.reasoning_effort", default: "medium"),
showCost: bool("display.show_cost", default: false),
approvalMode: str("approvals.mode", default: "manual"),
browserBackend: str("browser.backend"),
memoryProvider: str("memory.provider"),
dockerEnv: dockerEnv,
commandAllowlist: commandAllowlist,
memoryProfile: str("memory.profile"),
serviceTier: str("agent.service_tier", default: "normal"),
gatewayNotifyInterval: int("agent.gateway_notify_interval", default: 600),
forceIPv4: bool("network.force_ipv4", default: false),
contextEngine: str("context.engine", default: "compressor"),
interimAssistantMessages: values["display.interim_assistant_messages"] != "false",
honchoInitOnSessionStart: bool("honcho.initOnSessionStart", default: false),
timezone: str("timezone"),
userProfileEnabled: bool("memory.user_profile_enabled", default: true),
toolUseEnforcement: str("agent.tool_use_enforcement", default: "auto"),
gatewayTimeout: int("agent.gateway_timeout", default: 1800),
approvalTimeout: int("approvals.timeout", default: 60),
fileReadMaxChars: int("file_read_max_chars", default: 100_000),
cronWrapResponse: bool("cron.wrap_response", default: true),
prefillMessagesFile: str("prefill_messages_file"),
skillsExternalDirs: lists["skills.external_dirs"] ?? [],
display: display,
terminal: terminal,
browser: browser,
voice: voice,
auxiliary: auxiliary,
security: security,
humanDelay: humanDelay,
compression: compression,
checkpoints: checkpoints,
logging: logging,
delegation: delegation,
discord: discord,
telegram: telegram,
slack: slack,
matrix: matrix,
mattermost: mattermost,
whatsapp: whatsapp,
homeAssistant: homeAssistant
)
}
}
@@ -0,0 +1,137 @@
import Foundation
/// Parsed YAML result bundle. Flat dotted-path keys point at the
/// three value shapes we care about (scalars, bullet lists, maps).
///
/// **Scope note.** This is NOT a full YAML-spec parser. It handles
/// the subset used by Hermes's `config.yaml`: indent-based block
/// nesting, string/int/bool/float scalars, `- item` bullet lists,
/// and one level of nested `key: value` maps. Anchors, aliases,
/// multi-line scalars (`|` / `>` block scalars), flow-style `[ ]` /
/// `{ }` literals, tags none of those are supported. That covers
/// 100% of what the current Hermes config actually uses.
///
/// The original implementation lived in the Mac app's
/// `HermesFileService`. Ported into ScarfCore in M6 so iOS can read
/// `config.yaml` through the same parser without having to pull in a
/// third-party YAML dependency.
public struct ParsedYAML: Sendable {
/// Scalar key-value pairs at any indent level
/// `values["section.key"] = "..."`.
public var values: [String: String]
/// Bullet-list items attached to a parent key
/// `lists["section.key"] = [...]`.
public var lists: [String: [String]]
/// Nested `key: value` maps captured under a section header
/// `maps["section"] = [key: value, ...]`.
public var maps: [String: [String: String]]
public init(
values: [String: String] = [:],
lists: [String: [String]] = [:],
maps: [String: [String: String]] = [:]
) {
self.values = values
self.lists = lists
self.maps = maps
}
}
/// Entry points for Hermes-flavored YAML parsing. Stateless, pure
/// functions no Foundation types that differ cross-platform.
public enum HermesYAML {
/// Parse a YAML string into a `ParsedYAML` bundle.
public static func parseNestedYAML(_ yaml: String) -> ParsedYAML {
var values: [String: String] = [:]
var lists: [String: [String]] = [:]
var maps: [String: [String: String]] = [:]
// Path stack: each entry is (indent, name). Pop when indent shrinks.
var stack: [(indent: Int, name: String)] = []
func currentPath(joinedWith child: String? = nil) -> String {
var parts = stack.map(\.name)
if let child { parts.append(child) }
return parts.joined(separator: ".")
}
let rawLines = yaml.components(separatedBy: "\n")
for line in rawLines {
// Skip comment-only and blank lines but preserve indent semantics.
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
let indent = line.prefix(while: { $0 == " " }).count
let isListItem = trimmed.hasPrefix("- ")
// Pop stack entries with indent >= current indent.
// Exception: a list item at the same indent as its parent key is
// valid block-style YAML ("toolsets:\n- hermes-cli") keep the
// parent so the item is attributed to it.
while let top = stack.last {
let shouldPop: Bool
if isListItem && top.indent == indent {
shouldPop = false
} else {
shouldPop = top.indent >= indent
}
if shouldPop { stack.removeLast() } else { break }
}
if isListItem {
let item = String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces)
let stripped = stripYAMLQuotes(item)
let path = currentPath()
guard !path.isEmpty else { continue }
lists[path, default: []].append(stripped)
continue
}
// Key-value or section line.
guard let colonIdx = trimmed.firstIndex(of: ":") else { continue }
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
let afterColon = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
let path = currentPath(joinedWith: key)
if afterColon.isEmpty || afterColon == "|" || afterColon == ">" {
// Section header or empty-valued key push onto stack so children nest.
stack.append((indent: indent, name: key))
continue
}
// Inline `{}` / `[]` literals treat as empty.
if afterColon == "{}" {
values[path] = ""
maps[path] = [:]
continue
}
if afterColon == "[]" {
values[path] = ""
lists[path] = []
continue
}
values[path] = afterColon
// Also record as a map entry under the parent so blocks like
// `terminal.docker_env` are accessible as `[String: String]`
// without a separate scan.
if !stack.isEmpty {
let parentPath = currentPath()
maps[parentPath, default: [:]][key] = stripYAMLQuotes(afterColon)
}
}
return ParsedYAML(values: values, lists: lists, maps: maps)
}
/// Strip a single layer of surrounding single or double quotes from a YAML scalar.
public static func stripYAMLQuotes(_ s: String) -> String {
guard s.count >= 2 else { return s }
let first = s.first!
let last = s.last!
if (first == "'" && last == "'") || (first == "\"" && last == "\"") {
return String(s.dropFirst().dropLast())
}
return s
}
}
@@ -1,15 +1,14 @@
import Foundation
import Observation
/// iOS read-only Cron view-state. Loads `~/.hermes/cron/jobs.json`
/// via the transport, decodes into `CronJobsFile` (already Codable
/// in ScarfCore), exposes the list for SwiftUI.
/// iOS Cron view-state. Loads `~/.hermes/cron/jobs.json` via the
/// transport, decodes into `CronJobsFile` (Codable, from M0a),
/// exposes the sorted list for SwiftUI.
///
/// M5 is read-only by design editing cron jobs (add / delete /
/// toggle enabled) is deferred until we have a clearer iOS story for
/// rewriting `jobs.json` atomically across the SSH SFTP path. The
/// Mac app's `CronViewModel` does this through `HermesFileService`;
/// porting that is out of scope for M5.
/// M6 adds write paths: toggle enabled, delete, and upsert (add or
/// replace a job by id). All writes re-encode the full file with a
/// fresh `updatedAt` and call `transport.writeFile` which on iOS
/// dispatches to Citadel SFTP with atomic rename semantics.
@Observable
@MainActor
public final class IOSCronViewModel {
@@ -17,6 +16,7 @@ public final class IOSCronViewModel {
public private(set) var jobs: [HermesCronJob] = []
public private(set) var isLoading: Bool = true
public private(set) var isSaving: Bool = false
public private(set) var lastError: String?
public init(context: ServerContext) {
@@ -43,17 +43,7 @@ public final class IOSCronViewModel {
switch result {
case .success(let file):
// Sort: enabled first, then by nextRunAt ascending (nil
// last). Matches what the Mac app does for list rendering.
jobs = file.jobs.sorted { lhs, rhs in
if lhs.enabled != rhs.enabled { return lhs.enabled }
switch (lhs.nextRunAt, rhs.nextRunAt) {
case (let l?, let r?): return l < r
case (_?, nil): return true
case (nil, _?): return false
case (nil, nil): return lhs.name < rhs.name
}
}
jobs = Self.sorted(file.jobs)
isLoading = false
case .failure(let err as LoadError):
@@ -73,6 +63,97 @@ public final class IOSCronViewModel {
}
}
/// Toggle `enabled` on the job with the given id, re-encode, and
/// write back. On failure, leaves the in-memory state unchanged
/// and sets `lastError`.
@discardableResult
public func toggleEnabled(id: String) async -> Bool {
guard let idx = jobs.firstIndex(where: { $0.id == id }) else { return false }
var updated = jobs
let prev = updated[idx]
updated[idx] = prev.withEnabled(!prev.enabled)
return await saveJobs(updated)
}
/// Remove the job with `id` and save.
@discardableResult
public func delete(id: String) async -> Bool {
let updated = jobs.filter { $0.id != id }
guard updated.count != jobs.count else { return false }
return await saveJobs(updated)
}
/// Add a new job or replace an existing one with matching id.
@discardableResult
public func upsert(_ job: HermesCronJob) async -> Bool {
var updated = jobs
if let idx = updated.firstIndex(where: { $0.id == job.id }) {
updated[idx] = job
} else {
updated.append(job)
}
return await saveJobs(updated)
}
// MARK: - Internal
/// Shared persistence path: serialize `CronJobsFile` as pretty
/// JSON, write it atomically through the transport, and update
/// the in-memory list on success.
private func saveJobs(_ newJobs: [HermesCronJob]) async -> Bool {
guard !isSaving else { return false }
isSaving = true
lastError = nil
let ctx = context
let path = ctx.paths.cronJobsJSON
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime]
let file = CronJobsFile(jobs: newJobs, updatedAt: iso.string(from: Date()))
let ok: Bool = await Task.detached {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(file)
let transport = ctx.makeTransport()
// Ensure the cron/ directory exists on a fresh
// Hermes install this file won't be present.
let parent = (path as NSString).deletingLastPathComponent
if !transport.fileExists(parent) {
try? transport.createDirectory(parent)
}
try transport.writeFile(path, data: data)
return true
} catch {
return false
}
}.value
isSaving = false
if ok {
jobs = Self.sorted(newJobs)
return true
} else {
lastError = "Couldn't save jobs.json — check the connection and try again."
return false
}
}
/// Sort: enabled first, then by `nextRunAt` ascending (nil last,
/// then by name). Matches the Mac app's list rendering.
private static func sorted(_ jobs: [HermesCronJob]) -> [HermesCronJob] {
jobs.sorted { lhs, rhs in
if lhs.enabled != rhs.enabled { return lhs.enabled }
switch (lhs.nextRunAt, rhs.nextRunAt) {
case (let l?, let r?): return l < r
case (_?, nil): return true
case (nil, _?): return false
case (nil, nil): return lhs.name < rhs.name
}
}
}
public enum LoadError: Error, LocalizedError {
case missingFile(path: String)
@@ -83,3 +164,32 @@ public final class IOSCronViewModel {
}
}
}
// MARK: - HermesCronJob helpers
public extension HermesCronJob {
/// Return a copy with a different `enabled` flag. Used by the iOS
/// Cron list's toggle. All other fields pass through unchanged.
func withEnabled(_ newEnabled: Bool) -> HermesCronJob {
HermesCronJob(
id: id,
name: name,
prompt: prompt,
skills: skills,
model: model,
schedule: schedule,
enabled: newEnabled,
state: state,
deliver: deliver,
nextRunAt: nextRunAt,
lastRunAt: lastRunAt,
lastError: lastError,
preRunScript: preRunScript,
deliveryFailures: deliveryFailures,
lastDeliveryError: lastDeliveryError,
timeoutType: timeoutType,
timeoutSeconds: timeoutSeconds,
silent: silent
)
}
}
@@ -0,0 +1,58 @@
import Foundation
import Observation
/// iOS Settings view-state. Loads `~/.hermes/config.yaml` via the
/// transport, parses it into a `HermesConfig` with the ScarfCore
/// YAML port, and exposes the parsed struct plus a copy of the raw
/// text for users who want to see the source.
///
/// **M6 is read-only by design.** Editing config.yaml safely requires
/// either (a) a round-trip preserving YAML parser (comments, key
/// order, whitespace) or (b) delegating to `hermes config set` via
/// ACP. Either is more work than fits in M6; the Mac app's Settings
/// uses (a) via HermesFileService's manipulators. A later phase can
/// port the write side.
@Observable
@MainActor
public final class IOSSettingsViewModel {
public let context: ServerContext
/// Parsed config. Falls back to `.empty` when the file is missing
/// or malformed; `lastError` carries the reason so the UI can
/// surface it.
public private(set) var config: HermesConfig = .empty
/// Raw YAML text. Useful for the "View source" disclosure, and
/// for diagnosing parse failures (our parser is forgiving but
/// lossy on malformed input).
public private(set) var rawYAML: String = ""
public private(set) var isLoading: Bool = true
public private(set) var lastError: String?
public init(context: ServerContext) {
self.context = context
}
public func load() async {
isLoading = true
lastError = nil
let ctx = context
let path = ctx.paths.configYAML
let text: String? = await Task.detached {
ctx.readText(path)
}.value
guard let text else {
config = .empty
rawYAML = ""
lastError = "`\(path)` not found on \(ctx.displayName). Once Hermes is configured on this host, Settings will light up."
isLoading = false
return
}
rawYAML = text
config = HermesConfig(yaml: text)
isLoading = false
}
}
@@ -76,21 +76,16 @@ import Foundation
#expect(remoteDefault.paths.home == "~/.hermes")
}
@Test func serverContextMakeTransportDispatches() {
@Test func serverContextMakeTransportDispatchesLocal() {
// Only assert the .local path here. The .ssh SSHTransport
// default-factory assertion lives in the serialized
// M5FeatureVMTests suite because it depends on
// `ServerContext.sshTransportFactory` being nil, which races
// with any other parallel test installing a custom factory.
let local = ServerContext.local.makeTransport()
#expect(local is LocalTransport)
#expect(local.isRemote == false)
#expect(local.contextID == ServerContext.local.id)
let remoteCtx = ServerContext(
id: UUID(),
displayName: "r",
kind: .ssh(SSHConfig(host: "h"))
)
let remote = remoteCtx.makeTransport()
#expect(remote is SSHTransport)
#expect(remote.isRemote == true)
#expect(remote.contextID == remoteCtx.id)
}
@Test func fileStatMemberwise() {
@@ -326,4 +326,201 @@ import Foundation
#expect(p.options[0].optionId == "allow")
}
#endif
// MARK: - M0b default SSH transport factory path
//
// Moved here from M0bTransportTests because it asserts the
// default-factory (nil) behavior which any other test in a
// parallel suite installing a custom factory would clobber.
// Living in a .serialized suite + explicitly resetting the
// factory makes the assertion race-free.
@Test @MainActor func defaultFactoryProducesSSHTransportForRemoteContext() {
let previous = ServerContext.sshTransportFactory
defer { ServerContext.sshTransportFactory = previous }
ServerContext.sshTransportFactory = nil
let remoteCtx = ServerContext(
id: UUID(),
displayName: "r",
kind: .ssh(SSHConfig(host: "h"))
)
let remote = remoteCtx.makeTransport()
#expect(remote is SSHTransport)
#expect(remote.isRemote == true)
#expect(remote.contextID == remoteCtx.id)
}
// MARK: - M6 Cron editing (write paths)
//
// Live in this suite (rather than M6ConfigCronTests) because they
// install the `ServerContext.sshTransportFactory` static same
// pattern as the Memory/Cron/Skills read-path tests above. Mixing
// factory-users across multiple `.serialized` suites races on
// the static, so M6's factory-touching tests merge here.
@Test @MainActor func cronUpsertCreatesFileFromScratch() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, _) = try makeFakeHermes()
let vm = IOSCronViewModel(context: ctx)
await vm.load()
#expect(vm.jobs.isEmpty)
let job = HermesCronJob(
id: "job_abc",
name: "Morning brief",
prompt: "summarize my calendar",
skills: ["calendar"],
model: nil,
schedule: CronSchedule(kind: "cron", display: "9am", expression: "0 9 * * *"),
enabled: true,
state: "scheduled"
)
let ok = await vm.upsert(job)
#expect(ok)
#expect(vm.jobs.count == 1)
#expect(vm.jobs[0].name == "Morning brief")
let vm2 = IOSCronViewModel(context: ctx)
await vm2.load()
#expect(vm2.jobs.count == 1)
#expect(vm2.jobs[0].id == "job_abc")
#expect(vm2.jobs[0].prompt == "summarize my calendar")
#expect(vm2.jobs[0].skills == ["calendar"])
}
}
@Test @MainActor func cronToggleEnabledPersists() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, _) = try makeFakeHermes()
let vm = IOSCronViewModel(context: ctx)
await vm.upsert(HermesCronJob(
id: "j1", name: "A", prompt: "p",
schedule: CronSchedule(kind: "cron"),
enabled: true, state: "scheduled"
))
#expect(vm.jobs[0].enabled)
let ok = await vm.toggleEnabled(id: "j1")
#expect(ok)
#expect(vm.jobs[0].enabled == false)
let vm2 = IOSCronViewModel(context: ctx)
await vm2.load()
#expect(vm2.jobs[0].enabled == false)
}
}
@Test @MainActor func cronDeleteRemovesJob() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, _) = try makeFakeHermes()
let vm = IOSCronViewModel(context: ctx)
await vm.upsert(HermesCronJob(id: "a", name: "A", prompt: "p", schedule: CronSchedule(kind: "cron"), enabled: true, state: "scheduled"))
await vm.upsert(HermesCronJob(id: "b", name: "B", prompt: "q", schedule: CronSchedule(kind: "cron"), enabled: true, state: "scheduled"))
#expect(vm.jobs.count == 2)
let ok = await vm.delete(id: "a")
#expect(ok)
#expect(vm.jobs.count == 1)
#expect(vm.jobs[0].id == "b")
let vm2 = IOSCronViewModel(context: ctx)
await vm2.load()
#expect(vm2.jobs.count == 1)
#expect(vm2.jobs[0].id == "b")
}
}
@Test @MainActor func cronUpsertReplacesMatchingId() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, _) = try makeFakeHermes()
let vm = IOSCronViewModel(context: ctx)
await vm.upsert(HermesCronJob(
id: "j1", name: "Original", prompt: "p1",
schedule: CronSchedule(kind: "cron"),
enabled: true, state: "scheduled"
))
await vm.upsert(HermesCronJob(
id: "j1", name: "Renamed", prompt: "p2",
schedule: CronSchedule(kind: "interval"),
enabled: false, state: "scheduled"
))
#expect(vm.jobs.count == 1)
#expect(vm.jobs[0].name == "Renamed")
#expect(vm.jobs[0].prompt == "p2")
#expect(vm.jobs[0].enabled == false)
}
}
@Test @MainActor func cronPreservesRuntimeFieldsAcrossReloads() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, _) = try makeFakeHermes()
let vm = IOSCronViewModel(context: ctx)
await vm.upsert(HermesCronJob(
id: "j1", name: "Kept", prompt: "p",
skills: nil, model: "gpt-4",
schedule: CronSchedule(kind: "cron", display: "midnight"),
enabled: true,
state: "completed",
deliver: "discord:general",
nextRunAt: "2026-04-25T00:00:00Z",
lastRunAt: "2026-04-24T00:00:00Z",
deliveryFailures: 3,
lastDeliveryError: "rate limited",
timeoutType: "soft",
timeoutSeconds: 600,
silent: false
))
let vm2 = IOSCronViewModel(context: ctx)
await vm2.load()
let j = vm2.jobs[0]
#expect(j.nextRunAt == "2026-04-25T00:00:00Z")
#expect(j.lastRunAt == "2026-04-24T00:00:00Z")
#expect(j.deliveryFailures == 3)
#expect(j.lastDeliveryError == "rate limited")
#expect(j.timeoutSeconds == 600)
#expect(j.state == "completed")
}
}
// MARK: - M6 Settings
@Test @MainActor func settingsLoadsFromConfigYAML() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, home) = try makeFakeHermes()
let yaml = """
model:
default: gpt-4o
provider: openai
display:
skin: solarized
compact: true
"""
try yaml.write(
to: home.appendingPathComponent("config.yaml"),
atomically: true,
encoding: .utf8
)
let vm = IOSSettingsViewModel(context: ctx)
await vm.load()
#expect(vm.isLoading == false)
#expect(vm.config.model == "gpt-4o")
#expect(vm.config.provider == "openai")
#expect(vm.config.display.skin == "solarized")
#expect(vm.config.display.compact == true)
#expect(vm.rawYAML.contains("gpt-4o"))
#expect(vm.lastError == nil)
}
}
@Test @MainActor func settingsSurfacesMissingFile() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, _) = try makeFakeHermes()
let vm = IOSSettingsViewModel(context: ctx)
await vm.load()
#expect(vm.isLoading == false)
#expect(vm.lastError != nil)
#expect(vm.config.model == "unknown")
}
}
}
@@ -0,0 +1,282 @@
import Testing
import Foundation
@testable import ScarfCore
/// M6: YAML parser port + HermesConfig loader. Pure functions no
/// `ServerContext.sshTransportFactory` races, so this suite can run
/// in parallel with everything else.
///
/// The write-path tests for Cron editing + Settings-from-yaml live
/// in `M5FeatureVMTests` (the serialized suite that already owns
/// the factory-install pattern) to avoid cross-suite parallel
/// collisions on the shared factory static.
@Suite struct M6ConfigCronTests {
// MARK: - YAML parser
@Test func parsesScalarKeyValues() {
let yaml = """
model:
default: gpt-4o
provider: openai
"""
let p = HermesYAML.parseNestedYAML(yaml)
#expect(p.values["model.default"] == "gpt-4o")
#expect(p.values["model.provider"] == "openai")
}
@Test func parsesBulletLists() {
let yaml = """
permanent_allowlist:
- ls
- pwd
- 'cat /etc/hostname'
"""
let p = HermesYAML.parseNestedYAML(yaml)
#expect(p.lists["permanent_allowlist"] == ["ls", "pwd", "cat /etc/hostname"])
}
@Test func parsesNestedMaps() {
let yaml = """
terminal:
docker_env:
PATH: /usr/local/bin
HOME: /home/hermes
"""
let p = HermesYAML.parseNestedYAML(yaml)
#expect(p.maps["terminal.docker_env"]?["PATH"] == "/usr/local/bin")
#expect(p.maps["terminal.docker_env"]?["HOME"] == "/home/hermes")
#expect(p.values["terminal.docker_env.PATH"] == "/usr/local/bin")
}
@Test func ignoresCommentsAndBlankLines() {
let yaml = """
# Top-level comment
model:
# inline comment
default: gpt-4o
provider: openai
"""
let p = HermesYAML.parseNestedYAML(yaml)
#expect(p.values["model.default"] == "gpt-4o")
#expect(p.values["model.provider"] == "openai")
}
@Test func stripsQuotes() {
#expect(HermesYAML.stripYAMLQuotes("'quoted'") == "quoted")
#expect(HermesYAML.stripYAMLQuotes("\"quoted\"") == "quoted")
#expect(HermesYAML.stripYAMLQuotes("plain") == "plain")
#expect(HermesYAML.stripYAMLQuotes("'unbalanced") == "'unbalanced")
#expect(HermesYAML.stripYAMLQuotes("") == "")
}
@Test func handlesInlineLiterals() {
let yaml = """
empty_map: {}
empty_list: []
"""
let p = HermesYAML.parseNestedYAML(yaml)
#expect(p.maps["empty_map"] != nil)
#expect(p.lists["empty_list"] != nil)
}
// MARK: - HermesConfig from YAML
@Test func emptyYAMLProducesDefaults() {
let c = HermesConfig(yaml: "")
#expect(c.model == "unknown")
#expect(c.provider == "unknown")
#expect(c.display.skin == "default")
#expect(c.streaming == true)
#expect(c.security.redactSecrets == true)
#expect(c.compression.enabled == true)
#expect(c.voice.ttsProvider == "edge")
}
@Test func parsesTopLevelModel() {
let yaml = """
model:
default: claude-4-opus
provider: anthropic
agent:
reasoning_effort: high
service_tier: pro
max_turns: 50
"""
let c = HermesConfig(yaml: yaml)
#expect(c.model == "claude-4-opus")
#expect(c.provider == "anthropic")
#expect(c.reasoningEffort == "high")
#expect(c.serviceTier == "pro")
#expect(c.maxTurns == 50)
}
@Test func parsesDisplaySection() {
let yaml = """
display:
skin: dark
compact: true
streaming: false
show_reasoning: true
show_cost: true
personality: professional
"""
let c = HermesConfig(yaml: yaml)
#expect(c.display.skin == "dark")
#expect(c.display.compact == true)
#expect(c.streaming == false)
#expect(c.showReasoning == true)
#expect(c.showCost == true)
#expect(c.personality == "professional")
}
@Test func parsesSecuritySection() {
let yaml = """
security:
redact_secrets: false
tirith_enabled: false
tirith_timeout: 15
website_blocklist:
enabled: true
domains:
- example.com
- evil.org
"""
let c = HermesConfig(yaml: yaml)
#expect(c.security.redactSecrets == false)
#expect(c.security.tirithEnabled == false)
#expect(c.security.tirithTimeout == 15)
#expect(c.security.blocklistEnabled == true)
#expect(c.security.blocklistDomains == ["example.com", "evil.org"])
}
@Test func parsesSlackWithLegacyAndNewerPaths() {
// Newer path wins when both present.
let newerWins = HermesConfig(yaml: """
platforms:
slack:
reply_to_mode: all
slack:
reply_to_mode: first
""")
#expect(newerWins.slack.replyToMode == "all")
// Legacy-only path used when newer is absent.
let legacyFallback = HermesConfig(yaml: """
slack:
reply_to_mode: first
""")
#expect(legacyFallback.slack.replyToMode == "first")
// Default when neither present.
let defaulted = HermesConfig(yaml: "")
#expect(defaulted.slack.replyToMode == "first")
}
@Test func parsesAuxiliarySection() {
let yaml = """
auxiliary:
vision:
provider: openai
model: gpt-4-vision
timeout: 60
compression:
provider: anthropic
model: claude-3-haiku
"""
let c = HermesConfig(yaml: yaml)
#expect(c.auxiliary.vision.provider == "openai")
#expect(c.auxiliary.vision.model == "gpt-4-vision")
#expect(c.auxiliary.vision.timeout == 60)
#expect(c.auxiliary.compression.provider == "anthropic")
// Not-configured aux blocks default to "auto" / empty.
#expect(c.auxiliary.sessionSearch.provider == "auto")
#expect(c.auxiliary.mcp.provider == "auto")
}
@Test func parsesPermanentAllowlist() {
let yaml = """
permanent_allowlist:
- ls
- pwd
- stat
"""
let c = HermesConfig(yaml: yaml)
#expect(c.commandAllowlist == ["ls", "pwd", "stat"])
}
@Test func parsesCommandAllowlistLegacyName() {
// Fall back to `command_allowlist` when `permanent_allowlist` absent.
let yaml = """
command_allowlist:
- whoami
- id
"""
let c = HermesConfig(yaml: yaml)
#expect(c.commandAllowlist == ["whoami", "id"])
}
@Test func preservesQuotedStrings() {
let yaml = """
model:
default: "gpt-4o with spaces"
timezone: 'America/New_York'
"""
let c = HermesConfig(yaml: yaml)
#expect(c.model == "gpt-4o with spaces")
#expect(c.timezone == "America/New_York")
}
@Test func cronScheduleMemberwise() {
let s = CronSchedule(
kind: "cron",
runAt: nil,
display: "9am weekdays",
expression: "0 9 * * 1-5"
)
#expect(s.kind == "cron")
#expect(s.display == "9am weekdays")
}
@Test func hermesCronJobMemberwiseAndWithEnabled() {
let job = HermesCronJob(
id: "j1",
name: "Brief",
prompt: "summarize",
skills: ["cal"],
schedule: CronSchedule(kind: "cron"),
enabled: true,
state: "scheduled",
deliver: "discord:general"
)
#expect(job.enabled)
let toggled = job.withEnabled(false)
#expect(toggled.enabled == false)
// Every other field round-trips.
#expect(toggled.id == job.id)
#expect(toggled.name == job.name)
#expect(toggled.prompt == job.prompt)
#expect(toggled.skills == job.skills)
#expect(toggled.deliver == job.deliver)
}
@Test func cronJobsFileMemberwise() {
let jobs = [
HermesCronJob(
id: "a", name: "A", prompt: "p",
schedule: CronSchedule(kind: "cron"),
enabled: true, state: "scheduled"
)
]
let file = CronJobsFile(jobs: jobs, updatedAt: "2026-04-23T00:00:00Z")
#expect(file.jobs.count == 1)
#expect(file.updatedAt == "2026-04-23T00:00:00Z")
// Codable round-trip should survive.
let data = try! JSONEncoder().encode(file)
let decoded = try! JSONDecoder().decode(CronJobsFile.self, from: data)
#expect(decoded.jobs.count == 1)
#expect(decoded.jobs[0].name == "A")
#expect(decoded.updatedAt == file.updatedAt)
}
}