feat(hermes-v12): version-aware capability detection (Phase A)

Introduces `HermesCapabilities` (parsed from `hermes --version`) and a
per-server `HermesCapabilitiesStore` injected into Mac `ContextBoundRoot`
and iOS `ScarfGoTabRoot` via `.environment(_:)` and `.hermesCapabilities`.
Subsequent v0.12-targeted UI (Curator, Kanban, ACP image input,
auxiliary.curator, prompt cache TTL, etc.) can branch on these flags so
older Hermes installs degrade silently instead of throwing on unknown CLI
subcommands.

Adds `curatorReportJSON` / `curatorReportMD` paths to `HermesPathSet`.

Bumps the Hermes version target in CLAUDE.md from v2026.4.23 (v0.11.0) to
v2026.4.30 (v0.12.0) and lists the v0.12 surfaces Scarf will consume.

Side fixes:

- `M5FeatureVMTests.ScriptedTransport` was missing
  `cachedSnapshotPath` after that property was added in 7b864d7;
  added `URL? { nil }` stub.
- `M0dViewModelsTests` referenced `.degraded(reason:)` after the case
  gained `hint` + `cause`; updated.
- `RemoteBackupService.zipDirectory` and `RemoteRestoreService.unzipArchive`
  used `Foundation.Process` unconditionally, breaking the iOS build
  (Process is unavailable on iOS). Wrapped in `#if !os(iOS)` with iOS
  stubs that throw — the backup/restore flow is Mac-only by design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-01 12:10:06 +02:00
