feat: chat-start preflight, Nous catalog, remote-aware admin sheets

Three feature batches that were in progress on chat-resilience —
all aligned with v2.5.2's remote-context theme.

## Chat-start model preflight

When a chat-start hits a server whose config.yaml has no
model.default / model.provider, the upstream provider returns an
opaque "Model parameter is required" 400 only AFTER the user types
a prompt and hits send. New ModelPreflight in ScarfCore catches the
missing keys before any ACP work; ChatView presents the existing
ModelPickerSheet via a thin ChatModelPreflightSheet wrapper so the
picker / validation / Nous-catalog branch stay single-sourced.
ChatViewModel persists the selection via `hermes config set` and
replays the original startACPSession arguments — the chat the user
originally opened lands without re-clicking the project row.

## Nous Portal live catalog

NousModelCatalogService fetches `GET /v1/models` from
inference-api.nousresearch.com using the bearer token in
`auth.json`, caches to `~/.hermes/scarf/nous_models_cache.json`
(new path on HermesPathSet) with a 24h TTL. Picker's nous-overlay
detail switches from a free-form TextField to a real model list,
with a "Custom…" escape hatch (nousManualEntry) for IDs not yet in
the API response.

## Remote-aware admin sheets (mirror of #54's pattern)

The Add Project sheet got context-aware Verify in v2.5.1 (#54);
this batch extends the same shape to three more sheets:

- Profiles: remote import/export. ProfilesView gains
  showRemoteImportSheet + pendingRemoteExport state; reuses the
  same path-input + verify + run-via-hermes pattern from
  AddProjectSheet. Drives `hermes profile import <zip>` /
  `hermes profile export <name> <zip>` over SSH.
- Backup restore (Settings → Advanced): pickLocalBackupZip + new
  RemoteBackupPathSheet so the Restore action picks a local zip
  on local contexts and verifies a remote path on remote contexts.
- Template install destination: TemplateInstallSheet's parent-
  directory picker now branches on context. ParentDirectoryStep
  with browseLocalDirectory + verifyRemotePath + RemoteVerification
  — same UX vocabulary as AddProjectSheet, applied to where the
  template gets installed.

Plus a `runHermesWithStdin` helper on HermesFileService for the
profile import flow (passing zip bytes through stdin rather than
landing them on the remote disk first), and ProjectTemplateInstaller
gains a remote-path-aware code path for the install destination.

## Localizations

Localizable.xcstrings adds strings for all the new copy across
seven supported locales (en, zh-Hans, de, fr, es, ja, pt-BR).
This commit is contained in:
Alan Wizemann
2026-04-29 13:27:25 +02:00
parent c31dfccb9b
commit 2aab9dac07
17 changed files with 1672 additions and 67 deletions
@@ -81,6 +81,12 @@ public struct HermesPathSet: Sendable, Hashable {
/// Maps Hermes session IDs to the Scarf project path a chat was
/// started for. Scarf-owned; Hermes never touches this file.
public nonisolated var sessionProjectMap: String { scarfDir + "/session_project_map.json" }
/// Cached list of available Nous Portal models. Populated by
/// `NousModelCatalogService` from `GET https://inference-api.nousresearch.com/v1/models`
/// using the bearer token in `auth.json`. Refreshed on a 24h TTL or
/// on user request from the model picker. Survives offline runs so
/// the picker still has something to render.
public nonisolated var nousModelsCache: String { scarfDir + "/nous_models_cache.json" }
public nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
// MARK: - Binary resolution
@@ -25,6 +25,10 @@ public struct SSHConfig: Sendable, Hashable, Codable {
/// `HermesPathSet.defaultRemoteHome` (`~/.hermes`, shell-expanded on the
/// remote side).
public var remoteHome: String?
/// Override for where Scarf installs new project templates on this host.
/// `nil` uses `~/projects` (unexpanded remote shell resolves it).
/// Created on first install if missing.
public var projectsRoot: String?
/// Resolved remote path to the `hermes` binary. Populated by
/// `SSHTransport` after the first `command -v hermes` probe; cached here
/// so subsequent calls skip the round trip.
@@ -36,6 +40,7 @@ public struct SSHConfig: Sendable, Hashable, Codable {
port: Int? = nil,
identityFile: String? = nil,
remoteHome: String? = nil,
projectsRoot: String? = nil,
hermesBinaryHint: String? = nil
) {
self.host = host
@@ -43,6 +48,7 @@ public struct SSHConfig: Sendable, Hashable, Codable {
self.port = port
self.identityFile = identityFile
self.remoteHome = remoteHome
self.projectsRoot = projectsRoot
self.hermesBinaryHint = hermesBinaryHint
}
}
@@ -106,6 +112,27 @@ public struct ServerContext: Sendable, Hashable, Identifiable {
return false
}
/// Default parent directory under which `ProjectTemplateInstaller` lays
/// out new projects. Per-host configurable on `.ssh` via
/// `SSHConfig.projectsRoot`; local always resolves to `~/Projects` on the
/// user's Mac. The remote default is left as an unexpanded `~/projects`
/// the remote shell resolves the tilde, same convention as
/// `HermesPathSet.defaultRemoteHome`. The installer calls
/// `transport.createDirectory(_:)` at install time so a missing dir on a
/// fresh host is bootstrapped on first use rather than treated as an error.
public nonisolated var defaultProjectsRoot: String {
switch kind {
case .local:
return NSHomeDirectory() + "/Projects"
case .ssh(let config):
if let configured = config.projectsRoot,
!configured.trimmingCharacters(in: .whitespaces).isEmpty {
return configured
}
return "~/projects"
}
}
/// Construct the `ServerTransport` for this context. Local contexts get
/// a `LocalTransport`; SSH contexts get an `SSHTransport` configured
/// from `SSHConfig` by default, OR whatever `sshTransportFactory`
@@ -0,0 +1,56 @@
import Foundation
/// Pre-flight check used before opening an ACP session. Hermes resolves the
/// model+provider from `config.yaml` at session boot; on a fresh install that
/// file is missing or has neither key set, and the chat fails with an opaque
/// "Model parameter is required" 400 from the upstream provider only after the
/// user has typed a prompt and hit send. Catching the missing config here lets
/// the UI surface a real "pick a model" sheet before any ACP work starts.
///
/// `HermesConfig.empty` (returned on read failure) and the YAML parser's
/// missing-key fallback both use the literal string `"unknown"`, so the check
/// has to treat `""` and `"unknown"` as equivalent. Anything else is
/// considered configured we don't try to validate the model against the
/// provider's catalog here; that happens later in `ModelPickerSheet`.
public enum ModelPreflight: Sendable {
public enum Result: Equatable, Sendable {
case configured
case missingModel
case missingProvider
case missingBoth
public var isConfigured: Bool {
self == .configured
}
/// Short user-facing reason. Long enough to be honest, short enough
/// for a sheet header full messaging belongs to the picker UI.
public var reason: String {
switch self {
case .configured: return ""
case .missingModel: return "No primary model is set in this server's config."
case .missingProvider:return "No primary provider is set in this server's config."
case .missingBoth: return "No model is configured on this server yet."
}
}
}
/// Treat `""` and the YAML parser's `"unknown"` fallback as missing.
/// Trim whitespace so a stray newline in a hand-edited config.yaml
/// doesn't read as "configured."
public static func check(_ config: HermesConfig) -> Result {
let modelMissing = isUnset(config.model)
let providerMissing = isUnset(config.provider)
switch (modelMissing, providerMissing) {
case (true, true): return .missingBoth
case (true, false): return .missingModel
case (false, true): return .missingProvider
case (false, false): return .configured
}
}
private static func isUnset(_ value: String) -> Bool {
let trimmed = value.trimmingCharacters(in: .whitespaces).lowercased()
return trimmed.isEmpty || trimmed == "unknown"
}
}
@@ -0,0 +1,247 @@
import Foundation
import os
/// One Nous Portal model as exposed by `GET /v1/models`. The shape
/// mirrors the OpenAI-compatible response schema Nous's inference
/// API uses the same envelope. Optional fields stay optional because
/// not every entry includes them; `id` is the only field we strictly
/// need (it's what Hermes passes through to the provider).
public struct NousModel: Codable, Equatable, Sendable, Identifiable {
public let id: String
public let owned_by: String?
public let created: Int?
/// Free-text description if the API ships one. Nous's current
/// catalog doesn't include this, but the field is here so future
/// shape changes don't drop user-visible context on the floor.
public let description: String?
public init(id: String, owned_by: String? = nil, created: Int? = nil, description: String? = nil) {
self.id = id
self.owned_by = owned_by
self.created = created
self.description = description
}
}
/// On-disk cache shape. Versioned so a future schema change can lift
/// stale caches gracefully bump `version` and the loader rejects
/// anything older without trying to migrate. Stored as JSON next to
/// the projects registry so a Hermes wipe takes it with the rest of
/// the Scarf-owned state.
public struct NousModelsCache: Codable, Sendable {
public static let currentVersion = 1
public let version: Int
public let fetchedAt: Date
public let models: [NousModel]
public init(version: Int = NousModelsCache.currentVersion, fetchedAt: Date, models: [NousModel]) {
self.version = version
self.fetchedAt = fetchedAt
self.models = models
}
}
/// Result of a `loadModels` call. Distinguishes "fetched fresh from
/// the API" from "cache served, network failed" so the picker UI can
/// surface a "could not refresh" hint without hiding the cached list.
public enum NousModelsLoadResult: Sendable {
case fresh(models: [NousModel], fetchedAt: Date)
case cache(models: [NousModel], fetchedAt: Date, refreshError: String?)
case fallback(models: [NousModel], reason: String)
}
/// Fetches + caches the list of available Nous Portal models. Runs in
/// the Scarf process (not on the remote), authenticated with the
/// bearer token from `~/.hermes/auth.json` on the active server
/// `NousSubscriptionService` reads that file via the active transport,
/// so a remote droplet's token comes back over SSH and the network
/// call to Nous still happens from the user's Mac. That's correct:
/// we want the model list visible whenever the user has subscription
/// credentials, regardless of where Hermes will eventually run the
/// chat from.
public struct NousModelCatalogService: Sendable {
public static let baseURL = URL(string: "https://inference-api.nousresearch.com/v1/models")!
public static let cacheTTL: TimeInterval = 24 * 60 * 60 // 24h
public static let requestTimeout: TimeInterval = 10 // seconds
/// Hard-coded fallback for offline-with-no-cache. Short on purpose
/// only the canonical Hermes models (the family the user is most
/// likely to want) plus a reminder that fresh data is one
/// successful refresh away. Update when Nous releases a new
/// flagship; deliberately not exhaustive the API is the source
/// of truth, this just keeps the picker non-empty.
public static let fallbackModels: [NousModel] = [
NousModel(id: "Hermes-3-Llama-3.1-405B"),
NousModel(id: "Hermes-3-Llama-3.1-70B"),
NousModel(id: "Hermes-3-Llama-3.1-8B"),
NousModel(id: "DeepHermes-3-Llama-3-8B-Preview")
]
private static let logger = Logger(subsystem: "com.scarf", category: "NousModelCatalogService")
public let context: ServerContext
private let session: URLSession
private let cachePath: String
public init(context: ServerContext, session: URLSession = .shared) {
self.context = context
self.session = session
self.cachePath = context.paths.nousModelsCache
}
// MARK: - Cache I/O
/// Read the cache via the active transport (so a remote droplet's
/// cache lands on the droplet, not the user's Mac). Missing or
/// malformed cache nil; the loader treats that as "no cache" and
/// kicks off a fresh fetch.
public func readCache() -> NousModelsCache? {
let transport = context.makeTransport()
guard transport.fileExists(cachePath) else { return nil }
do {
let data = try transport.readFile(cachePath)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let cache = try decoder.decode(NousModelsCache.self, from: data)
guard cache.version == NousModelsCache.currentVersion else {
Self.logger.info("nous models cache schema mismatch (got v\(cache.version), expected v\(NousModelsCache.currentVersion)); ignoring")
return nil
}
return cache
} catch {
Self.logger.warning("couldn't decode nous models cache: \(error.localizedDescription, privacy: .public)")
return nil
}
}
private func writeCache(_ cache: NousModelsCache) {
let transport = context.makeTransport()
do {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(cache)
// Make sure the parent dir exists fresh remote installs
// may not yet have `~/.hermes/scarf/`. mkdir -p is cheap
// and idempotent on both transports.
let parent = (cachePath as NSString).deletingLastPathComponent
if !parent.isEmpty {
try? transport.createDirectory(parent)
}
try transport.writeFile(cachePath, data: data)
} catch {
Self.logger.warning("couldn't write nous models cache: \(error.localizedDescription, privacy: .public)")
}
}
public func isCacheStale(_ cache: NousModelsCache) -> Bool {
Date().timeIntervalSince(cache.fetchedAt) > Self.cacheTTL
}
// MARK: - Network fetch
/// Read the bearer token from `auth.json` on the active server.
/// Returns nil when the user isn't signed in to Nous, in which
/// case `loadModels` skips the network call and falls through to
/// cache or fallback.
private func bearerToken() -> String? {
// The subscription service already checks for `present`; we
// re-read the raw token here because we need the actual string,
// not just a Bool. Mirrors the SubscriptionService parse path.
let transport = context.makeTransport()
guard transport.fileExists(context.paths.authJSON) else { return nil }
guard let data = try? transport.readFile(context.paths.authJSON) else { return nil }
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
let providers = root["providers"] as? [String: Any] ?? [:]
let nous = providers["nous"] as? [String: Any]
let token = nous?["access_token"] as? String
guard let token, !token.isEmpty else { return nil }
return token
}
/// Make the API call. Times out after `requestTimeout` so a hung
/// network doesn't block the picker indefinitely. Returns the raw
/// `[NousModel]` on success, throws on any HTTP / decode error so
/// the caller can log + fall back.
public func fetchModels() async throws -> [NousModel] {
guard let token = bearerToken() else {
throw NousModelCatalogError.notAuthenticated
}
var request = URLRequest(url: Self.baseURL)
request.httpMethod = "GET"
request.timeoutInterval = Self.requestTimeout
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw NousModelCatalogError.transport("non-HTTP response")
}
guard (200..<300).contains(http.statusCode) else {
throw NousModelCatalogError.http(status: http.statusCode)
}
struct Envelope: Decodable { let data: [NousModel] }
let envelope = try JSONDecoder().decode(Envelope.self, from: data)
return envelope.data
}
// MARK: - Public entry
/// Top-level "give me models" entry point. Cache-first: serve from
/// cache if fresh, fetch + write through if stale or empty, fall
/// back to the hard-coded list when both fail. The caller renders
/// based on the case so it can show a "could not refresh" hint
/// next to a stale-but-still-useful list.
public func loadModels(forceRefresh: Bool = false) async -> NousModelsLoadResult {
let cached = readCache()
if let cached, !forceRefresh, !isCacheStale(cached) {
return .cache(models: cached.models, fetchedAt: cached.fetchedAt, refreshError: nil)
}
do {
let models = try await fetchModels()
let now = Date()
writeCache(NousModelsCache(fetchedAt: now, models: models))
return .fresh(models: models, fetchedAt: now)
} catch let error as NousModelCatalogError {
// Fetch failed but we may still have *something* useful.
if let cached {
return .cache(
models: cached.models,
fetchedAt: cached.fetchedAt,
refreshError: error.userMessage
)
}
return .fallback(models: Self.fallbackModels, reason: error.userMessage)
} catch {
if let cached {
return .cache(
models: cached.models,
fetchedAt: cached.fetchedAt,
refreshError: error.localizedDescription
)
}
return .fallback(models: Self.fallbackModels, reason: error.localizedDescription)
}
}
}
public enum NousModelCatalogError: Error, Sendable {
case notAuthenticated
case http(status: Int)
case transport(String)
public var userMessage: String {
switch self {
case .notAuthenticated:
return "Sign in to Nous Portal to fetch the latest model list."
case .http(let status) where status == 401:
return "Nous rejected the saved token (401). Sign in again."
case .http(let status):
return "Nous returned HTTP \(status)."
case .transport(let detail):
return "Couldn't reach Nous: \(detail)."
}
}
}