From a90a29add8f09c99a0d089789eb3d50eb2151ab6 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 1 May 2026 12:10:06 +0200 Subject: [PATCH] feat(hermes-v12): version-aware capability detection (Phase A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 22 +- .../ScarfCore/Models/HermesPathSet.swift | 6 + .../Services/HermesCapabilities.swift | 314 ++++++++++++++++++ .../Services/RemoteBackupService.swift | 9 + .../Services/RemoteRestoreService.swift | 8 + .../HermesCapabilitiesTests.swift | 136 ++++++++ .../ScarfCoreTests/M0dViewModelsTests.swift | 4 +- .../ScarfCoreTests/M5FeatureVMTests.swift | 1 + scarf/Scarf iOS/App/ScarfGoTabRoot.swift | 25 ++ scarf/scarf/scarfApp.swift | 9 + 10 files changed, 530 insertions(+), 4 deletions(-) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesCapabilities.swift create mode 100644 scarf/Packages/ScarfCore/Tests/ScarfCoreTests/HermesCapabilitiesTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 3288cc8..e422cc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,9 +113,27 @@ Public documentation lives in the GitHub wiki at https://github.com/awizemann/sc ## 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 ` (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 ` 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 `. +- **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 ` — 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. diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift index 004dd8a..b0fd2de 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesPathSet.swift @@ -75,6 +75,12 @@ public struct HermesPathSet: Sendable, Hashable { public nonisolated var errorsLog: String { home + "/logs/errors.log" } public nonisolated var agentLog: String { home + "/logs/agent.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 projectsRegistry: String { scarfDir + "/projects.json" } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesCapabilities.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesCapabilities.swift new file mode 100644 index 0000000..303acb5 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesCapabilities.swift @@ -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 ` non-interactive one-shot mode (v0.12+). + public var hasOneShot: Bool { atLeastSemver(0, 12, 0) } + + /// `hermes skills install ` 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[..= 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)..? + + 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 diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteBackupService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteBackupService.swift index 2d1836f..5a98e95 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteBackupService.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteBackupService.swift @@ -496,7 +496,15 @@ public final class RemoteBackupService: @unchecked Sendable { /// macOS ships `zip` at this fixed path so we don't need a PATH /// search. `-r` recurse, `-q` quiet, `-X` strip extended attrs /// 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 { + #if os(iOS) + throw BackupError.zipFailed("Backup zip is not supported on iOS — run the backup from the Mac app.") + #else let proc = Process() proc.executableURL = URL(fileURLWithPath: "/usr/bin/zip") proc.currentDirectoryURL = workDir @@ -515,6 +523,7 @@ public final class RemoteBackupService: @unchecked Sendable { .flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? "" throw BackupError.zipFailed("zip exited \(proc.terminationStatus): \(tail)") } + #endif } } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteRestoreService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteRestoreService.swift index 868b730..a6b9fc9 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteRestoreService.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteRestoreService.swift @@ -436,7 +436,14 @@ public final class RemoteRestoreService: @unchecked Sendable { // 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 { + #if os(iOS) + throw RestoreError.archiveUnreadable("Restore unzip is not supported on iOS — run the restore from the Mac app.") + #else let proc = Process() proc.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") 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) } } ?? "" throw RestoreError.archiveUnreadable("unzip exited \(proc.terminationStatus): \(tail)") } + #endif } /// Hash a local file in 1 MB chunks. We avoid loading the whole diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/HermesCapabilitiesTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/HermesCapabilitiesTests.swift new file mode 100644 index 0000000..2dda691 --- /dev/null +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/HermesCapabilitiesTests.swift @@ -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) + } +} diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift index d6ec9ee..3648370 100644 --- a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift @@ -37,8 +37,8 @@ import Foundation let b: ConnectionStatusViewModel.Status = .connected #expect(a == b) - let c: ConnectionStatusViewModel.Status = .degraded(reason: "x") - let d: ConnectionStatusViewModel.Status = .degraded(reason: "x") + let c: ConnectionStatusViewModel.Status = .degraded(reason: "x", hint: "y", cause: .unknown) + let d: ConnectionStatusViewModel.Status = .degraded(reason: "x", hint: "y", cause: .unknown) #expect(c == d) let e: ConnectionStatusViewModel.Status = .idle diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift index 5c84cdb..dbe3330 100644 --- a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift @@ -456,6 +456,7 @@ import Foundation } } func snapshotSQLite(remotePath: String) throws -> URL { URL(fileURLWithPath: remotePath) } + var cachedSnapshotPath: URL? { nil } func watchPaths(_ paths: [String]) -> AsyncStream { AsyncStream { $0.finish() } } diff --git a/scarf/Scarf iOS/App/ScarfGoTabRoot.swift b/scarf/Scarf iOS/App/ScarfGoTabRoot.swift index 0fe2512..939aa21 100644 --- a/scarf/Scarf iOS/App/ScarfGoTabRoot.swift +++ b/scarf/Scarf iOS/App/ScarfGoTabRoot.swift @@ -36,6 +36,29 @@ struct ScarfGoTabRoot: View { /// through here. @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 /// tab doesn't fire while the tab is unmounted — the coordinator /// is the single source of truth for scene-phase transitions @@ -118,6 +141,8 @@ struct ScarfGoTabRoot: View { .tabViewStyle(.sidebarAdaptable) .environment(\.serverContext, ctx) .environment(\.scarfGoCoordinator, coordinator) + .environment(capabilities) + .hermesCapabilities(capabilities) .onAppear { // Give the notification router a handle to this session's // coordinator so notification-taps can route across tabs. diff --git a/scarf/scarf/scarfApp.swift b/scarf/scarf/scarfApp.swift index 5dcbcc2..9379ad0 100644 --- a/scarf/scarf/scarfApp.swift +++ b/scarf/scarf/scarfApp.swift @@ -196,12 +196,19 @@ private struct ContextBoundRoot: View { @State private var coordinator: AppCoordinator @State private var fileWatcher: HermesFileWatcher @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) { self.context = context _coordinator = State(initialValue: AppCoordinator()) _fileWatcher = State(initialValue: HermesFileWatcher(context: context)) _chatViewModel = State(initialValue: ChatViewModel(context: context)) + _capabilities = State(initialValue: HermesCapabilitiesStore(context: context)) } var body: some View { @@ -209,6 +216,8 @@ private struct ContextBoundRoot: View { .environment(coordinator) .environment(fileWatcher) .environment(chatViewModel) + .environment(capabilities) + .hermesCapabilities(capabilities) // Per-window title shows which server this window is bound to. // Local: "Scarf — Local". Remote: "Scarf — Mardon Mac Mini". // The colored dot lives inside the toolbar switcher; the window