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