From 44d2d6d6c6493520bddecc7d7593443e28d1378d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 00:31:17 +0000 Subject: [PATCH] iOS port M6: YAML parser port, Settings view, Cron editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ScarfCore/Models/HermesCronJob.swift | 60 ++++ .../ScarfCore/Parsing/HermesConfig+YAML.swift | 275 +++++++++++++++++ .../ScarfCore/Parsing/HermesYAML.swift | 137 +++++++++ .../ViewModels/IOSCronViewModel.swift | 148 +++++++-- .../ViewModels/IOSSettingsViewModel.swift | 58 ++++ .../ScarfCoreTests/M0bTransportTests.swift | 17 +- .../ScarfCoreTests/M5FeatureVMTests.swift | 197 ++++++++++++ .../ScarfCoreTests/M6ConfigCronTests.swift | 282 ++++++++++++++++++ scarf/Scarf iOS/Cron/CronListView.swift | 274 ++++++++++++----- scarf/Scarf iOS/Dashboard/DashboardView.swift | 5 + scarf/Scarf iOS/Settings/SettingsView.swift | 225 ++++++++++++++ scarf/docs/IOS_PORT_PLAN.md | 55 +++- 12 files changed, 1625 insertions(+), 108 deletions(-) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesYAML.swift create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSettingsViewModel.swift create mode 100644 scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M6ConfigCronTests.swift create mode 100644 scarf/Scarf iOS/Settings/SettingsView.swift diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift index e709ddc..16754c5 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift @@ -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) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift new file mode 100644 index 0000000..151eb4d --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift @@ -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 + ) + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesYAML.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesYAML.swift new file mode 100644 index 0000000..52a30b0 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesYAML.swift @@ -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.." { + // 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 + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSCronViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSCronViewModel.swift index c829664..53ed66d 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSCronViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSCronViewModel.swift @@ -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 + ) + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSettingsViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSettingsViewModel.swift new file mode 100644 index 0000000..e9e1326 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSettingsViewModel.swift @@ -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 + } +} diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0bTransportTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0bTransportTests.swift index 07d7282..eca2ce5 100644 --- a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0bTransportTests.swift +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0bTransportTests.swift @@ -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() { diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift index 23e35b0..225ac1f 100644 --- a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift @@ -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") + } + } } diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M6ConfigCronTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M6ConfigCronTests.swift new file mode 100644 index 0000000..929710b --- /dev/null +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M6ConfigCronTests.swift @@ -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) + } +} diff --git a/scarf/Scarf iOS/Cron/CronListView.swift b/scarf/Scarf iOS/Cron/CronListView.swift index 7b4357b..1788eba 100644 --- a/scarf/Scarf iOS/Cron/CronListView.swift +++ b/scarf/Scarf iOS/Cron/CronListView.swift @@ -1,13 +1,14 @@ import SwiftUI import ScarfCore -/// iOS Cron screen. Read-only list of scheduled jobs pulled from -/// `~/.hermes/cron/jobs.json`. Editing is deferred to a later phase — -/// see `IOSCronViewModel`'s header for the scope rationale. +/// iOS Cron screen. M6 gained: toggle-enabled, swipe-to-delete, +/// "+" toolbar → editor sheet, and row-tap → edit existing job. struct CronListView: View { let config: IOSServerConfig @State private var vm: IOSCronViewModel + @State private var editingJob: HermesCronJob? + @State private var showingNewJob = false private static let sharedContextID: ServerID = ServerID( uuidString: "00000000-0000-0000-0000-0000000000A1" @@ -33,7 +34,7 @@ struct CronListView: View { VStack(alignment: .leading, spacing: 6) { Text("No cron jobs yet.") .font(.headline) - Text("Create cron jobs from the Mac app or by editing `~/.hermes/cron/jobs.json` directly. iOS will display them here.") + Text("Tap \(Image(systemName: "plus.circle.fill")) to create one, or manage them from the Mac app.") .font(.caption) .foregroundStyle(.secondary) } @@ -42,13 +43,34 @@ struct CronListView: View { } else { Section { ForEach(vm.jobs) { job in - CronRow(job: job) + CronRow(job: job) { + Task { await vm.toggleEnabled(id: job.id) } + } onTap: { + editingJob = job + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + Task { await vm.delete(id: job.id) } + } label: { + Label("Delete", systemImage: "trash") + } + } } } } } .navigationTitle("Cron jobs") .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingNewJob = true + } label: { + Image(systemName: "plus.circle.fill") + } + .disabled(vm.isSaving) + } + } .overlay { if vm.isLoading && vm.jobs.isEmpty { ProgressView("Loading jobs…") @@ -59,29 +81,42 @@ struct CronListView: View { } .refreshable { await vm.load() } .task { await vm.load() } + .sheet(item: $editingJob) { job in + CronEditorView(initial: job, title: "Edit cron job") { edited in + Task { await vm.upsert(edited) } + } + } + .sheet(isPresented: $showingNewJob) { + CronEditorView(initial: nil, title: "New cron job") { created in + Task { await vm.upsert(created) } + } + } } } private struct CronRow: View { let job: HermesCronJob + let onToggle: () -> Void + let onTap: () -> Void var body: some View { - NavigationLink { - CronDetailView(job: job) - } label: { - HStack(alignment: .top, spacing: 12) { - VStack { - Image(systemName: job.stateIcon) - .foregroundStyle(stateColor) - .font(.body) - } - .frame(width: 22) + HStack(alignment: .top, spacing: 12) { + Button(action: onToggle) { + Image(systemName: job.enabled + ? "checkmark.circle.fill" + : "circle") + .font(.title3) + .foregroundStyle(job.enabled ? Color.accentColor : Color.secondary) + } + .buttonStyle(.plain) + Button(action: onTap) { VStack(alignment: .leading, spacing: 3) { HStack { Text(job.name) .font(.body) .fontWeight(.medium) + .foregroundStyle(.primary) if !job.enabled { Text("DISABLED") .font(.caption2) @@ -108,86 +143,171 @@ private struct CronRow: View { .foregroundStyle(.tertiary) } } + .frame(maxWidth: .infinity, alignment: .leading) } - .padding(.vertical, 2) - } - } - - private var stateColor: Color { - switch job.state { - case "running": return .blue - case "completed": return .green - case "failed": return .red - default: return .secondary + .buttonStyle(.plain) } + .padding(.vertical, 2) } } -private struct CronDetailView: View { - let job: HermesCronJob +// MARK: - Editor + +/// Sheet for creating or editing a single `HermesCronJob`. Scoped +/// to the fields a user typically sets; runtime state fields +/// (delivery_failures, last_run_at, etc.) pass through untouched +/// when editing an existing job. +struct CronEditorView: View { + let title: String + let onSave: (HermesCronJob) -> Void + @Environment(\.dismiss) private var dismiss + + // Form-backing state. + @State private var id: String + @State private var name: String + @State private var prompt: String + @State private var model: String + @State private var skills: String // comma-separated + @State private var deliver: String + @State private var enabled: Bool + + @State private var scheduleKind: String + @State private var scheduleDisplay: String + @State private var scheduleRunAt: String + @State private var scheduleExpression: String + + private let existing: HermesCronJob? + + init( + initial: HermesCronJob?, + title: String, + onSave: @escaping (HermesCronJob) -> Void + ) { + self.title = title + self.onSave = onSave + self.existing = initial + _id = State(initialValue: initial?.id ?? "job_\(UUID().uuidString.prefix(8))") + _name = State(initialValue: initial?.name ?? "") + _prompt = State(initialValue: initial?.prompt ?? "") + _model = State(initialValue: initial?.model ?? "") + _skills = State(initialValue: (initial?.skills ?? []).joined(separator: ", ")) + _deliver = State(initialValue: initial?.deliver ?? "") + _enabled = State(initialValue: initial?.enabled ?? true) + _scheduleKind = State(initialValue: initial?.schedule.kind ?? "cron") + _scheduleDisplay = State(initialValue: initial?.schedule.display ?? "") + _scheduleRunAt = State(initialValue: initial?.schedule.runAt ?? "") + _scheduleExpression = State(initialValue: initial?.schedule.expression ?? "") + } var body: some View { - Form { - Section("Prompt") { - Text(job.prompt) - .font(.body) - .textSelection(.enabled) - } + NavigationStack { + Form { + Section("Job") { + TextField("Name", text: $name) + .autocorrectionDisabled() + Toggle("Enabled", isOn: $enabled) + } - Section("Schedule") { - LabeledContent("Kind", value: job.schedule.kind) - if let display = job.schedule.display { - LabeledContent("When", value: display) + Section("Prompt") { + TextEditor(text: $prompt) + .frame(minHeight: 120) + .font(.body) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) } - if let expr = job.schedule.expression { - LabeledContent("Expression", value: expr) - } - } - Section("State") { - LabeledContent("Enabled", value: job.enabled ? "yes" : "no") - LabeledContent("State", value: job.state) - if let last = job.lastRunAt { - LabeledContent("Last run", value: last) - } - if let next = job.nextRunAt { - LabeledContent("Next run", value: next) - } - if let err = job.lastError { - VStack(alignment: .leading, spacing: 4) { - Text("Last error") - .font(.caption) - .foregroundStyle(.secondary) - Text(err) - .font(.caption.monospaced()) - .foregroundStyle(.red) - .textSelection(.enabled) + Section("Schedule") { + Picker("Kind", selection: $scheduleKind) { + Text("cron").tag("cron") + Text("interval").tag("interval") + Text("once").tag("once") + } + TextField("Display (e.g. \"9am weekdays\")", text: $scheduleDisplay) + .autocorrectionDisabled() + if scheduleKind == "cron" { + TextField("Expression (e.g. \"0 9 * * 1-5\")", text: $scheduleExpression) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + if scheduleKind == "once" { + TextField("Run at (ISO8601)", text: $scheduleRunAt) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) } } - } - if let delivery = job.deliveryDisplay { - Section("Delivery") { - LabeledContent("Route", value: delivery) + Section("Optional") { + TextField("Model (leave blank to use default)", text: $model) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + TextField("Skills (comma-separated)", text: $skills) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + TextField("Deliver (e.g. discord:channel)", text: $deliver) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) } } - - if let skills = job.skills, !skills.isEmpty { - Section("Skills") { - ForEach(skills, id: \.self) { s in - Text(s) - .font(.caption.monospaced()) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Button("Save") { + onSave(buildJob()) + dismiss() } - } - } - - if let model = job.model { - Section("Model") { - Text(model).font(.caption.monospaced()) + .disabled(!isValid) + .bold() } } } - .navigationTitle(job.name) - .navigationBarTitleDisplayMode(.inline) + } + + private var isValid: Bool { + let n = name.trimmingCharacters(in: .whitespacesAndNewlines) + let p = prompt.trimmingCharacters(in: .whitespacesAndNewlines) + return !n.isEmpty && !p.isEmpty + } + + private func buildJob() -> HermesCronJob { + let skillList = skills + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let emptyToNil: (String) -> String? = { s in + let t = s.trimmingCharacters(in: .whitespacesAndNewlines) + return t.isEmpty ? nil : t + } + let schedule = CronSchedule( + kind: scheduleKind, + runAt: emptyToNil(scheduleRunAt), + display: emptyToNil(scheduleDisplay), + expression: emptyToNil(scheduleExpression) + ) + return HermesCronJob( + id: id, + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + prompt: prompt.trimmingCharacters(in: .whitespacesAndNewlines), + skills: skillList.isEmpty ? nil : skillList, + model: emptyToNil(model), + schedule: schedule, + enabled: enabled, + state: existing?.state ?? "scheduled", + deliver: emptyToNil(deliver), + // Preserve runtime state fields from the existing job so + // an edit doesn't reset last_run_at, failure counts, etc. + nextRunAt: existing?.nextRunAt, + lastRunAt: existing?.lastRunAt, + lastError: existing?.lastError, + preRunScript: existing?.preRunScript, + deliveryFailures: existing?.deliveryFailures, + lastDeliveryError: existing?.lastDeliveryError, + timeoutType: existing?.timeoutType, + timeoutSeconds: existing?.timeoutSeconds, + silent: existing?.silent + ) } } diff --git a/scarf/Scarf iOS/Dashboard/DashboardView.swift b/scarf/Scarf iOS/Dashboard/DashboardView.swift index 03adbd9..3f2f064 100644 --- a/scarf/Scarf iOS/Dashboard/DashboardView.swift +++ b/scarf/Scarf iOS/Dashboard/DashboardView.swift @@ -110,6 +110,11 @@ struct DashboardView: View { } label: { Label("Skills", systemImage: "sparkles") } + NavigationLink { + SettingsView(config: config) + } label: { + Label("Settings", systemImage: "gearshape.fill") + } } Section("Connected to") { diff --git a/scarf/Scarf iOS/Settings/SettingsView.swift b/scarf/Scarf iOS/Settings/SettingsView.swift new file mode 100644 index 0000000..2477714 --- /dev/null +++ b/scarf/Scarf iOS/Settings/SettingsView.swift @@ -0,0 +1,225 @@ +import SwiftUI +import ScarfCore + +/// iOS Settings screen. Read-only browser of `~/.hermes/config.yaml` +/// as it currently stands on the remote, grouped into sections that +/// mirror the Mac app's tabs. Source-of-truth toggle at the bottom +/// reveals the raw YAML for users who want to see what the parser +/// consumed. +struct SettingsView: View { + let config: IOSServerConfig + + @State private var vm: IOSSettingsViewModel + @State private var showRawYAML = false + + private static let sharedContextID: ServerID = ServerID( + uuidString: "00000000-0000-0000-0000-0000000000A1" + )! + + init(config: IOSServerConfig) { + self.config = config + let ctx = config.toServerContext(id: Self.sharedContextID) + _vm = State(initialValue: IOSSettingsViewModel(context: ctx)) + } + + var body: some View { + List { + if let err = vm.lastError { + Section { + Label(err, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + } + + if !vm.isLoading || vm.config.model != "unknown" { + modelSection + agentSection + displaySection + terminalSection + memorySection + voiceSection + securitySection + compressionSection + loggingSection + platformsSection + rawYAMLToggleSection + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .refreshable { await vm.load() } + .task { await vm.load() } + .overlay { + if vm.isLoading && vm.config.model == "unknown" { + ProgressView("Loading config.yaml…") + .padding() + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + } + + // MARK: - Sections + + @ViewBuilder + private var modelSection: some View { + Section("Model") { + LabeledContent("Default", value: vm.config.model) + if !vm.config.provider.isEmpty, vm.config.provider != "unknown" { + LabeledContent("Provider", value: vm.config.provider) + } + LabeledContent("Reasoning effort", value: vm.config.reasoningEffort) + if !vm.config.timezone.isEmpty { + LabeledContent("Timezone", value: vm.config.timezone) + } + } + } + + @ViewBuilder + private var agentSection: some View { + Section("Agent") { + LabeledContent("Approval mode", value: vm.config.approvalMode) + LabeledContent("Max turns", value: "\(vm.config.maxTurns)") + LabeledContent("Service tier", value: vm.config.serviceTier) + yesNoRow("Verbose logging", vm.config.verbose) + LabeledContent("Tool use enforcement", value: vm.config.toolUseEnforcement) + } + } + + @ViewBuilder + private var displaySection: some View { + Section("Display") { + yesNoRow("Streaming", vm.config.streaming) + yesNoRow("Show reasoning", vm.config.showReasoning) + yesNoRow("Show cost", vm.config.showCost) + LabeledContent("Skin", value: vm.config.display.skin) + yesNoRow("Compact", vm.config.display.compact) + yesNoRow("Inline diffs", vm.config.display.inlineDiffs) + LabeledContent("Personality", value: vm.config.personality) + } + } + + @ViewBuilder + private var terminalSection: some View { + Section("Terminal") { + LabeledContent("Backend", value: vm.config.terminalBackend) + LabeledContent("Cwd", value: vm.config.terminal.cwd) + LabeledContent("Timeout", value: "\(vm.config.terminal.timeout)s") + yesNoRow("Persistent shell", vm.config.terminal.persistentShell) + if !vm.config.terminal.dockerImage.isEmpty { + LabeledContent("Docker image", value: vm.config.terminal.dockerImage) + } + } + } + + @ViewBuilder + private var memorySection: some View { + Section("Memory") { + yesNoRow("Memory enabled", vm.config.memoryEnabled) + yesNoRow("User profile enabled", vm.config.userProfileEnabled) + if vm.config.memoryCharLimit > 0 { + LabeledContent("Char limit", value: "\(vm.config.memoryCharLimit)") + } + if !vm.config.memoryProfile.isEmpty { + LabeledContent("Profile", value: vm.config.memoryProfile) + } + if !vm.config.memoryProvider.isEmpty { + LabeledContent("Provider", value: vm.config.memoryProvider) + } + } + } + + @ViewBuilder + private var voiceSection: some View { + Section("Voice") { + yesNoRow("Auto TTS", vm.config.autoTTS) + LabeledContent("TTS provider", value: vm.config.voice.ttsProvider) + yesNoRow("STT enabled", vm.config.voice.sttEnabled) + LabeledContent("STT provider", value: vm.config.voice.sttProvider) + } + } + + @ViewBuilder + private var securitySection: some View { + Section("Security") { + yesNoRow("Redact secrets", vm.config.security.redactSecrets) + yesNoRow("Redact PII", vm.config.security.redactPII) + yesNoRow("Tirith enabled", vm.config.security.tirithEnabled) + yesNoRow("Website blocklist", vm.config.security.blocklistEnabled) + if !vm.config.security.blocklistDomains.isEmpty { + ForEach(vm.config.security.blocklistDomains.prefix(5), id: \.self) { domain in + Text(domain) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + if vm.config.security.blocklistDomains.count > 5 { + Text("+ \(vm.config.security.blocklistDomains.count - 5) more") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + } + + @ViewBuilder + private var compressionSection: some View { + Section("Compression") { + yesNoRow("Enabled", vm.config.compression.enabled) + LabeledContent("Threshold", value: String(format: "%.2f", vm.config.compression.threshold)) + LabeledContent("Target ratio", value: String(format: "%.2f", vm.config.compression.targetRatio)) + LabeledContent("Protect last N", value: "\(vm.config.compression.protectLastN)") + } + } + + @ViewBuilder + private var loggingSection: some View { + Section("Logging") { + LabeledContent("Level", value: vm.config.logging.level) + LabeledContent("Max size", value: "\(vm.config.logging.maxSizeMB) MB") + LabeledContent("Backup count", value: "\(vm.config.logging.backupCount)") + } + } + + @ViewBuilder + private var platformsSection: some View { + Section("Platforms") { + yesNoRow("Discord: require mention", vm.config.discord.requireMention) + yesNoRow("Discord: auto-thread", vm.config.discord.autoThread) + yesNoRow("Telegram: require mention", vm.config.telegram.requireMention) + LabeledContent("Slack: reply mode", value: vm.config.slack.replyToMode) + yesNoRow("Matrix: require mention", vm.config.matrix.requireMention) + } + } + + @ViewBuilder + private var rawYAMLToggleSection: some View { + Section { + DisclosureGroup("View source (config.yaml)", isExpanded: $showRawYAML) { + if vm.rawYAML.isEmpty { + Text("(empty)") + .font(.caption) + .foregroundStyle(.tertiary) + } else { + Text(vm.rawYAML) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } footer: { + Text("M6 is read-only. Edit config.yaml on the Mac app or via a shell; iOS reflects the current remote state.") + .font(.caption) + } + } + + // MARK: - Helpers + + @ViewBuilder + private func yesNoRow(_ label: String, _ value: Bool) -> some View { + LabeledContent(label) { + Text(value ? "yes" : "no") + .foregroundStyle(value ? .primary : .secondary) + } + } +} diff --git a/scarf/docs/IOS_PORT_PLAN.md b/scarf/docs/IOS_PORT_PLAN.md index 27db353..f9d7e55 100644 --- a/scarf/docs/IOS_PORT_PLAN.md +++ b/scarf/docs/IOS_PORT_PLAN.md @@ -696,4 +696,57 @@ Total **98 → 108 tests passing on Linux** via `docker run --rm -v $PWD/Package - **Editing Cron, adding Skills** — both are deferred. Cron editing needs atomic JSON rewrites (doable). Skills install needs git-clone + schema validation (larger). - **Settings tab on iOS is still missing** — requires a YAML parser in ScarfCore or porting `HermesFileService.loadConfig`. Next phase's job. -### M6 — pending +### M6 — shipped (on `claude/ios-m6-settings-polish` branch, separate PR, stacked on M5) + +Ports the Mac app's YAML parser into ScarfCore (unblocking iOS Settings), adds Settings browsing + Cron editing, consolidates the test-serialization story. App Store submission is deferred to a later task after real-device testing. + +**Shipped — ScarfCore:** + +- `Parsing/HermesYAML.swift` — `HermesYAML.parseNestedYAML(_:)` + `stripYAMLQuotes(_:)`. Lifted verbatim from `HermesFileService.parseNestedYAML` / `stripYAMLQuotes` but hoisted into a standalone enum for reuse. Scope unchanged: indent-based block nesting, scalar values, bullet lists, nested maps. Not full YAML-spec compliance; matches exactly what Hermes's `config.yaml` actually uses. +- `Parsing/HermesConfig+YAML.swift` — `HermesConfig.init(yaml:)`. Lifted from `HermesFileService.parseConfig` one-for-one. Every default, every key, every legacy fallback (e.g., `platforms.slack.*` vs `slack.*`) tracked to the Mac implementation. Forgiving: malformed YAML produces a partial-state `HermesConfig` rather than throwing. +- `ViewModels/IOSSettingsViewModel.swift` — `@Observable @MainActor` VM. Reads `~/.hermes/config.yaml` via transport, parses with the new loader, surfaces both the parsed `HermesConfig` and the raw text (so Settings view can offer a "View source" disclosure). M6 Settings is READ-ONLY — edit-path deferred until a round-trip-preserving YAML writer lands (commits, key order, whitespace would need preservation for a clean edit UX). +- `ViewModels/IOSCronViewModel.swift` — added write paths: `toggleEnabled(id:)`, `delete(id:)`, `upsert(_:)`. All funnel through `saveJobs(_:)` which re-encodes the full `CronJobsFile` (`.prettyPrinted + .sortedKeys`) and writes atomically via the transport (Data.write-atomic semantics from M5). Creates the `cron/` directory on fresh installs. +- Both `HermesCronJob` and `CronJobsFile` gained real memberwise inits (previously only hand-written `init(from:)` — Swift's synthesis was suppressed). Also `HermesCronJob.withEnabled(_:)` — clean field-passthrough instead of the JSON-roundtrip hack my first draft used. + +**Shipped — iOS app:** + +- `Scarf iOS/Settings/SettingsView.swift` — read-only browser grouped into sections that mirror the Mac app's tabs: Model, Agent, Display, Terminal, Memory, Voice, Security, Compression, Logging, Platforms. `DisclosureGroup` at the bottom reveals the raw YAML source for diagnostics. +- `Scarf iOS/Cron/CronListView.swift` rewritten: toggle-enabled circle (tap to flip), swipe-to-delete, "+" toolbar for new-job, row-tap opens the editor sheet. New `CronEditorView` form handles name / prompt / enabled / schedule (kind + display + expression + run_at) / optional model / comma-separated skills / delivery route. Preserves runtime state fields (nextRunAt, lastRunAt, deliveryFailures, etc.) when editing — no resetting the cron's observed history on a field edit. +- Dashboard's Surfaces section gets a 5th row: Settings. + +**Test-suite reorganization:** + +Discovered (and fixed) a cross-suite race: swift-testing's `.serialized` trait scopes to one @Suite, not globally. M5's serialized suite installed `ServerContext.sshTransportFactory`, M6's serialized suite did the same, and the M0b non-serialized `serverContextMakeTransportDispatches` test asserted the DEFAULT factory (nil) returned `SSHTransport` — all three raced on the shared static. + +Fix: keep the YAML-parse + memberwise tests in a plain (non-serialized) `M6ConfigCronTests` suite since they're pure. **Move every factory-touching test into the single `.serialized` `M5FeatureVMTests`** — including M6's Cron write-path tests, Settings-load tests, AND the M0b default-factory test (with explicit `factory = nil` reset for race-freedom). Single serialization domain eliminates the race. + +**Test counts:** 108 → **134 passing on Linux**. + +| Suite | New in M6 | Total | +|---|--:|--:| +| `ScarfCoreSmokeTests` | 0 | 1 | +| `M0aPublicInitTests` | 0 | 15 | +| `M0bTransportTests` | 0 (1 split out + moved) | 18 | +| `M0cServicesTests` | 0 | 8 | +| `M0dViewModelsTests` | 0 | 9 | +| `M1ACPTests` | 0 | 10 | +| `M2OnboardingTests` | 0 | 26 | +| `M3TransportTests` | 0 | 5 | +| `M4ACPIOSTests` | 0 | 2 | +| `M5FeatureVMTests` | **+7** (cron write paths + settings load + default-factory guard) | 21 | +| `M6ConfigCronTests` | **+19** (YAML parsing + HermesConfig decode + memberwise inits) | 19 | + +**Manual validation needed on Mac:** +1. Xcode compile clean. +2. Settings → confirm every section populates from your real `config.yaml`. Tap the "View source" disclosure to verify the raw text matches what's on the remote. +3. Cron: toggle a job's enabled flag, verify it survives a full refresh + relaunch. Swipe-to-delete a job. Tap "+" to create a new job; verify prompt + schedule + skills round-trip. Tap an existing job to edit; verify runtime fields (lastRunAt, deliveryFailures) aren't reset. +4. Skills: unchanged from M5, still browse-only. + +**Rules next phases can rely on:** +- **Any test that touches `ServerContext.sshTransportFactory` or any other global mutable state MUST live in `M5FeatureVMTests`** (the single `.serialized` suite) — or introduce a new cross-suite synchronization primitive. Swift-testing's `.serialized` does NOT serialize across suites. +- **YAML parser in ScarfCore is a hard ceiling** — it handles the Hermes config subset, not arbitrary YAML. If a future Hermes version adds constructs the parser doesn't cover (flow-style `[...]`, anchors, `&` references, multi-line `|` blocks), port them on both sides simultaneously. +- **Settings writes stay deferred** until a round-trip-preserving YAML writer ships. Options: (a) hand-write one, (b) adopt a YAML lib (adds dependency), (c) delegate to `hermes config set` via ACP. +- **Cron editing on iOS is atomic per-save** — full jobs.json rewrites on every change. Fine for current cron sizes (dozens of jobs). If that grows into the thousands, consider partial updates via `hermes cron add/rm/toggle` over ACP. +- **Skills install (git-clone + validation over SSH)** remains deferred — it's its own project. The iOS Skills list is read-only; users install from the Mac app or by cloning directly to the remote. + +### M7 — pending (post-testing App Store submission + any polish that surfaces)