iOS port M5: Chat polish + Memory + Cron + Skills features

Fleshes out the iOS app from "Chat + placeholder Dashboard" into a
real on-the-go Hermes companion: Chat now renders tool calls + tool
results + permission sheets + markdown + chain-of-thought, and the
Dashboard gains three new feature surfaces.

## Chat polish

scarf/Scarf iOS/Chat/ChatView.swift — several new small SwiftUI
view types:

  - ToolCallCard: expandable card for each HermesToolCall on an
    assistant message. Tool-kind icon in the header (from
    HermesToolCall.toolKind.icon), arguments summary collapsed,
    full JSON on tap.
  - ToolResultRow: compact "Tool output" disclosure for messages
    with role == "tool", shown indented beneath the preceding
    assistant bubble.
  - PermissionSheet: SwiftUI .sheet(item:) presentation of
    RichChatViewModel.pendingPermission. Tapping an option
    dispatches ChatController.respondToPermission → ACPClient.
  - ReasoningDisclosure: DisclosureGroup for HermesMessage.reasoning,
    collapsed by default so chatty thinkers don't dominate scroll.

MessageBubble now renders assistant content through
AttributedString(markdown: options: .inlineOnlyPreservingWhitespace).
User messages stay plain Text (no reason to parse what the user
just typed). Unknown markdown falls through as literal text — worst
case, no formatting.

ChatController gains respondToPermission(requestId:optionId:) that
forwards to ACPClient and clears vm.pendingPermission on the
MainActor.

## New feature surfaces

### Memory (read + edit)

ScarfCore/ViewModels/IOSMemoryViewModel.swift:
  - Kind enum (.memory / .user) → maps to paths.memoryMD / .userMD
  - text (mutable) + originalText (pristine) + hasUnsavedChanges
  - load() / save() / revert()
  - async file I/O via ServerContext.readText / writeText — run on
    a detached task so the MainActor doesn't hang on remote SFTP

scarf/Scarf iOS/Memory/:
  - MemoryListView: two-row NavigationLink (MEMORY.md, USER.md)
  - MemoryEditorView: TextEditor bound to vm.text, toolbar Save +
    Revert, "Saved" bottom toast on success.

### Cron (read-only)

ScarfCore/ViewModels/IOSCronViewModel.swift:
  - Loads ~/.hermes/cron/jobs.json via transport.readFile + decodes
    into CronJobsFile (Codable, shipped in M0a)
  - Missing file = empty list (no error — common on fresh installs)
  - Sort: enabled-first, then nextRunAt ascending, disabled last
  - Surfaces decode errors via lastError

scarf/Scarf iOS/Cron/CronListView.swift:
  - Row: state-icon + name + schedule.display + next-run-at.
  - Detail: prompt, schedule, state, delivery route (via
    job.deliveryDisplay), skills, model.

Editing is deferred — needs atomic jobs.json rewrites. Shipped the
read path so users can at least audit their cron config on the go.

### Skills (read-only)

ScarfCore/ViewModels/IOSSkillsViewModel.swift:
  - Scans ~/.hermes/skills/<category>/<name>/ via transport.listDirectory
    + transport.stat for directory-ness
  - Filters dotfiles. Skips empty categories. Swallows per-category
    listing errors (permissions etc.) rather than failing the whole
    load.
  - requiredConfig stays empty — YAML frontmatter parsing deferred
    (would need a parser in ScarfCore; see M5 plan note).

scarf/Scarf iOS/Skills/SkillsListView.swift:
  - Grouped by category, tap → SkillDetailView (path + file list).

## Supporting tweaks

- RichChatViewModel.PendingPermission: fields + public init promoted
  from `let`/internal to `public let` / `public init(...)` so
  PermissionSheet can read title/kind/options and tests can construct
  one directly.

- LocalTransport.writeFile refactored to use Data.write(options: .atomic)
  instead of FileManager.replaceItemAt. replaceItemAt is Apple-only;
  Linux swift-corelibs doesn't fully implement it, which was breaking
  the M5 save-path tests on Linux CI. Data.write(atomic) is cross-
  platform and has identical semantics (temp-file + rename). Also
  auto-creates the parent directory if missing, folding in the one
  bit of the old logic that wasn't atomicity-related.

- DashboardView: single Chat Section → "Surfaces" Section with four
  NavigationLinks (Chat / Memory / Cron / Skills).

## Tests (ScarfCoreTests/M5FeatureVMTests, 10 new)

.serialized suite — tests install a `withLocalTransportFactory`
helper that swaps ServerContext.sshTransportFactory to produce a
LocalTransport against real tmp files (so .ssh contexts in the
test resolve to local FS paths). Restored in defer. Serialized
because the factory is a static.

  - memoryLoadsEmptyWhenFileMissing
  - memoryRoundTripsFileContent  — seed file → load → edit → save
    → reload via fresh VM → confirm persistence
  - memoryRevertRestoresOriginal
  - memoryKindPathRouting        — pin .memory → memoryMD etc.
  - cronEmptyWhenJobsFileMissing — missing file is not an error
  - cronLoadsAndSortsJobs        — 3-job fixture, verify sort:
                                   enabled-before-disabled and
                                   nextRunAt-ascending within
  - cronSurfacesDecodeErrors     — garbage jobs.json
  - skillsEmptyWhenDirMissing
  - skillsScansCategoryAndSkillStructure — 2 categories, dotfile
                                           filter check
  - skillsSkipsEmptyCategories
  - pendingPermissionMemberwise  — SQLite3-gated (RichChatViewModel
                                   is gated)

**108 / 108 passing on Linux** (98 → 108).

## Manual validation needed on Mac

1. Xcode compile clean against M5 source additions.
2. Chat: trigger a tool call + a permission request. Verify cards
   render, options dispatch, markdown looks right.
3. Memory: edit MEMORY.md on phone → save → confirm via `cat` on
   the remote.
