Compare commits

..

10 Commits

Author SHA1 Message Date
Alan Wizemann cca99d4e13 chore: Bump version to 2.5.2 2026-04-29 13:36:53 +02:00
Alan Wizemann 2aab9dac07 feat: chat-start preflight, Nous catalog, remote-aware admin sheets
Three feature batches that were in progress on chat-resilience —
all aligned with v2.5.2's remote-context theme.

## Chat-start model preflight

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

## Nous Portal live catalog

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

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

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

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

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

## Localizations

Localizable.xcstrings adds strings for all the new copy across
seven supported locales (en, zh-Hans, de, fr, es, ja, pt-BR).
2026-04-29 13:27:25 +02:00
Alan Wizemann c31dfccb9b fix(ios-chat): move keyboard-dismiss chevron to leading edge (#57)
The keyboard accessory dismiss button added in #51 was placed at
the trailing edge of the keyboard toolbar (Spacer before Button),
which sits directly above the trailing-edge send button in the
composer below. Two near-identical-shape controls visually stack
on the right edge of the screen, confusing users about which is
which.

Move the Spacer() to AFTER the Button so the chevron lives at the
leading edge of the keyboard accessory bar — visually separated
from the send button below, and matches the iOS convention (Notes,
Mail, Reminders all put accessory dismiss on the leading side).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:22:51 +02:00
Alan Wizemann 61e61f556a feat(chat): hideable sessions + inspector panes for the Mac chat (#58)
The 3-pane layout (264px sessions list + transcript + 320px inspector)
ate ~584px of horizontal space on every chat window — squeezing the
actual transcript on smaller windows AND keeping the "No tool selected"
empty-state visible even when irrelevant. User reported that as
"reasoning, in/out, hard to read because of the tool selected box
taking so much space".

Add toolbar toggles + Settings parity to hide either side pane:

- Two new @AppStorage keys in ChatDensitySettings:
    scarf.chat.showSessionsList (default true)
    scarf.chat.showInspector    (default true)
- ChatView toolbar gains two buttons next to the View picker:
  sidebar.left toggles the sessions list, sidebar.right toggles the
  inspector. Both highlight in accent color when visible. Hidden when
  in terminal mode (the 3-pane layout doesn't apply there).
- RichChatView body conditionally renders each side pane and its
  divider, with .transition(.move + .opacity) and a 180ms easeInOut
  animation so the transcript reflows smoothly rather than snapping.
- Auto-show inspector when a tool card is focused so a click never
  silently dies — onChange of focusedToolCallId flips
  showInspector back on if it was off. The slide-in animation
  covers the visual transition.
- DisplayTab → Chat density gains parity Toggle rows for "Sessions
  list" and "Tool inspector" — same group as the existing density
  pickers from #47/#48 so the settings home is consistent.

Defaults match today's behavior so existing users see no change
until they opt out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:22:51 +02:00
Alan Wizemann 424711c3d9 fix(ios-snapshot): harden Citadel state.db snapshot path (#56)
Reported on iOS: dashboard shows "Connection issue / Citadel.SSH
Client.CommandFailed error 1", memory files (USER.md, SOUL.md) load
fine but Sessions / Activity / Tool Calls all show 0. The snapshot
operation that pulls ~/.hermes/state.db over SFTP via `sqlite3
.backup` was failing on the remote, but the iOS user got zero
actionable context.

Two latent bugs in CitadelServerTransport.asyncSnapshotSQLite —
both fixed in v2.5.0 for asyncRunProcess but missed on this path:

1. `executeCommand` throws CommandFailed on non-zero exit AND
   discards the captured stderr buffer. So when sqlite3 is missing
   (slim Docker images, statically-linked installs) or state.db
   doesn't exist, the user only saw "error 1" and a generic
   connection-issue banner with no remediation.

2. No `PATH=...` prefix. asyncRunProcess inline-prepends
   `PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"`
   so bare command resolution works on Citadel's stripped-PATH
   exec channel; the snapshot path didn't, so any sqlite3 install
   outside /usr/bin failed at exit 127 ("command not found").

Mirror the asyncRunProcess hardening on the snapshot path:

- Prepend the same PATH prefix so sqlite3 resolves on hosts where
  it lives at /usr/local/bin or /opt/homebrew/bin.
- Drive `executeCommandStream` instead of `executeCommand`.
  Capture stdout + stderr regardless of exit code.
- On non-zero exit, throw an NSError carrying the real stderr (or
  stdout if stderr is empty — sqlite3 sometimes errors via stdout
  depending on the remote shell). HermesDataService.humanize
  already keys off "sqlite3: command not found" /
  "permission denied" / "no such file" substrings, so once the
  real message reaches it the dashboard banner becomes actionable
  ("sqlite3 is not installed on <host>. Install with apt install
  sqlite3..." instead of the generic CommandFailed error).
- When the stream itself fails to start (network/auth-level), throw
  with a "Failed to start snapshot stream" message so the connect-
  level error path is distinguishable from the remote-exec failure.

iOS-only — Mac path was already correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:22:51 +02:00
Alan Wizemann 067aeda878 fix(catalog): async catalog reads — unfreezes Model + Credential sheets (#59)
Two views called ModelCatalogService.loadProviders() synchronously
from .onAppear on the MainActor:

- ModelPickerSheet (Settings → Model)
- AddCredentialSheet (Credential Pools → +)

loadProviders() walks loadCatalog() → transport.readFile() of
~/.hermes/models_dev_cache.json — a multi-megabyte JSON with ~1500
models across ~110 providers. On a remote SSH context that's a
synchronous SSH file read on the main thread; the user's reported
1–2 minute UI freeze on first open is exactly that. Even on local
contexts the JSONDecoder pass on the main thread is a noticeable
hiccup. Direct violation of CLAUDE.md's rule against sync I/O on
@MainActor.

Compound case: ModelPickerSheet.loadModelsForSelection() did the
same sync read every time the user clicked a different provider in
the picker — re-froze the UI per click.

Fix:
- Add async wrappers on the service:
    loadProvidersAsync()      -> [HermesProviderInfo]
    loadModelsAsync(for:)     -> [HermesModelInfo]
  Each await Task.detached { sync method }.value. Existing sync
  methods stay for tests and any non-View consumers.
- ModelPickerSheet: replace .onAppear with .task; await both async
  calls. Same conversion for loadModelsForSelection() — renamed to
  loadModelsForSelectionAsync() and called from the provider-list
  selection binding via Task { ... }. Subscription state load also
  routed through Task.detached since it's another auth.json read
  that's tiny on local but SSH-backed on remote.
- AddCredentialSheet (CredentialPoolsView): same .onAppear → .task
  conversion with isLoadingProviders @State driving an overlay
  ProgressView "Loading providers..." while the read is in flight.

No behavior or data-shape change; pure I/O dispatch fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:22:51 +02:00
Alan Wizemann 389620059c fix(credentials): recognize OAuth providers; warn on project-shadowed Hermes
Three related fixes for the "I authed Nous but Scarf doesn't see it" bug:

1. `hasAnyAICredential()` (HermesFileService) only probed the
   `credential_pool.<provider>` shape in auth.json. OAuth-authed providers
   land under `providers.<name>.access_token` instead — 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 we probe both shapes; refresh-only entries (pre-mint
   OAuth flows) also count.

2. `CredentialPoolsViewModel` decoded only `credential_pool.*` and
   ignored `providers.*` entirely. New `oauthProviders` array surfaces
   them in a parallel "OAuth providers" section above the rotation
   pools — read-only, with token tail, expiry badge, portal URL, and
   "managed by `hermes auth add`" footnote so users know where the
   write path lives.

3. New `ProjectHermesShadowDetector` (ScarfCore) probes each registered
   project for a `<project>/.hermes/` directory. Hermes' CLI binds to
   the closest `.hermes/` as `$HERMES_HOME` when run from inside such a
   project — `hermes auth add nous` lands in the project's auth.json
   instead of `~/.hermes/auth.json` and Scarf's global probes never
   see it. Surfaced as a yellow Dashboard banner listing affected
   projects with badges for `auth.json` / `state.db` presence and a
   "Copy fix command" button that emits a one-liner consolidating
   auth.json into the global home. Read-only — no auto-migration; the
   user decides what to keep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:48:20 +02:00
Alan Wizemann 4ffd353835 fix(diagnostics): treat config.yaml absence as informational, not failure
Same root cause as the connection-pill fix in 511726e: Hermes v0.11+
doesn't materialize config.yaml until the user changes a setting from
defaults, so a healthy fresh install was reporting "12/14 passing"
forever even though everything that mattered worked.

Probe.Status becomes tri-state (.pass / .fail / .skipped). The shell
script emits SKIP for the "config.yaml absent" branch (Hermes creates
it lazily); only "exists but unreadable" still emits FAIL. The view
renders .skipped with a grey info-circle and excludes those probes
from the summary's denominator — "12/12 passing (2 optional skipped)"
instead of the misleading "12/14."

Probe titles relabeled to "config.yaml readable (optional)" and
"config.yaml content (optional)" so users see the file is not
load-bearing at a glance. The failure hint for the genuine
permission-denied case explicitly notes that absence is fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:31:40 +02:00
Alan Wizemann 511726e2c0 feat(chat-resilience): iOS reconnect + snapshot fallback + paging + pill fix
Brings iOS chat to parity with Mac's reconnect behavior so a session
survives phone-sleep, network handoffs, and SSH socket drops without
losing the agent's work — Hermes already persists messages to state.db
in real-time, the iOS app just had no resync path.

Core changes (shared between Mac and iOS via ScarfCore):

- ServerTransport.cachedSnapshotPath: fall back to the cached state.db
  snapshot when a fresh pull fails. HermesDataService surfaces this via
  isUsingStaleSnapshot + lastSnapshotMtime so views can render "Last
  updated X ago." Default opt-in via refresh(forceFresh: false); chat
  history reload passes forceFresh: true to refuse stale data.
- HermesDataService.fetchMessages(sessionId:limit:before:): bounded
  pagination by id desc. Legacy unbounded overload deprecated. New
  HistoryPageSize constants centralize the budget.
- RichChatViewModel.loadEarlier(): pages back through the current
  session via oldestLoadedMessageID + hasMoreHistory.

iOS-only:

- ChatController gains the Mac reconnect machinery: 5-attempt
  exponential backoff (1→16s) via session/resume → session/load,
  reconcileWithDB on success, "Resynced N new messages" toast.
  startACPEventLoop + startHealthMonitor extracted as helpers.
- New NetworkReachabilityService (NWPathMonitor singleton). Suspends
  reconnect attempts while offline; kicks a fresh cycle on link-up.
- ScarfGoCoordinator + ScarfGoTabRoot funnel scenePhase transitions to
  ChatController.handleScenePhase. On .active we verify channel
  health and reconnect if dead.
- Draft persistence: UserDefaults keyed by (serverID, sessionID)
  survives force-quit. 7-day janitor at app launch.
- Connection-state banner: .reconnecting and .offline render slim
  ScarfDesign-tinted strips above the message list. .failed keeps
  using the existing full-screen overlay.

Bonus fix:

- ConnectionStatusViewModel tier-2 probe now checks state.db instead
  of config.yaml. Hermes v0.11+ doesn't materialize config.yaml until
  the user changes a setting, so a freshly-installed working Hermes
  was being marked "degraded — config missing" indefinitely. state.db
  is the file Scarf actually depends on.

Out of scope (deferred): APNs push notifications, BGTaskScheduler-
based extended-background keepalive, offline write queue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:57:49 +02:00
Alan Wizemann 587c6c36c8 fix(diagnostics): sqlite3 probe with login-PATH + candidate fallback (#19)
@cmalpass's April 25 follow-up on #19: diagnostics reported "sqlite3
not installed or on system PATH" while sqlite3 was actually installed
and Hermes was using it fine. Same false-negative class the `hermes`
probe pre-fix had — a bare `command -v sqlite3` in the non-login SSH
shell misses installs at /opt/homebrew/bin or /usr/local/bin when
the user's PATH export lives in .zprofile (the typical Homebrew
setup). The hermes probe was upgraded to source rc files + walk a
candidate list; sqlite3 wasn't.

Mirror the same pattern:

- Move the sqlite3 detection AFTER the rc-source loop so the login
  PATH is in scope.
- Add a standard-location fallback list:
  /usr/bin/sqlite3, /usr/local/bin/sqlite3,
  /opt/homebrew/bin/sqlite3, /opt/local/bin/sqlite3.
- Use the resolved sqlite3 binary explicitly in the
  sqlite3CanOpenStateDB probe so it 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 still carries the real error.

Hermes non-login probe stays as-is — that semantic ("is hermes on
the un-enriched PATH?") is meaningful and we don't want to muddle it.

Failure-hint copy on sqlite3Installed updated to spell out the new
fallback behavior so users who still see FAIL get accurate guidance
(install via package manager, OR symlink an existing binary into a
location the probe checks).

Closes the third and last open layer of #19. Layer 1 (104-byte
ControlMaster path) was fixed in v2.0.2; layer 2 (pill / diagnostics
disagreement) was fixed in v2.5.1 (#44). Ships in v2.5.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:55:18 +02:00
46 changed files with 3616 additions and 229 deletions
+55
View File
@@ -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 12 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,10 +118,30 @@ public actor HermesDataService {
)
localPath = url.path
lastOpenError = nil
isUsingStaleSnapshot = false
lastSnapshotMtime = mtime(at: url)
} catch {
lastOpenError = humanize(error)
Self.logger.warning("snapshotSQLite failed: \(error.localizedDescription, privacy: .public)")
return false
// 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
@@ -97,6 +149,8 @@ public actor HermesDataService {
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 12 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)."
}
}
}
@@ -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
@@ -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
}
+12
View File
@@ -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)
}
}
}
+7
View File
@@ -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
+720 -33
View File
@@ -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 124816s.
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
+20 -20
View File
@@ -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,17 +1442,44 @@ 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] {
for (_, entries) in pool {
guard let list = entries as? [[String: Any]] else { continue }
for cred in list {
if let token = cred["access_token"] as? String, !token.isEmpty {
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 {
if let token = cred["access_token"] as? String, !token.isEmpty {
return true
}
}
}
}
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
}
}
@@ -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) {
ChatSessionListPane(chatViewModel: chatViewModel, richChat: richChat)
.frame(width: 264)
Divider().background(ScarfColor.border)
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)
Divider().background(ScarfColor.border)
ChatInspectorPane(chatViewModel: chatViewModel)
.frame(width: 320)
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 12
/// 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 12 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,13 +112,21 @@ struct ProfilesView: View {
}
.controlSize(.small)
Button {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.zip]
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
viewModel.import(from: url.path)
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
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
viewModel.import(from: url.path)
}
}
} label: {
Label("Import", systemImage: "square.and.arrow.down")
@@ -119,11 +167,20 @@ struct ProfilesView: View {
renameNewName = profile.name
}
Button("Export…") {
let panel = NSSavePanel()
panel.allowedContentTypes = [.zip]
panel.nameFieldStringValue = "\(profile.name)-profile.zip"
if panel.runModal() == .OK, let url = panel.url {
viewModel.export(profile, to: url.path)
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"
if panel.runModal() == .OK, let url = panel.url {
viewModel.export(profile, to: url.path)
}
}
}
Divider()
@@ -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,21 +249,32 @@ 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 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"
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 FAIL "cannot read file contents"
emit configYAMLContents SKIP "not present (no content to read)"
fi
if [ -r "$H/state.db" ]; then
@@ -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 12 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 12
// 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,8 +207,14 @@ struct ModelPickerSheet: View {
@ViewBuilder
private var modelColumn: some View {
if let selected = providers.first(where: { $0.providerID == selectedProviderID }), selected.isOverlay {
overlayProviderDetail(selected)
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
}
@@ -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,9 +114,17 @@ struct AdvancedTab: View {
.controlSize(.small)
.disabled(viewModel.backupInProgress)
Button {
if let url = viewModel.presentRestorePicker() {
pendingRestoreURL = url
showRestoreConfirm = true
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 {
ParentDirectoryStep(
context: viewModel.context,
templateID: viewModel.inspection?.manifest.id,
header: parentStepHeader(),
onCancel: {
viewModel.cancel()
dismiss()
},
onContinue: { parentDir in
viewModel.pickParentDirectory(parentDir)
}
)
}
/// 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)
}
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") {
viewModel.cancel()
dismiss()
}
.keyboardShortcut(.cancelAction)
Spacer()
Button("Choose Folder…") { chooseParentDirectory() }
.keyboardShortcut(.defaultAction)
}
}
)
}
/// 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
}
}
+180 -8
View File
@@ -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?" : {
},