mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cca99d4e13 | |||
| 2aab9dac07 | |||
| c31dfccb9b | |||
| 61e61f556a | |||
| 424711c3d9 | |||
| 067aeda878 | |||
| 389620059c | |||
| 4ffd353835 | |||
| 511726e2c0 | |||
| 587c6c36c8 |
@@ -0,0 +1,55 @@
|
||||
## What's in 2.5.2
|
||||
|
||||
A patch with one substantial new feature (**iOS chat resilience** — reconnect, cached snapshot fallback, history paging) plus a stack of fixes for issues reported against 2.5.1 and earlier. Drop-in replacement for 2.5.1 on Mac; drop-in TestFlight build on iOS. No data migrations.
|
||||
|
||||
### iOS chat resilience
|
||||
|
||||
ScarfGo now survives phone-sleep, network handoffs, and SSH socket drops without losing the agent's work. Hermes was already persisting messages to `state.db` in real-time; iOS just had no resync path.
|
||||
|
||||
- **5-attempt exponential reconnect** (1s → 2s → 4s → 8s → 16s) via `session/resume` with `session/load` fallback. Reconciles with `state.db` on success and surfaces a *"Resynced N new messages"* toast when the agent kept working through the disconnect.
|
||||
- **`NetworkReachabilityService`** (NWPathMonitor singleton): suspends reconnect attempts while offline and kicks a fresh cycle on link-up. Two new banner states above the message list — `.reconnecting` and `.offline` — render as slim ScarfDesign-tinted strips so the user always knows what the chat is doing.
|
||||
- **Scene-phase awareness**: returning to foreground triggers a channel-health check; if dead, the reconnect cycle starts immediately rather than waiting for the next interaction.
|
||||
- **Draft persistence**: per-server, per-session draft survives force-quit (UserDefaults-backed, 7-day janitor at app launch).
|
||||
|
||||
### Cached snapshot fallback (Mac + iOS)
|
||||
|
||||
`ServerTransport.cachedSnapshotPath` lets `HermesDataService` fall back to the previously-pulled `state.db` snapshot when a fresh pull fails. `isUsingStaleSnapshot` + `lastSnapshotMtime` surface to views so they render *"Last updated X ago."* Chat-history reload still passes `forceFresh: true` to refuse stale data; everything else (Dashboard, Sessions list, Activity) gets read-while-disconnected for free.
|
||||
|
||||
### Bounded message-history paging
|
||||
|
||||
`HermesDataService.fetchMessages(sessionId:limit:before:)` paginates by id desc with centralized `HistoryPageSize` constants. `RichChatViewModel.loadEarlier()` walks back through long sessions via `oldestLoadedMessageID` + `hasMoreHistory`. Legacy unbounded overload deprecated.
|
||||
|
||||
### Bug fixes
|
||||
|
||||
#### Mac
|
||||
|
||||
- **[#46](https://github.com/awizemann/scarf/issues/46) — chat O(n)-per-token bog-down (already shipped in 2.5.1 for the trailing-group patch; this release retains the fix and pairs with the new history paging so chats with thousands of messages stay smooth).**
|
||||
- **[#19](https://github.com/awizemann/scarf/issues/19) layer-3 — sqlite3 false-negative in diagnostics.** Already in v2.5.1; kept here.
|
||||
- **[#44](https://github.com/awizemann/scarf/issues/44) — pill / diagnostics agreement** via shared `SSHScriptRunner`. From v2.5.1; the tier-2 probe now also checks `state.db` (not just `config.yaml`) so a healthy fresh install reports green.
|
||||
- **[#59](https://github.com/awizemann/scarf/issues/59) — Settings → Model and Credential Pools no longer freeze.** Both views called `ModelCatalogService.loadProviders()` synchronously from `.onAppear` on the MainActor; on a remote SSH context that's a multi-megabyte SSH file read on the main thread, freezing the UI for 1–2 minutes. New `loadProvidersAsync()` / `loadModelsAsync(for:)` wrappers dispatch off the main thread; both views now use `.task` + `await` with a `ProgressView("Loading providers…")` overlay. Per-provider switching in the picker is also async now, so clicking a different provider doesn't re-freeze the UI.
|
||||
- **Diagnostics tri-state.** Hermes v0.11+ doesn't materialize `config.yaml` until the user changes a setting from defaults — so the diagnostics view was reporting *"12/14 passing"* on healthy fresh installs. The probe now distinguishes `.pass` / `.fail` / `.skipped`; a missing `config.yaml` emits SKIP and is excluded from the summary's denominator. Reads as *"12/12 passing (2 optional skipped)"* instead of the misleading 12/14.
|
||||
- **Credentials: OAuth providers visible.** `hasAnyAICredential()` only probed `credential_pool.<provider>` in `auth.json`; OAuth-authed providers land under `providers.<name>.access_token` (Nous, Spotify, GH Copilot ACP, Qwen, Gemini all use that path). The chat banner kept showing *"No AI provider credentials"* even after a successful Nous sign-in. Now both shapes count. Credential Pools view gains a parallel "OAuth providers" section listing OAuth-authed providers with token tail, expiry badge, and portal URL.
|
||||
- **Project-shadowed Hermes detection.** New `ProjectHermesShadowDetector` (ScarfCore) probes each registered project at chat-start; if a `.hermes/` dir or `hermes.yaml` is found inside the project, the user gets a banner explaining that project-local Hermes config will shadow the server-level one (a quiet failure mode for users who didn't realize Hermes prefers project-local config).
|
||||
- **[#58](https://github.com/awizemann/scarf/issues/58) — Mac chat side panes are hideable.** Two toolbar buttons next to the View picker (`sidebar.left` / `sidebar.right`) toggle the sessions list and tool inspector with a slide animation; both default visible (today's behavior). Clicking a tool card auto-shows the inspector if hidden so the click never silently dies. Settings → Display → Chat density gains parity Toggle rows.
|
||||
|
||||
#### ScarfGo (iOS)
|
||||
|
||||
- **[#56](https://github.com/awizemann/scarf/issues/56) — *"Citadel.SSHClient.CommandFailed error 1"* on dashboard.** `asyncSnapshotSQLite` was missed during the v2.5.0 Citadel hardening — used raw `executeCommand` (which discards stderr on non-zero exit) and didn't prepend the Citadel-friendly `PATH=$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH`. Now uses `executeCommandStream` and the same PATH prefix. `HermesDataService.humanize` already translates `sqlite3: command not found` / `permission denied` / `no such file` into actionable user copy — the bug was that the snapshot path never fed it real stderr.
|
||||
- **[#57](https://github.com/awizemann/scarf/issues/57) — keyboard-dismiss chevron over send button.** The keyboard accessory dismiss button added in v2.5.1 (#51) was placed at the trailing edge of the keyboard toolbar, directly above the trailing-edge send button. Moved to the leading edge — matches the iOS convention (Notes, Mail, Reminders).
|
||||
|
||||
### New features (Mac)
|
||||
|
||||
- **Chat-start model preflight ([commit](https://github.com/awizemann/scarf/commit/2aab9da)).** Catches a missing `model.default` / `model.provider` in `config.yaml` *before* the ACP session starts. Pre-fix the user typed a prompt, hit send, and got an opaque *"Model parameter is required"* HTTP 400 from the upstream provider. Now `ChatModelPreflightSheet` wraps the existing model picker so the same selection / validation / Nous-catalog branch is single-sourced; the chat the user originally opened lands without re-clicking the project row.
|
||||
- **Nous Portal live model catalog.** `NousModelCatalogService` fetches `GET /v1/models` from `inference-api.nousresearch.com` using the bearer token in `auth.json`. Cached at `~/.hermes/scarf/nous_models_cache.json` with a 24h TTL. The picker's nous-overlay detail view switches from a free-form TextField to a real model list, with a *"Custom…"* escape hatch for IDs not yet in the API response.
|
||||
- **Remote-aware admin sheets.** Three sheets gained the same context-aware Verify pattern that Add Project got in v2.5.1 (#54):
|
||||
- **Profiles → Import / Export.** Buttons that drive `hermes profile import <zip>` / `hermes profile export <name> <zip>` over SSH. Local context picks via `NSOpenPanel`; remote context shows a path-input + Verify button.
|
||||
- **Settings → Advanced → Restore.** Pick a local backup zip OR enter+verify a remote path.
|
||||
- **Templates → Install destination.** The parent-directory step in the install sheet branches on context — local Browse, or remote text-input + Verify.
|
||||
|
||||
### Translations
|
||||
|
||||
`Localizable.xcstrings` adds strings for all the new copy across the seven supported locales (English, Simplified Chinese, German, French, Spanish, Japanese, Brazilian Portuguese).
|
||||
|
||||
### Notes for users running 2.5.1
|
||||
|
||||
No data migrations needed. `~/.hermes/scarf/nous_models_cache.json` is created lazily on first use of the Nous picker; everything else is forward-compatible with existing config / Keychain / project registries.
|
||||
@@ -27,6 +27,28 @@ public enum QueryDefaults: Sendable {
|
||||
public nonisolated static let defaultSilenceThreshold = 200
|
||||
}
|
||||
|
||||
/// Page sizes for `HermesDataService.fetchMessages(sessionId:limit:before:)`.
|
||||
/// Centralized so iOS, Mac, and the polling code paths can pick a
|
||||
/// consistent budget — and so we have one knob to retune if perf
|
||||
/// concerns shift.
|
||||
public enum HistoryPageSize: Sendable {
|
||||
/// Initial chat-history load: covers the vast majority of
|
||||
/// sessions in one fetch while keeping the snapshot read bounded
|
||||
/// for the rare 1000+-message session.
|
||||
public nonisolated static let initial = 200
|
||||
/// Reconnection reconcile against the DB. 200 rows is plenty —
|
||||
/// disconnects don't generate hundreds of unseen messages.
|
||||
public nonisolated static let reconcile = 200
|
||||
/// Mac sessions detail view. Larger to reduce paging UX in the
|
||||
/// desktop browser-style read; the desktop has the screen real
|
||||
/// estate and memory headroom for it.
|
||||
public nonisolated static let macSessionDetail = 500
|
||||
/// Terminal-mode polling refresh. Same 500-row budget as Mac
|
||||
/// detail; covers sessions long enough that the user is actively
|
||||
/// scrolling but bounded to keep each poll tick cheap.
|
||||
public nonisolated static let polling = 500
|
||||
}
|
||||
|
||||
// MARK: - File Size Formatting
|
||||
|
||||
public enum FileSizeUnit: Sendable {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -61,6 +61,26 @@ public actor HermesDataService {
|
||||
/// instead of an empty Dashboard with no explanation.
|
||||
public private(set) var lastOpenError: String?
|
||||
|
||||
/// Modification date of the underlying state.db that backs the
|
||||
/// currently-open connection. For local contexts this tracks the
|
||||
/// live DB's mtime; for remote contexts it's the cached snapshot's
|
||||
/// mtime — which equals "when did we last get fresh data."
|
||||
public private(set) var lastSnapshotMtime: Date?
|
||||
|
||||
/// True when a `snapshotSQLite` pull failed and the open succeeded
|
||||
/// against a previously-cached snapshot instead of a fresh one.
|
||||
/// Views render a "Last updated X ago" affordance when this is set
|
||||
/// alongside `lastOpenError`. Always `false` for local contexts.
|
||||
public private(set) var isUsingStaleSnapshot: Bool = false
|
||||
|
||||
/// Convenience: how long ago the cached snapshot was written, when
|
||||
/// we're using a stale snapshot. `nil` when the snapshot is fresh
|
||||
/// or no mtime could be read.
|
||||
public var staleAge: TimeInterval? {
|
||||
guard isUsingStaleSnapshot, let m = lastSnapshotMtime else { return nil }
|
||||
return Date().timeIntervalSince(m)
|
||||
}
|
||||
|
||||
public let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
|
||||
@@ -70,6 +90,18 @@ public actor HermesDataService {
|
||||
}
|
||||
|
||||
public func open() async -> Bool {
|
||||
await openInternal(forceFresh: false)
|
||||
}
|
||||
|
||||
/// Variant that refuses the stale-snapshot fallback. Used by call
|
||||
/// sites that genuinely need post-write consistency — most notably
|
||||
/// the chat session-history reload, where a stale snapshot would
|
||||
/// hide messages the agent just streamed.
|
||||
private func openStrict() async -> Bool {
|
||||
await openInternal(forceFresh: true)
|
||||
}
|
||||
|
||||
private func openInternal(forceFresh: Bool) async -> Bool {
|
||||
if db != nil { return true }
|
||||
let localPath: String
|
||||
if context.isRemote {
|
||||
@@ -86,17 +118,39 @@ public actor HermesDataService {
|
||||
)
|
||||
localPath = url.path
|
||||
lastOpenError = nil
|
||||
isUsingStaleSnapshot = false
|
||||
lastSnapshotMtime = mtime(at: url)
|
||||
} catch {
|
||||
// Fresh pull failed. If the caller demanded fresh data
|
||||
// (`forceFresh: true`) OR there's no usable cache on
|
||||
// disk, surface the error and bail. Otherwise serve
|
||||
// the cached snapshot with `isUsingStaleSnapshot = true`
|
||||
// so views can render a "Last updated X ago" banner.
|
||||
if !forceFresh,
|
||||
let cached = transport.cachedSnapshotPath,
|
||||
FileManager.default.fileExists(atPath: cached.path)
|
||||
{
|
||||
localPath = cached.path
|
||||
isUsingStaleSnapshot = true
|
||||
lastSnapshotMtime = mtime(at: cached)
|
||||
lastOpenError = humanize(error) // user still sees why it's stale
|
||||
Self.logger.warning(
|
||||
"Using stale snapshot after pull failure: \(error.localizedDescription, privacy: .public)"
|
||||
)
|
||||
} else {
|
||||
lastOpenError = humanize(error)
|
||||
Self.logger.warning("snapshotSQLite failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
localPath = context.paths.stateDB
|
||||
guard FileManager.default.fileExists(atPath: localPath) else {
|
||||
lastOpenError = "Hermes state database not found at \(localPath)."
|
||||
return false
|
||||
}
|
||||
isUsingStaleSnapshot = false
|
||||
lastSnapshotMtime = mtime(at: URL(fileURLWithPath: localPath))
|
||||
}
|
||||
// Remote snapshots are point-in-time copies that no one writes to;
|
||||
// opening them with `immutable=1` tells SQLite to skip WAL/SHM and
|
||||
@@ -151,17 +205,27 @@ public actor HermesDataService {
|
||||
return desc
|
||||
}
|
||||
|
||||
/// Force a fresh snapshot pull + reopen. Used on session-load and in
|
||||
/// any path that needs the UI to reflect writes Hermes just made.
|
||||
/// Without this, remote snapshots would be frozen at the first `open()`
|
||||
/// for the app's lifetime — new messages added to a resumed session
|
||||
/// would never appear because the snapshot was pulled before they were
|
||||
/// written. Local contexts pay essentially nothing: close+reopen on a
|
||||
/// live DB is a no-op.
|
||||
/// Close the current connection and re-open with a fresh snapshot
|
||||
/// pull (when remote). When `forceFresh` is `false` (default) and
|
||||
/// the snapshot pull fails, falls back to the cached snapshot —
|
||||
/// `isUsingStaleSnapshot` is set so views can render a "Last
|
||||
/// updated X ago" banner. Pass `forceFresh: true` from call sites
|
||||
/// that genuinely need post-write consistency (chat session
|
||||
/// history reload), where stale data would hide messages the
|
||||
/// agent just streamed.
|
||||
@discardableResult
|
||||
public func refresh() async -> Bool {
|
||||
public func refresh(forceFresh: Bool = false) async -> Bool {
|
||||
close()
|
||||
return await open()
|
||||
return await openInternal(forceFresh: forceFresh)
|
||||
}
|
||||
|
||||
/// Read the modification date of a local file. Returns `nil` if
|
||||
/// the file is unreachable or has no mtime metadata. Used to
|
||||
/// stamp `lastSnapshotMtime` so views can show "Last updated
|
||||
/// X ago" without each one duplicating the FileManager dance.
|
||||
private nonisolated func mtime(at url: URL) -> Date? {
|
||||
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
|
||||
return attrs?[.modificationDate] as? Date
|
||||
}
|
||||
|
||||
public func close() {
|
||||
@@ -294,6 +358,50 @@ public actor HermesDataService {
|
||||
return cols
|
||||
}
|
||||
|
||||
/// Bounded message fetch keyed by message id (monotonic per row,
|
||||
/// safer than timestamp-based pagination because streaming chunk
|
||||
/// timestamps can collide). Returns the most recent `limit`
|
||||
/// messages older than `before` (when supplied) in chronological
|
||||
/// (ASC) order ready to display. Pass `before: nil` for the
|
||||
/// initial load — the DB returns the newest `limit` rows.
|
||||
public func fetchMessages(
|
||||
sessionId: String,
|
||||
limit: Int,
|
||||
before: Int? = nil
|
||||
) -> [HermesMessage] {
|
||||
guard let db else { return [] }
|
||||
let sql: String
|
||||
if before != nil {
|
||||
sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? AND id < ? ORDER BY id DESC LIMIT ?"
|
||||
} else {
|
||||
sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT ?"
|
||||
}
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
|
||||
if let before {
|
||||
sqlite3_bind_int(stmt, 2, Int32(before))
|
||||
sqlite3_bind_int(stmt, 3, Int32(limit))
|
||||
} else {
|
||||
sqlite3_bind_int(stmt, 2, Int32(limit))
|
||||
}
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
messages.append(messageFromRow(stmt!))
|
||||
}
|
||||
// Caller wants chronological (oldest-first) order; the SELECT
|
||||
// is DESC for the LIMIT to bite the newest rows, so reverse.
|
||||
return messages.reversed()
|
||||
}
|
||||
|
||||
/// Legacy unbounded fetch retained for one release cycle so any
|
||||
/// out-of-tree consumers don't break. New code should use the
|
||||
/// bounded `fetchMessages(sessionId:limit:before:)` variant —
|
||||
/// snapshot loads on 1000+-message sessions stall the UI when
|
||||
/// they materialize the whole history at once.
|
||||
@available(*, deprecated, message: "Use fetchMessages(sessionId:limit:before:) instead.")
|
||||
public func fetchMessages(sessionId: String) -> [HermesMessage] {
|
||||
guard let db else { return [] }
|
||||
let sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY timestamp ASC"
|
||||
|
||||
@@ -169,6 +169,19 @@ public struct ModelCatalogService: Sendable {
|
||||
Self.overlayOnlyProviders[providerID]
|
||||
}
|
||||
|
||||
/// Async wrapper around `loadProviders()` for use from MainActor view
|
||||
/// code. The sync method does a transport-backed file read that on a
|
||||
/// remote SSH context can take 1–2 minutes (ControlMaster setup +
|
||||
/// pulling the multi-megabyte models.dev JSON), and on local contexts
|
||||
/// still parses ~1500 models — both unsuitable for the main thread.
|
||||
/// Issue #59. Existing call sites (tests, any non-View consumers)
|
||||
/// can keep using the sync method.
|
||||
public nonisolated func loadProvidersAsync() async -> [HermesProviderInfo] {
|
||||
await Task.detached { [self] in
|
||||
self.loadProviders()
|
||||
}.value
|
||||
}
|
||||
|
||||
/// Models for one provider, sorted by release date (newest first), then name.
|
||||
public func loadModels(for providerID: String) -> [HermesModelInfo] {
|
||||
guard let catalog = loadCatalog(), let provider = catalog[providerID] else { return [] }
|
||||
@@ -198,6 +211,17 @@ public struct ModelCatalogService: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Async wrapper around `loadModels(for:)`. Same rationale as
|
||||
/// `loadProvidersAsync()` — the View call site that fires on every
|
||||
/// provider-switch click in the picker sheet was reading the catalog
|
||||
/// synchronously on the MainActor, freezing the UI on remote contexts.
|
||||
/// Issue #59.
|
||||
public nonisolated func loadModelsAsync(for providerID: String) async -> [HermesModelInfo] {
|
||||
await Task.detached { [self] in
|
||||
self.loadModels(for: providerID)
|
||||
}.value
|
||||
}
|
||||
|
||||
/// Find the provider that ships a given model ID. Useful for auto-syncing
|
||||
/// provider when the user picks a model from a flat list or types one in.
|
||||
public func provider(for modelID: String) -> HermesProviderInfo? {
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// Detects when a registered project directory contains its own `.hermes/`
|
||||
/// subdirectory. Hermes' CLI uses the closest `.hermes/` as `$HERMES_HOME`
|
||||
/// when invoked from inside such a directory, which **shadows** the user's
|
||||
/// global Hermes home — credentials, config, sessions, skills, memories
|
||||
/// all bind to the project-local copy without warning.
|
||||
///
|
||||
/// This causes confusing failure modes: the user runs `hermes auth add nous`
|
||||
/// during setup expecting a global registration, but if their cwd happens to
|
||||
/// be inside a project that already has a `.hermes/` (e.g. seeded by a
|
||||
/// previous workflow, copied from another machine, or checked into git),
|
||||
/// Hermes writes the credentials to the project-local `.hermes/auth.json`.
|
||||
/// Scarf then reads the global path on every dashboard tick and shows
|
||||
/// "missing provider" warnings even though the user did sign in successfully.
|
||||
///
|
||||
/// The detector enumerates the registered projects on a given server and
|
||||
/// reports which ones carry a shadowing `.hermes/`. Views surface a yellow
|
||||
/// banner so the user can consolidate.
|
||||
public struct ProjectHermesShadowDetector: Sendable {
|
||||
public struct Shadow: Sendable, Hashable, Identifiable {
|
||||
public var id: String { projectPath }
|
||||
/// Project name from the registry (`ProjectEntry.name`).
|
||||
public let projectName: String
|
||||
/// Absolute path to the project on the target server.
|
||||
public let projectPath: String
|
||||
/// Absolute path to the shadowing `.hermes/` directory.
|
||||
public let shadowPath: String
|
||||
/// `true` when the shadow `.hermes/auth.json` exists. Strong signal
|
||||
/// that user credentials are landing in the wrong place.
|
||||
public let hasAuthJSON: Bool
|
||||
/// `true` when the shadow `.hermes/state.db` exists. Hermes wrote
|
||||
/// session state to the project-local home — the user's chat
|
||||
/// history is invisible to Scarf's global Dashboard for this slice.
|
||||
public let hasStateDB: Bool
|
||||
|
||||
public init(
|
||||
projectName: String,
|
||||
projectPath: String,
|
||||
shadowPath: String,
|
||||
hasAuthJSON: Bool,
|
||||
hasStateDB: Bool
|
||||
) {
|
||||
self.projectName = projectName
|
||||
self.projectPath = projectPath
|
||||
self.shadowPath = shadowPath
|
||||
self.hasAuthJSON = hasAuthJSON
|
||||
self.hasStateDB = hasStateDB
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(os)
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectHermesShadowDetector")
|
||||
#endif
|
||||
|
||||
private let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
|
||||
public init(context: ServerContext) {
|
||||
self.context = context
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
/// Probe every project in `projects` for a shadowing `.hermes/`. Skips
|
||||
/// archived projects and projects whose absolute path equals the
|
||||
/// resolved Hermes home (rare but possible — a project literally
|
||||
/// rooted at `~/.hermes` shouldn't trigger a self-warning).
|
||||
public func detect(in projects: [ProjectEntry]) async -> [Shadow] {
|
||||
let hermesHome = await context.resolvedUserHome() + "/.hermes"
|
||||
var found: [Shadow] = []
|
||||
for project in projects where !project.archived {
|
||||
// A project nested inside the Hermes home itself is a weird
|
||||
// edge case (someone made `~/.hermes/notes` a Scarf project).
|
||||
// The project is BELOW the Hermes home, so its `.hermes` is
|
||||
// the same dir as `~/.hermes/.hermes` — almost certainly not
|
||||
// present and definitely not a shadow.
|
||||
if project.path.hasPrefix(hermesHome) { continue }
|
||||
let shadowPath = project.path + "/.hermes"
|
||||
guard transport.fileExists(shadowPath) else { continue }
|
||||
// It's only a shadow if the path is a directory; a stray
|
||||
// `.hermes` file would be filtered out here.
|
||||
guard transport.stat(shadowPath)?.isDirectory == true else { continue }
|
||||
let hasAuth = transport.fileExists(shadowPath + "/auth.json")
|
||||
let hasDB = transport.fileExists(shadowPath + "/state.db")
|
||||
#if canImport(os)
|
||||
Self.logger.warning(
|
||||
"Detected shadow Hermes home at \(shadowPath, privacy: .public) (auth: \(hasAuth), state.db: \(hasDB))"
|
||||
)
|
||||
#endif
|
||||
found.append(Shadow(
|
||||
projectName: project.name,
|
||||
projectPath: project.path,
|
||||
shadowPath: shadowPath,
|
||||
hasAuthJSON: hasAuth,
|
||||
hasStateDB: hasDB
|
||||
))
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/// Suggested shell command the user can copy-paste / run on the remote
|
||||
/// to consolidate a shadow's auth.json into their global Hermes home.
|
||||
/// Skips state.db / sessions / skills migration intentionally — those
|
||||
/// require Hermes to be quiesced and risk data loss; the user should
|
||||
/// decide what to keep on a case-by-case basis. We give them the
|
||||
/// load-bearing one-liner (auth) and let them handle the rest.
|
||||
public static func consolidationCommand(for shadow: Shadow, hermesHome: String) -> String? {
|
||||
guard shadow.hasAuthJSON else { return nil }
|
||||
return "cp \(shadow.shadowPath)/auth.json \(hermesHome)/auth.json && chmod 600 \(hermesHome)/auth.json"
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,11 @@ public struct LocalTransport: ServerTransport {
|
||||
URL(fileURLWithPath: remotePath)
|
||||
}
|
||||
|
||||
/// Local transport reads the live DB directly — there's no cached
|
||||
/// snapshot to fall back to (and no failure mode where falling back
|
||||
/// would help, since a missing local file is missing both ways).
|
||||
public var cachedSnapshotPath: URL? { nil }
|
||||
|
||||
// MARK: - Watching
|
||||
|
||||
#if canImport(Darwin)
|
||||
|
||||
@@ -603,6 +603,14 @@ public struct SSHTransport: ServerTransport {
|
||||
return URL(fileURLWithPath: localPath)
|
||||
}
|
||||
|
||||
/// Path where the most recent successful snapshot was written —
|
||||
/// returned even when the remote is currently unreachable. The
|
||||
/// data service falls back to this when `snapshotSQLite` throws so
|
||||
/// Dashboard / Sessions / Chat-history stay viewable offline.
|
||||
public var cachedSnapshotPath: URL? {
|
||||
URL(fileURLWithPath: snapshotDir + "/state.db")
|
||||
}
|
||||
|
||||
// MARK: - Watching
|
||||
|
||||
public func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
|
||||
|
||||
@@ -90,6 +90,19 @@ public protocol ServerTransport: Sendable {
|
||||
/// `~/Library/Caches/scarf/<serverID>/state.db`, returning that URL.
|
||||
nonisolated func snapshotSQLite(remotePath: String) throws -> URL
|
||||
|
||||
/// Local filesystem URL where this transport caches its SQLite snapshot,
|
||||
/// returned even when the remote is unreachable. Callers should
|
||||
/// `FileManager.default.fileExists(atPath:)` before reading — the
|
||||
/// transport can't atomically check existence and return the URL
|
||||
/// in one step without TOCTOU. Local transports return `nil`
|
||||
/// (their data is the live DB, not a cache).
|
||||
///
|
||||
/// Used by `HermesDataService.open()` to fall back to the last
|
||||
/// successful snapshot when a fresh `snapshotSQLite` call fails,
|
||||
/// so the app keeps showing data with a "Last updated X ago"
|
||||
/// affordance instead of a blank screen.
|
||||
nonisolated var cachedSnapshotPath: URL? { get }
|
||||
|
||||
// MARK: - Watching
|
||||
|
||||
/// Observe changes to a set of paths and yield events when any of them
|
||||
|
||||
+30
-20
@@ -16,7 +16,7 @@ public final class ConnectionStatusViewModel {
|
||||
#endif
|
||||
|
||||
public enum Status: Equatable {
|
||||
/// Healthy: SSH connected AND we can read `~/.hermes/config.yaml`.
|
||||
/// Healthy: SSH connected AND we can read `~/.hermes/state.db`.
|
||||
case connected
|
||||
/// SSH connects but the follow-up read-access probe failed. Data
|
||||
/// views will be empty until this is resolved.
|
||||
@@ -38,14 +38,17 @@ public final class ConnectionStatusViewModel {
|
||||
/// Specific tier-2 failure mode emitted by the probe script. Used to
|
||||
/// drive both the pill copy and the popover hint (issue #53).
|
||||
public enum DegradedCause: Equatable {
|
||||
/// `config.yaml` is missing entirely. Most common cause: Hermes
|
||||
/// hasn't run `setup` yet on this remote.
|
||||
/// `state.db` is missing entirely. Most common cause: Hermes
|
||||
/// is installed but no session has run on this remote yet.
|
||||
/// Case name kept as `configMissing` for back-compat with
|
||||
/// callers that pattern-match on it; "config" here is loose
|
||||
/// for "Scarf's required state file."
|
||||
case configMissing
|
||||
/// `~/.hermes` itself doesn't exist. Hermes isn't installed for
|
||||
/// the SSH user on this host.
|
||||
case homeMissing
|
||||
/// File exists but the SSH user can't read it. Permission /
|
||||
/// ownership mismatch.
|
||||
/// ownership mismatch. Same back-compat note as above.
|
||||
case configUnreadable
|
||||
/// `~/.hermes/active_profile` points at a non-default Hermes
|
||||
/// profile and the configured Hermes home doesn't carry the
|
||||
@@ -110,10 +113,18 @@ public final class ConnectionStatusViewModel {
|
||||
let hermesHome = context.paths.home
|
||||
// Two-tier probe in one SSH round-trip:
|
||||
// tier 1: `true` — raw connectivity / auth / ControlMaster path
|
||||
// tier 2: `test -r $HERMESHOME/config.yaml` — can we actually
|
||||
// read the file Dashboard reads on every tick? Green pill
|
||||
// only if both pass; yellow "degraded" if tier 1 passes
|
||||
// but tier 2 fails (the exact symptom in issue #19).
|
||||
// tier 2: `test -r $HERMESHOME/state.db` — can we actually read
|
||||
// the file Dashboard / Sessions / Activity all hit on
|
||||
// every tick? Green pill only if both pass.
|
||||
//
|
||||
// Probe historically targeted `config.yaml`, but Hermes v0.11+
|
||||
// doesn't materialize that file eagerly — it ships with sane
|
||||
// defaults and only writes config.yaml when the user actually
|
||||
// changes something. Result: a freshly-installed Hermes that's
|
||||
// running, persisting sessions, and serving Scarf was being
|
||||
// marked "degraded — config missing" indefinitely. `state.db`
|
||||
// is created on first agent run and is the actual surface
|
||||
// Scarf depends on, so we probe that instead.
|
||||
// Script emits two lines: TIER1:<exitcode> and TIER2:<exitcode>.
|
||||
let homeArg: String
|
||||
if hermesHome.hasPrefix("~/") {
|
||||
@@ -124,22 +135,21 @@ public final class ConnectionStatusViewModel {
|
||||
homeArg = "\"\(hermesHome.replacingOccurrences(of: "\"", with: "\\\""))\""
|
||||
}
|
||||
// Probe emits a granular `TIER2:1:<cause>` code so the pill can
|
||||
// surface a specific hint (issue #53) instead of the prior
|
||||
// collapsed-to-binary "can't read config.yaml". Causes:
|
||||
// surface a specific hint (issue #53). Causes:
|
||||
// no-home — $H itself doesn't exist
|
||||
// missing — config.yaml absent
|
||||
// missing — state.db absent (Hermes hasn't been run yet)
|
||||
// perm — exists but unreadable by SSH user
|
||||
// profile:<name> — config missing AND ~/.hermes/active_profile
|
||||
// profile:<name> — state.db missing AND ~/.hermes/active_profile
|
||||
// points at a Hermes profile, suggesting Scarf
|
||||
// is reading the wrong dir
|
||||
let script = """
|
||||
echo TIER1:0
|
||||
H=\(homeArg)
|
||||
if [ -r "$H/config.yaml" ]; then
|
||||
if [ -r "$H/state.db" ]; then
|
||||
echo TIER2:0
|
||||
elif [ ! -d "$H" ]; then
|
||||
echo TIER2:1:no-home
|
||||
elif [ ! -e "$H/config.yaml" ]; then
|
||||
elif [ ! -e "$H/state.db" ]; then
|
||||
ACTIVE=""
|
||||
if [ -r "$HOME/.hermes/active_profile" ]; then
|
||||
ACTIVE=$(head -n1 "$HOME/.hermes/active_profile" 2>/dev/null | tr -d ' \\t\\r\\n')
|
||||
@@ -263,23 +273,23 @@ public final class ConnectionStatusViewModel {
|
||||
)
|
||||
case .configMissing:
|
||||
return (
|
||||
"Hermes hasn't been set up yet",
|
||||
"`\(hermesHome)/config.yaml` is missing. Run `hermes setup` (or your first `hermes chat`) on the remote to create it. Scarf will go green automatically once it appears."
|
||||
"Hermes hasn't been run yet",
|
||||
"`\(hermesHome)/state.db` is missing — Hermes creates it on first agent run. Start any session on the remote (e.g. `hermes chat`) and Scarf will go green automatically."
|
||||
)
|
||||
case .configUnreadable:
|
||||
return (
|
||||
"Permission denied on config.yaml",
|
||||
"`\(hermesHome)/config.yaml` exists but the SSH user can't read it. Check ownership: `ls -l \(hermesHome)/config.yaml`. Either run Hermes as the SSH user, `chmod a+r` the file, or SSH as the Hermes user."
|
||||
"Permission denied on state.db",
|
||||
"`\(hermesHome)/state.db` exists but the SSH user can't read it. Check ownership: `ls -l \(hermesHome)/state.db`. Either run Hermes as the SSH user, `chmod a+r` the file, or SSH as the Hermes user."
|
||||
)
|
||||
case .profileActive(let name):
|
||||
return (
|
||||
"Hermes profile \"\(name)\" is active",
|
||||
"The remote is using Hermes profile `\(name)` — its config lives at `~/.hermes/profiles/\(name)/config.yaml`, not `\(hermesHome)/config.yaml`. Either set this server's Hermes home to `~/.hermes/profiles/\(name)` in Manage Servers → Edit, or run `hermes profile use default` on the remote to revert."
|
||||
"The remote is using Hermes profile `\(name)` — its state lives at `~/.hermes/profiles/\(name)/state.db`, not `\(hermesHome)/state.db`. Either set this server's Hermes home to `~/.hermes/profiles/\(name)` in Manage Servers → Edit, or run `hermes profile use default` on the remote to revert."
|
||||
)
|
||||
case .unknown:
|
||||
return (
|
||||
"Can't read Hermes state",
|
||||
"SSH is fine but Scarf can't reach `\(hermesHome)/config.yaml`. Run diagnostics for a full breakdown."
|
||||
"SSH is fine but Scarf can't reach `\(hermesHome)/state.db`. Run diagnostics for a full breakdown."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +339,20 @@ public final class RichChatViewModel {
|
||||
/// The original CLI session ID when resuming a CLI session via ACP.
|
||||
/// Used to combine old CLI messages with new ACP messages.
|
||||
public private(set) var originSessionId: String?
|
||||
/// Smallest DB id currently loaded for the *current session* (i.e.
|
||||
/// `sessionId`). Drives `loadEarlier()`: page back with
|
||||
/// `before: oldestLoadedMessageID`. `nil` when nothing has been
|
||||
/// loaded yet or the session has no DB-persisted messages.
|
||||
public private(set) var oldestLoadedMessageID: Int?
|
||||
/// Whether the most recent fetch suggests there are more older
|
||||
/// messages on disk that haven't been loaded into `messages` yet.
|
||||
/// Set to `true` when the initial fetch returned exactly `limit`
|
||||
/// rows (a strong hint the table has more). Drives the "Load
|
||||
/// earlier" button visibility in chat views.
|
||||
public private(set) var hasMoreHistory: Bool = false
|
||||
/// Cleared during a `loadEarlier()` fetch so the UI can show a
|
||||
/// spinner and we don't fan out duplicate page requests.
|
||||
public private(set) var isLoadingEarlier: Bool = false
|
||||
private var nextLocalId = -1
|
||||
private var streamingAssistantText = ""
|
||||
private var streamingThinkingText = ""
|
||||
@@ -382,6 +396,9 @@ public final class RichChatViewModel {
|
||||
lastKnownFingerprint = nil
|
||||
sessionId = nil
|
||||
originSessionId = nil
|
||||
oldestLoadedMessageID = nil
|
||||
hasMoreHistory = false
|
||||
isLoadingEarlier = false
|
||||
isAgentWorking = false
|
||||
userSendPending = false
|
||||
resetTimestamp = Date()
|
||||
@@ -875,12 +892,15 @@ public final class RichChatViewModel {
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
|
||||
var dbMessages = await dataService.fetchMessages(sessionId: sessionId)
|
||||
// Reconnects don't generate hundreds of unseen messages, so a
|
||||
// 200-row tail is plenty for the merge — and it keeps us from
|
||||
// re-materializing 1000+ message sessions on every reconnect.
|
||||
var dbMessages = await dataService.fetchMessages(sessionId: sessionId, limit: HistoryPageSize.reconcile)
|
||||
|
||||
// If we have an origin session (CLI session continued via ACP),
|
||||
// include those messages too
|
||||
if let origin = originSessionId, origin != sessionId {
|
||||
let originMessages = await dataService.fetchMessages(sessionId: origin)
|
||||
let originMessages = await dataService.fetchMessages(sessionId: origin, limit: HistoryPageSize.reconcile)
|
||||
if !originMessages.isEmpty {
|
||||
dbMessages = originMessages + dbMessages
|
||||
dbMessages.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
|
||||
@@ -925,10 +945,18 @@ public final class RichChatViewModel {
|
||||
// would have cached a stale copy — on resume we need whatever
|
||||
// Hermes has actually persisted since then, or the resumed session
|
||||
// will show only history up to the moment the snapshot was taken.
|
||||
let opened = await dataService.refresh()
|
||||
// `forceFresh: true` refuses the stale-snapshot fallback the data
|
||||
// service grew in M11 — falling back here would silently hide
|
||||
// messages the agent streamed during the user's offline window.
|
||||
let opened = await dataService.refresh(forceFresh: true)
|
||||
guard opened else { return }
|
||||
|
||||
var allMessages = await dataService.fetchMessages(sessionId: sessionId)
|
||||
let pageSize = HistoryPageSize.initial
|
||||
var allMessages = await dataService.fetchMessages(sessionId: sessionId, limit: pageSize)
|
||||
// The DB has more on-disk history when the initial fetch
|
||||
// saturated the limit. The "Load earlier" affordance reads
|
||||
// this flag.
|
||||
var moreHistory = allMessages.count >= pageSize
|
||||
let session = await dataService.fetchSession(id: sessionId)
|
||||
|
||||
// If the ACP session is different from the origin, load its messages too
|
||||
@@ -936,10 +964,11 @@ public final class RichChatViewModel {
|
||||
if let acpId = acpSessionId, acpId != sessionId {
|
||||
originSessionId = sessionId
|
||||
self.sessionId = acpId
|
||||
let acpMessages = await dataService.fetchMessages(sessionId: acpId)
|
||||
let acpMessages = await dataService.fetchMessages(sessionId: acpId, limit: pageSize)
|
||||
if !acpMessages.isEmpty {
|
||||
allMessages.append(contentsOf: acpMessages)
|
||||
allMessages.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
|
||||
moreHistory = moreHistory || acpMessages.count >= pageSize
|
||||
}
|
||||
}
|
||||
|
||||
@@ -947,6 +976,51 @@ public final class RichChatViewModel {
|
||||
currentSession = session
|
||||
let minId = allMessages.map(\.id).min() ?? 0
|
||||
nextLocalId = min(minId - 1, -1)
|
||||
// Track the oldest loaded id from THIS session (not the merged
|
||||
// origin) so `loadEarlier()` pages back through the live ACP
|
||||
// session's history. Cross-session backfill (paging into the
|
||||
// CLI origin) isn't supported in v1 — the merged 2× pageSize
|
||||
// is enough headroom for the dashboard-resume case.
|
||||
let currentSessionId = self.sessionId ?? sessionId
|
||||
oldestLoadedMessageID = allMessages
|
||||
.filter { $0.sessionId == currentSessionId }
|
||||
.map(\.id)
|
||||
.min()
|
||||
hasMoreHistory = moreHistory
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
// MARK: - Load Earlier (pagination)
|
||||
|
||||
/// Page back through the current session's DB-persisted history
|
||||
/// before `oldestLoadedMessageID` and prepend the page to
|
||||
/// `messages`. Cheap on the SQLite side (`id` is the primary
|
||||
/// key); the cost is the data-service `open()` round-trip on
|
||||
/// remote contexts. `pageSize` defaults to the same 200-row
|
||||
/// budget as the initial load.
|
||||
public func loadEarlier(pageSize: Int = HistoryPageSize.initial) async {
|
||||
guard !isLoadingEarlier, hasMoreHistory else { return }
|
||||
guard let sessionId, let oldest = oldestLoadedMessageID else { return }
|
||||
isLoadingEarlier = true
|
||||
defer { isLoadingEarlier = false }
|
||||
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
|
||||
let older = await dataService.fetchMessages(
|
||||
sessionId: sessionId,
|
||||
limit: pageSize,
|
||||
before: oldest
|
||||
)
|
||||
guard !older.isEmpty else {
|
||||
hasMoreHistory = false
|
||||
return
|
||||
}
|
||||
messages.insert(contentsOf: older, at: 0)
|
||||
oldestLoadedMessageID = older.first?.id
|
||||
// If this fetch returned fewer than the page size we've hit
|
||||
// the bottom of the table — no further pages worth fetching.
|
||||
hasMoreHistory = older.count >= pageSize
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
@@ -990,7 +1064,7 @@ public final class RichChatViewModel {
|
||||
let fingerprint = await dataService.fetchMessageFingerprint(sessionId: sessionId)
|
||||
|
||||
if fingerprint != lastKnownFingerprint {
|
||||
let fetched = await dataService.fetchMessages(sessionId: sessionId)
|
||||
let fetched = await dataService.fetchMessages(sessionId: sessionId, limit: HistoryPageSize.polling)
|
||||
let session = await dataService.fetchSession(id: sessionId)
|
||||
lastKnownFingerprint = fingerprint
|
||||
|
||||
|
||||
@@ -165,6 +165,15 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
|
||||
try runSync { try await self.asyncSnapshotSQLite(remotePath: remotePath) }
|
||||
}
|
||||
|
||||
/// Path where the most recent successful snapshot was written —
|
||||
/// returned even when the SSH connection is currently down. The
|
||||
/// data service falls back to this when `snapshotSQLite` throws so
|
||||
/// Dashboard / Sessions / Chat-history stay viewable while the
|
||||
/// phone is offline.
|
||||
public var cachedSnapshotPath: URL? {
|
||||
snapshotBaseDir.appendingPathComponent("state.db")
|
||||
}
|
||||
|
||||
// MARK: - ServerTransport: watching
|
||||
|
||||
public func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
|
||||
@@ -398,8 +407,76 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
|
||||
let remoteTmp = "/tmp/scarf-snapshot-\(UUID().uuidString).db"
|
||||
// Double-quote paths; $HOME expansion happens inside double quotes.
|
||||
let rewritten = Self.rewriteHomeRelative(remotePath)
|
||||
let backupScript = #"sqlite3 "\#(rewritten)" ".backup '\#(remoteTmp)'" && sqlite3 '\#(remoteTmp)' "PRAGMA journal_mode=DELETE;" > /dev/null"#
|
||||
_ = try await client.executeCommand(backupScript + " 2>&1")
|
||||
|
||||
// Prepend the same PATH prefix `asyncRunProcess` uses so `sqlite3`
|
||||
// resolves on hosts where it lives in /usr/local/bin or
|
||||
// /opt/homebrew/bin (issue #56). Citadel's bare exec channel
|
||||
// inherits a stripped PATH (typically `/usr/bin:/bin` on Linux);
|
||||
// without this, statically-linked or custom-prefix sqlite3
|
||||
// installs fail "command not found" at exit 127.
|
||||
let backupScript =
|
||||
#"PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" "#
|
||||
+ #"sqlite3 "\#(rewritten)" ".backup '\#(remoteTmp)'" && sqlite3 '\#(remoteTmp)' "PRAGMA journal_mode=DELETE;" > /dev/null"#
|
||||
|
||||
// Drive `executeCommandStream` instead of `executeCommand` so we
|
||||
// capture stderr regardless of exit code (issue #56). Pre-fix
|
||||
// a non-zero exit threw `CommandFailed` and discarded the buffer
|
||||
// — surfaced as the unhelpful "Citadel.SSHClient.CommandFailed
|
||||
// error 1" banner. Now we propagate the real stderr so
|
||||
// `HermesDataService.humanize` can translate "sqlite3: command
|
||||
// not found" / "no such file" / "permission denied" into the
|
||||
// dashboard banner with actionable copy.
|
||||
let stream: AsyncThrowingStream<ExecCommandOutput, Error>
|
||||
do {
|
||||
stream = try await client.executeCommandStream(backupScript)
|
||||
} catch {
|
||||
throw NSError(
|
||||
domain: "CitadelServerTransport",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Failed to start snapshot stream: \(error.localizedDescription)"]
|
||||
)
|
||||
}
|
||||
var stdout = Data()
|
||||
var stderr = Data()
|
||||
var exitCode: Int32 = 0
|
||||
do {
|
||||
for try await chunk in stream {
|
||||
switch chunk {
|
||||
case .stdout(var buf):
|
||||
if let s = buf.readString(length: buf.readableBytes) {
|
||||
stdout.append(Data(s.utf8))
|
||||
}
|
||||
case .stderr(var buf):
|
||||
if let s = buf.readString(length: buf.readableBytes) {
|
||||
stderr.append(Data(s.utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch let failed as SSHClient.CommandFailed {
|
||||
exitCode = Int32(failed.exitCode)
|
||||
} catch {
|
||||
stderr.append(Data(error.localizedDescription.utf8))
|
||||
exitCode = -1
|
||||
}
|
||||
if exitCode != 0 {
|
||||
// Combine stdout + stderr into the error message — sqlite3
|
||||
// sometimes prints "Error: ..." on stdout depending on the
|
||||
// remote shell. HermesDataService.humanize keys off
|
||||
// substrings like "sqlite3: command not found",
|
||||
// "permission denied", "no such file", so as long as one of
|
||||
// them ends up in the message we get a useful banner.
|
||||
let messageBytes = stderr.isEmpty ? stdout : stderr
|
||||
let message = String(data: messageBytes, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
throw NSError(
|
||||
domain: "CitadelServerTransport",
|
||||
code: Int(exitCode),
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: message.isEmpty
|
||||
? "Snapshot exited \(exitCode) with no output (likely sqlite3 missing on remote)"
|
||||
: message
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// SFTP-download the remote tmp into our local snapshot cache.
|
||||
let sftp = try await connectionHolder.sftp()
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// Process-wide reachability monitor wrapping `NWPathMonitor`. Used by
|
||||
/// `ChatController` to decide when to attempt a reconnect (on
|
||||
/// `.satisfied`) vs. mark the chat offline (on `.unsatisfied`).
|
||||
///
|
||||
/// Singleton because `NWPathMonitor` is per-process by design — there's
|
||||
/// no benefit to instantiating multiple monitors and the cost (a small
|
||||
/// background queue per instance) accumulates if every controller
|
||||
/// spawns its own.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// Don't read the published state from a SwiftUI view body — the
|
||||
/// runtime samples through `NWPathMonitor`'s queue, but a `body`
|
||||
/// re-evaluation that touches `currentPath` directly would block. Read
|
||||
/// `isSatisfied` / observe `transitionTick` instead. Tests and
|
||||
/// non-iOS callers can use the no-op default behavior (`isSatisfied`
|
||||
/// reports `true`).
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class NetworkReachabilityService {
|
||||
public static let shared = NetworkReachabilityService()
|
||||
|
||||
/// `true` when the OS reports a usable network path (any
|
||||
/// interface). Inverted via `!isSatisfied` for "we're offline."
|
||||
public private(set) var isSatisfied: Bool = true
|
||||
|
||||
/// Mirrors `NWPath.isExpensive`. Useful as a hint to UI for not
|
||||
/// auto-fetching big payloads on cellular. Not consumed yet —
|
||||
/// reserved so callers don't have to add another property later.
|
||||
public private(set) var isExpensive: Bool = false
|
||||
|
||||
/// Monotonic counter that bumps every time `isSatisfied` changes.
|
||||
/// Views observe `transitionTick` rather than `isSatisfied` to
|
||||
/// kick a `.onChange` even if the value is the same as before
|
||||
/// (rare but possible during rapid network flapping).
|
||||
public private(set) var transitionTick: Int = 0
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "com.scarf.ios.reachability")
|
||||
|
||||
#if canImport(os)
|
||||
private static let logger = Logger(subsystem: "com.scarf.ios", category: "NetworkReachability")
|
||||
#endif
|
||||
|
||||
private init() {
|
||||
// Seed from the current path synchronously so first reads on
|
||||
// launch don't show "satisfied" while the OS reports otherwise.
|
||||
// `currentPath` is safe here at init (the monitor hasn't been
|
||||
// started yet, no queue handler is firing).
|
||||
let initial = monitor.currentPath
|
||||
self.isSatisfied = (initial.status == .satisfied)
|
||||
self.isExpensive = initial.isExpensive
|
||||
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
// Bounce back through MainActor — the `Observable`
|
||||
// protocol's published-property invariants require main-
|
||||
// thread mutation. The pathUpdateHandler is invoked on
|
||||
// `queue`, which is a private background queue.
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
let satisfied = (path.status == .satisfied)
|
||||
if self.isSatisfied != satisfied {
|
||||
self.isSatisfied = satisfied
|
||||
self.transitionTick &+= 1
|
||||
#if canImport(os)
|
||||
Self.logger.info(
|
||||
"Reachability transition: \(satisfied ? "satisfied" : "unsatisfied", privacy: .public)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
self.isExpensive = path.isExpensive
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Singleton is process-lifetime; this only runs on shutdown.
|
||||
monitor.cancel()
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,28 @@ final class ScarfGoCoordinator {
|
||||
/// `AppCoordinator.pendingProjectChat`.
|
||||
var pendingProjectChat: String?
|
||||
|
||||
/// Most-recent scene-phase value observed at the WindowGroup
|
||||
/// level. Tab-specific view models (e.g. `ChatController`)
|
||||
/// observe `scenePhaseTick` to react to transitions even when
|
||||
/// they're on a non-foreground tab — `.onChange(of: ScenePhase)`
|
||||
/// alone wouldn't fire for views that aren't on screen.
|
||||
private(set) var scenePhase: ScenePhase = .active
|
||||
private(set) var scenePhaseTick: Int = 0
|
||||
/// Wallclock when we last observed `.background`. Used by tab
|
||||
/// view-models to decide whether a quick `.active` transition is
|
||||
/// worth a full re-verify (long suspensions warrant it; brief
|
||||
/// notification-center peeks don't). `nil` until the first
|
||||
/// background transition.
|
||||
private(set) var lastBackgroundedAt: Date?
|
||||
|
||||
func setScenePhase(_ phase: ScenePhase) {
|
||||
if phase == .background, scenePhase != .background {
|
||||
lastBackgroundedAt = Date()
|
||||
}
|
||||
scenePhase = phase
|
||||
scenePhaseTick &+= 1
|
||||
}
|
||||
|
||||
enum Tab: Hashable {
|
||||
case dashboard, projects, chat, skills, system
|
||||
}
|
||||
|
||||
@@ -36,6 +36,12 @@ struct ScarfGoTabRoot: View {
|
||||
/// through here.
|
||||
@State private var coordinator = ScarfGoCoordinator()
|
||||
|
||||
/// 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
|
||||
/// across all tabs.
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
// The transport factory is keyed by ServerID, so the correct
|
||||
// Keychain slot + config is picked automatically. Reuses the
|
||||
@@ -119,6 +125,12 @@ struct ScarfGoTabRoot: View {
|
||||
// just observes.
|
||||
NotificationRouter.shared.coordinator = coordinator
|
||||
}
|
||||
// Funnel scene-phase transitions through the coordinator so
|
||||
// tab view-models (notably ChatController) can react even
|
||||
// when their tab isn't currently on-screen.
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
coordinator.setScenePhase(newPhase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,13 @@ struct ScarfIOSApp: App {
|
||||
// Hermes gains a push sender.
|
||||
await MainActor.run { NotificationRouter.shared.setUpOnLaunch() }
|
||||
}
|
||||
.task {
|
||||
// Drop chat drafts older than 7 days so the
|
||||
// UserDefaults plist doesn't grow unbounded across
|
||||
// years of use. Cheap; UserDefaults is already in
|
||||
// memory by the time we read keys.
|
||||
ChatController.pruneStaleDrafts()
|
||||
}
|
||||
// Clamp Dynamic Type at the scene root. ScarfGo is a
|
||||
// developer tool that needs more density than Apple's
|
||||
// .xxxLarge default, but we still scale from .xSmall
|
||||
|
||||
@@ -50,6 +50,7 @@ struct ChatView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
connectionBanner
|
||||
errorBanner
|
||||
projectContextBar
|
||||
messageList
|
||||
@@ -118,6 +119,23 @@ struct ChatView: View {
|
||||
coordinator?.pendingProjectChat = nil
|
||||
Task { await consumePendingProjectChat(projectPath) }
|
||||
}
|
||||
// React to network reachability transitions. The service
|
||||
// updates its `transitionTick` on every `.satisfied <->
|
||||
// .unsatisfied` edge; the `.onChange` here funnels each
|
||||
// edge into ChatController so the reconnect machinery can
|
||||
// suspend on link-down and resume on link-up.
|
||||
.onChange(of: NetworkReachabilityService.shared.transitionTick) { _, _ in
|
||||
Task { await controller.handleReachabilityChange() }
|
||||
}
|
||||
// React to scene-phase transitions (background → active etc).
|
||||
// Source of truth is the coordinator, not `@Environment(\.scenePhase)`,
|
||||
// so the chat tab still picks up phase changes that happened
|
||||
// while it was unmounted (the user is on Dashboard when the
|
||||
// app backgrounds; sees Chat after resume).
|
||||
.onChange(of: coordinator?.scenePhaseTick) { _, _ in
|
||||
guard let phase = coordinator?.scenePhase else { return }
|
||||
Task { await controller.handleScenePhase(phase) }
|
||||
}
|
||||
// Deliberately NOT tearing down the ACP session on .onDisappear.
|
||||
// `TabView` unmounts tab content when the user switches tabs
|
||||
// (disappear fires), but `@State var controller` keeps the
|
||||
@@ -141,6 +159,21 @@ struct ChatView: View {
|
||||
connectingOverlay
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { controller.modelPreflightReason != nil },
|
||||
set: { newValue in
|
||||
if !newValue { controller.cancelModelPreflight() }
|
||||
}
|
||||
)) {
|
||||
IOSModelPreflightSheet(
|
||||
reason: controller.modelPreflightReason ?? "",
|
||||
serverDisplayName: controller.context.displayName,
|
||||
onSelect: { model, provider in
|
||||
controller.confirmModelPreflight(model: model, provider: provider)
|
||||
},
|
||||
onCancel: { controller.cancelModelPreflight() }
|
||||
)
|
||||
}
|
||||
.sheet(item: Binding(
|
||||
get: { controller.vm.pendingPermission.map(PermissionWrapper.init) },
|
||||
set: { if $0 == nil { controller.vm.pendingPermission = nil } }
|
||||
@@ -201,6 +234,9 @@ struct ChatView: View {
|
||||
emptyState
|
||||
}
|
||||
}
|
||||
if controller.vm.hasMoreHistory {
|
||||
loadEarlierButton
|
||||
}
|
||||
ForEach(controller.vm.messages) { msg in
|
||||
MessageBubble(
|
||||
message: msg,
|
||||
@@ -247,6 +283,37 @@ struct ChatView: View {
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
|
||||
/// "Load earlier messages" affordance pinned above the oldest
|
||||
/// loaded bubble. Only rendered when `vm.hasMoreHistory == true`,
|
||||
/// so it disappears organically once the user has paged back to
|
||||
/// the start of the session.
|
||||
@ViewBuilder
|
||||
private var loadEarlierButton: some View {
|
||||
Button {
|
||||
Task { await controller.vm.loadEarlier() }
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
if controller.vm.isLoadingEarlier {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
} else {
|
||||
Image(systemName: "arrow.up.circle")
|
||||
.font(.caption)
|
||||
}
|
||||
Text(controller.vm.isLoadingEarlier ? "Loading earlier…" : "Load earlier messages")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.regularMaterial, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(controller.vm.isLoadingEarlier)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
@@ -290,6 +357,58 @@ struct ChatView: View {
|
||||
.padding(.top, 60)
|
||||
}
|
||||
|
||||
/// Top-of-screen banner for transient connection states. `.failed`
|
||||
/// keeps using the existing full-screen overlay (so the user has
|
||||
/// somewhere obvious to tap "Retry"); `.reconnecting` and
|
||||
/// `.offline` are non-modal so the user can keep reading the
|
||||
/// transcript while we work in the background.
|
||||
@ViewBuilder
|
||||
private var connectionBanner: some View {
|
||||
switch controller.state {
|
||||
case .reconnecting(let attempt, let total):
|
||||
connectionBannerStrip(
|
||||
text: "Reconnecting (\(attempt)/\(total))…",
|
||||
tint: ScarfColor.warning,
|
||||
showSpinner: true
|
||||
)
|
||||
case .offline(let reason):
|
||||
connectionBannerStrip(
|
||||
text: reason,
|
||||
tint: ScarfColor.danger,
|
||||
showSpinner: false
|
||||
)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private func connectionBannerStrip(
|
||||
text: String,
|
||||
tint: Color,
|
||||
showSpinner: Bool
|
||||
) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
if showSpinner {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
.tint(tint)
|
||||
} else {
|
||||
Image(systemName: "wifi.slash")
|
||||
.font(.caption)
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
Text(text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(tint)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(tint.opacity(0.16))
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
/// Soft pill above the composer confirming a non-interruptive
|
||||
/// command was received (e.g. `/steer`). Auto-clears via the
|
||||
@@ -326,21 +445,32 @@ struct ChatView: View {
|
||||
.onSubmit {
|
||||
Task { await controller.send() }
|
||||
}
|
||||
// Persist the half-typed message across app suspensions
|
||||
// and force-quits. Debounced inside `scheduleDraftSave`
|
||||
// so we coalesce per-keystroke writes.
|
||||
.onChange(of: controller.draft) { _, _ in
|
||||
controller.scheduleDraftSave()
|
||||
}
|
||||
// Explicit dismiss-keyboard affordance, complementing the
|
||||
// interactive scroll-to-dismiss on the message list. iOS
|
||||
// shows a keyboard accessory toolbar above the system
|
||||
// keyboard whenever a focused TextField is on screen;
|
||||
// putting a "Done" chevron there is the most-discoverable
|
||||
// dismissal pattern (issue #51).
|
||||
// dismissal pattern (issue #51). Pinned to the LEADING
|
||||
// edge (Spacer trails) so the chevron doesn't visually
|
||||
// stack above the trailing-edge send button in the
|
||||
// composer below — that stacking was the complaint in
|
||||
// issue #57. Matches iOS convention (Notes, Mail, Reminders
|
||||
// all put accessory dismiss on the leading side).
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
Spacer()
|
||||
Button {
|
||||
composerFocused = false
|
||||
} label: {
|
||||
Image(systemName: "keyboard.chevron.compact.down")
|
||||
}
|
||||
.accessibilityLabel("Hide keyboard")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,12 +681,42 @@ final class ChatController {
|
||||
case idle
|
||||
case connecting
|
||||
case ready
|
||||
/// Mid-recovery: the SSH exec channel died but the agent on
|
||||
/// the remote may still be running. We're trying to reattach
|
||||
/// via `session/resume` (or `session/load` as a fallback).
|
||||
case reconnecting(attempt: Int, of: Int)
|
||||
/// Network reachability is unsatisfied. Distinct from
|
||||
/// `.failed` so the banner can stay tinted yellow ("we'll
|
||||
/// retry") instead of red ("dead").
|
||||
case offline(reason: String)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
private(set) var state: State = .idle
|
||||
var vm: RichChatViewModel
|
||||
var draft: String = ""
|
||||
|
||||
/// Set when chat-start is blocked because the active server's
|
||||
/// `config.yaml` has no `model.default` / `model.provider`. ChatView
|
||||
/// observes this to present an inline "pick a model" sheet — the
|
||||
/// Mac picker UI doesn't ship on iOS today, so the iOS sheet
|
||||
/// captures model + provider as text fields and persists them via
|
||||
/// the same `hermes config set` path. Reset on cancel or after a
|
||||
/// successful retry.
|
||||
var modelPreflightReason: String?
|
||||
|
||||
/// Stash of the original chat-start intent while we wait for the
|
||||
/// user to fill in a model. Captured by the gate inside `start`,
|
||||
/// `startInternal`, `startResuming`; replayed verbatim once
|
||||
/// `confirmModelPreflight` writes the chosen values to config.yaml
|
||||
/// so the chat the user originally tried to open lands without
|
||||
/// them having to click the project row again.
|
||||
private enum PendingStart {
|
||||
case fresh
|
||||
case project(path: String, name: String)
|
||||
case resume(sessionID: String)
|
||||
}
|
||||
private var pendingStartIntent: PendingStart?
|
||||
/// Display name of the Scarf project this session is scoped to,
|
||||
/// or nil for "quick chat" / global sessions. Surfaced as a
|
||||
/// subtitle under the "Chat" title in the nav bar so users can
|
||||
@@ -571,25 +731,214 @@ final class ChatController {
|
||||
/// chip on the right side of the project context bar.
|
||||
private(set) var currentGitBranch: String?
|
||||
|
||||
private let context: ServerContext
|
||||
/// Public so the surrounding `ChatView` can read `displayName`
|
||||
/// when presenting sheets (e.g., the model preflight). Still
|
||||
/// `let` — set once at init, never mutated after.
|
||||
let context: ServerContext
|
||||
private var client: ACPClient?
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private var healthMonitorTask: Task<Void, Never>?
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
private var isHandlingDisconnect = false
|
||||
private var pendingDraftSave: Task<Void, Never>?
|
||||
|
||||
/// Session id of the currently-active chat. Saved when state
|
||||
/// reaches `.ready` and cleared on explicit `stop()` so a
|
||||
/// user-initiated disconnect doesn't get auto-reconnected when
|
||||
/// network/scene events fire later.
|
||||
private var lastActiveSessionID: String?
|
||||
/// Optional project working directory of the currently-active
|
||||
/// session. Used as `cwd` on the recovery path so a project-
|
||||
/// scoped session reconnects with the right scope.
|
||||
private var lastProjectPath: String?
|
||||
|
||||
// Reconnect tuning — verbatim from the Mac implementation at
|
||||
// scarf/Features/Chat/ViewModels/ChatViewModel.swift:563-693.
|
||||
private static let maxReconnectAttempts = 5
|
||||
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1s
|
||||
private static let maxReconnectDelay: UInt64 = 16_000_000_000 // 16s
|
||||
|
||||
private static let logger = Logger(
|
||||
subsystem: "com.scarf.ios",
|
||||
category: "ChatController"
|
||||
)
|
||||
|
||||
// MARK: - Draft persistence
|
||||
|
||||
private static let draftKeyPrefix = "scarf.chat.draft.v1"
|
||||
private static let draftMaxAge: TimeInterval = 7 * 24 * 60 * 60 // 7 days
|
||||
|
||||
private static func draftKey(serverID: ServerID, sessionID: String?) -> String {
|
||||
// `_no_session` covers the brief connecting window before
|
||||
// `vm.setSessionId` lands. The TextField is disabled in that
|
||||
// window today, so this slot is essentially never written —
|
||||
// but the sentinel is here so the key is always well-formed.
|
||||
"\(draftKeyPrefix).\(serverID.uuidString).\(sessionID ?? "_no_session")"
|
||||
}
|
||||
|
||||
private static func draftTimestampKey(forKey key: String) -> String { key + ".ts" }
|
||||
|
||||
private func saveDraft() {
|
||||
let key = Self.draftKey(serverID: context.id, sessionID: vm.sessionId)
|
||||
let tsKey = Self.draftTimestampKey(forKey: key)
|
||||
if draft.isEmpty {
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
UserDefaults.standard.removeObject(forKey: tsKey)
|
||||
} else {
|
||||
UserDefaults.standard.set(draft, forKey: key)
|
||||
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: tsKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadDraft() {
|
||||
let key = Self.draftKey(serverID: context.id, sessionID: vm.sessionId)
|
||||
if let saved = UserDefaults.standard.string(forKey: key), !saved.isEmpty {
|
||||
draft = saved
|
||||
}
|
||||
}
|
||||
|
||||
private func clearStoredDraft() {
|
||||
let key = Self.draftKey(serverID: context.id, sessionID: vm.sessionId)
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
UserDefaults.standard.removeObject(forKey: Self.draftTimestampKey(forKey: key))
|
||||
}
|
||||
|
||||
/// Debounced draft save. The view layer hooks this off
|
||||
/// `.onChange(of: controller.draft)` so per-keystroke writes are
|
||||
/// coalesced into one UserDefaults flush per ~1s of typing.
|
||||
func scheduleDraftSave() {
|
||||
pendingDraftSave?.cancel()
|
||||
pendingDraftSave = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.saveDraft()
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot janitor invoked at app launch. Removes draft slots
|
||||
/// whose timestamp sidecar predates `draftMaxAge`. Cheap enough
|
||||
/// to call synchronously — UserDefaults is in-memory at runtime.
|
||||
static func pruneStaleDrafts(now: Date = Date()) {
|
||||
let defaults = UserDefaults.standard
|
||||
let cutoff = now.timeIntervalSince1970 - draftMaxAge
|
||||
for key in defaults.dictionaryRepresentation().keys
|
||||
where key.hasPrefix(draftKeyPrefix) && key.hasSuffix(".ts")
|
||||
{
|
||||
guard let ts = defaults.object(forKey: key) as? TimeInterval, ts < cutoff else { continue }
|
||||
let baseKey = String(key.dropLast(3)) // strip ".ts"
|
||||
defaults.removeObject(forKey: baseKey)
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
init(context: ServerContext) {
|
||||
self.context = context
|
||||
self.vm = RichChatViewModel(context: context)
|
||||
}
|
||||
|
||||
/// Pre-flight: returns true when `config.yaml` has both
|
||||
/// `model.default` and `model.provider`. Returns false and stashes
|
||||
/// the start intent so the preflight sheet can replay it after the
|
||||
/// user picks a model. Reads via `context.readText` (transport-
|
||||
/// aware) and parses with the ScarfCore YAML parser — same path
|
||||
/// `IOSSettingsViewModel.load` uses, just synchronous because the
|
||||
/// preflight runs before any `state = .connecting` UI transition.
|
||||
private func passModelPreflight(intent: PendingStart) -> Bool {
|
||||
let raw = context.readText(context.paths.configYAML) ?? ""
|
||||
let config = HermesConfig(yaml: raw)
|
||||
let result = ModelPreflight.check(config)
|
||||
if result.isConfigured { return true }
|
||||
pendingStartIntent = intent
|
||||
modelPreflightReason = result.reason
|
||||
return false
|
||||
}
|
||||
|
||||
/// User confirmed model + provider in the preflight sheet. Persist
|
||||
/// to `config.yaml` via `hermes config set` (transport-aware — runs
|
||||
/// over SSH on the active server) and replay the original start
|
||||
/// intent. iOS picker is a free-form text input today (matches the
|
||||
/// Mac overlay-provider field for `nous`), so trust the user's
|
||||
/// input — Hermes will surface a runtime error if the model isn't
|
||||
/// valid for the provider.
|
||||
func confirmModelPreflight(model: String, provider: String) {
|
||||
let intent = pendingStartIntent
|
||||
modelPreflightReason = nil
|
||||
pendingStartIntent = nil
|
||||
|
||||
let trimmedModel = model.trimmingCharacters(in: .whitespaces)
|
||||
let trimmedProvider = provider.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedProvider.isEmpty else { return }
|
||||
|
||||
let ctx = context
|
||||
Task.detached { [weak self] in
|
||||
// Same PATH-prefix trick `IOSSettingsViewModel.saveValue`
|
||||
// uses so non-interactive shells find `hermes` even when
|
||||
// it's in ~/.local/bin / /opt/homebrew/bin.
|
||||
let hermes = ctx.paths.hermesBinary
|
||||
let providerScript = """
|
||||
PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$HOME/.hermes/bin:$PATH" \
|
||||
\(hermes) config set 'model.provider' '\(Self.escapeShellArg(trimmedProvider))'
|
||||
"""
|
||||
let providerOK = (try? ctx.makeTransport().runProcess(
|
||||
executable: "/bin/sh",
|
||||
args: ["-c", providerScript],
|
||||
stdin: nil,
|
||||
timeout: 15
|
||||
))?.exitCode == 0
|
||||
var modelOK = true
|
||||
if providerOK, !trimmedModel.isEmpty {
|
||||
let modelScript = """
|
||||
PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$HOME/.hermes/bin:$PATH" \
|
||||
\(hermes) config set 'model.default' '\(Self.escapeShellArg(trimmedModel))'
|
||||
"""
|
||||
modelOK = (try? ctx.makeTransport().runProcess(
|
||||
executable: "/bin/sh",
|
||||
args: ["-c", modelScript],
|
||||
stdin: nil,
|
||||
timeout: 15
|
||||
))?.exitCode == 0
|
||||
}
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
if providerOK, modelOK, let intent {
|
||||
Task { @MainActor in
|
||||
switch intent {
|
||||
case .fresh:
|
||||
await self.start()
|
||||
case .project(let path, let name):
|
||||
await self.start(projectPath: path, projectName: name)
|
||||
case .resume(let id):
|
||||
await self.startResuming(sessionID: id)
|
||||
}
|
||||
}
|
||||
} else if !(providerOK && modelOK) {
|
||||
self.state = .failed("Couldn't save model+provider to config.yaml.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-quote escape a shell argument. Handles embedded single
|
||||
/// quotes via the standard `'"'"'` trick. Mirrors the helper on
|
||||
/// `IOSSettingsViewModel`. `nonisolated static` so the
|
||||
/// `Task.detached` body can call it without a `self` capture and
|
||||
/// without hopping back to the MainActor.
|
||||
nonisolated private static func escapeShellArg(_ s: String) -> String {
|
||||
s.replacingOccurrences(of: "'", with: "'\"'\"'")
|
||||
}
|
||||
|
||||
func cancelModelPreflight() {
|
||||
modelPreflightReason = nil
|
||||
pendingStartIntent = nil
|
||||
}
|
||||
|
||||
/// Open the SSH exec channel, send ACP `initialize`, then
|
||||
/// `session/new` — so that by the time `state == .ready` the user
|
||||
/// can type and hit send immediately.
|
||||
func start() async {
|
||||
if state == .connecting || state == .ready { return }
|
||||
guard passModelPreflight(intent: .fresh) else { return }
|
||||
state = .connecting
|
||||
vm.reset()
|
||||
let client = ACPClient.forIOSApp(
|
||||
@@ -626,16 +975,10 @@ final class ChatController {
|
||||
// Start streaming ACP events into the view-model BEFORE we
|
||||
// send session/new, so the `available_commands_update`
|
||||
// notification that the server sends on session init is
|
||||
// captured.
|
||||
let stream = await client.events
|
||||
eventTask = Task { [weak self] in
|
||||
for await event in stream {
|
||||
guard let self else { break }
|
||||
await MainActor.run {
|
||||
self.vm.handleACPEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
// captured. Health monitor catches socket-level death the
|
||||
// event-stream EOF wouldn't see (e.g., a hung remote read).
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
|
||||
// Create a fresh ACP session. `cwd` is the remote user's home
|
||||
// directory — Hermes defaults to that for tool scoping.
|
||||
@@ -643,7 +986,10 @@ final class ChatController {
|
||||
let home = await context.resolvedUserHome()
|
||||
let sessionId = try await client.newSession(cwd: home)
|
||||
vm.setSessionId(sessionId)
|
||||
loadDraft()
|
||||
state = .ready
|
||||
lastActiveSessionID = sessionId
|
||||
lastProjectPath = nil
|
||||
} catch {
|
||||
state = .failed(error.localizedDescription)
|
||||
await vm.recordACPFailure(error, client: client)
|
||||
@@ -661,6 +1007,7 @@ final class ChatController {
|
||||
let sessionId = vm.sessionId ?? ""
|
||||
guard !sessionId.isEmpty else { return }
|
||||
draft = ""
|
||||
clearStoredDraft()
|
||||
vm.addUserMessage(text: text)
|
||||
// /steer is non-interruptive — the agent is still on its
|
||||
// current turn; the guidance applies after the next tool call.
|
||||
@@ -721,13 +1068,283 @@ final class ChatController {
|
||||
/// Stop the current session + tear down the SSH exec channel.
|
||||
/// Idempotent.
|
||||
func stop() async {
|
||||
eventTask?.cancel()
|
||||
eventTask = nil
|
||||
eventTask?.cancel(); eventTask = nil
|
||||
healthMonitorTask?.cancel(); healthMonitorTask = nil
|
||||
reconnectTask?.cancel(); reconnectTask = nil
|
||||
if let client {
|
||||
await client.stop()
|
||||
}
|
||||
client = nil
|
||||
state = .idle
|
||||
// Explicit user-initiated disconnect — clear the session
|
||||
// memory so reachability/scenePhase events don't try to
|
||||
// resurrect the dead chat.
|
||||
lastActiveSessionID = nil
|
||||
lastProjectPath = nil
|
||||
isHandlingDisconnect = false
|
||||
}
|
||||
|
||||
// MARK: - Reconnect machinery (Section 1)
|
||||
|
||||
/// Stream ACP events into the view-model. When the stream ends
|
||||
/// without us cancelling it, the channel died; route into the
|
||||
/// reconnect path. Direct port of Mac's `startACPEventLoop`
|
||||
/// (scarf/Features/Chat/ViewModels/ChatViewModel.swift:563).
|
||||
private func startACPEventLoop(client: ACPClient) {
|
||||
eventTask = Task { @MainActor [weak self] in
|
||||
let stream = await client.events
|
||||
for await event in stream {
|
||||
guard !Task.isCancelled else { break }
|
||||
self?.vm.handleACPEvent(event)
|
||||
}
|
||||
// Stream ended — if we weren't explicitly cancelled the
|
||||
// channel died (EOF on stdin/out, write to dead pipe,
|
||||
// SSH socket gone). The Mac caller calls
|
||||
// `handleConnectionDied`; we mirror that.
|
||||
if !Task.isCancelled {
|
||||
self?.handleConnectionDied()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 5-second heartbeat that catches dead channels which don't
|
||||
/// explicitly EOF the stream (e.g., a hung SSH socket waiting
|
||||
/// for the next chunk that never arrives). When `isHealthy`
|
||||
/// returns false, route into the reconnect path. Mirrors Mac's
|
||||
/// `startHealthMonitor`.
|
||||
private func startHealthMonitor(client: ACPClient) {
|
||||
healthMonitorTask = Task { @MainActor [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
||||
guard !Task.isCancelled else { break }
|
||||
let healthy = await client.isHealthy
|
||||
if !healthy {
|
||||
self?.handleConnectionDied()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-stop cleanup + reconnect dispatch. Idempotent — guarded by
|
||||
/// `isHandlingDisconnect` so concurrent triggers (event-stream
|
||||
/// EOF + health monitor + write failure) don't tear down the same
|
||||
/// client twice.
|
||||
private func handleConnectionDied() {
|
||||
guard client != nil, !isHandlingDisconnect else { return }
|
||||
isHandlingDisconnect = true
|
||||
Self.logger.warning("ACP connection died")
|
||||
|
||||
// Capture any in-progress streaming text into a finalized
|
||||
// message before we attempt to merge against the DB. The VM
|
||||
// doesn't add a system "Connection lost" bubble — that would
|
||||
// create a phantom message during reconnect.
|
||||
vm.finalizeOnDisconnect()
|
||||
|
||||
let savedSessionId = vm.sessionId
|
||||
|
||||
// Tear down the dead client. The eventTask will be cancelled
|
||||
// immediately; awaiting `stop()` on the dead client is the
|
||||
// detached fire-and-forget pattern Mac uses (its `Task` block).
|
||||
eventTask?.cancel(); eventTask = nil
|
||||
healthMonitorTask?.cancel(); healthMonitorTask = nil
|
||||
if let dead = client { Task { await dead.stop() } }
|
||||
client = nil
|
||||
|
||||
guard let savedSessionId else {
|
||||
// No session id to resume — surface the failure.
|
||||
state = .failed("Connection lost")
|
||||
isHandlingDisconnect = false
|
||||
return
|
||||
}
|
||||
attemptReconnect(sessionId: savedSessionId)
|
||||
}
|
||||
|
||||
/// React to an iOS scene-phase transition.
|
||||
///
|
||||
/// `.background`: cancel the keepalive — iOS will suspend the
|
||||
/// socket within ~30s anyway, and fighting it via background
|
||||
/// tasks costs battery for marginal benefit (the agent's work is
|
||||
/// persisted to state.db on the remote, so we recover on resume).
|
||||
///
|
||||
/// `.active`: if we had a session running before suspension and
|
||||
/// the channel is now unhealthy, route into the reconnect path
|
||||
/// so the user sees fresh state without having to tap anything.
|
||||
func handleScenePhase(_ phase: ScenePhase) async {
|
||||
switch phase {
|
||||
case .background:
|
||||
healthMonitorTask?.cancel(); healthMonitorTask = nil
|
||||
case .active:
|
||||
// No session worth verifying.
|
||||
guard let id = lastActiveSessionID else { return }
|
||||
// Already mid-recovery — let it finish.
|
||||
if case .reconnecting = state { return }
|
||||
await verifyAndResume(sessionId: id)
|
||||
case .inactive:
|
||||
break // brief: control center, banners, split-screen
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the existing client's health on resume. If alive,
|
||||
/// just re-arm the heartbeat; if dead, route into the reconnect
|
||||
/// path (which preserves the session id and reconciles against
|
||||
/// the DB).
|
||||
private func verifyAndResume(sessionId: String) async {
|
||||
if let client {
|
||||
if await client.isHealthy {
|
||||
startHealthMonitor(client: client)
|
||||
return
|
||||
}
|
||||
}
|
||||
handleConnectionDied()
|
||||
}
|
||||
|
||||
/// React to a transition in `NetworkReachabilityService`. While
|
||||
/// the device has no network, suppress reconnect attempts (they'd
|
||||
/// just burn the 5-attempt budget against guaranteed failures);
|
||||
/// when the network comes back, kick a fresh cycle if we're
|
||||
/// stuck in `.failed` / `.offline` with a saved session id.
|
||||
func handleReachabilityChange() async {
|
||||
let satisfied = NetworkReachabilityService.shared.isSatisfied
|
||||
if !satisfied {
|
||||
// Stop the in-flight reconnect cycle — every attempt
|
||||
// will fail until the link is back. We'll restart on
|
||||
// the next `.satisfied` edge.
|
||||
reconnectTask?.cancel(); reconnectTask = nil
|
||||
if case .reconnecting = state {
|
||||
state = .offline(reason: "No network")
|
||||
}
|
||||
return
|
||||
}
|
||||
// Network back. If we have a session worth restoring AND
|
||||
// we're currently in a non-recoverable state, kick a fresh
|
||||
// reconnect cycle.
|
||||
guard let id = lastActiveSessionID else { return }
|
||||
switch state {
|
||||
case .offline, .failed:
|
||||
attemptReconnect(sessionId: id)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// 5-attempt exponential-backoff reconnect targeting the same
|
||||
/// session id. Tries `session/resume` first (correct semantics
|
||||
/// for live recovery), falls back to `session/load` for older
|
||||
/// remotes. NEVER `session/new` — that would lose the agent's
|
||||
/// in-context conversation. After a successful reattach, calls
|
||||
/// `vm.reconcileWithDB` so messages the agent wrote during the
|
||||
/// outage become visible.
|
||||
private func attemptReconnect(sessionId: String) {
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
for attempt in 1...Self.maxReconnectAttempts {
|
||||
guard !Task.isCancelled else { return }
|
||||
state = .reconnecting(attempt: attempt, of: Self.maxReconnectAttempts)
|
||||
|
||||
// Skip backoff on the first attempt so a quick
|
||||
// recovery (e.g., a momentary SSH socket flap) feels
|
||||
// instant. Subsequent attempts back off 1→2→4→8→16s.
|
||||
if attempt > 1 {
|
||||
let delay = min(
|
||||
Self.reconnectBaseDelay * UInt64(1 << (attempt - 1)),
|
||||
Self.maxReconnectDelay
|
||||
)
|
||||
try? await Task.sleep(nanoseconds: delay)
|
||||
guard !Task.isCancelled else { return }
|
||||
}
|
||||
|
||||
let client = ACPClient.forIOSApp(
|
||||
context: context,
|
||||
keyProvider: {
|
||||
let store = KeychainSSHKeyStore()
|
||||
guard let key = try await store.load() else {
|
||||
throw SSHKeyStoreError.backendFailure(
|
||||
message: "No SSH key in Keychain — re-run onboarding.",
|
||||
osStatus: nil
|
||||
)
|
||||
}
|
||||
return key
|
||||
}
|
||||
)
|
||||
|
||||
do {
|
||||
try await client.start()
|
||||
|
||||
// Project-scoped sessions reconnect with their
|
||||
// project path as cwd; everything else uses the
|
||||
// remote user's home directory.
|
||||
let cwd: String
|
||||
if let path = lastProjectPath {
|
||||
cwd = path
|
||||
} else {
|
||||
cwd = await context.resolvedUserHome()
|
||||
}
|
||||
|
||||
let resolvedSessionId: String
|
||||
do {
|
||||
resolvedSessionId = try await client.resumeSession(cwd: cwd, sessionId: sessionId)
|
||||
} catch {
|
||||
Self.logger.info(
|
||||
"session/resume failed, trying session/load: \(error.localizedDescription, privacy: .public)"
|
||||
)
|
||||
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: sessionId)
|
||||
}
|
||||
|
||||
// Wire up the new client BEFORE merging messages
|
||||
// so any streaming chunks that arrive during the
|
||||
// reconcile land in the right place.
|
||||
self.client = client
|
||||
vm.acpStderrProvider = { [weak client] in
|
||||
await client?.recentStderr ?? ""
|
||||
}
|
||||
vm.setSessionId(resolvedSessionId)
|
||||
|
||||
// Merge in-memory state (any local-only user
|
||||
// messages typed before the disconnect) with
|
||||
// whatever Hermes has persisted to state.db
|
||||
// since we last looked. This is what makes the
|
||||
// "agent kept working while you were locked"
|
||||
// case visible to the user.
|
||||
let countBefore = vm.messages.count
|
||||
await vm.reconcileWithDB(sessionId: resolvedSessionId)
|
||||
let added = vm.messages.count - countBefore
|
||||
if added > 0 {
|
||||
vm.transientHint = "Resynced \(added) new message\(added == 1 ? "" : "s")."
|
||||
Task { @MainActor [weak vm] in
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
if vm?.transientHint?.hasPrefix("Resynced") == true {
|
||||
vm?.transientHint = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
state = .ready
|
||||
lastActiveSessionID = resolvedSessionId
|
||||
|
||||
isHandlingDisconnect = false
|
||||
Self.logger.info("Reconnected on attempt \(attempt)")
|
||||
return
|
||||
} catch {
|
||||
Self.logger.warning(
|
||||
"Reconnect attempt \(attempt) failed: \(error.localizedDescription, privacy: .public)"
|
||||
)
|
||||
await client.stop()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Exhausted all attempts. Surface a manual-recovery prompt.
|
||||
guard !Task.isCancelled else { return }
|
||||
state = .failed("Connection lost")
|
||||
isHandlingDisconnect = false
|
||||
}
|
||||
}
|
||||
|
||||
/// User tapped "New chat". Stop, reset the VM, start again.
|
||||
@@ -818,6 +1435,13 @@ final class ChatController {
|
||||
projectName: String?
|
||||
) async {
|
||||
if state == .connecting || state == .ready { return }
|
||||
let intent: PendingStart
|
||||
if let projectPath, let projectName {
|
||||
intent = .project(path: projectPath, name: projectName)
|
||||
} else {
|
||||
intent = .fresh
|
||||
}
|
||||
guard passModelPreflight(intent: intent) else { return }
|
||||
state = .connecting
|
||||
let client = ACPClient.forIOSApp(
|
||||
context: context,
|
||||
@@ -845,15 +1469,8 @@ final class ChatController {
|
||||
return
|
||||
}
|
||||
|
||||
let stream = await client.events
|
||||
eventTask = Task { [weak self] in
|
||||
for await event in stream {
|
||||
guard let self else { break }
|
||||
await MainActor.run {
|
||||
self.vm.handleACPEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
|
||||
do {
|
||||
// Use the project's path as cwd when provided; else the
|
||||
@@ -866,7 +1483,10 @@ final class ChatController {
|
||||
}
|
||||
let sessionId = try await client.newSession(cwd: cwd)
|
||||
vm.setSessionId(sessionId)
|
||||
loadDraft()
|
||||
state = .ready
|
||||
lastActiveSessionID = sessionId
|
||||
lastProjectPath = projectPath
|
||||
|
||||
// If this was a project-scoped session, record the
|
||||
// attribution so Dashboard's Sessions tab can render the
|
||||
@@ -905,6 +1525,7 @@ final class ChatController {
|
||||
/// to `session/load` if the remote doesn't support `session/resume`
|
||||
/// (Hermes < 0.9.x).
|
||||
func startResuming(sessionID: String) async {
|
||||
guard passModelPreflight(intent: .resume(sessionID: sessionID)) else { return }
|
||||
await stop()
|
||||
vm.reset()
|
||||
// Clear eagerly so a lingering project name from a prior
|
||||
@@ -976,15 +1597,8 @@ final class ChatController {
|
||||
return
|
||||
}
|
||||
|
||||
let stream = await client.events
|
||||
eventTask = Task { [weak self] in
|
||||
for await event in stream {
|
||||
guard let self else { break }
|
||||
await MainActor.run {
|
||||
self.vm.handleACPEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
|
||||
do {
|
||||
let home = await context.resolvedUserHome()
|
||||
@@ -998,6 +1612,7 @@ final class ChatController {
|
||||
resolvedID = try await client.loadSession(cwd: home, sessionId: sessionID)
|
||||
}
|
||||
vm.setSessionId(resolvedID)
|
||||
loadDraft()
|
||||
// Pull the transcript out of state.db so the user sees
|
||||
// everything said up to now. Mirrors the Mac resume flow
|
||||
// (scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift:376).
|
||||
@@ -1009,6 +1624,8 @@ final class ChatController {
|
||||
acpSessionId: resolvedID == sessionID ? nil : resolvedID
|
||||
)
|
||||
state = .ready
|
||||
lastActiveSessionID = resolvedID
|
||||
lastProjectPath = resolved?.path
|
||||
} catch {
|
||||
state = .failed(error.localizedDescription)
|
||||
await vm.recordACPFailure(error, client: client)
|
||||
@@ -1510,6 +2127,76 @@ private struct PermissionSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS preflight sheet for the model + provider on a server whose
|
||||
/// `config.yaml` is missing them. The Mac picker (`ModelPickerSheet`)
|
||||
/// doesn't ship in the iOS target — the catalog UI is Mac-only today —
|
||||
/// so this is a pair of `TextField`s plus a hint pointing at common
|
||||
/// formats. Confirms via the same `setModelAndProvider` path the Mac
|
||||
/// preflight uses, so persistence + replay logic stays single-sourced
|
||||
/// in `ChatController.confirmModelPreflight`.
|
||||
private struct IOSModelPreflightSheet: View {
|
||||
let reason: String
|
||||
let serverDisplayName: String
|
||||
let onSelect: (_ model: String, _ provider: String) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var model: String = ""
|
||||
@State private var provider: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Text(reasonLine)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Section("Provider") {
|
||||
TextField("e.g. anthropic, nous, openai", text: $provider)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
Section("Model") {
|
||||
TextField("e.g. claude-sonnet-4.6, hermes-3", text: $model)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
Text("Hermes will pass these through verbatim. Leave model blank if you're using Nous Portal — Hermes picks its default.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Pick a model")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") {
|
||||
onCancel()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Save & Start") {
|
||||
let p = provider.trimmingCharacters(in: .whitespaces)
|
||||
let m = model.trimmingCharacters(in: .whitespaces)
|
||||
guard !p.isEmpty else { return }
|
||||
onSelect(m, p)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(provider.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var reasonLine: String {
|
||||
let suffix = "Scarf will save these to `config.yaml` on \(serverDisplayName) and start the chat."
|
||||
guard !reason.isEmpty else { return suffix }
|
||||
return "\(reason) \(suffix)"
|
||||
}
|
||||
}
|
||||
|
||||
#endif // canImport(SQLite3)
|
||||
|
||||
// Empty shim so the file compiles on platforms without SQLite3 — the
|
||||
|
||||
@@ -529,7 +529,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -546,7 +546,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -571,7 +571,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -588,7 +588,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -612,7 +612,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -635,7 +635,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -658,7 +658,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -680,7 +680,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -834,7 +834,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
@@ -848,7 +848,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -870,7 +870,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
@@ -884,7 +884,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -902,12 +902,12 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -924,12 +924,12 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -945,11 +945,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -965,11 +965,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
|
||||
@@ -1442,13 +1442,24 @@ struct HermesFileService: Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scan auth.json (Credential Pools file written by the Configure →
|
||||
// Credential Pools UI). Schema:
|
||||
// { "credential_pool": { "<provider>": [ { "access_token": "...", ... }, ... ] } }
|
||||
// Defensive parse: any malformed input falls through to the next check.
|
||||
// Scan auth.json. Two shapes need to count as "credential present":
|
||||
//
|
||||
// 1. credential_pool.<provider>[].access_token
|
||||
// — written by Configure → Credential Pools (manual key entry,
|
||||
// round-robin / least-used routing).
|
||||
//
|
||||
// 2. providers.<name>.access_token
|
||||
// — written by `hermes auth add <name>` for OAuth-authed
|
||||
// providers (Nous Portal, Spotify, GitHub Copilot ACP, etc.).
|
||||
// Pre-fix this was ignored, so a user with only Nous OAuth
|
||||
// kept seeing the "No AI provider credentials" banner even
|
||||
// after a successful Nous sign-in.
|
||||
//
|
||||
// Defensive parse: malformed input falls through to the next check.
|
||||
if let data = readFileData(context.paths.authJSON),
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let pool = root["credential_pool"] as? [String: Any] {
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
{
|
||||
if let pool = root["credential_pool"] as? [String: Any] {
|
||||
for (_, entries) in pool {
|
||||
guard let list = entries as? [[String: Any]] else { continue }
|
||||
for cred in list {
|
||||
@@ -1458,6 +1469,22 @@ struct HermesFileService: Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let providers = root["providers"] as? [String: Any] {
|
||||
for (_, value) in providers {
|
||||
guard let entry = value as? [String: Any] else { continue }
|
||||
if let token = entry["access_token"] as? String, !token.isEmpty {
|
||||
return true
|
||||
}
|
||||
// Some auth records (Spotify) carry only a refresh
|
||||
// token until the first access-token mint — count
|
||||
// that too so we don't false-negative seconds-old
|
||||
// OAuth flows.
|
||||
if let refresh = entry["refresh_token"] as? String, !refresh.isEmpty {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scan config.yaml for `api_key:` lines with a non-empty value.
|
||||
// Covers both `auxiliary.<task>.api_key` and `delegation.api_key`
|
||||
// without needing to parse YAML structure.
|
||||
@@ -1473,6 +1500,42 @@ struct HermesFileService: Sendable {
|
||||
return false
|
||||
}
|
||||
|
||||
/// Persist the primary model + provider to `config.yaml` in one call.
|
||||
/// Used by the chat-start preflight when the user picks a model from
|
||||
/// the picker sheet — we need to write both keys before re-attempting
|
||||
/// `client.start()`. Wraps two `hermes config set` invocations because
|
||||
/// Hermes doesn't expose a combined "set model" command.
|
||||
///
|
||||
/// Returns `true` only if both writes succeed. If the second write
|
||||
/// fails the first is left in place — `model.default` without a
|
||||
/// matching `model.provider` is no worse than the all-empty state we
|
||||
/// started in, and the next preflight pass will re-prompt anyway.
|
||||
@discardableResult
|
||||
nonisolated func setModelAndProvider(model: String, provider: String) -> Bool {
|
||||
let trimmedModel = model.trimmingCharacters(in: .whitespaces)
|
||||
let trimmedProvider = provider.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedProvider.isEmpty else { return false }
|
||||
|
||||
let providerResult = runHermesCLI(args: ["config", "set", "model.provider", trimmedProvider], timeout: 30)
|
||||
guard providerResult.exitCode == 0 else {
|
||||
Self.logger.warning("hermes config set model.provider failed: \(providerResult.output, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
// Subscription-gated overlay providers (Nous Portal) accept an
|
||||
// empty model — Hermes picks its own default. Skip the model
|
||||
// write in that case rather than persisting the empty string,
|
||||
// which Hermes would treat as "unset" and the preflight would
|
||||
// catch again on the next start.
|
||||
guard !trimmedModel.isEmpty else { return true }
|
||||
|
||||
let modelResult = runHermesCLI(args: ["config", "set", "model.default", trimmedModel], timeout: 30)
|
||||
guard modelResult.exitCode == 0 else {
|
||||
Self.logger.warning("hermes config set model.default failed: \(modelResult.output, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) {
|
||||
// Resolve the executable path — for remote, prefer the cached
|
||||
|
||||
@@ -21,6 +21,7 @@ struct ProjectTemplateInstaller: Sendable {
|
||||
/// to the registry so the caller can set `AppCoordinator.selectedProjectName`.
|
||||
@discardableResult
|
||||
nonisolated func install(plan: TemplateInstallPlan) throws -> ProjectEntry {
|
||||
try bootstrapProjectsRoot(plan: plan)
|
||||
try preflight(plan: plan)
|
||||
try createProjectFiles(plan: plan)
|
||||
try createSkillsFiles(plan: plan)
|
||||
@@ -32,6 +33,24 @@ struct ProjectTemplateInstaller: Sendable {
|
||||
return entry
|
||||
}
|
||||
|
||||
// MARK: - Bootstrap
|
||||
|
||||
/// Idempotently `mkdir -p` the parent directory so a fresh remote
|
||||
/// host (or a local user with no `~/Projects`) can complete the
|
||||
/// first install. Runs *before* preflight — preflight then checks
|
||||
/// the project dir itself, which we deliberately don't create
|
||||
/// here so the "already exists" collision check still fires for
|
||||
/// repeat installs at the same path.
|
||||
///
|
||||
/// Safe on both transports: `LocalTransport.createDirectory` uses
|
||||
/// `withIntermediateDirectories: true`; `SSHTransport.createDirectory`
|
||||
/// runs `mkdir -p`. Idempotent for existing dirs in both cases.
|
||||
nonisolated private func bootstrapProjectsRoot(plan: TemplateInstallPlan) throws {
|
||||
let parentDir = (plan.projectDir as NSString).deletingLastPathComponent
|
||||
guard !parentDir.isEmpty, parentDir != "/" else { return }
|
||||
try context.makeTransport().createDirectory(parentDir)
|
||||
}
|
||||
|
||||
// MARK: - Preflight
|
||||
|
||||
nonisolated private func preflight(plan: TemplateInstallPlan) throws {
|
||||
|
||||
@@ -15,6 +15,13 @@ enum ChatDensityKeys {
|
||||
static let toolCardStyle = "scarf.chat.toolCardStyle"
|
||||
static let reasoningStyle = "scarf.chat.reasoningStyle"
|
||||
static let fontScale = "scarf.chat.fontScale"
|
||||
/// Whether the left sessions list pane is visible in the Mac
|
||||
/// 3-pane chat layout. Defaults true (today's behavior). Issue #58.
|
||||
static let showSessionsList = "scarf.chat.showSessionsList"
|
||||
/// Whether the right tool inspector pane is visible. Defaults true.
|
||||
/// When hidden, clicking a tool card auto-flips it back on so the
|
||||
/// click does what the user expects (`ToolCallCard.onFocus`). Issue #58.
|
||||
static let showInspector = "scarf.chat.showInspector"
|
||||
}
|
||||
|
||||
/// How `RichMessageBubble` renders the per-call tool widgets.
|
||||
|
||||
@@ -142,6 +142,20 @@ final class ChatViewModel {
|
||||
/// True when `hasAnyAICredential()` returned false at last preflight.
|
||||
var missingCredentials: Bool = false
|
||||
|
||||
/// Set when chat-start is blocked because the active server's
|
||||
/// `config.yaml` has no `model.default` / `model.provider`. The chat
|
||||
/// view observes this and presents `ChatModelPreflightSheet`; on
|
||||
/// successful pick we persist via `setModelAndProvider` and re-attempt
|
||||
/// the original `startACPSession` call from `pendingStartArgs`.
|
||||
/// Nil when no preflight is pending.
|
||||
var modelPreflightReason: String?
|
||||
|
||||
/// Stash of the original `startACPSession` arguments while we wait
|
||||
/// for the user to pick a model. Replayed verbatim once
|
||||
/// `confirmModelPreflight` writes the chosen model+provider to
|
||||
/// config.yaml. Cleared on cancel or after replay.
|
||||
private var pendingStartArgs: (sessionId: String?, projectPath: String?)?
|
||||
|
||||
private static let maxReconnectAttempts = 5
|
||||
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
|
||||
private static let maxReconnectDelay: UInt64 = 16_000_000_000 // 16 seconds
|
||||
@@ -404,6 +418,23 @@ final class ChatViewModel {
|
||||
private func startACPSession(resume sessionId: String?, projectPath: String? = nil) {
|
||||
stopACP()
|
||||
clearACPErrorState()
|
||||
|
||||
// Pre-flight: bail before opening any ACP plumbing if the
|
||||
// active server's `config.yaml` has no primary model or
|
||||
// provider. Hermes would otherwise let `session/new` succeed
|
||||
// and only fail at first prompt with an opaque
|
||||
// "Model parameter is required" 400. Stashing the start
|
||||
// arguments here lets `confirmModelPreflight` replay them
|
||||
// unchanged after the user picks a model.
|
||||
let preflight = ModelPreflight.check(fileService.loadConfig())
|
||||
if !preflight.isConfigured {
|
||||
pendingStartArgs = (sessionId, projectPath)
|
||||
modelPreflightReason = preflight.reason
|
||||
acpStatus = ""
|
||||
hasActiveProcess = false
|
||||
return
|
||||
}
|
||||
|
||||
acpStatus = "Starting..."
|
||||
|
||||
let client = ACPClient.forMacApp(context: context)
|
||||
@@ -716,6 +747,44 @@ final class ChatViewModel {
|
||||
isHandlingDisconnect = false
|
||||
}
|
||||
|
||||
// MARK: - Model preflight
|
||||
|
||||
/// Called by `ChatModelPreflightSheet` once the user has picked a
|
||||
/// model in the embedded `ModelPickerSheet`. Persists the choice via
|
||||
/// `hermes config set` (transport-aware — works on remote droplets
|
||||
/// too) and replays the pending `startACPSession` call so the chat
|
||||
/// the user originally tried to open finally lands.
|
||||
@MainActor
|
||||
func confirmModelPreflight(model: String, provider: String) {
|
||||
let pending = pendingStartArgs
|
||||
modelPreflightReason = nil
|
||||
pendingStartArgs = nil
|
||||
|
||||
let svc = fileService
|
||||
Task.detached { [weak self] in
|
||||
let ok = svc.setModelAndProvider(model: model, provider: provider)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
if ok {
|
||||
if let pending {
|
||||
self.startACPSession(resume: pending.sessionId, projectPath: pending.projectPath)
|
||||
}
|
||||
} else {
|
||||
self.acpError = "Couldn't save model+provider to config.yaml. Open Settings to retry."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User dismissed the preflight sheet without picking a model. Drop
|
||||
/// the stashed start arguments and leave the chat in its idle state
|
||||
/// — no error banner, since this isn't a failure, just a deferral.
|
||||
@MainActor
|
||||
func cancelModelPreflight() {
|
||||
modelPreflightReason = nil
|
||||
pendingStartArgs = nil
|
||||
}
|
||||
|
||||
/// Respond to a permission request from the ACP agent.
|
||||
func respondToPermission(optionId: String) {
|
||||
guard let client = acpClient,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
|
||||
/// Pre-flight sheet shown when a chat-start hits a server whose
|
||||
/// `config.yaml` has no `model.default` / `model.provider`. Wraps the
|
||||
/// existing `ModelPickerSheet` so the picker surface, validation, and
|
||||
/// Nous-catalog branch all remain in one place.
|
||||
///
|
||||
/// The host (`ChatView`) owns persistence + retry: this sheet only
|
||||
/// captures the user's selection and calls `onSelect`. The
|
||||
/// `ChatViewModel` writes via `hermes config set` and replays the
|
||||
/// original `startACPSession` arguments, so the chat the user
|
||||
/// originally opened lands without them having to click the project
|
||||
/// row again.
|
||||
struct ChatModelPreflightSheet: View {
|
||||
let reason: String
|
||||
let serverDisplayName: String
|
||||
let onSelect: (_ model: String, _ provider: String) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
ModelPickerSheet(
|
||||
initialProvider: "",
|
||||
initialModel: "",
|
||||
onSelect: { modelID, providerID in
|
||||
onSelect(modelID, providerID)
|
||||
dismiss()
|
||||
},
|
||||
onCancel: {
|
||||
onCancel()
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "cpu")
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
.font(.title2)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Pick a model to start chatting")
|
||||
.scarfStyle(.headline)
|
||||
Text(detailMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var detailMessage: String {
|
||||
let suffix = "Hermes uses `model.default` + `model.provider` from `config.yaml`. Pick one and Scarf will save it on \(serverDisplayName) before starting the chat."
|
||||
guard !reason.isEmpty else { return suffix }
|
||||
return "\(reason) \(suffix)"
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,10 @@ struct ChatTranscriptPane: View {
|
||||
isWorking: richChat.isGenerating,
|
||||
isLoadingSession: chatViewModel.isPreparingSession,
|
||||
scrollTrigger: richChat.scrollTrigger,
|
||||
turnDurations: richChat.turnDurations
|
||||
turnDurations: richChat.turnDurations,
|
||||
hasMoreHistory: richChat.hasMoreHistory,
|
||||
isLoadingEarlier: richChat.isLoadingEarlier,
|
||||
onLoadEarlier: { Task { await richChat.loadEarlier() } }
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
@@ -7,6 +7,15 @@ struct ChatView: View {
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@State private var showErrorDetails = false
|
||||
|
||||
/// Side-pane visibility toggles (issue #58). Drive the new
|
||||
/// sidebar.left / sidebar.right toolbar buttons; `RichChatView.body`
|
||||
/// reads the same `@AppStorage` keys and conditionally renders the
|
||||
/// panes with a slide animation.
|
||||
@AppStorage(ChatDensityKeys.showSessionsList)
|
||||
private var showSessionsList: Bool = true
|
||||
@AppStorage(ChatDensityKeys.showInspector)
|
||||
private var showInspector: Bool = true
|
||||
|
||||
var body: some View {
|
||||
@Bindable var vm = viewModel
|
||||
@Bindable var coord = coordinator
|
||||
@@ -225,6 +234,30 @@ struct ChatView: View {
|
||||
voiceControls
|
||||
}
|
||||
|
||||
// Side-pane toggles (issue #58). Only meaningful in rich-chat
|
||||
// mode where the 3-pane layout exists; terminal mode is a
|
||||
// single SwiftTerm view and these would do nothing. Hide
|
||||
// them on the terminal side so the toolbar stays uncluttered.
|
||||
if viewModel.displayMode == .richChat {
|
||||
Button {
|
||||
showSessionsList.toggle()
|
||||
} label: {
|
||||
Image(systemName: "sidebar.left")
|
||||
.foregroundStyle(showSessionsList ? Color.accentColor : .secondary)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help(showSessionsList ? "Hide sessions list" : "Show sessions list")
|
||||
|
||||
Button {
|
||||
showInspector.toggle()
|
||||
} label: {
|
||||
Image(systemName: "sidebar.right")
|
||||
.foregroundStyle(showInspector ? Color.accentColor : .secondary)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help(showInspector ? "Hide tool inspector" : "Show tool inspector")
|
||||
}
|
||||
|
||||
Picker("View", selection: Bindable(viewModel).displayMode) {
|
||||
Image(systemName: "terminal")
|
||||
.help("Terminal")
|
||||
@@ -386,6 +419,23 @@ struct ChatView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
// Model preflight — open before any ACP plumbing when the active
|
||||
// server has no `model.default` / `model.provider` set. Keeps the
|
||||
// user from typing a prompt only to find out the upstream
|
||||
// provider rejected it.
|
||||
.sheet(isPresented: modelPreflightBinding) {
|
||||
ChatModelPreflightSheet(
|
||||
reason: viewModel.modelPreflightReason ?? "",
|
||||
serverDisplayName: viewModel.context.displayName,
|
||||
onSelect: { model, provider in
|
||||
viewModel.confirmModelPreflight(model: model, provider: provider)
|
||||
},
|
||||
onCancel: {
|
||||
viewModel.cancelModelPreflight()
|
||||
}
|
||||
)
|
||||
.environment(\.serverContext, viewModel.context)
|
||||
}
|
||||
}
|
||||
|
||||
private var permissionBinding: Binding<RichChatViewModel.PendingPermission?> {
|
||||
@@ -394,6 +444,15 @@ struct ChatView: View {
|
||||
set: { viewModel.richChatViewModel.pendingPermission = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
private var modelPreflightBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { viewModel.modelPreflightReason != nil },
|
||||
set: { newValue in
|
||||
if !newValue { viewModel.cancelModelPreflight() }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Permission Approval View
|
||||
|
||||
@@ -15,6 +15,13 @@ struct RichChatMessageList: View {
|
||||
/// bubble's metadata footer can render the v2.5 stopwatch pill.
|
||||
/// Defaults empty so callers that don't care can omit it.
|
||||
var turnDurations: [Int: TimeInterval] = [:]
|
||||
/// Show the "Load earlier messages" button at the top of the
|
||||
/// transcript when the underlying session has more on-disk
|
||||
/// history that hasn't been paged in yet. Hidden by default so
|
||||
/// existing callers who haven't opted in see no UI change.
|
||||
var hasMoreHistory: Bool = false
|
||||
var isLoadingEarlier: Bool = false
|
||||
var onLoadEarlier: (() -> Void)? = nil
|
||||
|
||||
/// Scrolling strategy: plain `VStack` (not `LazyVStack`) plus
|
||||
/// `.defaultScrollAnchor(.bottom)`.
|
||||
@@ -57,6 +64,30 @@ struct RichChatMessageList: View {
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if hasMoreHistory, let onLoadEarlier {
|
||||
Button {
|
||||
onLoadEarlier()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
if isLoadingEarlier {
|
||||
ProgressView().scaleEffect(0.7)
|
||||
} else {
|
||||
Image(systemName: "arrow.up.circle")
|
||||
.font(.caption)
|
||||
}
|
||||
Text(isLoadingEarlier ? "Loading earlier…" : "Load earlier messages")
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(.regularMaterial, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isLoadingEarlier)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
ForEach(groups) { group in
|
||||
MessageGroupView(group: group, turnDurations: turnDurations)
|
||||
.equatable()
|
||||
|
||||
@@ -29,14 +29,25 @@ struct RichChatView: View {
|
||||
@AppStorage(ChatDensityKeys.fontScale)
|
||||
private var fontScale: Double = ChatFontScale.default
|
||||
|
||||
/// Sessions-list / inspector pane visibility (issue #58). Defaults
|
||||
/// `true` so existing users see no change until they opt out via
|
||||
/// the toolbar buttons or Settings → Display → Chat density.
|
||||
@AppStorage(ChatDensityKeys.showSessionsList)
|
||||
private var showSessionsList: Bool = true
|
||||
@AppStorage(ChatDensityKeys.showInspector)
|
||||
private var showInspector: Bool = true
|
||||
|
||||
/// In ACP mode, events drive updates directly — no DB polling needed.
|
||||
private var isACPMode: Bool { chatViewModel.isACPConnected }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
if showSessionsList {
|
||||
ChatSessionListPane(chatViewModel: chatViewModel, richChat: richChat)
|
||||
.frame(width: 264)
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
Divider().background(ScarfColor.border)
|
||||
}
|
||||
ChatTranscriptPane(
|
||||
richChat: richChat,
|
||||
chatViewModel: chatViewModel,
|
||||
@@ -44,12 +55,30 @@ struct RichChatView: View {
|
||||
isEnabled: isEnabled
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
if showInspector {
|
||||
Divider().background(ScarfColor.border)
|
||||
ChatInspectorPane(chatViewModel: chatViewModel)
|
||||
.frame(width: 320)
|
||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)
|
||||
.environment(\.dynamicTypeSize, ChatFontScale.dynamicTypeSize(for: fontScale))
|
||||
// Animate side-pane shows/hides so the transcript reflows
|
||||
// smoothly rather than snapping. ~180ms feels responsive
|
||||
// without being jarring.
|
||||
.animation(.easeInOut(duration: 0.18), value: showSessionsList)
|
||||
.animation(.easeInOut(duration: 0.18), value: showInspector)
|
||||
// Auto-show inspector when a tool call is focused so a click
|
||||
// on a tool card is never silently lost (issue #58 follow-up).
|
||||
// Tool clicks set `chatViewModel.focusedToolCallId`; if that
|
||||
// becomes non-nil while the inspector is hidden, flip it back
|
||||
// on. The animation modifiers above cover the slide-in.
|
||||
.onChange(of: chatViewModel.focusedToolCallId) { _, new in
|
||||
if new != nil, !showInspector {
|
||||
showInspector = true
|
||||
}
|
||||
}
|
||||
// DB polling fallback for terminal mode only — never overwrite ACP messages
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil {
|
||||
|
||||
@@ -52,6 +52,21 @@ struct HermesCredentialPool: Identifiable, Sendable {
|
||||
let credentials: [HermesCredential]
|
||||
}
|
||||
|
||||
/// OAuth-authed provider parsed from `auth.json.providers.<name>`. Distinct
|
||||
/// from `HermesCredentialPool` because OAuth providers don't pool — one
|
||||
/// active token per provider, refresh handled by Hermes. Nous, Spotify,
|
||||
/// GitHub Copilot ACP, Qwen, Gemini all land here.
|
||||
struct HermesOAuthProvider: Identifiable, Sendable, Equatable {
|
||||
var id: String { provider }
|
||||
let provider: String // "nous" | "spotify" | ...
|
||||
let tokenTail: String // last 4 of access_token, never the full token
|
||||
let hasAccessToken: Bool
|
||||
let hasRefreshToken: Bool
|
||||
let expiresAt: Date?
|
||||
let portalURL: String? // "portal_base_url" — Nous-specific but generic-shaped
|
||||
let updatedAt: Date?
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class CredentialPoolsViewModel {
|
||||
@@ -64,6 +79,13 @@ final class CredentialPoolsViewModel {
|
||||
}
|
||||
|
||||
var pools: [HermesCredentialPool] = []
|
||||
/// OAuth-authed providers from `auth.json.providers.<name>` (Nous,
|
||||
/// Spotify, etc.). These have a different shape from `credential_pool`
|
||||
/// entries — one access token per provider, no rotation strategy —
|
||||
/// so they render in a parallel section rather than as a single-entry
|
||||
/// pool. Without this, OAuth providers were invisible in the UI even
|
||||
/// after a successful sign-in.
|
||||
var oauthProviders: [HermesOAuthProvider] = []
|
||||
var isLoading = false
|
||||
var message: String?
|
||||
|
||||
@@ -101,13 +123,70 @@ final class CredentialPoolsViewModel {
|
||||
decodedPools = []
|
||||
}
|
||||
|
||||
// OAuth providers are a parallel surface — different shape, so
|
||||
// we parse via `JSONSerialization` instead of folding into the
|
||||
// strict `AuthFile` decoder. A malformed `providers` block is
|
||||
// a non-fatal shrug: empty list, no banner.
|
||||
let oauth = Self.parseOAuthProviders(from: authData)
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
self?.pools = decodedPools
|
||||
self?.oauthProviders = oauth
|
||||
self?.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull `providers.<name>` entries out of `auth.json` and shape them
|
||||
/// for the UI. Returns an empty array when the file is missing,
|
||||
/// unparseable, or has no `providers` key.
|
||||
nonisolated private static func parseOAuthProviders(from data: Data?) -> [HermesOAuthProvider] {
|
||||
guard let data,
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let providers = root["providers"] as? [String: Any]
|
||||
else { return [] }
|
||||
|
||||
return providers.keys.sorted().compactMap { name in
|
||||
guard let entry = providers[name] as? [String: Any] else { return nil }
|
||||
let access = entry["access_token"] as? String ?? ""
|
||||
let refresh = entry["refresh_token"] as? String ?? ""
|
||||
// Worth surfacing if there's ANY token shape — pre-mint
|
||||
// refresh-only entries shouldn't be hidden.
|
||||
guard !access.isEmpty || !refresh.isEmpty else { return nil }
|
||||
|
||||
let expiresAt: Date? = {
|
||||
if let ms = entry["expires_at_ms"] as? Double, ms > 0 {
|
||||
return Date(timeIntervalSince1970: ms / 1000.0)
|
||||
}
|
||||
if let secs = entry["expires_at"] as? Double, secs > 0 {
|
||||
// Hermes' Nous flow writes epoch seconds as a Double here.
|
||||
return Date(timeIntervalSince1970: secs)
|
||||
}
|
||||
if let iso = entry["expires_at"] as? String {
|
||||
return Self.parseISO8601(iso)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
let updatedAt: Date? = {
|
||||
if let iso = entry["obtained_at"] as? String {
|
||||
return Self.parseISO8601(iso)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
return HermesOAuthProvider(
|
||||
provider: name,
|
||||
tokenTail: Self.tail(of: access.isEmpty ? refresh : access),
|
||||
hasAccessToken: !access.isEmpty,
|
||||
hasRefreshToken: !refresh.isEmpty,
|
||||
expiresAt: expiresAt,
|
||||
portalURL: entry["portal_base_url"] as? String,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `credential_pool_strategies:` map lives in config.yaml as `<provider>: <strategy>`.
|
||||
/// Pure-function form so it's safe to call from the detached load task.
|
||||
nonisolated private static func parseStrategies(from yaml: String) -> [String: String] {
|
||||
|
||||
@@ -20,9 +20,12 @@ struct CredentialPoolsView: View {
|
||||
safetyNotice
|
||||
if viewModel.isLoading {
|
||||
ProgressView().padding()
|
||||
} else if viewModel.pools.isEmpty {
|
||||
} else if viewModel.pools.isEmpty && viewModel.oauthProviders.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
if !viewModel.oauthProviders.isEmpty {
|
||||
oauthProvidersSection
|
||||
}
|
||||
ForEach(viewModel.pools) { pool in
|
||||
poolSection(pool)
|
||||
}
|
||||
@@ -37,7 +40,7 @@ struct CredentialPoolsView: View {
|
||||
.loadingOverlay(
|
||||
viewModel.isLoading,
|
||||
label: "Loading credentials…",
|
||||
isEmpty: viewModel.pools.isEmpty
|
||||
isEmpty: viewModel.pools.isEmpty && viewModel.oauthProviders.isEmpty
|
||||
)
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $showAddSheet) {
|
||||
@@ -114,6 +117,97 @@ struct CredentialPoolsView: View {
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
|
||||
/// Render OAuth-authed providers (`auth.json.providers.<name>`) as a
|
||||
/// single section above the rotation pools. Read-only — Hermes owns
|
||||
/// the write path via `hermes auth add <name>`. Rendered only when
|
||||
/// `viewModel.oauthProviders` is non-empty so users without any
|
||||
/// OAuth-authed providers don't see an empty header.
|
||||
@ViewBuilder
|
||||
private var oauthProvidersSection: some View {
|
||||
SettingsSection(title: LocalizedStringKey("OAuth providers"), icon: "person.badge.key") {
|
||||
ForEach(viewModel.oauthProviders) { provider in
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "person.badge.key")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(provider.provider.capitalized)
|
||||
.font(.system(.body, weight: .medium))
|
||||
Text("oauth")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
if !provider.hasAccessToken && provider.hasRefreshToken {
|
||||
Text("refresh-only")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
oauthExpiryBadge(provider)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Text(provider.tokenTail.isEmpty ? "—" : provider.tokenTail)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
if let updated = provider.updatedAt {
|
||||
Text("authed · \(Self.relativeAge(updated))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
if let url = provider.portalURL, !url.isEmpty {
|
||||
Text(url)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
HStack {
|
||||
Text("Managed by `hermes auth add <provider>` — Scarf is read-only here.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func oauthExpiryBadge(_ provider: HermesOAuthProvider) -> some View {
|
||||
if let expiresAt = provider.expiresAt {
|
||||
let secondsRemaining = expiresAt.timeIntervalSinceNow
|
||||
if secondsRemaining <= 0 {
|
||||
Text("expired")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.red)
|
||||
.clipShape(Capsule())
|
||||
} else if secondsRemaining < 7 * 86_400 {
|
||||
let days = max(1, Int(secondsRemaining / 86_400))
|
||||
Text("expires in \(days)d")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.orange)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func poolSection(_ pool: HermesCredentialPool) -> some View {
|
||||
SettingsSection(title: LocalizedStringKey(pool.provider), icon: "key.horizontal") {
|
||||
@@ -263,6 +357,11 @@ private struct AddCredentialSheet: View {
|
||||
@State private var apiKey: String = ""
|
||||
@State private var label: String = ""
|
||||
@State private var providers: [HermesProviderInfo] = []
|
||||
/// True while the initial models.dev catalog read is in flight.
|
||||
/// Drives the loading-overlay placeholder. Pre-fix this work ran
|
||||
/// synchronously inside `.onAppear` and froze the sheet for 1–2
|
||||
/// minutes on remote contexts (issue #59).
|
||||
@State private var isLoadingProviders: Bool = true
|
||||
@State private var oauthStarted: Bool = false
|
||||
@State private var authCode: String = ""
|
||||
/// Drives presentation of the dedicated Nous sign-in sheet from inside
|
||||
@@ -291,8 +390,23 @@ private struct AddCredentialSheet: View {
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 600, minHeight: 460)
|
||||
.onAppear {
|
||||
providers = catalog.loadProviders()
|
||||
.overlay {
|
||||
if isLoadingProviders {
|
||||
ProgressView("Loading providers…")
|
||||
.progressViewStyle(.circular)
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Off-MainActor read of the multi-megabyte models.dev cache
|
||||
// (via SSHTransport on remote contexts). Pre-fix this ran
|
||||
// sync inside `.onAppear` and froze the Add Credential sheet
|
||||
// for 1–2 minutes on remote contexts (issue #59).
|
||||
isLoadingProviders = true
|
||||
providers = await catalog.loadProvidersAsync()
|
||||
isLoadingProviders = false
|
||||
}
|
||||
// Auto-close the sheet once a credential is actually saved. We key
|
||||
// off `succeeded` which the controller sets only when hermes exited
|
||||
|
||||
@@ -33,6 +33,14 @@ final class DashboardViewModel {
|
||||
/// surfaceable error.
|
||||
var lastReadError: String?
|
||||
|
||||
/// Projects with their own `<project>/.hermes/` directory shadowing
|
||||
/// the global Hermes home. Hermes' CLI uses the closest `.hermes/`
|
||||
/// when invoked from inside such a project, which silently routes
|
||||
/// `hermes auth add` / setup writes into the project-local copy
|
||||
/// instead of `~/.hermes/`. Surfaced as a yellow banner so users
|
||||
/// can consolidate before more state drifts.
|
||||
var hermesShadows: [ProjectHermesShadowDetector.Shadow] = []
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
// refresh() = close + reopen, forces a fresh remote snapshot. Cheap
|
||||
@@ -110,6 +118,17 @@ final class DashboardViewModel {
|
||||
} else {
|
||||
lastReadError = nil
|
||||
}
|
||||
|
||||
// Probe for projects with shadow `.hermes/` directories. Read-only
|
||||
// — we just stat each registered project's path. Detached so the
|
||||
// SSH round-trips don't block the load completion.
|
||||
let ctx = context
|
||||
let detector = ProjectHermesShadowDetector(context: ctx)
|
||||
let projects = await Task.detached {
|
||||
ProjectDashboardService(context: ctx).loadRegistry().projects
|
||||
}.value
|
||||
hermesShadows = await detector.detect(in: projects)
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ struct DashboardView: View {
|
||||
if let err = viewModel.lastReadError {
|
||||
readErrorBanner(err)
|
||||
}
|
||||
if !viewModel.hermesShadows.isEmpty {
|
||||
hermesShadowBanner(viewModel.hermesShadows)
|
||||
}
|
||||
statusRow
|
||||
statsSection
|
||||
recentTwoColumn
|
||||
@@ -126,6 +129,99 @@ struct DashboardView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Hermes shadow banner
|
||||
|
||||
/// One row per project that carries its own `<project>/.hermes/`
|
||||
/// directory. Hermes' CLI binds to that as `$HERMES_HOME` when run
|
||||
/// from inside, which silently shadows the user's global setup —
|
||||
/// `hermes auth add nous` lands in the project, not in `~/.hermes/`,
|
||||
/// and Scarf's global probes show "missing provider" until consolidated.
|
||||
private func hermesShadowBanner(_ shadows: [ProjectHermesShadowDetector.Shadow]) -> some View {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
HStack(alignment: .top, spacing: ScarfSpace.s2) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Project-local Hermes home shadowing global setup")
|
||||
.scarfStyle(.bodyEmph)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
Text("These projects carry their own `.hermes/` directory. Hermes' CLI uses the closest one as `$HERMES_HOME` when run from inside the project, so credentials and config written there don't show up in your global Hermes setup. Consolidate to clear this warning.")
|
||||
.scarfStyle(.footnote)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
ForEach(shadows) { shadow in
|
||||
shadowRow(shadow)
|
||||
}
|
||||
}
|
||||
.padding(ScarfSpace.s3)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||
.fill(ScarfColor.warning.opacity(0.10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||
.strokeBorder(ScarfColor.warning.opacity(0.30), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func shadowRow(_ shadow: ProjectHermesShadowDetector.Shadow) -> some View {
|
||||
HStack(alignment: .top, spacing: ScarfSpace.s2) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(shadow.projectName)
|
||||
.scarfStyle(.bodyEmph)
|
||||
Text(shadow.shadowPath)
|
||||
.font(ScarfFont.monoSmall)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.textSelection(.enabled)
|
||||
HStack(spacing: 6) {
|
||||
if shadow.hasAuthJSON {
|
||||
Text("auth.json present")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(ScarfColor.warning.opacity(0.20))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
if shadow.hasStateDB {
|
||||
Text("state.db present")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(ScarfColor.warning.opacity(0.20))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if shadow.hasAuthJSON {
|
||||
Button("Copy fix command") {
|
||||
Task { @MainActor in
|
||||
let home = await viewModel.context.resolvedUserHome() + "/.hermes"
|
||||
if let cmd = ProjectHermesShadowDetector.consolidationCommand(
|
||||
for: shadow,
|
||||
hermesHome: home
|
||||
) {
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
pb.setString(cmd, forType: .string)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(ScarfSecondaryButton())
|
||||
.controlSize(.small)
|
||||
.help("Copies a one-liner that consolidates this project's auth.json into your global ~/.hermes/. Run it on the remote, then refresh the Dashboard.")
|
||||
}
|
||||
}
|
||||
.padding(ScarfSpace.s2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
|
||||
.fill(ScarfColor.warning.opacity(0.06))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Status row
|
||||
|
||||
private var statusRow: some View {
|
||||
|
||||
@@ -20,6 +20,16 @@ struct ProfilesView: View {
|
||||
@State private var renameTarget: HermesProfile?
|
||||
@State private var renameNewName = ""
|
||||
@State private var pendingDelete: HermesProfile?
|
||||
/// Remote-import sheet visibility. Local imports use `NSOpenPanel`
|
||||
/// inline; remote imports route through `RemoteProfilePathSheet`
|
||||
/// because the zip the user wants to import lives on the remote
|
||||
/// host (that's where `hermes profile export` produced it), and
|
||||
/// `NSOpenPanel` can only browse the local Mac.
|
||||
@State private var showRemoteImportSheet = false
|
||||
/// When non-nil, the export button on the named profile presents
|
||||
/// `RemoteProfilePathSheet` to ask for an output path on the
|
||||
/// remote host. Local exports continue to use `NSSavePanel`.
|
||||
@State private var pendingRemoteExport: HermesProfile?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -53,6 +63,36 @@ struct ProfilesView: View {
|
||||
} message: {
|
||||
Text("This removes the profile directory and all data within it. This cannot be undone.")
|
||||
}
|
||||
.sheet(isPresented: $showRemoteImportSheet) {
|
||||
RemoteProfilePathSheet(
|
||||
context: viewModel.context,
|
||||
title: "Import profile",
|
||||
prompt: "Enter the path to a profile `.zip` on \(viewModel.context.displayName).",
|
||||
placeholder: "e.g. ~/profiles/my-profile.zip",
|
||||
confirmLabel: "Import",
|
||||
mode: .existingFile,
|
||||
onCancel: { showRemoteImportSheet = false },
|
||||
onConfirm: { path in
|
||||
showRemoteImportSheet = false
|
||||
viewModel.import(from: path)
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(item: $pendingRemoteExport) { profile in
|
||||
RemoteProfilePathSheet(
|
||||
context: viewModel.context,
|
||||
title: "Export profile '\(profile.name)'",
|
||||
prompt: "Enter the destination path on \(viewModel.context.displayName) where the `.zip` should be written.",
|
||||
placeholder: "e.g. ~/\(profile.name)-profile.zip",
|
||||
confirmLabel: "Export",
|
||||
mode: .writableFile(initialName: "\(profile.name)-profile.zip"),
|
||||
onCancel: { pendingRemoteExport = nil },
|
||||
onConfirm: { path in
|
||||
pendingRemoteExport = nil
|
||||
viewModel.export(profile, to: path)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var listSection: some View {
|
||||
@@ -72,6 +112,13 @@ struct ProfilesView: View {
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button {
|
||||
if viewModel.context.isRemote {
|
||||
// The zip lives on the remote (where `hermes profile
|
||||
// export` produced it). NSOpenPanel can only browse
|
||||
// the user's Mac, so route through a remote-path
|
||||
// input sheet instead.
|
||||
showRemoteImportSheet = true
|
||||
} else {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.canChooseFiles = true
|
||||
@@ -80,6 +127,7 @@ struct ProfilesView: View {
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
viewModel.import(from: url.path)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Import", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
@@ -119,6 +167,14 @@ struct ProfilesView: View {
|
||||
renameNewName = profile.name
|
||||
}
|
||||
Button("Export…") {
|
||||
if viewModel.context.isRemote {
|
||||
// Exporting a remote profile must write to a
|
||||
// remote path — NSSavePanel would write to
|
||||
// the user's Mac, leaving the remote
|
||||
// profile zip nowhere on the host where
|
||||
// anyone can use it.
|
||||
pendingRemoteExport = profile
|
||||
} else {
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.nameFieldStringValue = "\(profile.name)-profile.zip"
|
||||
@@ -126,6 +182,7 @@ struct ProfilesView: View {
|
||||
viewModel.export(profile, to: url.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button("Delete", role: .destructive) { pendingDelete = profile }
|
||||
.disabled(profile.isActive)
|
||||
@@ -264,3 +321,147 @@ struct ProfilesView: View {
|
||||
.frame(minWidth: 440, minHeight: 180)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remote-path picker for profile import + export. Used when the active
|
||||
/// `ServerContext` is `.ssh` — `NSOpenPanel` / `NSSavePanel` would
|
||||
/// browse the user's Mac, which is the wrong host. The sheet takes a
|
||||
/// remote path string and verifies it via the active transport before
|
||||
/// handing it back. The `mode` distinguishes "must already exist" from
|
||||
/// "we're about to write here," each with appropriate validation.
|
||||
private struct RemoteProfilePathSheet: View {
|
||||
enum Mode {
|
||||
/// Import flow: zip must already exist on the remote.
|
||||
case existingFile
|
||||
/// Export flow: we'll be writing to the path. Permissive on
|
||||
/// non-existence (that's expected); warn on existing dir or
|
||||
/// non-zip extension.
|
||||
case writableFile(initialName: String)
|
||||
}
|
||||
|
||||
let context: ServerContext
|
||||
let title: String
|
||||
let prompt: String
|
||||
let placeholder: String
|
||||
let confirmLabel: String
|
||||
let mode: Mode
|
||||
let onCancel: () -> Void
|
||||
let onConfirm: (String) -> Void
|
||||
|
||||
@State private var path: String = ""
|
||||
@State private var verification: Verification = .idle
|
||||
|
||||
private enum Verification: Equatable {
|
||||
case idle
|
||||
case verifying
|
||||
case ok(String)
|
||||
case warn(String)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(title).font(.headline)
|
||||
Text(prompt)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
HStack {
|
||||
TextField(placeholder, text: $path)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: path) { _, _ in
|
||||
if verification != .idle { verification = .idle }
|
||||
}
|
||||
Button("Verify") { Task { await verify() } }
|
||||
.disabled(path.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| verification == .verifying)
|
||||
}
|
||||
verificationBadge
|
||||
HStack {
|
||||
Button("Cancel") { onCancel() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button(confirmLabel) {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
onConfirm(trimmed)
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(path.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 520)
|
||||
.onAppear {
|
||||
if case .writableFile(let initialName) = mode, path.isEmpty {
|
||||
path = "~/" + initialName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var verificationBadge: some View {
|
||||
switch verification {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
case .verifying:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Checking on \(context.displayName)…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
case .ok(let detail):
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text(detail).font(.caption)
|
||||
}
|
||||
case .warn(let detail):
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text(detail).font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func verify() async {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
verification = .verifying
|
||||
let snapshot = context
|
||||
let snapshotMode = mode
|
||||
let result: Verification = await Task.detached {
|
||||
let transport = snapshot.makeTransport()
|
||||
let exists = transport.fileExists(trimmed)
|
||||
switch snapshotMode {
|
||||
case .existingFile:
|
||||
guard exists else {
|
||||
return .warn("Path doesn't exist on \(snapshot.displayName).")
|
||||
}
|
||||
guard let stat = transport.stat(trimmed) else {
|
||||
return .warn("Found, but couldn't stat — check permissions.")
|
||||
}
|
||||
if stat.isDirectory {
|
||||
return .warn("Path is a directory, not a file.")
|
||||
}
|
||||
if !trimmed.lowercased().hasSuffix(".zip") {
|
||||
return .warn("File found, but extension isn't `.zip`. Profile import expects a zip archive.")
|
||||
}
|
||||
return .ok("File found on \(snapshot.displayName).")
|
||||
case .writableFile:
|
||||
if exists {
|
||||
if let stat = transport.stat(trimmed), stat.isDirectory {
|
||||
return .warn("Path is a directory. Choose a file path that doesn't yet exist.")
|
||||
}
|
||||
return .warn("File already exists on \(snapshot.displayName) — export will overwrite it.")
|
||||
}
|
||||
if !trimmed.lowercased().hasSuffix(".zip") {
|
||||
return .warn("Extension isn't `.zip`. The export command writes a zip archive.")
|
||||
}
|
||||
return .ok("Path is available on \(snapshot.displayName).")
|
||||
}
|
||||
}.value
|
||||
verification = result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ final class AddServerViewModel {
|
||||
var identityFile: String = ""
|
||||
/// Override for `~/.hermes` on the remote. Empty = default.
|
||||
var remoteHome: String = ""
|
||||
/// Override for the parent dir under which template installs land on
|
||||
/// this host. Empty = default (`~/projects`). Created on first install
|
||||
/// if missing.
|
||||
var projectsRoot: String = ""
|
||||
|
||||
var isTesting: Bool = false
|
||||
/// Outcome of the most recent Test Connection run. `nil` = not yet run.
|
||||
@@ -44,6 +48,7 @@ final class AddServerViewModel {
|
||||
port: Int(port),
|
||||
identityFile: nonEmpty(identityFile),
|
||||
remoteHome: nonEmpty(remoteHome),
|
||||
projectsRoot: nonEmpty(projectsRoot),
|
||||
hermesBinaryHint: nil
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,8 +50,8 @@ final class RemoteDiagnosticsViewModel {
|
||||
case .hermesHomeConfigured: return "Hermes home directory"
|
||||
case .hermesDirExists: return "Hermes directory exists"
|
||||
case .hermesDirReadable: return "Hermes directory readable"
|
||||
case .configYAMLReadable: return "config.yaml readable"
|
||||
case .configYAMLContents: return "config.yaml actually readable (content)"
|
||||
case .configYAMLReadable: return "config.yaml readable (optional)"
|
||||
case .configYAMLContents: return "config.yaml content (optional)"
|
||||
case .stateDBReadable: return "state.db readable"
|
||||
case .sqlite3Installed: return "sqlite3 binary installed on remote"
|
||||
case .sqlite3CanOpenStateDB: return "sqlite3 can open state.db"
|
||||
@@ -75,11 +75,15 @@ final class RemoteDiagnosticsViewModel {
|
||||
case .hermesDirReadable:
|
||||
return "The SSH user can see `~/.hermes` but can't list it. Check permissions: `ls -ld ~/.hermes` on the remote — the SSH user needs at least `r-x`."
|
||||
case .configYAMLReadable, .configYAMLContents:
|
||||
return "Scarf can't read `config.yaml`. This usually means the SSH user is different from the user Hermes runs as. Either (a) run Hermes as the SSH user, (b) `chmod a+r ~/.hermes/config.yaml`, or (c) configure Scarf to SSH as the Hermes user."
|
||||
// Reached only when the file EXISTS but is unreadable —
|
||||
// a real permission issue. The "file absent" case emits
|
||||
// SKIP (Hermes v0.11+ creates config.yaml lazily, only
|
||||
// when the user changes a setting from defaults).
|
||||
return "`config.yaml` exists on the remote but the SSH user can't read it. Either (a) run Hermes as the SSH user, (b) `chmod a+r ~/.hermes/config.yaml`, or (c) configure Scarf to SSH as the Hermes user. If `config.yaml` is missing entirely, that's fine — Hermes only creates it when you change a setting from the defaults."
|
||||
case .stateDBReadable:
|
||||
return "Scarf can't read `state.db` — Sessions, Activity, Dashboard stats all depend on this. Same fix pattern as config.yaml."
|
||||
return "Scarf can't read `state.db` — Sessions, Activity, Dashboard stats all depend on this. Either (a) run Hermes as the SSH user, (b) `chmod a+r ~/.hermes/state.db`, or (c) configure Scarf to SSH as the Hermes user."
|
||||
case .sqlite3Installed:
|
||||
return "Scarf pulls a snapshot of state.db via `sqlite3 .backup`, so sqlite3 must be installed on the remote. Install: `sudo apt install sqlite3` (Ubuntu/Debian), `sudo yum install sqlite` (RHEL/Fedora), `apk add sqlite` (Alpine)."
|
||||
return "Scarf pulls a snapshot of state.db via `sqlite3 .backup`, so sqlite3 must be installed on the remote AND visible to non-interactive SSH sessions. The probe sources `~/.zshenv` / `.zprofile` / `.bash_profile` / `.profile` and falls back to `/usr/bin`, `/usr/local/bin`, `/opt/homebrew/bin`, and `/opt/local/bin` — if it's still not found, either install via your package manager (`sudo apt install sqlite3` / `sudo yum install sqlite` / `apk add sqlite`) or symlink the existing binary into a location the probe checks (e.g. `sudo ln -s /your/path/sqlite3 /usr/local/bin/sqlite3`)."
|
||||
case .sqlite3CanOpenStateDB:
|
||||
return "sqlite3 exists but can't open state.db. Could be a permission issue, a corrupt DB, or a version skew."
|
||||
case .hermesBinaryNonLogin:
|
||||
@@ -92,10 +96,26 @@ final class RemoteDiagnosticsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tri-state probe outcome. `.skipped` covers checks that didn't
|
||||
/// run because they aren't applicable (e.g. config.yaml absence on
|
||||
/// a fresh Hermes v0.11+ install — the file is created lazily, so
|
||||
/// missing is normal). UI renders skipped probes with a grey info
|
||||
/// icon and excludes them from "X/Y failing" tallies.
|
||||
enum ProbeStatus: Sendable, Equatable {
|
||||
case pass
|
||||
case fail
|
||||
case skipped
|
||||
}
|
||||
|
||||
struct Probe: Identifiable, Sendable {
|
||||
let id: ProbeID
|
||||
let passed: Bool
|
||||
let status: ProbeStatus
|
||||
let detail: String
|
||||
|
||||
/// Back-compat for callers (Copy Full Report, view counters)
|
||||
/// that still think in pass/fail. Skipped probes report `true`
|
||||
/// so they don't count as failures.
|
||||
var passed: Bool { status != .fail }
|
||||
}
|
||||
|
||||
private(set) var probes: [Probe] = []
|
||||
@@ -135,10 +155,10 @@ final class RemoteDiagnosticsViewModel {
|
||||
rawStderr = msg
|
||||
rawExitCode = -1
|
||||
probes = [
|
||||
Probe(id: .connectivity, passed: false, detail: msg)
|
||||
Probe(id: .connectivity, status: .fail, detail: msg)
|
||||
] + ProbeID.allCases
|
||||
.filter { $0 != .connectivity }
|
||||
.map { Probe(id: $0, passed: false, detail: "(skipped — SSH didn't connect)") }
|
||||
.map { Probe(id: $0, status: .fail, detail: "(skipped — SSH didn't connect)") }
|
||||
case .completed(let stdout, let stderr, let exitCode):
|
||||
rawStdout = stdout
|
||||
rawStderr = stderr
|
||||
@@ -151,18 +171,37 @@ final class RemoteDiagnosticsViewModel {
|
||||
Self.logger.info("Diagnostics for \(self.context.displayName, privacy: .public) finished — \(self.passingCount)/\(self.probes.count) passing")
|
||||
}
|
||||
|
||||
/// Quick summary string, e.g. "9/14 passing". Used in the header.
|
||||
/// Quick summary string. Skipped probes (e.g. config.yaml absent
|
||||
/// on a fresh Hermes v0.11+ install) are excluded from the
|
||||
/// denominator so the user sees "12/12 passing" instead of a
|
||||
/// misleading "12/14 passing." When any probe is skipped we
|
||||
/// append a parenthetical so it's still visible at a glance.
|
||||
var summary: String {
|
||||
guard !probes.isEmpty else { return "Not yet run." }
|
||||
return "\(passingCount)/\(probes.count) checks passing"
|
||||
let total = probes.filter { $0.status != .skipped }.count
|
||||
var s = "\(passingCount)/\(total) checks passing"
|
||||
if skippedCount > 0 {
|
||||
s += " (\(skippedCount) optional skipped)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var passingCount: Int {
|
||||
probes.filter { $0.passed }.count
|
||||
probes.filter { $0.status == .pass }.count
|
||||
}
|
||||
|
||||
var skippedCount: Int {
|
||||
probes.filter { $0.status == .skipped }.count
|
||||
}
|
||||
|
||||
var failingCount: Int {
|
||||
probes.filter { $0.status == .fail }.count
|
||||
}
|
||||
|
||||
/// True iff every applicable probe passed — skipped probes don't
|
||||
/// block the green-banner state because they're informational.
|
||||
var allPassed: Bool {
|
||||
!probes.isEmpty && passingCount == probes.count
|
||||
!probes.isEmpty && failingCount == 0
|
||||
}
|
||||
|
||||
// MARK: - Script + parsing
|
||||
@@ -210,22 +249,33 @@ final class RemoteDiagnosticsViewModel {
|
||||
emit hermesDirReadable FAIL "cannot read/enter $H (check perms on the dir)"
|
||||
fi
|
||||
|
||||
# config.yaml is OPTIONAL on Hermes v0.11+ — the file is created
|
||||
# lazily when the user changes a setting from defaults. So a
|
||||
# working fresh install is expected to have no config.yaml.
|
||||
# The probe distinguishes:
|
||||
# PASS — file exists and is readable
|
||||
# SKIP — file is absent (informational, not a failure)
|
||||
# FAIL — file exists but the SSH user can't read it (real perm issue)
|
||||
if [ -r "$H/config.yaml" ]; then
|
||||
emit configYAMLReadable PASS ""
|
||||
else
|
||||
if [ -e "$H/config.yaml" ]; then
|
||||
emit configYAMLReadable FAIL "exists but not readable by $user"
|
||||
else
|
||||
emit configYAMLReadable FAIL "file does not exist"
|
||||
emit configYAMLReadable SKIP "not present (Hermes creates it on first config change)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -e "$H/config.yaml" ]; then
|
||||
if head -c 1 "$H/config.yaml" > /dev/null 2>&1; then
|
||||
size=$(wc -c < "$H/config.yaml" 2>/dev/null | tr -d ' ')
|
||||
emit configYAMLContents PASS "${size} bytes"
|
||||
else
|
||||
emit configYAMLContents FAIL "cannot read file contents"
|
||||
fi
|
||||
else
|
||||
emit configYAMLContents SKIP "not present (no content to read)"
|
||||
fi
|
||||
|
||||
if [ -r "$H/state.db" ]; then
|
||||
size=$(wc -c < "$H/state.db" 2>/dev/null | tr -d ' ')
|
||||
@@ -238,21 +288,10 @@ final class RemoteDiagnosticsViewModel {
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v sqlite3 > /dev/null 2>&1; then
|
||||
sq=$(command -v sqlite3)
|
||||
emit sqlite3Installed PASS "$sq"
|
||||
else
|
||||
emit sqlite3Installed FAIL "sqlite3 not on PATH"
|
||||
fi
|
||||
|
||||
if sqlite3 "$H/state.db" 'SELECT 1' > /dev/null 2>&1; then
|
||||
emit sqlite3CanOpenStateDB PASS ""
|
||||
else
|
||||
err=$(sqlite3 "$H/state.db" 'SELECT 1' 2>&1 | head -1)
|
||||
emit sqlite3CanOpenStateDB FAIL "$err"
|
||||
fi
|
||||
|
||||
# Non-login PATH: just ask the current shell.
|
||||
# Non-login PATH probe for `hermes` runs in the bare shell BEFORE
|
||||
# sourcing rc files — that semantic ("is hermes on the un-enriched
|
||||
# PATH the SSH session inherits?") is meaningful and we don't
|
||||
# want to muddle it.
|
||||
hpath=$(command -v hermes 2>/dev/null)
|
||||
if [ -n "$hpath" ]; then
|
||||
emit hermesBinaryNonLogin PASS "$hpath"
|
||||
@@ -260,10 +299,18 @@ final class RemoteDiagnosticsViewModel {
|
||||
emit hermesBinaryNonLogin FAIL "not on non-login PATH ($PATH)"
|
||||
fi
|
||||
|
||||
# Login PATH: source rc files (mirroring TestConnectionProbe) and re-probe.
|
||||
# Source rc files (mirroring TestConnectionProbe) so subsequent
|
||||
# probes see the user's full login PATH. sqlite3 / hermes-login
|
||||
# detection happens AFTER this so installs in Homebrew /
|
||||
# `/usr/local/bin` / pipx / etc. are findable on hosts where the
|
||||
# non-login SSH session inherits a stripped PATH (issue #19,
|
||||
# @cmalpass's case where sqlite3 was installed but probed as
|
||||
# missing — the non-login shell didn't have Homebrew on PATH).
|
||||
for rc in "$HOME/.zshenv" "$HOME/.zprofile" "$HOME/.bash_profile" "$HOME/.profile"; do
|
||||
[ -f "$rc" ] && . "$rc" 2>/dev/null
|
||||
done
|
||||
|
||||
# Login-PATH `hermes` probe with hardcoded candidate fallback.
|
||||
hpath2=$(command -v hermes 2>/dev/null)
|
||||
if [ -z "$hpath2" ]; then
|
||||
for cand in "$HOME/.local/bin/hermes" "/opt/homebrew/bin/hermes" "/usr/local/bin/hermes" "$HOME/.hermes/bin/hermes"; do
|
||||
@@ -276,6 +323,36 @@ final class RemoteDiagnosticsViewModel {
|
||||
emit hermesBinaryLogin FAIL "not found after sourcing rc files"
|
||||
fi
|
||||
|
||||
# sqlite3 detection — also after sourcing rc files, with a
|
||||
# standard-location fallback that mirrors the hermes probe
|
||||
# above. Pre-fix this was a bare `command -v sqlite3` in the
|
||||
# non-login shell, which produced false negatives on Homebrew
|
||||
# / `/usr/local/bin` installs (issue #19 layer 3).
|
||||
sqbin=$(command -v sqlite3 2>/dev/null)
|
||||
if [ -z "$sqbin" ]; then
|
||||
for cand in "/usr/bin/sqlite3" "/usr/local/bin/sqlite3" "/opt/homebrew/bin/sqlite3" "/opt/local/bin/sqlite3"; do
|
||||
if [ -x "$cand" ]; then sqbin="$cand"; break; fi
|
||||
done
|
||||
fi
|
||||
if [ -n "$sqbin" ]; then
|
||||
emit sqlite3Installed PASS "$sqbin"
|
||||
else
|
||||
emit sqlite3Installed FAIL "not found on PATH or in standard locations"
|
||||
fi
|
||||
|
||||
# Use the resolved sqlite3 path explicitly so the open-state.db
|
||||
# probe doesn't re-fail-by-PATH when the binary is at e.g.
|
||||
# /opt/homebrew/bin. Falls back to bare `sqlite3` so the FAIL
|
||||
# detail line (with the underlying error) is still informative
|
||||
# if no candidate was found.
|
||||
sqcmd="${sqbin:-sqlite3}"
|
||||
if "$sqcmd" "$H/state.db" 'SELECT 1' > /dev/null 2>&1; then
|
||||
emit sqlite3CanOpenStateDB PASS ""
|
||||
else
|
||||
err=$("$sqcmd" "$H/state.db" 'SELECT 1' 2>&1 | head -1)
|
||||
emit sqlite3CanOpenStateDB FAIL "$err"
|
||||
fi
|
||||
|
||||
if command -v pgrep > /dev/null 2>&1; then
|
||||
emit pgrepAvailable PASS "$(command -v pgrep)"
|
||||
else
|
||||
@@ -292,12 +369,18 @@ final class RemoteDiagnosticsViewModel {
|
||||
let parts = line.split(separator: "|", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
guard parts.count == 3 else { continue }
|
||||
let key = String(parts[0]).trimmingCharacters(in: .whitespaces)
|
||||
let status = String(parts[1]).trimmingCharacters(in: .whitespaces)
|
||||
let statusRaw = String(parts[1]).trimmingCharacters(in: .whitespaces)
|
||||
let detail = String(parts[2]).trimmingCharacters(in: .whitespaces)
|
||||
guard let probe = ProbeID(rawValue: key) else { continue }
|
||||
let status: ProbeStatus
|
||||
switch statusRaw {
|
||||
case "PASS": status = .pass
|
||||
case "SKIP": status = .skipped
|
||||
default: status = .fail
|
||||
}
|
||||
results[probe] = Probe(
|
||||
id: probe,
|
||||
passed: status == "PASS",
|
||||
status: status,
|
||||
detail: detail
|
||||
)
|
||||
}
|
||||
@@ -315,7 +398,7 @@ final class RemoteDiagnosticsViewModel {
|
||||
}
|
||||
|
||||
return ProbeID.allCases.map { id in
|
||||
results[id] ?? Probe(id: id, passed: false, detail: fallbackDetail)
|
||||
results[id] ?? Probe(id: id, status: .fail, detail: fallbackDetail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,16 @@ struct AddServerSheet: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
LabeledField("Projects directory") {
|
||||
TextField("Default: ~/projects", text: $viewModel.projectsRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
Text("Where Scarf installs new project templates on this host. Created on first install if missing.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text("Scarf uses ssh-agent for authentication. If your key has a passphrase, run `ssh-add` before connecting — Scarf never prompts for or stores passphrases.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -93,8 +93,10 @@ struct RemoteDiagnosticsView: View {
|
||||
|
||||
private func probeRow(_ probe: RemoteDiagnosticsViewModel.Probe) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: probe.passed ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.foregroundStyle(probe.passed ? .green : .red)
|
||||
// Tri-state icon: green check on pass, red x on fail, grey
|
||||
// info-circle on skipped (the optional-and-absent state).
|
||||
Image(systemName: iconName(for: probe.status))
|
||||
.foregroundStyle(iconColor(for: probe.status))
|
||||
.font(.title3)
|
||||
.padding(.top, 2)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -106,7 +108,7 @@ struct RemoteDiagnosticsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if !probe.passed, let hint = probe.id.failureHint {
|
||||
if probe.status == .fail, let hint = probe.id.failureHint {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Image(systemName: "lightbulb")
|
||||
.foregroundStyle(.yellow)
|
||||
@@ -128,6 +130,22 @@ struct RemoteDiagnosticsView: View {
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func iconName(for status: RemoteDiagnosticsViewModel.ProbeStatus) -> String {
|
||||
switch status {
|
||||
case .pass: return "checkmark.circle.fill"
|
||||
case .fail: return "xmark.circle.fill"
|
||||
case .skipped: return "info.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private func iconColor(for status: RemoteDiagnosticsViewModel.ProbeStatus) -> Color {
|
||||
switch status {
|
||||
case .pass: return .green
|
||||
case .fail: return .red
|
||||
case .skipped: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Raw-output disclosure. Shown whenever anything fails — we need
|
||||
@@ -189,10 +207,15 @@ struct RemoteDiagnosticsView: View {
|
||||
lines.append("Result: \(viewModel.summary)")
|
||||
lines.append("")
|
||||
for probe in viewModel.probes {
|
||||
let mark = probe.passed ? "PASS" : "FAIL"
|
||||
let mark: String
|
||||
switch probe.status {
|
||||
case .pass: mark = "PASS"
|
||||
case .fail: mark = "FAIL"
|
||||
case .skipped: mark = "SKIP"
|
||||
}
|
||||
lines.append("[\(mark)] \(probe.id.title)")
|
||||
if !probe.detail.isEmpty { lines.append(" \(probe.detail)") }
|
||||
if !probe.passed, let hint = probe.id.failureHint {
|
||||
if probe.status == .fail, let hint = probe.id.failureHint {
|
||||
lines.append(" hint: \(hint)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ final class SessionsViewModel {
|
||||
|
||||
func selectSession(_ session: HermesSession) async {
|
||||
selectedSession = session
|
||||
messages = await dataService.fetchMessages(sessionId: session.id)
|
||||
messages = await dataService.fetchMessages(sessionId: session.id, limit: HistoryPageSize.macSessionDetail)
|
||||
subagentSessions = await dataService.fetchSubagentSessions(parentId: session.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -271,10 +271,16 @@ final class SettingsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func runRestore(from url: URL) {
|
||||
/// Restore from a backup `.zip`. The path may be local (the user picked
|
||||
/// it via `NSOpenPanel` on a local context) or remote (the user typed it
|
||||
/// in the remote-path sheet). Either way, the call goes through
|
||||
/// `fileService.runHermesCLI`, which is transport-aware — for an SSH
|
||||
/// context the `hermes import <path>` command runs on the remote shell
|
||||
/// where `<path>` is a remote filesystem path.
|
||||
func runRestore(fromPath path: String) {
|
||||
backupInProgress = true
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["import", url.path], timeout: 300)
|
||||
let result = fileService.runHermesCLI(args: ["import", path], timeout: 300)
|
||||
await MainActor.run {
|
||||
self.backupInProgress = false
|
||||
self.saveMessage = result.exitCode == 0 ? "Restore complete — restart Scarf" : "Restore failed"
|
||||
@@ -299,17 +305,6 @@ final class SettingsViewModel {
|
||||
return String(output[r])
|
||||
}
|
||||
|
||||
func presentRestorePicker() -> URL? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.message = "Choose a Hermes backup archive to restore"
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return nil }
|
||||
return url
|
||||
}
|
||||
|
||||
func openConfigInEditor() {
|
||||
// No-op for remote contexts — the file is on the remote host, not
|
||||
// this Mac. The Settings tab's in-app editor is the supported way
|
||||
|
||||
@@ -22,6 +22,12 @@ struct ModelPickerSheet: View {
|
||||
@State private var models: [HermesModelInfo] = []
|
||||
@State private var selectedModelID: String = ""
|
||||
@State private var searchText: String = ""
|
||||
/// True while the initial catalog load (or a per-provider model
|
||||
/// reload) is in flight. Drives the loading-overlay placeholder.
|
||||
/// Pre-fix this work ran synchronously inside `.onAppear` — issue
|
||||
/// #59. The catalog file is multi-MB on remote contexts; sync I/O
|
||||
/// on the MainActor froze the picker for 1–2 minutes.
|
||||
@State private var isLoadingCatalog: Bool = true
|
||||
|
||||
// Custom model entry — used when the catalog doesn't have the exact model
|
||||
// the user needs (e.g., provider-prefixed IDs like "openrouter/some/model").
|
||||
@@ -41,6 +47,20 @@ struct ModelPickerSheet: View {
|
||||
/// "Sign in to Nous Portal" button in the subscription summary.
|
||||
@State private var showNousSignIn: Bool = false
|
||||
|
||||
/// Cached + freshly-fetched Nous model list for the picker's
|
||||
/// nous-overlay branch. Populated on appear (cache-first) and
|
||||
/// refreshed when the user signs in or hits the Refresh button.
|
||||
@State private var nousModels: [NousModel] = []
|
||||
@State private var nousFetchedAt: Date?
|
||||
@State private var nousRefreshError: String?
|
||||
@State private var nousIsRefreshing: Bool = false
|
||||
/// When true, render the Nous detail with the original free-form
|
||||
/// TextField + manual hint instead of the model list. Used when
|
||||
/// the user explicitly wants to type a model not in the catalog —
|
||||
/// the API list is comprehensive but not infallible, so always
|
||||
/// keep the escape hatch reachable.
|
||||
@State private var nousManualEntry: Bool = false
|
||||
|
||||
/// Validation failure surfaced on Select when the typed / selected
|
||||
/// model isn't in the chosen provider's catalog. Pass-1 M7 #5
|
||||
/// cross-platform fix — previously Scarf let you save any string
|
||||
@@ -67,13 +87,33 @@ struct ModelPickerSheet: View {
|
||||
footer
|
||||
}
|
||||
.frame(minWidth: 720, minHeight: 520)
|
||||
.onAppear {
|
||||
providers = catalog.loadProviders()
|
||||
.overlay {
|
||||
if isLoadingCatalog {
|
||||
ProgressView("Loading providers…")
|
||||
.progressViewStyle(.circular)
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Off-MainActor read of the multi-megabyte models.dev cache
|
||||
// (via SSHTransport on remote contexts). Pre-fix this ran
|
||||
// sync inside `.onAppear` and froze the picker for 1–2
|
||||
// minutes on remote contexts (issue #59).
|
||||
isLoadingCatalog = true
|
||||
providers = await catalog.loadProvidersAsync()
|
||||
selectedProviderID = initialProvider.isEmpty ? (providers.first?.providerID ?? "") : initialProvider
|
||||
selectedModelID = initialModel
|
||||
overlayModelID = initialModel
|
||||
subscription = subscriptionService.loadState()
|
||||
loadModelsForSelection()
|
||||
// subscriptionService.loadState() reads auth.json — tiny
|
||||
// on local but still SSH-backed on remote, so route it
|
||||
// through a detached task too. The result is a small
|
||||
// value type; safe to assign back onto MainActor.
|
||||
let svc = subscriptionService
|
||||
subscription = await Task.detached { svc.loadState() }.value
|
||||
await loadModelsForSelectionAsync()
|
||||
isLoadingCatalog = false
|
||||
}
|
||||
.sheet(isPresented: $showNousSignIn) {
|
||||
NousSignInSheet {
|
||||
@@ -81,6 +121,10 @@ struct ModelPickerSheet: View {
|
||||
// status row flips to "active" without waiting for the
|
||||
// picker to be re-opened.
|
||||
subscription = subscriptionService.loadState()
|
||||
// Sign-in unlocked the bearer token — kick a fresh
|
||||
// model-list fetch so the picker populates without the
|
||||
// user needing to hit Refresh manually.
|
||||
Task { await refreshNousModels(forceRefresh: true) }
|
||||
}
|
||||
}
|
||||
.alert(item: $validationIssue) { issue in
|
||||
@@ -134,7 +178,7 @@ struct ModelPickerSheet: View {
|
||||
get: { selectedProviderID },
|
||||
set: { newValue in
|
||||
selectedProviderID = newValue
|
||||
loadModelsForSelection()
|
||||
Task { await loadModelsForSelectionAsync() }
|
||||
}
|
||||
)) {
|
||||
ForEach(filteredProviders) { provider in
|
||||
@@ -163,11 +207,17 @@ struct ModelPickerSheet: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var modelColumn: some View {
|
||||
if let selected = providers.first(where: { $0.providerID == selectedProviderID }), selected.isOverlay {
|
||||
if let selected = providers.first(where: { $0.providerID == selectedProviderID }) {
|
||||
if selected.providerID == "nous" {
|
||||
nousOverlayDetail(selected)
|
||||
} else if selected.isOverlay {
|
||||
overlayProviderDetail(selected)
|
||||
} else {
|
||||
cachedModelList
|
||||
}
|
||||
} else {
|
||||
cachedModelList
|
||||
}
|
||||
}
|
||||
|
||||
private var cachedModelList: some View {
|
||||
@@ -215,6 +265,147 @@ struct ModelPickerSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Right-column detail for Nous Portal — same overlay shape as
|
||||
/// `overlayProviderDetail` but with a live model list fetched from
|
||||
/// Nous's OpenAI-compatible `/v1/models` endpoint. The list is
|
||||
/// cache-first so opening the sheet feels instant; refresh runs
|
||||
/// in the background. Falls back to a hard-coded short list when
|
||||
/// the user has no token AND no cache (offline first-run on a
|
||||
/// fresh remote install). The "Custom…" button below the list
|
||||
/// flips to the original free-form TextField — Nous occasionally
|
||||
/// adds a model before our cache hits 24h, and we don't want
|
||||
/// users locked out of the latest releases.
|
||||
@ViewBuilder
|
||||
private func nousOverlayDetail(_ provider: HermesProviderInfo) -> some View {
|
||||
let overlay = catalog.overlayMetadata(for: provider.providerID)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(provider.providerName).font(.title3.bold())
|
||||
if provider.subscriptionGated {
|
||||
capsuleTag("Subscription", tint: .accentColor)
|
||||
}
|
||||
}
|
||||
if provider.subscriptionGated {
|
||||
subscriptionSummary(provider: provider, overlay: overlay)
|
||||
}
|
||||
Divider()
|
||||
if nousManualEntry {
|
||||
nousManualEntryBlock(provider: provider)
|
||||
} else {
|
||||
nousModelListBlock
|
||||
}
|
||||
if let docURL = overlay?.docURL, let url = URL(string: docURL) {
|
||||
Link(destination: url) {
|
||||
Label("Setup documentation", systemImage: "book")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var nousModelListBlock: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Available models")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if nousIsRefreshing {
|
||||
HStack(spacing: 4) {
|
||||
ProgressView().controlSize(.mini)
|
||||
Text("Refreshing…").font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
Task { await refreshNousModels(forceRefresh: true) }
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help(nousFetchedAtTooltip)
|
||||
}
|
||||
Button("Custom…") { nousManualEntry = true }
|
||||
.controlSize(.small)
|
||||
}
|
||||
if let err = nousRefreshError, !nousIsRefreshing {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text(err)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
List(selection: $overlayModelID) {
|
||||
ForEach(nousModels) { model in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(model.id)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
if let owner = model.owned_by, !owner.isEmpty {
|
||||
Text(owner)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.tag(model.id)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.frame(minHeight: 220)
|
||||
.overlay {
|
||||
if nousModels.isEmpty && !nousIsRefreshing {
|
||||
ContentUnavailableView(
|
||||
"No models loaded",
|
||||
systemImage: "cpu",
|
||||
description: Text("Sign in to Nous Portal to load the catalog, or enter a model ID manually.")
|
||||
)
|
||||
}
|
||||
}
|
||||
if nousFetchedAt == nil && !nousModels.isEmpty {
|
||||
Text("Showing built-in fallback list — couldn't reach Nous to refresh.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Text("Leave blank in config to let Hermes pick the default Nous model. Picking one above writes it explicitly.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func nousManualEntryBlock(provider: HermesProviderInfo) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("Model ID").font(.caption).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Use list") { nousManualEntry = false }
|
||||
.controlSize(.small)
|
||||
}
|
||||
TextField(modelIDPlaceholder(for: provider), text: $overlayModelID)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Text("Type a model ID exactly as Nous expects it. Leave blank to use Hermes's default.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
private var nousFetchedAtTooltip: String {
|
||||
guard let date = nousFetchedAt else {
|
||||
return "Fetch the latest model list from Nous."
|
||||
}
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .short
|
||||
return "Last refreshed \(formatter.localizedString(for: date, relativeTo: Date()))"
|
||||
}
|
||||
|
||||
/// Right-column detail for overlay-only providers (Nous Portal, OpenAI
|
||||
/// Codex, Qwen OAuth, …). models.dev has no catalog for them, so the user
|
||||
/// either trusts Hermes's default (subscription providers) or types a
|
||||
@@ -424,17 +615,70 @@ struct ModelPickerSheet: View {
|
||||
return resolved.isEmpty ? "Provider will not be changed" : "Provider → \(resolved)"
|
||||
}
|
||||
|
||||
private func loadModelsForSelection() {
|
||||
/// Async variant of the per-provider catalog read. Pre-fix this
|
||||
/// was synchronous on the MainActor and froze the picker every
|
||||
/// time the user clicked a different provider — same root cause
|
||||
/// as the open-sheet freeze (issue #59). Routes through
|
||||
/// `loadModelsAsync(for:)` which dispatches the SSHTransport
|
||||
/// file read off the main thread.
|
||||
private func loadModelsForSelectionAsync() async {
|
||||
guard !selectedProviderID.isEmpty else {
|
||||
models = []
|
||||
return
|
||||
}
|
||||
models = catalog.loadModels(for: selectedProviderID)
|
||||
models = await catalog.loadModelsAsync(for: selectedProviderID)
|
||||
// If the current selection is not in the new list, don't try to keep
|
||||
// stale highlight state — clear unless the user originally had this model.
|
||||
if !models.contains(where: { $0.modelID == selectedModelID }) {
|
||||
selectedModelID = models.first?.modelID ?? ""
|
||||
}
|
||||
// Cache-first kick for the Nous catalog. Renders from cache
|
||||
// immediately, fires a background refresh if stale or empty.
|
||||
if selectedProviderID == "nous" {
|
||||
Task { await refreshNousModels(forceRefresh: false) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache-first load of the Nous model list. Updates the four
|
||||
/// `@State` vars the detail view reads. Force-refresh skips the
|
||||
/// TTL check so the user-tapped Refresh button always hits the
|
||||
/// network — the cache write keeps the next sheet-open instant.
|
||||
private func refreshNousModels(forceRefresh: Bool) async {
|
||||
let service = NousModelCatalogService(context: serverContext)
|
||||
// Render from cache immediately on the first pass so the user
|
||||
// doesn't see an empty list while the network call is in
|
||||
// flight. The async load below overwrites with fresh data
|
||||
// when it returns.
|
||||
if !forceRefresh, let cache = service.readCache(), !cache.models.isEmpty, nousModels.isEmpty {
|
||||
nousModels = cache.models
|
||||
nousFetchedAt = cache.fetchedAt
|
||||
nousRefreshError = nil
|
||||
}
|
||||
nousIsRefreshing = true
|
||||
let result = await service.loadModels(forceRefresh: forceRefresh)
|
||||
nousIsRefreshing = false
|
||||
switch result {
|
||||
case .fresh(let models, let fetchedAt):
|
||||
nousModels = models
|
||||
nousFetchedAt = fetchedAt
|
||||
nousRefreshError = nil
|
||||
case .cache(let models, let fetchedAt, let refreshError):
|
||||
nousModels = models
|
||||
nousFetchedAt = fetchedAt
|
||||
nousRefreshError = refreshError
|
||||
case .fallback(let models, let reason):
|
||||
nousModels = models
|
||||
nousFetchedAt = nil
|
||||
nousRefreshError = reason
|
||||
}
|
||||
// Pre-fill `overlayModelID` with the user's previously chosen
|
||||
// model when it's in the freshly-loaded list — otherwise the
|
||||
// selection state highlights nothing on first paint.
|
||||
if !overlayModelID.isEmpty,
|
||||
!nousModels.contains(where: { $0.id == overlayModelID }) {
|
||||
// Leave overlayModelID alone — it's a user-typed value
|
||||
// that may legitimately not be in the catalog.
|
||||
}
|
||||
}
|
||||
|
||||
/// When the user enters a custom model ID without explicitly naming a
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Advanced tab — network, compression, checkpoints, logging, delegation, file read cap,
|
||||
/// cron wrap, config diagnostics, backup/restore, paths, raw config.
|
||||
@@ -7,7 +9,8 @@ struct AdvancedTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
@State private var showRawConfig = false
|
||||
@State private var showRestoreConfirm = false
|
||||
@State private var pendingRestoreURL: URL?
|
||||
@State private var pendingRestorePath: String?
|
||||
@State private var showRemoteRestoreSheet = false
|
||||
@State private var diagnosticsOutput: String = ""
|
||||
@State private var showDiagnostics = false
|
||||
|
||||
@@ -111,10 +114,18 @@ struct AdvancedTab: View {
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.backupInProgress)
|
||||
Button {
|
||||
if let url = viewModel.presentRestorePicker() {
|
||||
pendingRestoreURL = url
|
||||
if viewModel.context.isRemote {
|
||||
// The backup zip lives on the remote (that's where
|
||||
// `hermes backup` ran). NSOpenPanel can only browse
|
||||
// the user's Mac, so present a remote-path input
|
||||
// sheet instead.
|
||||
showRemoteRestoreSheet = true
|
||||
} else {
|
||||
if let path = pickLocalBackupZip() {
|
||||
pendingRestorePath = path
|
||||
showRestoreConfirm = true
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Restore…", systemImage: "arrow.up.doc")
|
||||
}
|
||||
@@ -131,15 +142,40 @@ struct AdvancedTab: View {
|
||||
}
|
||||
.confirmationDialog("Restore from backup?", isPresented: $showRestoreConfirm) {
|
||||
Button("Restore", role: .destructive) {
|
||||
if let url = pendingRestoreURL {
|
||||
viewModel.runRestore(from: url)
|
||||
if let path = pendingRestorePath {
|
||||
viewModel.runRestore(fromPath: path)
|
||||
}
|
||||
pendingRestoreURL = nil
|
||||
pendingRestorePath = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingRestoreURL = nil }
|
||||
Button("Cancel", role: .cancel) { pendingRestorePath = nil }
|
||||
} message: {
|
||||
Text("This will overwrite files under ~/.hermes/ with the archive contents.")
|
||||
Text("This will overwrite files under \(viewModel.context.paths.home) with the archive contents.")
|
||||
}
|
||||
.sheet(isPresented: $showRemoteRestoreSheet) {
|
||||
RemoteBackupPathSheet(
|
||||
context: viewModel.context,
|
||||
onCancel: { showRemoteRestoreSheet = false },
|
||||
onConfirm: { path in
|
||||
showRemoteRestoreSheet = false
|
||||
pendingRestorePath = path
|
||||
showRestoreConfirm = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// NSOpenPanel for local backup zip. Lifted from
|
||||
/// `SettingsViewModel.presentRestorePicker` — kept in the view layer
|
||||
/// because it's a UI concern that has no business on the VM.
|
||||
private func pickLocalBackupZip() -> String? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.message = "Choose a Hermes backup archive to restore"
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return nil }
|
||||
return url.path
|
||||
}
|
||||
|
||||
private var pathsSection: some View {
|
||||
@@ -178,3 +214,115 @@ struct AdvancedTab: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remote-backup-path picker. NSOpenPanel can only browse the user's
|
||||
/// Mac, which is the wrong host for a remote restore — `hermes backup`
|
||||
/// produced the zip on the remote, so the path the user wants is on
|
||||
/// the remote too. This sheet takes a remote path string + verifies
|
||||
/// it via `transport.fileExists` before handing it back to the
|
||||
/// caller. Future iteration: add an "Upload local zip first" path so
|
||||
/// users can restore from a backup that lives on this Mac.
|
||||
private struct RemoteBackupPathSheet: View {
|
||||
let context: ServerContext
|
||||
let onCancel: () -> Void
|
||||
let onConfirm: (String) -> Void
|
||||
|
||||
@State private var path: String = ""
|
||||
@State private var verification: Verification = .idle
|
||||
|
||||
private enum Verification: Equatable {
|
||||
case idle
|
||||
case verifying
|
||||
case ok
|
||||
case warn(String)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Restore from remote backup")
|
||||
.font(.headline)
|
||||
Text("Enter the path to a Hermes backup `.zip` on \(context.displayName). Hermes ran the backup there, so the file lives on the remote — Scarf can't browse the remote from a local file picker.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
HStack {
|
||||
TextField("e.g. ~/.hermes-backups/hermes-2026-04-28.zip", text: $path)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: path) { _, _ in
|
||||
if verification != .idle { verification = .idle }
|
||||
}
|
||||
Button("Verify") { Task { await verify() } }
|
||||
.disabled(path.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| verification == .verifying)
|
||||
}
|
||||
verificationBadge
|
||||
HStack {
|
||||
Button("Cancel") { onCancel() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button("Restore…") {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
onConfirm(trimmed)
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(path.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 520)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var verificationBadge: some View {
|
||||
switch verification {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
case .verifying:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Checking on \(context.displayName)…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
case .ok:
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text("File found on \(context.displayName).")
|
||||
.font(.caption)
|
||||
}
|
||||
case .warn(let detail):
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text(detail).font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func verify() async {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
verification = .verifying
|
||||
let snapshot = context
|
||||
let result: Verification = await Task.detached {
|
||||
let transport = snapshot.makeTransport()
|
||||
guard transport.fileExists(trimmed) else {
|
||||
return .warn("Path doesn't exist on \(snapshot.displayName).")
|
||||
}
|
||||
guard let stat = transport.stat(trimmed) else {
|
||||
return .warn("Found, but couldn't stat — check permissions.")
|
||||
}
|
||||
if stat.isDirectory {
|
||||
return .warn("Path is a directory, not a file. Restore expects a `.zip` archive.")
|
||||
}
|
||||
if !trimmed.lowercased().hasSuffix(".zip") {
|
||||
return .warn("File found, but extension isn't `.zip`. Restore expects a Hermes backup archive.")
|
||||
}
|
||||
return .ok
|
||||
}.value
|
||||
verification = result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ struct DisplayTab: View {
|
||||
private var reasoningStyle: String = ReasoningStyle.disclosure.rawValue
|
||||
@AppStorage(ChatDensityKeys.fontScale)
|
||||
private var fontScale: Double = ChatFontScale.default
|
||||
/// Side-pane visibility (issue #58). Mirrors the toolbar buttons in
|
||||
/// ChatView; this is the canonical preferences home.
|
||||
@AppStorage(ChatDensityKeys.showSessionsList)
|
||||
private var showSessionsList: Bool = true
|
||||
@AppStorage(ChatDensityKeys.showInspector)
|
||||
private var showInspector: Bool = true
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Chat density", icon: "rectangle.compress.vertical") {
|
||||
@@ -30,6 +36,8 @@ struct DisplayTab: View {
|
||||
options: ReasoningStyle.allCases.map { ($0.rawValue, $0.displayName) }
|
||||
)
|
||||
FontScaleRow(scale: $fontScale)
|
||||
ToggleRow(label: "Sessions list", isOn: showSessionsList) { showSessionsList = $0 }
|
||||
ToggleRow(label: "Tool inspector", isOn: showInspector) { showInspector = $0 }
|
||||
DensityFootnote()
|
||||
}
|
||||
|
||||
|
||||
@@ -65,28 +65,36 @@ struct TemplateInstallSheet: View {
|
||||
}
|
||||
|
||||
private var pickParentView: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if let manifest = viewModel.inspection?.manifest {
|
||||
manifestHeader(manifest)
|
||||
Divider()
|
||||
}
|
||||
Text("Where should this project live?")
|
||||
.scarfStyle(.headline)
|
||||
Text("Scarf will create a new folder inside the directory you pick, named after the template id.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
HStack {
|
||||
Button("Cancel") {
|
||||
ParentDirectoryStep(
|
||||
context: viewModel.context,
|
||||
templateID: viewModel.inspection?.manifest.id,
|
||||
header: parentStepHeader(),
|
||||
onCancel: {
|
||||
viewModel.cancel()
|
||||
dismiss()
|
||||
},
|
||||
onContinue: { parentDir in
|
||||
viewModel.pickParentDirectory(parentDir)
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button("Choose Folder…") { chooseParentDirectory() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the manifest banner that sits above the parent-directory
|
||||
/// picker. Returned as `AnyView` so `ParentDirectoryStep` can stay
|
||||
/// non-generic and `pickParentView` doesn't have to bubble its
|
||||
/// generics back up the stack. Empty when inspection is still in
|
||||
/// flight.
|
||||
private func parentStepHeader() -> AnyView {
|
||||
guard let manifest = viewModel.inspection?.manifest else {
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
return AnyView(
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
manifestHeader(manifest)
|
||||
Divider()
|
||||
.padding(.top, 8)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Configure step for schemaful templates. Inlines
|
||||
@@ -417,17 +425,191 @@ struct TemplateInstallSheet: View {
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
}
|
||||
|
||||
private func chooseParentDirectory() {
|
||||
/// Parent-directory picker step. Uses the active `ServerContext` so a
|
||||
/// remote install never opens an `NSOpenPanel` against the local Mac
|
||||
/// filesystem — the panel's choices are useless when the project lives
|
||||
/// on the remote host. Mirrors the `AddProjectSheet` pattern in
|
||||
/// `ProjectsView`: text input + Verify (remote) or Browse… (local), an
|
||||
/// idle/verifying/ok/warn badge for remote feedback, and a Continue
|
||||
/// button that hands the chosen path back via `onContinue`.
|
||||
///
|
||||
/// **Bootstrap.** The path is allowed to not yet exist — the installer
|
||||
/// runs `transport.createDirectory(_:)` on the parent dir at install
|
||||
/// time (`mkdir -p` / `withIntermediateDirectories: true`). The Verify
|
||||
/// badge surfaces "doesn't exist" as a warn rather than blocking
|
||||
/// Continue, so a fresh remote host with no `~/projects` still
|
||||
/// completes the install.
|
||||
private struct ParentDirectoryStep: View {
|
||||
let context: ServerContext
|
||||
let templateID: String?
|
||||
let header: AnyView
|
||||
let onCancel: () -> Void
|
||||
let onContinue: (String) -> Void
|
||||
|
||||
@State private var parentPath: String
|
||||
@State private var remoteVerification: RemoteVerification = .idle
|
||||
|
||||
init(
|
||||
context: ServerContext,
|
||||
templateID: String?,
|
||||
header: AnyView,
|
||||
onCancel: @escaping () -> Void,
|
||||
onContinue: @escaping (String) -> Void
|
||||
) {
|
||||
self.context = context
|
||||
self.templateID = templateID
|
||||
self.header = header
|
||||
self.onCancel = onCancel
|
||||
self.onContinue = onContinue
|
||||
self._parentPath = State(initialValue: context.defaultProjectsRoot)
|
||||
}
|
||||
|
||||
private enum RemoteVerification: Equatable {
|
||||
case idle
|
||||
case verifying
|
||||
case ok(String)
|
||||
case warn(String)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
header
|
||||
Text("Where should this project live?")
|
||||
.scarfStyle(.headline)
|
||||
Text(installPreviewCaption)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
pathInputRow
|
||||
if context.isRemote {
|
||||
Text("Path on \(context.displayName) — Scarf creates it on first install if missing.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
verificationBadge
|
||||
}
|
||||
Spacer()
|
||||
footer
|
||||
}
|
||||
}
|
||||
|
||||
private var installPreviewCaption: String {
|
||||
let trimmedPath = parentPath.trimmingCharacters(in: .whitespaces)
|
||||
let parentDisplay = trimmedPath.isEmpty ? "<parent>" : trimmedPath
|
||||
let slug = templateID ?? "<template-id>"
|
||||
return "Project will be installed at \(parentDisplay)/\(slug) on \(context.displayName)."
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var pathInputRow: some View {
|
||||
HStack {
|
||||
TextField("Parent directory", text: $parentPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: parentPath) { _, _ in
|
||||
if remoteVerification != .idle {
|
||||
remoteVerification = .idle
|
||||
}
|
||||
}
|
||||
if context.isRemote {
|
||||
Button("Verify") { Task { await verifyRemotePath() } }
|
||||
.disabled(parentPath.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| remoteVerification == .verifying)
|
||||
} else {
|
||||
Button("Browse…") { browseLocalDirectory() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var verificationBadge: some View {
|
||||
switch remoteVerification {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
case .verifying:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Checking on \(context.displayName)…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
case .ok(let detail):
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(ScarfColor.success)
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
case .warn(let detail):
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
Button("Cancel") { onCancel() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button("Continue") {
|
||||
let trimmed = parentPath.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
onContinue(trimmed)
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(parentPath.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func browseLocalDirectory() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.prompt = String(localized: "Choose Parent Folder")
|
||||
let trimmed = parentPath.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmed.isEmpty {
|
||||
let expanded = (trimmed as NSString).expandingTildeInPath
|
||||
if FileManager.default.fileExists(atPath: expanded) {
|
||||
panel.directoryURL = URL(fileURLWithPath: expanded)
|
||||
}
|
||||
}
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
viewModel.pickParentDirectory(url.path)
|
||||
parentPath = url.path
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the entered path on the remote via the SSH transport's
|
||||
/// `stat`. Mirrors `AddProjectSheet.verifyRemotePath`. A missing
|
||||
/// directory is reported as a *warn*, not an error — Continue is
|
||||
/// still enabled because the installer's `mkdir -p` creates the
|
||||
/// parent on first install.
|
||||
private func verifyRemotePath() async {
|
||||
let path = parentPath.trimmingCharacters(in: .whitespaces)
|
||||
guard !path.isEmpty, context.isRemote else { return }
|
||||
remoteVerification = .verifying
|
||||
let snapshot = context
|
||||
let result: RemoteVerification = await Task.detached {
|
||||
let transport = snapshot.makeTransport()
|
||||
guard transport.fileExists(path) else {
|
||||
return .warn("Path doesn't exist on \(snapshot.displayName) — Scarf will create it on install.")
|
||||
}
|
||||
guard let stat = transport.stat(path) else {
|
||||
return .warn("Found, but couldn't stat — check parent directory permissions.")
|
||||
}
|
||||
if stat.isDirectory {
|
||||
return .ok("Directory exists on \(snapshot.displayName).")
|
||||
} else {
|
||||
return .warn("Path is a file, not a directory. Project paths must be directories.")
|
||||
}
|
||||
}.value
|
||||
remoteVerification = result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2841,6 +2841,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.json present" : {
|
||||
|
||||
},
|
||||
"authed · %@" : {
|
||||
"comment" : "A label that shows when a provider's access token is still valid. The argument is a relative date string.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Authentication" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3097,6 +3104,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Available models" : {
|
||||
"comment" : "A label for the list of available models.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Back" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3543,6 +3554,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Browse…" : {
|
||||
"comment" : "A button that opens a file browser to select a directory.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Browser" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3917,6 +3932,14 @@
|
||||
"comment" : "A label that shows the name of the active Scarf project, followed by \"Chat\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Chat density" : {
|
||||
"comment" : "Title of a settings section that lets the user configure the chat density (tool calls, reasoning, font size).",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Chat font size" : {
|
||||
"comment" : "A label displayed above a slider that adjusts the font size of chat messages.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Chat is scoped to Scarf project \"%@\"" : {
|
||||
"comment" : "Tooltip for the folder-chip indicator.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -4129,6 +4152,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Checking on %@…" : {
|
||||
"comment" : "A label indicating that a project is being verified. The argument is the name of the project being verified.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Checking…" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -4452,10 +4479,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Choose Folder…" : {
|
||||
"comment" : "A button that opens a dialog for choosing a folder.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Choose Parent Folder" : {
|
||||
|
||||
},
|
||||
@@ -4670,6 +4693,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Click to inspect this tool call" : {
|
||||
"comment" : "A tooltip for a tool call button.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Click to inspect tool calls" : {
|
||||
"comment" : "A tooltip for the button that opens a list of tool calls.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Clicking Start OAuth opens the provider's authorization page in your browser. After you approve, copy the code the provider displays and paste it back into the terminal that appears next." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -5367,7 +5398,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Connected — %@" : {
|
||||
"comment" : "A connected state with a reason for",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Connected — can't read Hermes state" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -5574,6 +5610,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Controls how Scarf renders the chat. Use Output → Show Reasoning to control what Hermes sends." : {
|
||||
"comment" : "A footnote that describes how the chat is rendered.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Copied" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -5614,6 +5654,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Copies a one-liner that consolidates this project's auth.json into your global ~/.hermes/. Run it on the remote, then refresh the Dashboard." : {
|
||||
"comment" : "A tooltip for the \"Copy fix command\" button.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Copy" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -5777,6 +5821,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Copy fix command" : {
|
||||
"comment" : "A button that copies a one-liner that consolidates a project's auth.json into your global ~/.hermes/.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Copy Full Report" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -6726,6 +6774,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Default: ~/projects" : {
|
||||
"comment" : "A description of the default location of the user's projects directory.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Defaults to ~/.ssh/config or current user" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -7444,6 +7496,10 @@
|
||||
},
|
||||
"Duplicate" : {
|
||||
|
||||
},
|
||||
"e.g. ~/.hermes-backups/hermes-2026-04-28.zip" : {
|
||||
"comment" : "A placeholder for a remote backup path.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"e.g. anthropic" : {
|
||||
"localizations" : {
|
||||
@@ -8225,6 +8281,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Enter the path to a Hermes backup `.zip` on %@. Hermes ran the backup there, so the file lives on the remote — Scarf can't browse the remote from a local file picker." : {
|
||||
"comment" : "A label at the top of the remote backup path picker.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Entity Filters (config.yaml only)" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -8992,6 +9052,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"File found on %@." : {
|
||||
"comment" : "A label indicating that a file was found at the path provided by the user.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Files" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -11251,6 +11315,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Leave blank in config to let Hermes pick the default Nous model. Picking one above writes it explicitly." : {
|
||||
"comment" : "A description of the default model selection.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Leave blank to infer from the model ID's prefix (\"openai/...\" → openai)." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -11499,6 +11567,10 @@
|
||||
"comment" : "A description of the logs feature.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Load earlier messages" : {
|
||||
"comment" : "A button to load older messages.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Loaded" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -11547,6 +11619,10 @@
|
||||
"comment" : "A message displayed while loading the configuration.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Loading earlier…" : {
|
||||
"comment" : "A label displayed while loading older messages.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Loading session…" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -11952,6 +12028,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Managed by `hermes auth add <provider>` — Scarf is read-only here." : {
|
||||
"comment" : "A footer describing how OAuth providers are managed.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Mark as seen" : {
|
||||
"comment" : "A button that marks the current skill set as seen and dismisses the \"What's New\" pill.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -13612,6 +13692,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No models loaded" : {
|
||||
"comment" : "A message that appears when the user is not logged in to Nous Portal.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No output yet." : {
|
||||
"comment" : "A message displayed when a tool call has not yet produced output.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -14265,6 +14349,10 @@
|
||||
},
|
||||
"npx" : {
|
||||
|
||||
},
|
||||
"oauth" : {
|
||||
"comment" : "A label for OAuth tokens.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"OAuth" : {
|
||||
|
||||
@@ -14312,6 +14400,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"OAuth providers" : {
|
||||
"comment" : "Title of a section in the credential pools view that lists OAuth-authed providers.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"OK" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -14936,6 +15028,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Or run this on the remote to switch back to the default profile:" : {
|
||||
"comment" : "A hint to the user on how to switch back to the default",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Other" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@@ -15149,6 +15245,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Parent directory" : {
|
||||
|
||||
},
|
||||
"Paste an https URL pointing at a .scarftemplate file." : {
|
||||
"comment" : "A description of the URL field in the template installation prompt.",
|
||||
@@ -15194,6 +15293,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Path on %@ — must already exist on the server. Tool calls run with this directory as their working directory." : {
|
||||
"comment" : "A label that describes the path of a project on a remote server.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Path on %@ — Scarf creates it on first install if missing." : {
|
||||
"comment" : "A label that describes a warning about a project's path on a remote host. The argument is the name of the host.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Paths" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -15478,6 +15585,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Pick a model to start chatting" : {
|
||||
"comment" : "A heading for the chat model picker sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Pick an MCP server to add." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -16070,6 +16181,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Project-local Hermes home shadowing global setup" : {
|
||||
|
||||
},
|
||||
"Project's current git branch" : {
|
||||
"comment" : "A tooltip for the git branch of the project.",
|
||||
@@ -16914,6 +17028,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"refresh-only" : {
|
||||
"comment" : "A label for a refresh-only OAuth provider.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Refreshing…" : {
|
||||
"comment" : "A message that appears when the app is refreshing",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Reload" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -17942,6 +18064,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Restore from remote backup" : {
|
||||
"comment" : "A heading for a sheet that lets the user restore from a remote backup.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Restore…" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -18357,6 +18483,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Run diagnostics" : {
|
||||
"comment" : "A button that runs diagnostics.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Run Diagnostics…" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -18739,6 +18869,10 @@
|
||||
"comment" : "A description of the warning about not switching models.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Scarf is reading from Hermes profile \"%@\". Switch profiles with `hermes profile use <name>` and relaunch Scarf." : {
|
||||
"comment" : "A warning that Scarf is reading from a Hermes profile that is not the default.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Scarf never prompts for passphrases. Add your key to ssh-agent in Terminal, then click Retry. If your key isn't `id_ed25519`, swap the path:" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -18863,10 +18997,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Scarf will create a new folder inside the directory you pick, named after the template id." : {
|
||||
"comment" : "A description of how a template will be installed.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"scarf-default" : {
|
||||
"comment" : "A tool gateway policy applied at run time.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -20456,6 +20586,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Showing built-in fallback list — couldn't reach Nous to refresh." : {
|
||||
"comment" : "A message that appears when the user has selected a",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Shown as the subtitle in the chat slash menu." : {
|
||||
"comment" : "A description of a field that describes a slash command.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -20470,6 +20604,10 @@
|
||||
},
|
||||
"Sign in to Nous Portal" : {
|
||||
|
||||
},
|
||||
"Sign in to Nous Portal to load the catalog, or enter a model ID manually." : {
|
||||
"comment" : "A description of the error message shown when the",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Sign in to Spotify" : {
|
||||
"comment" : "A label for a Spotify sign-in button.",
|
||||
@@ -20955,7 +21093,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SSH works but %@. Click for details." : {
|
||||
"comment" : "A tooltip for a degraded connection status pill. The argument is a reason for the degraded connection.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"SSH works but %@. Click for diagnostics." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -21358,6 +21501,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"state.db present" : {
|
||||
"comment" : "A label indicating that a project has a state.db file.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"state.db readable" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -22366,6 +22513,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"These projects carry their own `.hermes/` directory. Hermes' CLI uses the closest one as `$HERMES_HOME` when run from inside the project, so credentials and config written there don't show up in your global Hermes setup. Consolidate to clear this warning." : {
|
||||
"comment" : "A description of the warning that appears when a project's Hermes home shadows the user's global Hermes setup.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"This is the prompt Hermes will receive. The user sees the literal `/%@` they typed in their own bubble; the expanded body goes to the agent with a `<!-- scarf-slash:<name> -->` marker." : {
|
||||
"comment" : "A description of what the preview pane shows.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -22628,7 +22779,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"This will overwrite files under %@ with the archive contents." : {
|
||||
"comment" : "A message in the confirmation dialog for restoring from a backup.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"This will overwrite files under ~/.hermes/ with the archive contents." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -23342,6 +23498,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Type a model ID exactly as Nous expects it. Leave blank to use Hermes's default." : {
|
||||
"comment" : "A description of how to enter a model ID for a",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Unarchive" : {
|
||||
"comment" : "A button that unarchives a project.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -23853,6 +24013,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Use list" : {
|
||||
"comment" : "A button that lets users cancel entering a custom model ID.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Use this" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -23942,6 +24106,10 @@
|
||||
},
|
||||
"value" : {
|
||||
|
||||
},
|
||||
"Verify" : {
|
||||
"comment" : "A button that verifies a project path on a remote server.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Verifying token…" : {
|
||||
"comment" : "A label displayed in the Spotify sign-in sheet",
|
||||
@@ -24712,6 +24880,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Where Scarf installs new project templates on this host. Created on first install if missing." : {
|
||||
"comment" : "A description of the location of the projects directory.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Where should this project live?" : {
|
||||
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user