4. Cron: existing jobs show sorted + detail view useful.
5. Skills: browse matches `ls ~/.hermes/skills/<cat>/<name>/`.

Updated scarf/docs/IOS_PORT_PLAN.md with M5's scope, rationale
for the LocalTransport.writeFile refactor (Linux CI), and the M6
Settings-blocker (needs YAML parser port).

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
This commit is contained in:
Claude
2026-04-23 00:15:16 +00:00
parent bd6e722029
commit 6b731ddfb8
13 changed files with 1428 additions and 46 deletions
@@ -36,31 +36,32 @@ public struct LocalTransport: ServerTransport {
}
public func writeFile(_ path: String, data: Data) throws {
let tmp = path + ".scarf.tmp"
do {
try data.write(to: URL(fileURLWithPath: tmp))
// Preserve `0600` for dotfiles holding secrets (.env, .auth, ...).
// The existing files already use 0600 via HermesEnvService; we
// mirror that here so a brand-new file created via this write
// also starts with safe permissions.
if Self.shouldEnforcePrivateMode(for: path) {
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tmp)
// Ensure the parent dir exists callers sometimes pass a
// path whose parent hasn't been mkdir'd yet (e.g.,
// `~/.hermes/memories/MEMORY.md` on a Hermes install that
// never wrote memories before).
let parent = (path as NSString).deletingLastPathComponent
if !parent.isEmpty, !FileManager.default.fileExists(atPath: parent) {
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
}
// Atomic swap onto the final path.
let destURL = URL(fileURLWithPath: path)
let tmpURL = URL(fileURLWithPath: tmp)
if FileManager.default.fileExists(atPath: path) {
_ = try FileManager.default.replaceItemAt(destURL, withItemAt: tmpURL)
} else {
// Ensure parent exists.
let parent = (path as NSString).deletingLastPathComponent
if !parent.isEmpty, !FileManager.default.fileExists(atPath: parent) {
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
}
try FileManager.default.moveItem(at: tmpURL, to: destURL)
// Atomic write: Data.write(options: .atomic) drops a temp
// file alongside the destination and rename(2)s it into
// place. Cross-platform (macOS + iOS + Linux CI for tests).
//
// Earlier this method used `FileManager.replaceItemAt`,
// which is Apple-only Linux swift-corelibs would fail.
// Data.write-atomic works everywhere with identical
// semantics.
try data.write(to: URL(fileURLWithPath: path), options: .atomic)
// Preserve 0600 for files that conventionally hold secrets.
// The existing files use 0600 via HermesEnvService; apply
// the same to brand-new files so we never demote
// permissions on a rewrite.
if Self.shouldEnforcePrivateMode(for: path) {
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path)
}
} catch {
try? FileManager.default.removeItem(atPath: tmp)
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
}
}
@@ -0,0 +1,85 @@
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.
///
/// 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.
@Observable
@MainActor
public final class IOSCronViewModel {
public let context: ServerContext
public private(set) var jobs: [HermesCronJob] = []
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.cronJobsJSON
let result: Result<CronJobsFile, Error> = await Task.detached {
do {
guard let data = ctx.readData(path) else {
throw LoadError.missingFile(path: path)
}
let decoded = try JSONDecoder().decode(CronJobsFile.self, from: data)
return .success(decoded)
} catch {
return .failure(error)
}
}.value
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
}
}
isLoading = false
case .failure(let err as LoadError):
// Missing jobs.json is the common case on a fresh Hermes
// install don't surface as an error, show an empty
// list + hint in the UI.
if case .missingFile = err {
jobs = []
} else {
lastError = err.localizedDescription
}
isLoading = false
case .failure(let err):
lastError = "Couldn't parse jobs.json: \(err.localizedDescription)"
isLoading = false
}
}
public enum LoadError: Error, LocalizedError {
case missingFile(path: String)
public var errorDescription: String? {
switch self {
case .missingFile(let p): return "No cron jobs defined (\(p) doesn't exist yet)"
}
}
}
}
@@ -0,0 +1,133 @@
import Foundation
import Observation
/// iOS Memory editor state. Loads MEMORY.md / USER.md via the
/// transport, holds the text in-memory, saves on explicit action.
///
/// Lives in ScarfCore (not ScarfIOS) because it's pure file-I/O on
/// top of `ServerContext.readText` / `writeText` no Keychain, no
/// Citadel, no UIKit and that lets the state machine be unit-
/// tested on Linux with `InMemory` mocks.
///
/// **Which file.** Constructor takes `kind` (`.memory` or `.user`)
/// and picks the corresponding path via `ServerContext.paths`. Users
/// toggle between the two via navigation.
@Observable
@MainActor
public final class IOSMemoryViewModel {
public enum Kind: Sendable, Equatable {
/// `~/.hermes/memories/MEMORY.md` the agent's persistent
/// memory. Visible (and editable) to the agent at every
/// session start.
case memory
/// `~/.hermes/memories/USER.md` user-profile notes the
/// agent reads but (by default) does not write.
case user
/// Heading shown in the UI.
public var displayName: String {
switch self {
case .memory: return "MEMORY.md"
case .user: return "USER.md"
}
}
/// SF Symbol used in the list row.
public var iconName: String {
switch self {
case .memory: return "brain.head.profile"
case .user: return "person.crop.square"
}
}
/// Terse explanation shown under the heading.
public var subtitle: String {
switch self {
case .memory:
return "Agent's persistent memory. Appears in every session prompt."
case .user:
return "Notes about you. Read by the agent but not modified automatically."
}
}
/// Resolve the remote path for this memory file on the
/// given context. `ServerContext.paths` exposes both
/// `memoryMD` and `userMD` directly.
public func path(on context: ServerContext) -> String {
switch self {
case .memory: return context.paths.memoryMD
case .user: return context.paths.userMD
}
}
}
public let kind: Kind
public let context: ServerContext
/// Content loaded from the file. `text` binds to the editor; the
/// view compares against `originalText` to gate the Save button.
public var text: String = ""
public private(set) var originalText: String = ""
public private(set) var isLoading: Bool = true
public private(set) var isSaving: Bool = false
public private(set) var lastError: String?
public var hasUnsavedChanges: Bool { text != originalText }
public init(kind: Kind, context: ServerContext) {
self.kind = kind
self.context = context
}
public func load() async {
isLoading = true
lastError = nil
// Run the file read on a detached task `ServerContext.readText`
// blocks on transport I/O, and we don't want the MainActor
// hanging during a remote SFTP fetch.
let ctx = context
let path = kind.path(on: context)
let loaded: String? = await Task.detached {
ctx.readText(path)
}.value
if let loaded {
text = loaded
originalText = loaded
} else {
// `readText` returns nil on missing file treat as
// empty (the user is creating the file for the first
// time) rather than an error.
text = ""
originalText = ""
}
isLoading = false
}
public func save() async -> Bool {
guard !isSaving else { return false }
isSaving = true
lastError = nil
let ctx = context
let path = kind.path(on: context)
let snapshot = text
let ok: Bool = await Task.detached {
ctx.writeText(path, content: snapshot)
}.value
isSaving = false
if ok {
originalText = snapshot
return true
} else {
lastError = "Couldn't save \(kind.displayName) — check the connection and try again."
return false
}
}
/// Revert in-memory edits back to whatever the file contained
/// at last load.
public func revert() {
text = originalText
}
}
@@ -0,0 +1,100 @@
import Foundation
import Observation
/// iOS read-only Skills view-state. Scans `~/.hermes/skills/` for
/// category directories, then each category for skill directories,
/// then each skill directory for its file list. Mirrors what the
/// Mac app's `HermesFileService.loadSkills` does but scoped to what
/// the transport's `listDirectory` primitive can surface (no deep
/// YAML frontmatter parsing users who want to inspect a skill's
/// full definition still do that on the Mac).
///
/// M5 is read-only by design. Installing new skills would need a
/// git-clone over SSH plus schema validation; that's a separate
/// feature in a later phase.
@Observable
@MainActor
public final class IOSSkillsViewModel {
public let context: ServerContext
public private(set) var categories: [HermesSkillCategory] = []
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 skillsRoot = ctx.paths.skillsDir
let loaded: Result<[HermesSkillCategory], Error> = await Task.detached {
let transport = ctx.makeTransport()
guard transport.fileExists(skillsRoot) else {
// Fresh install no skills/ dir yet.
return .success([])
}
do {
let categoryNames = try transport.listDirectory(skillsRoot)
.filter { !$0.hasPrefix(".") }
.sorted()
var categories: [HermesSkillCategory] = []
for categoryName in categoryNames {
let categoryPath = skillsRoot + "/" + categoryName
// Only include directories.
guard transport.stat(categoryPath)?.isDirectory == true else { continue }
var skills: [HermesSkill] = []
let skillNames: [String]
do {
skillNames = try transport.listDirectory(categoryPath)
.filter { !$0.hasPrefix(".") }
.sorted()
} catch {
// Skip categories we can't read (permissions etc.)
// rather than failing the whole load.
continue
}
for skillName in skillNames {
let skillPath = categoryPath + "/" + skillName
guard transport.stat(skillPath)?.isDirectory == true else { continue }
let files: [String] = (try? transport.listDirectory(skillPath)) ?? []
skills.append(HermesSkill(
id: categoryName + "/" + skillName,
name: skillName,
category: categoryName,
path: skillPath,
files: files.filter { !$0.hasPrefix(".") }.sorted(),
requiredConfig: [] // Skills frontmatter parsing deferred.
))
}
if !skills.isEmpty {
categories.append(HermesSkillCategory(
id: categoryName,
name: categoryName,
skills: skills
))
}
}
return .success(categories)
} catch {
return .failure(error)
}
}.value
switch loaded {
case .success(let cats):
categories = cats
case .failure(let err):
categories = []
lastError = "Couldn't list skills: \(err.localizedDescription)"
}
isLoading = false
}
}
@@ -95,10 +95,22 @@ public final class RichChatViewModel {
private var activePollingTimer: Timer?
public struct PendingPermission {
let requestId: Int
let title: String
let kind: String
let options: [(optionId: String, name: String)]
public let requestId: Int
public let title: String
public let kind: String
public let options: [(optionId: String, name: String)]
public init(
requestId: Int,
title: String,
kind: String,
options: [(optionId: String, name: String)]
) {
self.requestId = requestId
self.title = title
self.kind = kind
self.options = options
}
}
// MARK: - Reset
@@ -0,0 +1,329 @@
import Testing
import Foundation
@testable import ScarfCore
/// M5 iOS feature ViewModels: Memory (read/write), Cron (read-only
/// JSON), Skills (read-only directory scan). All exercised through
/// `LocalTransport` against tmpfs paths so the suite runs on Linux
/// CI with the same file-I/O codepaths iOS hits (just without SFTP
/// in front).
@Suite(.serialized) struct M5FeatureVMTests {
/// Build a context rooted at a fresh tmp directory. Also pre-
/// creates the Hermes subfolders so the VMs' `paths.*` resolve
/// to real locations.
@MainActor
private func makeFakeHermes() throws -> (context: ServerContext, home: URL) {
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("scarf-m5-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
// We can't easily override ServerContext.paths without building
// a new ServerKind, and HermesPathSet is keyed on "home". So
// we LIE to ServerContext.local by symlinking? No too risky.
// Instead: construct a remote-kind context whose remoteHome
// points at our tmp dir, then install a custom transport
// factory that returns a LocalTransport pointed at local
// files. LocalTransport ignores the path's "remote-ness"
// since on Linux everything resolves to the actual FS.
let kind = ServerKind.ssh(SSHConfig(host: "fake.invalid", remoteHome: tmp.path))
let ctx = ServerContext(id: UUID(), displayName: "fake", kind: kind)
// Pre-create subdirs the VMs look for.
try FileManager.default.createDirectory(
at: tmp.appendingPathComponent("memories"),
withIntermediateDirectories: true
)
try FileManager.default.createDirectory(
at: tmp.appendingPathComponent("cron"),
withIntermediateDirectories: true
)
try FileManager.default.createDirectory(
at: tmp.appendingPathComponent("skills"),
withIntermediateDirectories: true
)
return (ctx, tmp)
}
/// Wrap each test body in a factory override so `ctx.makeTransport()`
/// returns a `LocalTransport` instead of trying to spawn a real SSH
/// subprocess. The `.serialized` suite trait guarantees no other
/// test races on the factory static.
@MainActor
private func withLocalTransportFactory<T>(
_ body: @MainActor () async throws -> T
) async throws -> T {
let previous = ServerContext.sshTransportFactory
defer { ServerContext.sshTransportFactory = previous }
ServerContext.sshTransportFactory = { id, _, _ in
LocalTransport(contextID: id)
}
return try await body()
}
// MARK: - Memory
@Test @MainActor func memoryLoadsEmptyWhenFileMissing() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, _) = try makeFakeHermes()
let vm = IOSMemoryViewModel(kind: .memory, context: ctx)
await vm.load()
#expect(vm.text == "")
#expect(vm.originalText == "")
#expect(vm.isLoading == false)
#expect(vm.hasUnsavedChanges == false)
}
}
@Test @MainActor func memoryRoundTripsFileContent() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, home) = try makeFakeHermes()
// Seed a MEMORY.md file.
let seed = "# Known facts\n\n- scarf is a Hermes companion\n"
try seed.write(
to: home.appendingPathComponent("memories/MEMORY.md"),
atomically: true,
encoding: .utf8
)
let vm = IOSMemoryViewModel(kind: .memory, context: ctx)
await vm.load()
#expect(vm.text == seed)
#expect(vm.originalText == seed)
#expect(!vm.hasUnsavedChanges)
vm.text = seed + "- also does iOS now\n"
#expect(vm.hasUnsavedChanges)
let saved = await vm.save()
#expect(saved)
#expect(!vm.hasUnsavedChanges)
// Re-load via a fresh VM to confirm persistence.
let vm2 = IOSMemoryViewModel(kind: .memory, context: ctx)
await vm2.load()
#expect(vm2.text.contains("iOS"))
}
}
@Test @MainActor func memoryRevertRestoresOriginal() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, home) = try makeFakeHermes()
try "seed".write(
to: home.appendingPathComponent("memories/USER.md"),
atomically: true,
encoding: .utf8
)
let vm = IOSMemoryViewModel(kind: .user, context: ctx)
await vm.load()
vm.text = "scratch edit"
#expect(vm.hasUnsavedChanges)
vm.revert()
#expect(vm.text == "seed")
#expect(!vm.hasUnsavedChanges)
}
}
@Test func memoryKindPathRouting() {
// Pin that .memory memoryMD, .user userMD.
let ctx = ServerContext.local
#expect(IOSMemoryViewModel.Kind.memory.path(on: ctx) == ctx.paths.memoryMD)
#expect(IOSMemoryViewModel.Kind.user.path(on: ctx) == ctx.paths.userMD)
}
// MARK: - Cron
@Test @MainActor func cronEmptyWhenJobsFileMissing() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, _) = try makeFakeHermes()
let vm = IOSCronViewModel(context: ctx)
await vm.load()
#expect(vm.jobs.isEmpty)
#expect(vm.lastError == nil) // "missing file" is not an error
#expect(vm.isLoading == false)
}
}
@Test @MainActor func cronLoadsAndSortsJobs() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, home) = try makeFakeHermes()
// Two enabled, one disabled verify disabled sinks to bottom.
let json = #"""
{
"jobs": [
{
"id": "b",
"name": "Late riser",
"prompt": "brief me",
"skills": null,
"model": null,
"schedule": {"kind": "cron", "run_at": null, "display": "9am weekdays", "expression": "0 9 * * 1-5"},
"enabled": true,
"state": "scheduled",
"deliver": null,
"next_run_at": "2026-04-24T09:00:00Z",
"last_run_at": null,
"last_error": null,
"pre_run_script": null,
"delivery_failures": 0,
"last_delivery_error": null,
"timeout_type": null,
"timeout_seconds": null,
"silent": false
},
{
"id": "a",
"name": "Early bird",
"prompt": "wake me",
"skills": null,
"model": null,
"schedule": {"kind": "cron", "run_at": null, "display": "6am daily", "expression": "0 6 * * *"},
"enabled": true,
"state": "scheduled",
"deliver": "discord:general",
"next_run_at": "2026-04-23T06:00:00Z",
"last_run_at": null,
"last_error": null,
"pre_run_script": null,
"delivery_failures": 0,
"last_delivery_error": null,
"timeout_type": null,
"timeout_seconds": null,
"silent": false
},
{
"id": "c",
"name": "Off",
"prompt": "quiet",
"skills": null,
"model": null,
"schedule": {"kind": "interval", "run_at": null, "display": "every hour", "expression": null},
"enabled": false,
"state": "scheduled",
"deliver": null,
"next_run_at": null,
"last_run_at": null,
"last_error": null,
"pre_run_script": null,
"delivery_failures": 0,
"last_delivery_error": null,
"timeout_type": null,
"timeout_seconds": null,
"silent": false
}
],
"updated_at": "2026-04-22T12:00:00Z"
}
"""#
try json.write(
to: home.appendingPathComponent("cron/jobs.json"),
atomically: true,
encoding: .utf8
)
let vm = IOSCronViewModel(context: ctx)
await vm.load()
#expect(vm.lastError == nil)
#expect(vm.jobs.count == 3)
// Enabled + next_run_at earlier first
#expect(vm.jobs[0].name == "Early bird")
#expect(vm.jobs[1].name == "Late riser")
// Disabled last
#expect(vm.jobs[2].name == "Off")
#expect(vm.jobs[0].deliveryDisplay?.contains("Discord") == true)
}
}
@Test @MainActor func cronSurfacesDecodeErrors() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, home) = try makeFakeHermes()
try "garbage, not json".write(
to: home.appendingPathComponent("cron/jobs.json"),
atomically: true,
encoding: .utf8
)
let vm = IOSCronViewModel(context: ctx)
await vm.load()
#expect(vm.lastError != nil)
#expect(vm.jobs.isEmpty)
}
}
// MARK: - Skills
@Test @MainActor func skillsEmptyWhenDirMissing() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, home) = try makeFakeHermes()
// Remove the skills/ dir we pre-created.
try FileManager.default.removeItem(
at: home.appendingPathComponent("skills")
)
let vm = IOSSkillsViewModel(context: ctx)
await vm.load()
#expect(vm.categories.isEmpty)
#expect(vm.lastError == nil)
}
}
@Test @MainActor func skillsScansCategoryAndSkillStructure() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, home) = try makeFakeHermes()
let skills = home.appendingPathComponent("skills")
let dev = skills.appendingPathComponent("dev")
let personal = skills.appendingPathComponent("personal")
try FileManager.default.createDirectory(at: dev, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: personal, withIntermediateDirectories: true)
// dev/git/
let devGit = dev.appendingPathComponent("git")
try FileManager.default.createDirectory(at: devGit, withIntermediateDirectories: true)
try "".write(to: devGit.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8)
try "".write(to: devGit.appendingPathComponent("helpers.sh"), atomically: true, encoding: .utf8)
// personal/journaling/
let pJournal = personal.appendingPathComponent("journaling")
try FileManager.default.createDirectory(at: pJournal, withIntermediateDirectories: true)
try "".write(to: pJournal.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8)
// Dotfile should be filtered
try "".write(to: pJournal.appendingPathComponent(".DS_Store"), atomically: true, encoding: .utf8)
let vm = IOSSkillsViewModel(context: ctx)
await vm.load()
#expect(vm.categories.count == 2)
#expect(vm.categories[0].name == "dev")
#expect(vm.categories[1].name == "personal")
#expect(vm.categories[0].skills.count == 1)
#expect(vm.categories[0].skills[0].name == "git")
#expect(vm.categories[0].skills[0].files.sorted() == ["SKILL.md", "helpers.sh"])
// Dotfile filtered out
#expect(vm.categories[1].skills[0].files == ["SKILL.md"])
}
}
@Test @MainActor func skillsSkipsEmptyCategories() async throws {
try await withLocalTransportFactory { [self] in
let (ctx, home) = try makeFakeHermes()
// Empty category shouldn't appear in the list.
try FileManager.default.createDirectory(
at: home.appendingPathComponent("skills/empty-cat"),
withIntermediateDirectories: true
)
let vm = IOSSkillsViewModel(context: ctx)
await vm.load()
#expect(vm.categories.isEmpty)
}
}
// MARK: - RichChatViewModel PendingPermission public init
#if canImport(SQLite3)
@Test func pendingPermissionMemberwise() {
let p = RichChatViewModel.PendingPermission(
requestId: 99,
title: "write_file: /etc/hosts",
kind: "edit",
options: [("allow", "Allow once"), ("deny", "Deny")]
)
#expect(p.requestId == 99)
#expect(p.title == "write_file: /etc/hosts")
#expect(p.kind == "edit")
#expect(p.options.count == 2)
#expect(p.options[0].optionId == "allow")
}
#endif
}
+257 -19
View File
@@ -65,6 +65,17 @@ struct ChatView: View {
errorOverlay(msg)
}
}
.sheet(item: Binding(
get: { controller.vm.pendingPermission.map(PermissionWrapper.init) },
set: { if $0 == nil { controller.vm.pendingPermission = nil } }
)) { wrapper in
PermissionSheet(permission: wrapper.value) { optionId in
await controller.respondToPermission(
requestId: wrapper.value.requestId,
optionId: optionId
)
}
}
}
// MARK: - Subviews
@@ -302,6 +313,23 @@ final class ChatController {
vm.reset()
await start()
}
/// Dispatch the user's answer to a pending permission request.
/// Called by `PermissionSheet`.
func respondToPermission(requestId: Int, optionId: String) async {
guard let client else { return }
await client.respondToPermission(requestId: requestId, optionId: optionId)
vm.pendingPermission = nil
}
}
/// `Identifiable` wrapper so SwiftUI's `.sheet(item:)` can key off
/// the pending permission. Two permissions for the same request-id
/// are treated as identical (rare would only happen if the remote
/// sends a duplicate).
private struct PermissionWrapper: Identifiable {
let value: RichChatViewModel.PendingPermission
var id: Int { value.requestId }
}
// MARK: - Message bubble
@@ -310,33 +338,243 @@ private struct MessageBubble: View {
let message: HermesMessage
var body: some View {
HStack {
if message.isUser { Spacer(minLength: 40) }
VStack(alignment: message.isUser ? .trailing : .leading, spacing: 4) {
Text(message.content)
.font(.body)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.foregroundStyle(message.isUser ? Color.white : Color.primary)
.background(
message.isUser ? Color.accentColor : Color(.secondarySystemBackground)
)
.clipShape(RoundedRectangle(cornerRadius: 14))
.textSelection(.enabled)
if message.hasReasoning, let r = message.reasoning, !r.isEmpty {
Text("🧠 \(r)")
.font(.caption2)
.italic()
if message.isToolResult {
ToolResultRow(message: message)
} else {
HStack(alignment: .bottom) {
if message.isUser { Spacer(minLength: 40) }
VStack(alignment: message.isUser ? .trailing : .leading, spacing: 4) {
if message.hasReasoning, let r = message.reasoning, !r.isEmpty {
ReasoningDisclosure(reasoning: r)
}
bubbleContent
if !message.toolCalls.isEmpty {
VStack(alignment: .leading, spacing: 6) {
ForEach(message.toolCalls) { call in
ToolCallCard(call: call)
}
}
}
}
if !message.isUser { Spacer(minLength: 40) }
}
.padding(.horizontal)
}
}
@ViewBuilder
private var bubbleContent: some View {
// Render markdown on the assistant side so bold/code/links
// look right. User messages stay plain no reason to parse
// what the user just typed. AttributedString(markdown:) is
// conservative unknown constructs fall through as literal
// text, so the worst case is just "no formatting".
let text: Text = {
if message.isUser {
return Text(message.content)
}
if let attributed = try? AttributedString(
markdown: message.content,
options: AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace
)
) {
return Text(attributed)
}
return Text(message.content)
}()
text
.font(.body)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.foregroundStyle(message.isUser ? Color.white : Color.primary)
.background(
message.isUser ? Color.accentColor : Color(.secondarySystemBackground)
)
.clipShape(RoundedRectangle(cornerRadius: 14))
.textSelection(.enabled)
}
}
/// Inline, expandable "chain-of-thought" disclosure shown above the
/// assistant's primary message when the remote surfaces `reasoning`.
/// Collapsed by default so a chatty model doesn't dominate the scroll
/// position.
private struct ReasoningDisclosure: View {
let reasoning: String
@State private var isExpanded = false
var body: some View {
DisclosureGroup(isExpanded: $isExpanded) {
Text(reasoning)
.font(.caption)
.foregroundStyle(.secondary)
.italic()
.textSelection(.enabled)
.padding(.top, 4)
} label: {
Label("Thinking…", systemImage: "brain")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 6)
}
}
/// Expanding card for a single `HermesToolCall` shows function name
/// + summary collapsed; full JSON arguments expanded.
private struct ToolCallCard: View {
let call: HermesToolCall
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Button {
withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() }
} label: {
HStack(spacing: 6) {
Image(systemName: iconName)
.foregroundStyle(.tint)
Text(call.functionName)
.font(.caption.monospaced())
.foregroundStyle(.primary)
Text(call.argumentsSummary.prefix(60))
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 12)
.lineLimit(1)
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
if !message.isUser { Spacer(minLength: 40) }
.buttonStyle(.plain)
if isExpanded {
Text(call.arguments)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.padding(.top, 2)
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color(.tertiarySystemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color(.separator), lineWidth: 0.5)
)
}
private var iconName: String {
call.toolKind.icon
}
}
/// Row showing a tool-result (role="tool"). Styled as a small
/// quoted block beneath whichever assistant message preceded it.
private struct ToolResultRow: View {
let message: HermesMessage
@State private var isExpanded = false
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Button {
withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() }
} label: {
HStack(spacing: 6) {
Image(systemName: "arrow.turn.down.right")
.font(.caption2)
.foregroundStyle(.secondary)
Text("Tool output")
.font(.caption)
.foregroundStyle(.secondary)
Text(message.content.prefix(80))
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
if isExpanded {
Text(message.content)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.padding(.top, 2)
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color(.tertiarySystemBackground))
)
Spacer(minLength: 40)
}
.padding(.horizontal)
}
}
// MARK: - Permission sheet
/// Sheet presented when the remote asks for permission (e.g.,
/// "allow write to /etc/hosts"). Renders the VM's `PendingPermission`
/// options as tappable buttons. Tapping responds via the ChatController
/// which dispatches the answer over the ACP channel.
private struct PermissionSheet: View {
let permission: RichChatViewModel.PendingPermission
let onRespond: (_ optionId: String) async -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section {
VStack(alignment: .leading, spacing: 8) {
Text(permission.title)
.font(.headline)
.textSelection(.enabled)
Text("Kind: \(permission.kind)")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
Section("Your response") {
ForEach(permission.options, id: \.optionId) { opt in
Button {
Task {
await onRespond(opt.optionId)
dismiss()
}
} label: {
HStack {
Text(opt.name)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
}
}
}
.navigationTitle("Agent permission")
.navigationBarTitleDisplayMode(.inline)
}
}
}
#endif // canImport(SQLite3)
// Empty shim so the file compiles on platforms without SQLite3 the
+193
View File
@@ -0,0 +1,193 @@
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.
struct CronListView: View {
let config: IOSServerConfig
@State private var vm: IOSCronViewModel
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: IOSCronViewModel(context: ctx))
}
var body: some View {
List {
if let err = vm.lastError {
Section {
Label(err, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
}
if vm.jobs.isEmpty, !vm.isLoading {
Section {
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.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
} else {
Section {
ForEach(vm.jobs) { job in
CronRow(job: job)
}
}
}
}
.navigationTitle("Cron jobs")
.navigationBarTitleDisplayMode(.inline)
.overlay {
if vm.isLoading && vm.jobs.isEmpty {
ProgressView("Loading jobs…")
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
.refreshable { await vm.load() }
.task { await vm.load() }
}
}
private struct CronRow: View {
let job: HermesCronJob
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)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(job.name)
.font(.body)
.fontWeight(.medium)
if !job.enabled {
Text("DISABLED")
.font(.caption2)
.fontWeight(.bold)
.foregroundStyle(.secondary)
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(Color(.secondarySystemFill))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
if let schedule = job.schedule.display, !schedule.isEmpty {
Text(schedule)
.font(.caption)
.foregroundStyle(.secondary)
} else if !job.schedule.kind.isEmpty {
Text(job.schedule.kind)
.font(.caption)
.foregroundStyle(.secondary)
}
if let nextRun = job.nextRunAt {
Text("Next: \(nextRun)")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
.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
}
}
}
private struct CronDetailView: View {
let job: HermesCronJob
var body: some View {
Form {
Section("Prompt") {
Text(job.prompt)
.font(.body)
.textSelection(.enabled)
}
Section("Schedule") {
LabeledContent("Kind", value: job.schedule.kind)
if let display = job.schedule.display {
LabeledContent("When", value: display)
}
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)
}
}
}
if let delivery = job.deliveryDisplay {
Section("Delivery") {
LabeledContent("Route", value: delivery)
}
}
if let skills = job.skills, !skills.isEmpty {
Section("Skills") {
ForEach(skills, id: \.self) { s in
Text(s)
.font(.caption.monospaced())
}
}
}
if let model = job.model {
Section("Model") {
Text(model).font(.caption.monospaced())
}
}
}
.navigationTitle(job.name)
.navigationBarTitleDisplayMode(.inline)
}
}
+16 -1
View File
@@ -89,12 +89,27 @@ struct DashboardView: View {
}
}
Section {
Section("Surfaces") {
NavigationLink {
ChatView(config: config, key: key)
} label: {
Label("Chat", systemImage: "bubble.left.and.bubble.right.fill")
}
NavigationLink {
MemoryListView(config: config)
} label: {
Label("Memory", systemImage: "brain.head.profile")
}
NavigationLink {
CronListView(config: config)
} label: {
Label("Cron", systemImage: "clock.arrow.circlepath")
}
NavigationLink {
SkillsListView(config: config)
} label: {
Label("Skills", systemImage: "sparkles")
}
}
Section("Connected to") {
@@ -0,0 +1,80 @@
import SwiftUI
import ScarfCore
/// Editor for a single memory file (MEMORY.md or USER.md). Owns an
/// `IOSMemoryViewModel` instance, renders its `text` in a TextEditor,
/// and exposes Save + Revert toolbar buttons.
struct MemoryEditorView: View {
@State private var vm: IOSMemoryViewModel
@State private var showSavedConfirmation = false
init(kind: IOSMemoryViewModel.Kind, context: ServerContext) {
_vm = State(initialValue: IOSMemoryViewModel(kind: kind, context: context))
}
var body: some View {
VStack(spacing: 0) {
if vm.isLoading {
Spacer()
ProgressView("Loading \(vm.kind.displayName)")
Spacer()
} else {
TextEditor(text: $vm.text)
.font(.system(.body, design: .monospaced))
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.padding(.horizontal, 8)
if let err = vm.lastError {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text(err)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.regularMaterial)
}
}
}
.navigationTitle(vm.kind.displayName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
Task {
let ok = await vm.save()
if ok {
showSavedConfirmation = true
Task {
try? await Task.sleep(nanoseconds: 1_500_000_000)
showSavedConfirmation = false
}
}
}
}
.disabled(!vm.hasUnsavedChanges || vm.isSaving)
}
ToolbarItem(placement: .topBarLeading) {
if vm.hasUnsavedChanges {
Button("Revert") { vm.revert() }
}
}
}
.overlay(alignment: .bottom) {
if showSavedConfirmation {
Label("Saved", systemImage: "checkmark.circle.fill")
.font(.callout)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.thinMaterial, in: Capsule())
.padding(.bottom, 16)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.2), value: showSavedConfirmation)
.task { await vm.load() }
}
}
@@ -0,0 +1,52 @@
import SwiftUI
import ScarfCore
/// Entry screen for the Memory feature. Two rows: MEMORY.md and
/// USER.md. Each taps into `MemoryEditorView`. Pure SwiftUI the
/// actual load/save happens in `IOSMemoryViewModel` which lives in
/// ScarfCore and is tested on Linux.
struct MemoryListView: View {
let config: IOSServerConfig
private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A1"
)!
var body: some View {
let ctx = config.toServerContext(id: Self.sharedContextID)
List {
Section {
memoryRow(.memory, context: ctx)
memoryRow(.user, context: ctx)
} footer: {
Text("These files live under `~/.hermes/memories/` on the remote host.")
.font(.caption)
}
}
.navigationTitle("Memory")
.navigationBarTitleDisplayMode(.inline)
}
@ViewBuilder
private func memoryRow(_ kind: IOSMemoryViewModel.Kind, context: ServerContext) -> some View {
NavigationLink {
MemoryEditorView(kind: kind, context: context)
} label: {
HStack(alignment: .top, spacing: 12) {
Image(systemName: kind.iconName)
.font(.title3)
.foregroundStyle(.tint)
.frame(width: 28, alignment: .center)
VStack(alignment: .leading, spacing: 2) {
Text(kind.displayName)
.font(.body)
.fontWeight(.medium)
Text(kind.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
}
}
+102
View File
@@ -0,0 +1,102 @@
import SwiftUI
import ScarfCore
/// iOS Skills browser. Read-only list grouped by category. Tapping
/// a skill shows its files + on-disk path enough for a user to
/// verify what's installed without opening Terminal.
struct SkillsListView: View {
let config: IOSServerConfig
@State private var vm: IOSSkillsViewModel
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: IOSSkillsViewModel(context: ctx))
}
var body: some View {
List {
if let err = vm.lastError {
Section {
Label(err, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
}
if vm.categories.isEmpty, !vm.isLoading {
Section {
VStack(alignment: .leading, spacing: 6) {
Text("No skills installed")
.font(.headline)
Text("Skills live under `~/.hermes/skills/<category>/<name>/` on the remote. Install them from the Mac app or by cloning directly.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
} else {
ForEach(vm.categories) { category in
Section(category.name) {
ForEach(category.skills) { skill in
NavigationLink {
SkillDetailView(skill: skill)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(skill.name)
.font(.body)
Text("\(skill.files.count) file\(skill.files.count == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
}
}
.navigationTitle("Skills")
.navigationBarTitleDisplayMode(.inline)
.overlay {
if vm.isLoading && vm.categories.isEmpty {
ProgressView("Scanning skills…")
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
.refreshable { await vm.load() }
.task { await vm.load() }
}
}
private struct SkillDetailView: View {
let skill: HermesSkill
var body: some View {
List {
Section("Location") {
LabeledContent("Category", value: skill.category)
Text(skill.path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if !skill.files.isEmpty {
Section("Files") {
ForEach(skill.files, id: \.self) { file in
Text(file)
.font(.caption.monospaced())
}
}
}
}
.navigationTitle(skill.name)
.navigationBarTitleDisplayMode(.inline)
}
}
+43 -1
View File
@@ -653,5 +653,47 @@ the 3 ScarfIOS tests.
- **Chat is rich-chat-only on iOS in v1.** Terminal mode (embedded SwiftTerm) deferred.
- **Message markdown / tool-call cards / permission sheets** are M5 polish.
### M5 — pending
### M5 — shipped (on `claude/ios-m5-polish-writes` branch, separate PR, stacked on M4)
Chat polish + three new iOS feature surfaces — Memory (read + edit), Cron (read-only), Skills (read-only).
**Chat polish:**
- **Tool-call cards** — assistant messages with embedded `HermesToolCall`s now render each call as an expandable card (tapped → shows full JSON arguments). Tool-kind icon in the card header.
- **Tool-result row** — messages with `role == "tool"` render as a compact "Tool output" disclosure beneath the assistant bubble they flowed from. Keeps the transcript scannable while letting users drill in.
- **Permission sheet** — when the remote emits `session/request_permission`, a SwiftUI `.sheet(item:)` modal presents the title + kind + option buttons. Tapping an option dispatches `respondToPermission(requestId:optionId:)` through `ChatController``ACPClient`.
- **Markdown rendering** — assistant messages (only) go through `AttributedString(markdown:options: .inlineOnlyPreservingWhitespace)`. Bold/italic/code/links render; unknown constructs fall through as plain text.
- **Reasoning disclosure**`HermesMessage.reasoning` (when present) renders as an "Thinking…" `DisclosureGroup` above the main bubble. Collapsed by default so chain-of-thought-heavy models don't dominate the scroll.
**New feature surfaces** (all in `scarf/Scarf iOS/` + ScarfCore ViewModels):
- **Memory**`IOSMemoryViewModel` (ScarfCore) + `MemoryListView` / `MemoryEditorView`. Two rows: MEMORY.md and USER.md. TextEditor bound to `vm.text`; toolbar Save + Revert. "Saved" toast confirmation. VM uses `ServerContext.readText` / `writeText` — works through any transport.
- **Cron**`IOSCronViewModel` + `CronListView` + `CronDetailView`. Read-only. Decodes `jobs.json` via `CronJobsFile` (Codable, from ScarfCore M0a). Sorted enabled-first, then by `nextRunAt`. Missing file = empty list (no error — common on fresh installs).
- **Skills**`IOSSkillsViewModel` + `SkillsListView` + `SkillDetailView`. Read-only. Scans `~/.hermes/skills/<category>/<name>/` via `transport.listDirectory` + `stat` (no YAML frontmatter parsing — defer). Empty categories filtered. Dotfiles filtered.
**Supporting changes:**
- `RichChatViewModel.PendingPermission` fields promoted `public` — the iOS `PermissionSheet` needs to read `title` / `kind` / `options`.
- `LocalTransport.writeFile` refactored to use `Data.write(options: .atomic)` instead of `FileManager.replaceItemAt`. replaceItemAt is Apple-only (Linux swift-corelibs doesn't implement it fully), which broke the M5 tests on Linux CI. The atomic-write option is cross-platform and has identical semantics. No behavior change on Mac/iOS; auto-creates the parent dir if missing.
- Dashboard's single Chat-only Section became a **Surfaces** section with four `NavigationLink`s: Chat, Memory, Cron, Skills.
**Test coverage (`M5FeatureVMTests`, 10 new tests):**
- Memory: missing-file → empty state, full load+edit+save+reload round-trip, revert restores original, `Kind.path(on:)` routing.
- Cron: missing jobs.json → empty (no error), full-load sorts (enabled-first + next-run ascending + disabled-last), decode-error surfaces via `lastError`.
- Skills: missing skills dir → empty, directory scan extracts category/skill/files + filters dotfiles, empty categories excluded from list.
- `PendingPermission.init` pinned (SQLite3-gated).
Total **98 → 108 tests passing on Linux** via `docker run --rm -v $PWD/Packages/ScarfCore:/work -w /work swift:6.0 swift test`. All M5 tests use a `.serialized` suite + a `withLocalTransportFactory` helper so the shared `ServerContext.sshTransportFactory` static doesn't race.
**Manual validation needed on Mac:**
1. Xcode compile clean against M5 source additions.
2. Chat: trigger a tool call (e.g., ask "list files in ~") → verify the card renders + expands. Trigger a permission request (e.g., ask to write a file) → verify the sheet presents + responding dispatches correctly.
3. Markdown: ask for a bulleted list or bold/italic text → verify rendering.
4. Memory: edit MEMORY.md from phone → save → verify on remote filesystem via `cat ~/.hermes/memories/MEMORY.md`.
5. Cron: if you have existing cron jobs, verify they show up sorted correctly + the detail view is useful.
6. Skills: browse the list, tap a skill, verify the file list matches `ls ~/.hermes/skills/<cat>/<name>/`.
**Rules next phases can rely on:**
- **`IOSMemoryViewModel`, `IOSCronViewModel`, `IOSSkillsViewModel`** live in ScarfCore (not ScarfIOS) because they only use `ServerContext.readText` / `writeText` / `makeTransport` — no iOS-only APIs. Tests on Linux with `LocalTransport` are legitimate coverage.
- **`LocalTransport.writeFile` is atomic cross-platform** — services writing through it get POSIX rename-atomic semantics on every target. Don't reintroduce `replaceItemAt`.
- **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