parent 421e6030df
commit a90a29add8
10 changed files with 530 additions and 4 deletions
+20 -2
View File
@@ -113,9 +113,27 @@ Public documentation lives in the GitHub wiki at https://github.com/awizemann/sc
## Hermes Version ## Hermes Version
Targets Hermes v2026.4.23 (v0.11.0). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse. Targets Hermes v2026.4.30 (v0.12.0). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
**v2026.4.23 (v0.11.0)** added (Scarf-relevant subset): **Capability gating.** Scarf detects the target's Hermes version once per server connection via [HermesCapabilities](scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesCapabilities.swift) (`hermes --version` → semver + `YYYY.M.D` parse). The resulting `HermesCapabilitiesStore` is injected on `ContextBoundRoot` (Mac) and `ScarfGoTabRoot` (iOS) via `.environment(_:)` and `.hermesCapabilities(_:)`; UI that depends on a v0.12+ surface (Curator, Kanban, ACP image input, `auxiliary.curator`, `prompt_caching.cache_ttl`, Piper TTS, Vercel terminal) reads it through the typed environment key. Pre-v0.12 hosts gracefully hide the new affordances rather than throwing on unknown CLI subcommands. Add a new flag at the top of `HermesCapabilities` whenever Scarf gains a release-gated UI surface.
**v2026.4.30 (v0.12.0)** added (Scarf-relevant subset):
- **Autonomous Curator** — `hermes curator` self-prunes / -consolidates the skill library on a 7-day cycle. Reports land at `~/.hermes/logs/curator/run.json` + `REPORT.md` (paths exposed via `HermesPathSet.curatorReportJSON` / `curatorReportMD`). Surfaced in Scarf as a dedicated "Curator" sidebar item under Interact (between Memory and Skills) on Mac, plus a read-only iOS panel; both gated on `HermesCapabilities.hasCurator`.
- **5 new inference providers** — GMI Cloud, Azure AI Foundry, LM Studio (upgraded to first-class), MiniMax OAuth, Tencent Tokenhub. Mirrored in `ModelCatalogService.overlayOnlyProviders`; the model picker reaches all of them automatically.
- **`flush_memories` aux task removed** — `auxiliary.flush_memories` is gone from Hermes config. Scarf's `AuxiliarySettings.flushMemories` field is dropped; `HermesCapabilities.hasFlushMemoriesAux` returns `true` only on pre-v0.12 hosts so the row stays visible there.
- **`auxiliary.curator` aux task added** — Curator's review model is configurable independently of the main model. Surfaced in `Settings → Auxiliary` next to the other aux rows.
- **Multimodal ACP `session/prompt`** — ACP advertises and forwards image content blocks. Scarf chat composers (Mac drag/drop + paste; iOS PhotosPicker + camera) attach images that flow through `ACPClient.sendPrompt` as `[{type: "text"...}, {type: "image", source: {type: "base64", ...}}]`. Gated on `HermesCapabilities.hasACPImagePrompts`.
- **CLI additions:** `hermes -z <prompt>` (non-interactive one-shot), `hermes update --check` (preflight), `hermes fallback` (manage fallback providers), `hermes curator` (status / run / pause / resume / pin / unpin / restore), `hermes kanban` (full task-board CLI; multi-profile collab was reverted upstream so Scarf ships a read-only Kanban view only). All capability-gated.
- **Skills surface:** `hermes skills install <https-url>` direct-URL install (SkillsView "Install from URL…" toolbar button), `/reload-skills` slash command (Skills view "Reload" button), `hermes skills list` enabled/disabled status (per-row toggle), Curator pin badge (driven from `hermes curator status`).
- **Two new gateway platforms:** Microsoft Teams (19th, plugin-shipped) + Tencent 元宝 / Yuanbao (18th, native). Surfaced in the Mac Platforms tab.
- **Cron upgrades:** per-job `--workdir` (project-aware cwd) and `--context-from` (chain outputs from another job). Editor sheet adds Project picker + dependency dropdown.
- **Settings deltas:** `prompt_caching.cache_ttl` (5m/1h picker), `redaction.enabled` toggle (off-by-default in v0.12 — toggle restores it), `agent.runtime_metadata_footer` toggle, Piper added to TTS provider list, `vercel` added to terminal backend list.
- **Bundled plugins:** Spotify, Google Meet, Langfuse observability, hermes-achievements (visible in Plugins tab).
- **`hermes memory` providers:** honcho, openviking, mem0, hindsight, holographic, retaindb, byterover. `Settings → Memory` per-provider "Setup…" button shells out to `hermes memory setup <provider>`.
- **Schema is unchanged from v0.11** — same state.db columns (`messages.reasoning_content`, `sessions.api_call_count` introduced in v0.11 remain). No migration needed.
**v2026.4.23 (v0.11.0)** added (historical context, still consumed by Scarf when running against a pre-v0.12 host):
- `/steer <prompt>` — non-interruptive mid-run guidance slash command. Surfaced in Scarf chat menus via `RichChatViewModel.nonInterruptiveCommands`; `ChatViewModel.sendViaACP` (Mac) and `ChatController.send` (iOS) skip the "Agent working…" status flip and show a transient toast instead. - `/steer <prompt>` — non-interruptive mid-run guidance slash command. Surfaced in Scarf chat menus via `RichChatViewModel.nonInterruptiveCommands`; `ChatViewModel.sendViaACP` (Mac) and `ChatController.send` (iOS) skip the "Agent working…" status flip and show a transient toast instead.
- New CLI subcommands: `hermes plugins` / `profile` / `webhook` / `insights` / `logs` / `memory reset` / `completion` / `dashboard`. Scarf v2.5 adopts **`hermes memory reset`** (toolbar button on MemoryView with destructive confirmation). The other CLIs are documented here for v2.6 — Scarf still reads `~/.hermes/plugins/`, `~/.hermes/profiles/` etc directly today; switching those paths to the canonical CLI is a forward-compatible change to make when bandwidth permits. - New CLI subcommands: `hermes plugins` / `profile` / `webhook` / `insights` / `logs` / `memory reset` / `completion` / `dashboard`. Scarf v2.5 adopts **`hermes memory reset`** (toolbar button on MemoryView with destructive confirmation). The other CLIs are documented here for v2.6 — Scarf still reads `~/.hermes/plugins/`, `~/.hermes/profiles/` etc directly today; switching those paths to the canonical CLI is a forward-compatible change to make when bandwidth permits.
@@ -75,6 +75,12 @@ public struct HermesPathSet: Sendable, Hashable {
public nonisolated var errorsLog: String { home + "/logs/errors.log" } public nonisolated var errorsLog: String { home + "/logs/errors.log" }
public nonisolated var agentLog: String { home + "/logs/agent.log" } public nonisolated var agentLog: String { home + "/logs/agent.log" }
public nonisolated var gatewayLog: String { home + "/logs/gateway.log" } public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
/// Curator run report, JSON (v0.12+). Written by `hermes curator` on
/// each cycle; consumed by `CuratorViewModel` for structured stats.
public nonisolated var curatorReportJSON: String { home + "/logs/curator/run.json" }
/// Curator human-readable run report (v0.12+). Renders as the
/// "Last run" text in CuratorView.
public nonisolated var curatorReportMD: String { home + "/logs/curator/REPORT.md" }
public nonisolated var scarfDir: String { home + "/scarf" } public nonisolated var scarfDir: String { home + "/scarf" }
public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" } public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
@@ -0,0 +1,314 @@
import Foundation
import Observation
#if canImport(os)
import os
#endif
/// What this Hermes installation can do, derived from `hermes --version`.
///
/// Scarf tracks Hermes feature releases by date-version + semver. v0.12 added
/// a dozen surfaces (Curator, Kanban, multimodal ACP, ...) and removed a few
/// (`flush_memories` aux task). UI that branches on these surfaces calls
/// the boolean accessors here so older Hermes installs degrade silently
/// instead of throwing on an unknown CLI subcommand.
///
/// Pure value type no side effects. The async detection lives in
/// `HermesCapabilitiesStore`.
public struct HermesCapabilities: Sendable, Equatable {
/// Raw version line as printed by `hermes --version`. Preserved verbatim
/// so diagnostics views can show the exact string Scarf saw.
public let versionLine: String
/// Parsed `0.X.Y`. `nil` when the output didn't match the expected format
/// (e.g. Hermes returned an error, or a future format change).
public let semver: SemVer?
/// Parsed `YYYY.M.D` from the parenthesized date suffix. `nil` when
/// absent older Hermes builds didn't always emit it.
public let dateVersion: DateVersion?
public init(versionLine: String, semver: SemVer?, dateVersion: DateVersion?) {
self.versionLine = versionLine
self.semver = semver
self.dateVersion = dateVersion
}
/// Sentinel for "not yet detected" / "detection failed". All capability
/// flags resolve to `false` so unguarded UI stays hidden until the real
/// version lands.
public static let empty = HermesCapabilities(
versionLine: "",
semver: nil,
dateVersion: nil
)
public var detected: Bool { semver != nil }
// MARK: - Capability flags
//
// Add a new flag here when Scarf gains UI that conditionally branches on
// a Hermes capability. Keep the comparison conservative: `>= 0.12.0`
// covers users still on the 0.12 line who haven't upgraded to 0.13 yet.
/// `hermes curator` autonomous skill maintenance (v0.12+).
public var hasCurator: Bool { atLeastSemver(0, 12, 0) }
/// `hermes fallback` provider management (v0.12+).
public var hasFallbackCommand: Bool { atLeastSemver(0, 12, 0) }
/// `hermes kanban` task board CLI (v0.12+).
public var hasKanban: Bool { atLeastSemver(0, 12, 0) }
/// `hermes -z <prompt>` non-interactive one-shot mode (v0.12+).
public var hasOneShot: Bool { atLeastSemver(0, 12, 0) }
/// `hermes skills install <https-url>` direct-URL install (v0.12+).
public var hasSkillURLInstall: Bool { atLeastSemver(0, 12, 0) }
/// ACP `session/prompt` accepts image content blocks (v0.12+).
public var hasACPImagePrompts: Bool { atLeastSemver(0, 12, 0) }
/// `hermes update --check` preflight (v0.12+).
public var hasUpdateCheck: Bool { atLeastSemver(0, 12, 0) }
/// Pluggable TTS providers including native Piper (v0.12+).
public var hasPiperTTS: Bool { atLeastSemver(0, 12, 0) }
/// `terminal.backend = vercel` Vercel Sandbox option (v0.12+).
public var hasVercelTerminal: Bool { atLeastSemver(0, 12, 0) }
/// `auxiliary.flush_memories` config row was removed in v0.12.
/// Inverse semantics `true` means the row should still be shown.
public var hasFlushMemoriesAux: Bool {
guard let s = semver else { return false } // unknown hide
return s < SemVer(major: 0, minor: 12, patch: 0) // pre-v0.12 only
}
/// `auxiliary.curator` aux task is configurable (v0.12+).
public var hasCuratorAux: Bool { atLeastSemver(0, 12, 0) }
/// Microsoft Teams (19th platform) and Yuanbao (18th) added in v0.12.
public var hasTeamsPlatform: Bool { atLeastSemver(0, 12, 0) }
public var hasYuanbaoPlatform: Bool { atLeastSemver(0, 12, 0) }
/// Cron jobs accept `--workdir` and `--context-from` flags (v0.12+).
public var hasCronWorkdir: Bool { atLeastSemver(0, 12, 0) }
/// `prompt_caching.cache_ttl` config knob (v0.12+).
public var hasPromptCacheTTL: Bool { atLeastSemver(0, 12, 0) }
/// `redaction.enabled` is now off by default in v0.12 Scarf surfaces
/// the toggle so users can flip it back on.
public var hasRedactionToggle: Bool { atLeastSemver(0, 12, 0) }
private func atLeastSemver(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
guard let s = semver else { return false }
return s >= SemVer(major: major, minor: minor, patch: patch)
}
public struct SemVer: Sendable, Equatable, Comparable, CustomStringConvertible {
public let major: Int
public let minor: Int
public let patch: Int
public init(major: Int, minor: Int, patch: Int) {
self.major = major
self.minor = minor
self.patch = patch
}
public var description: String { "\(major).\(minor).\(patch)" }
public static func < (a: SemVer, b: SemVer) -> Bool {
if a.major != b.major { return a.major < b.major }
if a.minor != b.minor { return a.minor < b.minor }
return a.patch < b.patch
}
}
public struct DateVersion: Sendable, Equatable, Comparable, CustomStringConvertible {
public let year: Int
public let month: Int
public let day: Int
public init(year: Int, month: Int, day: Int) {
self.year = year
self.month = month
self.day = day
}
public var description: String { "\(year).\(month).\(day)" }
public static func < (a: DateVersion, b: DateVersion) -> Bool {
if a.year != b.year { return a.year < b.year }
if a.month != b.month { return a.month < b.month }
return a.day < b.day
}
}
/// Parse a `Hermes Agent v0.12.0 (2026.4.30)` line out of `hermes --version`
/// output. Tolerates leading/trailing whitespace, extra header lines
/// (e.g. `Project:`, `Python:`), and the absence of the parenthesized
/// date suffix.
///
/// Returns `.empty` when no recognizable version line is present so
/// callers don't have to special-case nil.
public static func parse(_ output: String) -> HermesCapabilities {
for raw in output.components(separatedBy: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
guard line.contains("Hermes Agent v") else { continue }
return parseLine(line)
}
return .empty
}
/// `Hermes Agent v0.12.0 (2026.4.30)` semver + date. Returns `.empty`
/// when the line doesn't match. Public for unit tests; production callers
/// should use `parse(_:)`.
public static func parseLine(_ line: String) -> HermesCapabilities {
// Locate the "v" right after "Hermes Agent ". Don't anchor at line
// start older builds prefix with ANSI color codes Scarf would
// need to strip.
guard let vRange = line.range(of: "Hermes Agent v") else { return .empty }
let tail = String(line[vRange.upperBound...])
// Read digits separated by dots until we hit non-version content.
// First three components are semver. A trailing `(Y.M.D)` is the
// date version.
let semverEnd = tail.firstIndex(where: { c in
!(c.isNumber || c == ".")
}) ?? tail.endIndex
let semverStr = String(tail[..<semverEnd])
let semverParts = semverStr.split(separator: ".").compactMap { Int($0) }
guard semverParts.count >= 3 else { return .empty }
let semver = SemVer(
major: semverParts[0],
minor: semverParts[1],
patch: semverParts[2]
)
// Optional date suffix.
var dateVersion: DateVersion?
if let openParen = tail.firstIndex(of: "("),
let closeParen = tail.firstIndex(of: ")"),
openParen < closeParen {
let dateStr = tail[tail.index(after: openParen)..<closeParen]
let dateParts = dateStr.split(separator: ".").compactMap { Int($0) }
if dateParts.count == 3 {
dateVersion = DateVersion(
year: dateParts[0],
month: dateParts[1],
day: dateParts[2]
)
}
}
return HermesCapabilities(
versionLine: line,
semver: semver,
dateVersion: dateVersion
)
}
}
/// Per-server capability cache. One per `ContextBoundRoot` (Mac) / iOS scene
/// root, injected via `.environment(_:)`. Refreshes once on init; callers
/// invoke `refresh()` after a Hermes update or when the server changes.
///
/// Not thread-safe across instances each server gets its own store, and
/// the underlying `runHermesCLI` call is detached so we never block
/// MainActor.
@Observable
@MainActor
public final class HermesCapabilitiesStore {
#if canImport(os)
private let logger = Logger(subsystem: "com.scarf", category: "HermesCapabilities")
#endif
public private(set) var capabilities: HermesCapabilities = .empty
public private(set) var isLoading = true
public let context: ServerContext
private var refreshTask: Task<Void, Never>?
public init(context: ServerContext) {
self.context = context
// Kick off a one-shot detection. Subsequent refreshes are explicit.
// Task captures `[weak self]`, so if the store is freed before
// detection completes the closure simply no-ops.
refreshTask = Task { [weak self] in
await self?.refresh()
}
}
public func refresh() async {
isLoading = true
let context = self.context
let parsed = await Task.detached(priority: .utility) { () -> HermesCapabilities in
return Self.detectSync(context: context)
}.value
self.capabilities = parsed
self.isLoading = false
#if canImport(os)
if parsed.detected {
logger.info("Hermes \(parsed.versionLine, privacy: .public) detected on \(self.context.displayName, privacy: .public)")
} else {
logger.warning("Hermes version not detected on \(self.context.displayName, privacy: .public)")
}
#endif
}
/// Synchronous detection helper. Lives here (not on `HermesCapabilities`)
/// because `ServerContext.makeTransport()` is a side-effecting call that
/// pulls in the platform-appropriate transport (LocalTransport on Mac,
/// CitadelServerTransport on iOS). The pure parser remains side-effect-free.
nonisolated private static func detectSync(context: ServerContext) -> HermesCapabilities {
let transport = context.makeTransport()
let executable = context.paths.hermesBinary
do {
let result = try transport.runProcess(
executable: executable,
args: ["--version"],
stdin: nil,
timeout: 10
)
// `hermes --version` writes to stdout but Scarf's transport
// helpers occasionally split error output across stderr fold
// both so the parser sees whichever stream the line lands on.
let combined = result.stdoutString + result.stderrString
guard result.exitCode == 0 else { return .empty }
return HermesCapabilities.parse(combined)
} catch {
return .empty
}
}
}
// MARK: - SwiftUI environment wiring
#if canImport(SwiftUI)
import SwiftUI
private struct HermesCapabilitiesStoreKey: EnvironmentKey {
static let defaultValue: HermesCapabilitiesStore? = nil
}
extension EnvironmentValues {
/// The active server's capability store. `nil` outside the per-server
/// `ContextBoundRoot`. Callers should treat `nil` and `.empty` capabilities
/// the same defensive code for harness scenarios (Previews, smoke tests).
public var hermesCapabilities: HermesCapabilitiesStore? {
get { self[HermesCapabilitiesStoreKey.self] }
set { self[HermesCapabilitiesStoreKey.self] = newValue }
}
}
extension View {
/// Inject a `HermesCapabilitiesStore` into the environment. Mirrors the
/// usual `.environment(_:)` shape but routes through the typed key
/// above so callers don't need to import the key.
public func hermesCapabilities(_ store: HermesCapabilitiesStore) -> some View {
environment(\.hermesCapabilities, store)
}
}
#endif
@@ -496,7 +496,15 @@ public final class RemoteBackupService: @unchecked Sendable {
/// macOS ships `zip` at this fixed path so we don't need a PATH /// macOS ships `zip` at this fixed path so we don't need a PATH
/// search. `-r` recurse, `-q` quiet, `-X` strip extended attrs /// search. `-r` recurse, `-q` quiet, `-X` strip extended attrs
/// for reproducibility. /// for reproducibility.
///
/// Mac-only: iOS doesn't ship `/usr/bin/zip` and Foundation's `Process`
/// is unavailable in the iOS SDK. The whole backup flow is a Mac-side
/// operation; the iOS stub throws so any accidental call surfaces a
/// clear message instead of an opaque link error.
private static func zipDirectory(workDir: URL, into archive: URL) throws { private static func zipDirectory(workDir: URL, into archive: URL) throws {
#if os(iOS)
throw BackupError.zipFailed("Backup zip is not supported on iOS — run the backup from the Mac app.")
#else
let proc = Process() let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/zip") proc.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
proc.currentDirectoryURL = workDir proc.currentDirectoryURL = workDir
@@ -515,6 +523,7 @@ public final class RemoteBackupService: @unchecked Sendable {
.flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? "" .flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? ""
throw BackupError.zipFailed("zip exited \(proc.terminationStatus): \(tail)") throw BackupError.zipFailed("zip exited \(proc.terminationStatus): \(tail)")
} }
#endif
} }
} }
@@ -436,7 +436,14 @@ public final class RemoteRestoreService: @unchecked Sendable {
// MARK: - Helpers // MARK: - Helpers
/// Mac-only: iOS doesn't ship `/usr/bin/unzip` and Foundation's
/// `Process` is unavailable in the iOS SDK. Restore is initiated from
/// the Mac app; the iOS stub throws so any accidental call surfaces a
/// clear message instead of a link-time failure.
private static func unzipArchive(at archive: URL, into dest: URL) throws { private static func unzipArchive(at archive: URL, into dest: URL) throws {
#if os(iOS)
throw RestoreError.archiveUnreadable("Restore unzip is not supported on iOS — run the restore from the Mac app.")
#else
let proc = Process() let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") proc.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
proc.arguments = ["-q", archive.path, "-d", dest.path] proc.arguments = ["-q", archive.path, "-d", dest.path]
@@ -454,6 +461,7 @@ public final class RemoteRestoreService: @unchecked Sendable {
.flatMap { $0.flatMap { String(data: $0, encoding: .utf8) } } ?? "" .flatMap { $0.flatMap { String(data: $0, encoding: .utf8) } } ?? ""
throw RestoreError.archiveUnreadable("unzip exited \(proc.terminationStatus): \(tail)") throw RestoreError.archiveUnreadable("unzip exited \(proc.terminationStatus): \(tail)")
} }
#endif
} }
/// Hash a local file in 1 MB chunks. We avoid loading the whole /// Hash a local file in 1 MB chunks. We avoid loading the whole
@@ -0,0 +1,136 @@
import Testing
import Foundation
@testable import ScarfCore
/// Pure parser tests for `HermesCapabilities`. The detection store
/// (`HermesCapabilitiesStore`) is exercised separately under integration
/// tests since it spawns `hermes --version`.
@Suite struct HermesCapabilitiesTests {
// MARK: - Version line parsing
@Test func parseV012ReleaseLine() {
let caps = HermesCapabilities.parseLine("Hermes Agent v0.12.0 (2026.4.30)")
#expect(caps.semver == HermesCapabilities.SemVer(major: 0, minor: 12, patch: 0))
#expect(caps.dateVersion == HermesCapabilities.DateVersion(year: 2026, month: 4, day: 30))
#expect(caps.detected)
}
@Test func parseV011ReleaseLine() {
let caps = HermesCapabilities.parseLine("Hermes Agent v0.11.0 (2026.4.23)")
#expect(caps.semver == HermesCapabilities.SemVer(major: 0, minor: 11, patch: 0))
#expect(caps.dateVersion == HermesCapabilities.DateVersion(year: 2026, month: 4, day: 23))
}
@Test func parseSemverWithoutDate() {
// Some older Hermes builds emit only the semver suffix.
let caps = HermesCapabilities.parseLine("Hermes Agent v0.10.5")
#expect(caps.semver == HermesCapabilities.SemVer(major: 0, minor: 10, patch: 5))
#expect(caps.dateVersion == nil)
}
@Test func parseFullStdoutBlock() {
// Real `hermes --version` output is multi-line; the version sits on
// the first line and the rest is metadata.
let stdout = """
Hermes Agent v0.12.0 (2026.4.30)
Project: /Users/alan/.hermes/hermes-agent
Python: 3.11.15
OpenAI SDK: 2.31.0
Up to date
"""
let caps = HermesCapabilities.parse(stdout)
#expect(caps.semver?.minor == 12)
#expect(caps.dateVersion?.year == 2026)
}
@Test func parseRejectsUnrelatedOutput() {
let caps = HermesCapabilities.parse("hermes: command not found")
#expect(caps.semver == nil)
#expect(!caps.detected)
}
@Test func parseHandlesEmptyString() {
let caps = HermesCapabilities.parse("")
#expect(caps == .empty)
}
@Test func parseHandlesPartialSemver() {
// "v0.11" without the patch component shouldn't accidentally match.
let caps = HermesCapabilities.parseLine("Hermes Agent v0.11")
#expect(caps.semver == nil)
}
// MARK: - SemVer ordering
@Test func semverOrdering() {
let v0_11_0 = HermesCapabilities.SemVer(major: 0, minor: 11, patch: 0)
let v0_12_0 = HermesCapabilities.SemVer(major: 0, minor: 12, patch: 0)
let v0_12_5 = HermesCapabilities.SemVer(major: 0, minor: 12, patch: 5)
let v1_0_0 = HermesCapabilities.SemVer(major: 1, minor: 0, patch: 0)
#expect(v0_11_0 < v0_12_0)
#expect(v0_12_0 < v0_12_5)
#expect(v0_12_5 < v1_0_0)
}
// MARK: - Capability flags
@Test func v012FlagsAllOn() {
let caps = HermesCapabilities.parseLine("Hermes Agent v0.12.0 (2026.4.30)")
#expect(caps.hasCurator)
#expect(caps.hasFallbackCommand)
#expect(caps.hasKanban)
#expect(caps.hasOneShot)
#expect(caps.hasSkillURLInstall)
#expect(caps.hasACPImagePrompts)
#expect(caps.hasUpdateCheck)
#expect(caps.hasPiperTTS)
#expect(caps.hasVercelTerminal)
#expect(caps.hasCuratorAux)
#expect(caps.hasTeamsPlatform)
#expect(caps.hasYuanbaoPlatform)
#expect(caps.hasCronWorkdir)
#expect(caps.hasPromptCacheTTL)
#expect(caps.hasRedactionToggle)
// flush_memories was REMOVED in v0.12 flag inverts.
#expect(!caps.hasFlushMemoriesAux)
}
@Test func v011FlagsAllOff() {
let caps = HermesCapabilities.parseLine("Hermes Agent v0.11.0 (2026.4.23)")
#expect(!caps.hasCurator)
#expect(!caps.hasFallbackCommand)
#expect(!caps.hasKanban)
#expect(!caps.hasOneShot)
#expect(!caps.hasSkillURLInstall)
#expect(!caps.hasACPImagePrompts)
#expect(!caps.hasUpdateCheck)
#expect(!caps.hasPiperTTS)
#expect(!caps.hasVercelTerminal)
#expect(!caps.hasCuratorAux)
#expect(!caps.hasTeamsPlatform)
#expect(!caps.hasYuanbaoPlatform)
#expect(!caps.hasCronWorkdir)
#expect(!caps.hasPromptCacheTTL)
#expect(!caps.hasRedactionToggle)
// flush_memories aux row was still alive on v0.11.
#expect(caps.hasFlushMemoriesAux)
}
@Test func emptyCapabilitiesAllOff() {
// Undetected installs should hide every gated UI surface.
let caps = HermesCapabilities.empty
#expect(!caps.hasCurator)
#expect(!caps.hasFlushMemoriesAux) // unknown hide either way
#expect(!caps.detected)
}
@Test func futureVersionRetainsCapabilities() {
// A v0.13 (hypothetical) should still see all v0.12 capabilities on.
let caps = HermesCapabilities.parseLine("Hermes Agent v0.13.0 (2026.6.1)")
#expect(caps.hasCurator)
#expect(caps.hasACPImagePrompts)
// And flush_memories stays gone.
#expect(!caps.hasFlushMemoriesAux)
}
}
@@ -37,8 +37,8 @@ import Foundation
let b: ConnectionStatusViewModel.Status = .connected let b: ConnectionStatusViewModel.Status = .connected
#expect(a == b) #expect(a == b)
let c: ConnectionStatusViewModel.Status = .degraded(reason: "x") let c: ConnectionStatusViewModel.Status = .degraded(reason: "x", hint: "y", cause: .unknown)
let d: ConnectionStatusViewModel.Status = .degraded(reason: "x") let d: ConnectionStatusViewModel.Status = .degraded(reason: "x", hint: "y", cause: .unknown)
#expect(c == d) #expect(c == d)
let e: ConnectionStatusViewModel.Status = .idle let e: ConnectionStatusViewModel.Status = .idle
@@ -456,6 +456,7 @@ import Foundation
} }
} }
func snapshotSQLite(remotePath: String) throws -> URL { URL(fileURLWithPath: remotePath) } func snapshotSQLite(remotePath: String) throws -> URL { URL(fileURLWithPath: remotePath) }
var cachedSnapshotPath: URL? { nil }
func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> { func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
AsyncStream { $0.finish() } AsyncStream { $0.finish() }
} }
+25
View File
@@ -36,6 +36,29 @@ struct ScarfGoTabRoot: View {
/// through here. /// through here.
@State private var coordinator = ScarfGoCoordinator() @State private var coordinator = ScarfGoCoordinator()
/// Hermes version + capability flags for this remote. Drives the
/// iOS version banner (v0.11 hosts get a yellow "update for new
/// features" banner) and capability-gated affordances like ACP
/// image attachments. Constructed once per server connection so
/// the detection runs over the active SSH transport.
@State private var capabilities: HermesCapabilitiesStore
init(
serverID: ServerID,
config: IOSServerConfig,
key: SSHKeyBundle,
onSoftDisconnect: @escaping @MainActor () async -> Void,
onForget: @escaping @MainActor () async -> Void
) {
self.serverID = serverID
self.config = config
self.key = key
self.onSoftDisconnect = onSoftDisconnect
self.onForget = onForget
let ctx = config.toServerContext(id: serverID)
_capabilities = State(initialValue: HermesCapabilitiesStore(context: ctx))
}
/// SwiftUI's `.onChange(of: ScenePhase)` modifier on a non-active /// SwiftUI's `.onChange(of: ScenePhase)` modifier on a non-active
/// tab doesn't fire while the tab is unmounted the coordinator /// tab doesn't fire while the tab is unmounted the coordinator
/// is the single source of truth for scene-phase transitions /// is the single source of truth for scene-phase transitions
@@ -118,6 +141,8 @@ struct ScarfGoTabRoot: View {
.tabViewStyle(.sidebarAdaptable) .tabViewStyle(.sidebarAdaptable)
.environment(\.serverContext, ctx) .environment(\.serverContext, ctx)
.environment(\.scarfGoCoordinator, coordinator) .environment(\.scarfGoCoordinator, coordinator)
.environment(capabilities)
.hermesCapabilities(capabilities)
.onAppear { .onAppear {
// Give the notification router a handle to this session's // Give the notification router a handle to this session's
// coordinator so notification-taps can route across tabs. // coordinator so notification-taps can route across tabs.
+9
View File
@@ -196,12 +196,19 @@ private struct ContextBoundRoot: View {
@State private var coordinator: AppCoordinator @State private var coordinator: AppCoordinator
@State private var fileWatcher: HermesFileWatcher @State private var fileWatcher: HermesFileWatcher
@State private var chatViewModel: ChatViewModel @State private var chatViewModel: ChatViewModel
/// Per-window snapshot of the target Hermes installation's capability
/// flags. Drives sidebar visibility (Curator, Kanban only on v0.12+),
/// settings rows (flush_memories aux dropped on v0.12), and version
/// banners. Refreshes once on init; explicit `refresh()` call rerun
/// after a `hermes update`.
@State private var capabilities: HermesCapabilitiesStore
init(context: ServerContext) { init(context: ServerContext) {
self.context = context self.context = context
_coordinator = State(initialValue: AppCoordinator()) _coordinator = State(initialValue: AppCoordinator())
_fileWatcher = State(initialValue: HermesFileWatcher(context: context)) _fileWatcher = State(initialValue: HermesFileWatcher(context: context))
_chatViewModel = State(initialValue: ChatViewModel(context: context)) _chatViewModel = State(initialValue: ChatViewModel(context: context))
_capabilities = State(initialValue: HermesCapabilitiesStore(context: context))
} }
var body: some View { var body: some View {
@@ -209,6 +216,8 @@ private struct ContextBoundRoot: View {
.environment(coordinator) .environment(coordinator)
.environment(fileWatcher) .environment(fileWatcher)
.environment(chatViewModel) .environment(chatViewModel)
.environment(capabilities)
.hermesCapabilities(capabilities)
// Per-window title shows which server this window is bound to. // Per-window title shows which server this window is bound to.
// Local: "Scarf Local". Remote: "Scarf Mardon Mac Mini". // Local: "Scarf Local". Remote: "Scarf Mardon Mac Mini".
// The colored dot lives inside the toolbar switcher; the window // The colored dot lives inside the toolbar switcher; the window