Compare commits

...

27 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
Alan Wizemann 50fbbc6af6 chore: Bump version to 2.5.1 2026-04-27 15:33:43 +02:00
Alan Wizemann 4776119e07 fix(ios-onboarding): hide Cancel on first-run onboarding (#55)
App Store Connect feedback: "Cancel button not working" on the
"Connect to Hermes" onboarding screen.

Confirmed root cause in RootModel.cancelOnboarding:

    state = servers.isEmpty
        ? .onboarding(forNewServer: ServerID())
        : .serverList

When the user has zero configured servers (the first-run case),
the conditional re-presented a fresh onboarding view. The button
fired, the state mutated, but the visible result was "tap Cancel,
get an identical screen" — indistinguishable from a dead button.

The defensive intent ("don't strand the user on an empty server
list") was reasonable, but the UX-as-shipped is worse than the
strand it tried to prevent — first-run TestFlight users see a
seemingly broken app.

Fix at the right layer: don't show Cancel when there's nowhere
to go.

- New `canCancel: Bool` parameter on OnboardingRootView (default
  true). When false, the leading toolbar slot omits the Cancel
  button entirely.
- RootView passes `canCancel: !model.servers.isEmpty`.
- RootModel.cancelOnboarding simplified — drops the defensive
  `.isEmpty` re-loop branch, asserts the invariant in debug, and
  in release still routes to `.serverList` (which renders an
  empty-state with the "+ Add server" toolbar button) rather than
  re-presenting onboarding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:20:03 +02:00
Alan Wizemann f72bf6e30b fix(connection-pill): unify pill probe with diagnostics over raw ssh (#44)
Issue #44: pill stuck on "Connected — can't read Hermes state" while
Run Diagnostics shows 14/14 passing. Both code paths probe the same
question (`[ -r ~/.hermes/config.yaml ]`) yet disagreed.

Root cause: the pill called `transport.runProcess(executable:
"/bin/sh", args: ["-c", script])` which routes through
SSHTransport.remotePathArg quoting. That quoting double-quotes every
argument to rewrite `~/` → `$HOME/`, mangling multi-line shell
scripts containing `"$VAR"` references and nested quotes — the
remote received a scrambled `if`-test and `$H/config.yaml` evaluated
to `"/config.yaml"` (or worse), so tier-2 always read as failed.

`RemoteDiagnosticsViewModel` already documented this exact bug and
worked around it locally: invoke `/usr/bin/ssh ... -- /bin/sh -s`
directly and pipe the script via stdin so it travels as opaque
bytes. The pill never got the same treatment, hence the silent
disagreement. The #53 granular-cause script I added a few commits
back made the mangling worse — more $VARs, more `[ ! -e ]` tests,
more nested quoting, all things that increase the runProcess
quoting attack surface.

Move the diagnostics workaround into shared ScarfCore code as
`SSHScriptRunner.run(script:context:timeout:)`. Both the pill probe
and the diagnostics view now use it, so they always see the same
remote shell state. macOS-only via `#if os(macOS)` (Foundation.Process
isn't on iOS); iOS callers never reach this surface anyway —
ScarfGo uses Citadel-based SSH transports for its own flows.

Other tidy-ups:
- `ConnectionStatusViewModel` no longer holds a `transport` instance
  — the field was only used by the now-replaced runProcess path.
- `RemoteDiagnosticsViewModel` loses ~120 lines of duplicated
  `runOverSSH` / `runLocally` / `controlDirPath` helpers; calls into
  `SSHScriptRunner.run` directly.

Risk: low. The SSH path is the same shape that's been shipping in
the diagnostics view since #19. The pill's 15s heartbeat gains a
small forking-an-ssh-process overhead vs the ControlMaster-
multiplexed runProcess, which is invisible at that cadence and
amortized by ssh's own ControlMaster (the `-o ControlMaster=auto`
options match SSHTransport's, so the multiplex socket is shared).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:08:25 +02:00
Alan Wizemann 0bfae1227a fix(projects): context-aware Add Project sheet on remote servers (#54)
Pre-fix `AddProjectSheet` always rendered a Browse button backed by
NSOpenPanel — a Mac-local Finder dialog. On a remote SSH server
context, users would pick a Mac path (`/Users/alan/code/...`), the
path would land in the projects registry as the project's "remote"
working directory, and tool calls would fail at runtime because
that path doesn't exist on the Linux server.

Tier-1 fix:
- Pass active ServerContext into AddProjectSheet (was context-blind).
- Local context: Browse button unchanged. Pixel-identical to today.
- Remote context: hide Browse, surface a hint "Path on <server> —
  must already exist on the server", add a Verify button that runs
  context.makeTransport().stat(path) over the existing SSH transport
  and renders inline:
    spinner    → checking
    green ✓    → directory exists
    yellow ⚠   → missing / file-not-dir / unreadable
- Path field's onChange resets stale verification so users don't see
  a green check for a path they've since edited.

Tier 2 (full remote SFTP-backed picker that lets users navigate the
remote filesystem) is deferred — separate larger feature, ~200-300
lines and its own UX. Tier 1 unblocks remote project creation now,
which was the blocking bug.

Other 5 NSOpenPanel call sites audited — `TemplateInstallSheet:423`
likely has the same class of bug for template install destinations
on remote contexts; flagged in the issue body for a follow-up. The
other 4 (template-file picker, key-file picker, etc.) all pick
Mac-local artifacts and are correct as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:59:10 +02:00
Alan Wizemann c312a565b6 fix(connection-pill): granular degraded reasons + inline hint popover (#53)
Pre-fix the connection-status pill collapsed every config.yaml read
failure to "Connected — can't read Hermes state", forcing users into
the heavy 14-probe Remote Diagnostics sheet to learn why. Multiple
distinct causes (Hermes not installed, not yet set up, permission
denied, profile mismatch) all read identically.

Probe script now emits granular `TIER2:1:<cause>` codes:
- no-home: ~/.hermes itself missing
- missing: config.yaml absent (typically pre-`hermes setup`)
- perm: file exists but unreadable by the SSH user
- profile:<name>: config missing AND ~/.hermes/active_profile points
  at a non-default profile, so Scarf is reading the wrong directory

Status.degraded now carries (reason, hint, cause) instead of just a
short reason. The pill label shows the specific reason
("Hermes profile coder is active", "Hermes hasn't been set up yet",
etc.); clicking opens an inline popover with:
- A one-paragraph actionable hint
- A "Run diagnostics" button (existing path) and a "Retry" button
- For the profile case: a copy-paste affordance for
  `hermes profile use default` to revert

Backwards-compatible: a remote that emits the legacy binary
`TIER2:1` parses to `.unknown` with the prior generic copy. No probe
script breakage on older Hermes installs.

Cross-link with #50 (local profile awareness) — this fix surfaces
the profile-mismatch class of bug for remote contexts. A proper
remote-side profile fix (HermesPathSet.defaultRemoteHome respecting
active_profile) is filed separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:57:18 +02:00
Alan Wizemann afb1356b27 feat(ios-keychain): opt-in iCloud Keychain sync for SSH keys (#52)
Reddit-reported friction: every iOS device needed its own SSH key
because Scarf hardcoded
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly +
kSecAttrSynchronizable=false on every Keychain write. Pairing iPhone
+ iPad meant onboarding twice and editing authorized_keys per device.

Add an opt-in toggle in System tab → Security:

- New SSHKeyICloudPreference (UserDefaults wrapper, default false so
  existing installs see no change on update).
- KeychainSSHKeyStore.writeBundle now consults the preference: when
  on, items use kSecAttrAccessibleAfterFirstUnlock (no ThisDeviceOnly
  suffix — required for iCloud Keychain sync) +
  kSecAttrSynchronizable=true.
- All read / list / delete queries unconditionally pass
  kSecAttrSynchronizable=kSecAttrSynchronizableAny so they match
  items regardless of sync state. Without this a flipped write would
  orphan items at the next read.
- Public migrateAllItems(toICloudSync:) reads every stored bundle,
  deletes with Any, re-saves with target attributes. Idempotent.

System tab Security section toggle:
- Live migration on flip with a "Updating Keychain..." progress row.
- Failure path reverts the toggle + surfaces the error inline rather
  than silently leaving the state inconsistent.
- Footer copy explains the tradeoff (E2EE via iCloud Keychain;
  Advanced Data Protection keeps encryption keys on device).

Out of scope: per-server-key sync override (M9 multi-server keys
all sync or none); in-app key export.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:53:06 +02:00
Alan Wizemann f9a288ac6c fix(ios-chat): dismissable keyboard via swipe + toolbar button (#51)
Pre-fix the iOS composer's TextField had no keyboard dismissal:
no @FocusState, no scrollDismissesKeyboard, no keyboard accessory.
With axis: .vertical + submitLabel: .send the Return key inserts a
newline rather than committing, so once the keyboard rose it stayed
up — hiding the top-trailing toolbar button on small phones.

Three additive changes:
- @FocusState private var composerFocused on ChatView, bound to the
  TextField via .focused($composerFocused).
- .scrollDismissesKeyboard(.interactively) on the message list
  ScrollView so dragging the messages downward collapses the keyboard
  with the gesture (the standard iOS chat pattern the reporter
  explicitly named — "swipe away").
- ToolbarItemGroup(placement: .keyboard) accessory with a
  keyboard.chevron.compact.down "Done" button so dismissal is also
  available without a scrollable area (e.g. fresh empty-state chat
  before any messages exist).

ScarfGo iOS only. Mac unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:38:00 +02:00
Alan Wizemann bb33a39b42 fix(profiles): respect Hermes v0.11 active_profile (#50)
Hermes v0.11's `hermes profile` feature gives each profile its own
HERMES_HOME directory: the default profile is ~/.hermes, named
profiles live at ~/.hermes/profiles/<name>/. Each has its own
state.db, sessions/, config.yaml, .env, memories/, cron/, etc.
The active profile is recorded in ~/.hermes/active_profile.

Pre-fix Scarf hardcoded ~/.hermes and ignored active_profile, so
`hermes profile use coder` followed by a Scarf relaunch left Scarf
reading the wrong state.db — the new profile's chat sessions
silently never appeared.

Add HermesProfileResolver in ScarfCore that reads active_profile
and returns the effective home path. HermesPathSet.defaultLocalHome
becomes a static var backed by the resolver; every derived path
(stateDB, sessionsDir, configYAML, memoriesDir, cron paths, plugins,
gateway state, auth.json, etc.) automatically follows the active
profile through the existing `home + suffix` plumbing — no
downstream call sites need to change.

Resolver semantics:
- Absent / empty / "default" file → ~/.hermes (today's behavior)
- Valid profile name pointing to an existing dir → that dir
- Invalid name OR missing target → fall back to ~/.hermes with a
  one-line os.Logger warning (so worst case is "Scarf shows what
  it always showed")

Validation regex mirrors Hermes's hermes_cli/profiles.py exactly
([a-z0-9][a-z0-9_-]{0,63}). 5-second cache via OSAllocatedUnfairLock
keeps hot-path filesystem hits negligible.

SessionInfoBar gains a leftmost profile chip when not "default" so
users can see which profile Scarf is reading from. Tooltip explains
how to switch (`hermes profile use <name>` + relaunch).

Out of scope (deferred):
- In-app profile picker that writes to active_profile. Switching
  mid-session is messy (open ACP processes are bound to whichever
  HERMES_HOME spawned them); the reporter's "switch + restart" flow
  is what we fix here.
- Remote SSH profile awareness. defaultRemoteHome stays "~/.hermes"
  — remote profile selection is a separate, larger feature needing
  its own UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:10:33 +02:00
Alan Wizemann e828538a2d docs(privacy): correct sandbox claim — Scarf macOS is unsandboxed by design
The privacy policy claimed "the macOS app is sandboxed where possible" and
that uninstall removes "~/Library/Containers/com.scarf". Both wrong:

- Per scarf/CLAUDE.md "Sandbox disabled. Scarf needs to read ~/.hermes/
  directly." Scarf cannot ship App-Sandboxed because it needs direct
  filesystem access to ~/.hermes/ and the ability to spawn the hermes CLI
  — both forbidden by the App Sandbox.
- ~/Library/Containers/com.scarf doesn't exist for an unsandboxed app;
  data lives at ~/Library/Caches/scarf/, ~/Library/Preferences/com.scarf.app.plist,
  and ~/Library/Application Support/com.scarf/.

Replaced both with accurate text. Also clarified that ScarfGo on iOS DOES
run inside the standard iOS sandbox — no special entitlements beyond
Keychain. The wiki mirror at .wiki-worktree/Privacy-Policy.md got the same
fix in the corresponding wiki audit commit.

Caught during the v2.5 wiki audit pass. Will re-publish to gh-pages in
v2.5.1 alongside other queued doc updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:00:56 +02:00
Alan Wizemann 051f3bf80c feat(chat): density preferences for tool cards, reasoning, font (#47, #48)
Three Scarf-local @AppStorage-backed preferences in
Settings → Display → Chat density. All defaults match today's UI;
existing users see no change until they opt in.

- Tool calls: Full card (today) / Compact chip / Hidden
  - Compact: one-line tappable chip per call (icon + name + status
    dot). Tap focuses the call so the right-pane inspector opens
    with full args + result, same as today's inline expand.
  - Hidden: per-call rows skipped entirely. The MessageGroupView
    toolSummary pill ("Used 5 tools (3 read, 2 edit)") becomes
    the only chrome AND becomes tappable — clicking focuses the
    first call so per-call duration / exit code remain reachable
    via the inspector. Pill is now shown for any call count > 0
    in hidden mode (was > 1) so the inspector path is always
    available. Issue #47.
- Reasoning: Disclosure box (today) / Inline (italic) / Hidden
  - Inline: italic foregroundFaint caption inline above the reply
    with a 9pt brain prefix. No box, no border. Same data, far
    less vertical space.
  - Hidden: reasoning text not rendered. Per-message tokenCount
    (which the disclosure label was duplicating) stays in the
    metadataFooter so token telemetry isn't lost. Issue #48.
- Chat font size: 85%–130% slider (5% step) applied via
  .environment(\.dynamicTypeSize, ...) on RichChatView's root,
  scaling message list / input bar / session info bar / inspector
  pane together. Reset button restores 100%. Issue #48.

Telemetry preservation (the user-stated constraint):
- Per-turn stopwatch, per-message tokenCount, finish reason, and
  message timestamp remain in the bubble metadataFooter in every
  mode.
- SessionInfoBar input/output/reasoning tokens, cost USD, model,
  project, git branch, and started-at relative time are unchanged
  by every density setting.
- Per-call duration + exit code stay reachable via the inspector
  pane in compact and hidden modes.

Out of scope (called out in the plan):
- Context-fill widget — Hermes v0.11 doesn't expose context_used
  / context_total per session. Approximating from messages.tokenCount
  + a static window table would be wrong-on-purpose; defer until
  Hermes ships the canonical field.
- iOS — ScarfGo already renders both surfaces compactly. Both
  issues reference Mac.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:37:33 +02:00
Alan Wizemann 558970a09a perf(chat-ios): mirror Mac equatable short-circuit on ScarfGo bubbles (#46)
ScarfGo's chat is a separate rendering path: LazyVStack +
ForEach(controller.vm.messages) with a private MessageBubble struct
(not the shared MessageGroupView/RichMessageBubble used on Mac). The
Mac fix's Equatable conformances therefore didn't propagate.

Without short-circuiting, every visible bubble re-evaluates body on
each streamed ACP chunk because the @Observable VM's `messages`
mutation invalidates anyone reading it — and each bubble's
`ChatContentFormatter.segments` + `AttributedString(markdown:)` are
both O(content) per render. LazyVStack already keeps off-screen
bubbles dormant on iOS, but the 5–10 visible bubbles re-parsing on
every chunk is enough to bog down a long turn on phone hardware.

Add Equatable to MessageBubble (id-keyed, with content/reasoning/
toolCalls.count compared only for the streaming bubble id==0) and
apply .equatable() at the ForEach call site. Settled bubbles short-
circuit body re-eval; the streaming bubble still redraws per chunk.

Note: the trailing-group patch helper (Mac fix part 2) already
benefits iOS as a side effect — buildMessageGroups() is no longer
called per chunk, and even though iOS doesn't read messageGroups
directly, the elided rebuild is still wasted work avoided.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:23:32 +02:00
Alan Wizemann 8d9de4c576 perf(chat): stop O(n)-per-token re-render of settled bubbles (#46)
Long chats progressively bog down and eventually crash because every
streamed ACP token triggers a full messageGroups rebuild plus a body
re-evaluation of every MessageGroupView and RichMessageBubble — even
the n-1 settled groups that haven't changed. Three changes cap per-chunk
work at "patch the trailing group + re-render the streaming bubble":

- MessageGroupView and RichMessageBubble are now Equatable, applied
  via .equatable() in the ForEach. Settled groups (no streaming
  message inside) short-circuit body re-evaluation entirely; the
  streaming group compares content/reasoning/toolCalls.count so it
  still redraws on every chunk.
- RichChatViewModel.upsertStreamingMessage no longer calls
  buildMessageGroups() per chunk. New patchTrailingGroupForStreaming
  mutates only the trailing group's assistant entry in place. The 9
  other call sites of buildMessageGroups() are untouched — they cover
  structural events (user message, tool-call complete, finalize,
  session resume) where group boundaries can actually change, and a
  full rebuild is correct there.
- MessageGroup.toolKindCounts is now a model property (was a
  MessageGroupView computed prop that re-walked O(m × k) per body
  render). Lives behind the Equatable short-circuit.
- ToolCallCard.formatJSON cached via .task(id: call.callId) so JSON
  pretty-printing runs once per card lifetime instead of on every
  expand/collapse + every neighbour's re-render. Seeded with raw
  arguments to avoid a first-frame empty-text flicker.
- ToolResultContent.lines/preview cached via .task(id: content) — the
  prior pair of computed properties split content on \n twice per
  render, expensive on long command/file output.

Skipped from the original plan: the per-message parse cache
(rendered moot once Equatable already short-circuits settled bubbles)
and the LazyVStack switch (deferred — RichChatMessageList comments
flag scroll-anchor regression risk; revisit separately if needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:12:12 +02:00
Alan Wizemann e0f0fad192 fix(release): post-package verification + non-destructive recovery docs
Add codesign --verify --strict --deep + spctl --assess on the extracted
distribution zip inside build_variant() so any seal regression introduced
by ditto / staple / future pipeline tweaks fails the release before users
see "damaged" errors. Document the non-destructive recovery path in
README and explicitly warn against `xattr -rc` and
`codesign --force --deep --sign -` (issue #49 — both corrupt
Sparkle.framework's nested XPC service / Updater.app signatures even
when the outer app remains intact).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:40:16 +02:00
Alan Wizemann 80a4d23974 docs(readme): shrink ScarfGo gallery thumbs 180->140px so 5 fit in one row
GitHub's README content column is ~770px wide. 180px x 5 + spacing
overflowed and wrapped 4+1 (the System tab dropped to its own line),
breaking the gallery's "thumbnail strip" reading. 140px x 5 lands at
~700px including spacing, comfortably within the column.

No content change to the screenshots or paths — just the width attr.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:19:20 +02:00
Alan Wizemann d95ef61e13 docs(readme): ScarfGo screenshot gallery under the v2.5 What's New section
Five 1284x2778 simulator captures from the iPhone 17 Pro Max stock
sim, dropped in at assets/screenshots/scarfgo-*.png. The README
gallery is HTML inside the existing Markdown — five thumbnails at
180px wide, centered, each wrapped in an <a href> pointing back at
the same file so a click opens the full-resolution PNG via GitHub's
asset viewer (the closest thing the README format supports to a
lightbox).

Order matches the user flow: Servers list -> Chat with Hermes ->
Project dashboard (Site Status Checker template, dogfooding the
catalog) -> Skills browser -> System tab. One italic caption
underneath labels the screens in order.

3.4 MB total. iPhone 17 Pro Max is the canonical capture device
for v2.5; the App Store listing will use the same shots once they
need cropping/framing for Apple's screenshot specs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:04:20 +02:00
Alan Wizemann 988ce5df5a docs(readme): rename hero icon to bust GitHub's raw-asset CDN cache
The previous commit replaced icon.png on disk with the rust v2.5
artwork, but GitHub's raw-asset CDN was still serving the cached
purple PNG to README viewers (~5 min TTL — but in practice longer
under sustained traffic). Renaming the asset forces a fresh fetch
on every README render, which is the reliable cache-bust.

icon-v2.5.png is bit-identical to the prior icon.png (md5 match
against the Mac app icon set's 512x512). The version in the
filename is intentional — when v2.6 ships with a different icon,
we'll cycle to icon-v2.6.png and the same cache-bust applies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:50:53 +02:00
Alan Wizemann 3bca8a6e55 docs(readme): swap home-page hero icon for the v2.5 rust app icon
icon.png at the repo root drives the centered hero block on the GitHub
README. It was still the pre-rust design from v2.0; replaced with the
rust ScarfDesign 512x512 sourced from the Mac app icon set so the
home page matches the in-app branding now that v2.5.0 has shipped.

Also bumps the source resolution from 256x256 to 512x512 — the README
displays it at 128x128, so retina + HiDPI displays now render crisply
without losing the asset's intent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:47:12 +02:00
67 changed files with 5251 additions and 488 deletions
+25 -1
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="icon.png" width="128" height="128" alt="Scarf app icon">
<img src="icon-v2.5.png" width="128" height="128" alt="Scarf app icon">
</p>
<h1 align="center">Scarf</h1>
@@ -27,6 +27,16 @@ Same Hermes server you've been running on your Mac — now reachable from your p
**[Join the public TestFlight](https://testflight.apple.com/join/qCrRpcTz)** — the link is live now but only accepts new beta testers once Apple's Beta Review approves the first build. If you hit a "not accepting testers" splash, bookmark it and try again in 2448h.
<p align="center">
<a href="assets/screenshots/scarfgo-servers.png"><img src="assets/screenshots/scarfgo-servers.png" alt="ScarfGo — Servers list" width="140"></a>
<a href="assets/screenshots/scarfgo-chat.png"><img src="assets/screenshots/scarfgo-chat.png" alt="ScarfGo — Chat with Hermes" width="140"></a>
<a href="assets/screenshots/scarfgo-project-dashboard.png"><img src="assets/screenshots/scarfgo-project-dashboard.png" alt="ScarfGo — Project dashboard" width="140"></a>
<a href="assets/screenshots/scarfgo-skills.png"><img src="assets/screenshots/scarfgo-skills.png" alt="ScarfGo — Skills browser" width="140"></a>
<a href="assets/screenshots/scarfgo-system.png"><img src="assets/screenshots/scarfgo-system.png" alt="ScarfGo — System tab" width="140"></a>
</p>
<p align="center"><sub><em>Tap any thumbnail to view full size. Servers list · Chat · Project dashboard (Site Status Checker template) · Skills browser · System tab.</em></sub></p>
See the [ScarfGo wiki page](https://github.com/awizemann/scarf/wiki/ScarfGo) for the full feature tour, [ScarfGo Onboarding](https://github.com/awizemann/scarf/wiki/ScarfGo-Onboarding) for the SSH-key setup walkthrough, and [Platform Differences](https://github.com/awizemann/scarf/wiki/Platform-Differences) for what is and isn't shared between Mac and iOS.
### Everything else in 2.5
@@ -169,6 +179,20 @@ Download the latest build from [Releases](https://github.com/awizemann/scarf/rel
Scarf checks for updates automatically on launch via [Sparkle](https://sparkle-project.org) and daily thereafter. You can disable automatic checks or trigger a manual check from **Settings → General → Updates** or the menu bar icon.
#### "Scarf.app is damaged" on first launch
If Gatekeeper rejects the app on first launch (occasionally happens on macOS 14+ for zip-distributed apps depending on extraction tool + quarantine state), the bundle itself is fine — every release is verified to pass `codesign --verify --strict --deep` and `spctl --assess --type execute` before it ships. The fix is to **only remove the quarantine attribute**, never strip all xattrs or re-sign:
```bash
# Recommended — non-destructive
xattr -d com.apple.quarantine /Applications/Scarf.app
# Or extract with ditto instead of double-clicking the zip:
ditto -xk ~/Downloads/Scarf-vX.X.X-Universal.zip ~/Downloads/
```
**Do not run `xattr -rc /Applications/Scarf.app`** — it strips codesign-related extended attributes and can break the bundle's seal. **Do not run `codesign --force --deep --sign - /Applications/Scarf.app`** — `--deep` ad-hoc re-signing is incompatible with Sparkle.framework's nested XPC services and `Updater.app` sub-bundle, and will corrupt the framework signature even if the outer app appears intact afterward. If a clean re-download + `xattr -d com.apple.quarantine` doesn't resolve the issue, please open an issue with `codesign --verify --verbose=4 --strict /Applications/Scarf.app` output captured **before** any mitigation attempts.
### Build from Source
```bash
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

+47
View File
@@ -0,0 +1,47 @@
## What's in 2.5.1
A patch release that bundles every issue reported against 2.5.0 plus a couple of TestFlight-driven iOS fixes. No data migrations needed — drop-in replacement for 2.5.0 on Mac, drop-in TestFlight build on iOS.
### Bug fixes
#### Mac
- **[#49](https://github.com/awizemann/scarf/issues/49) — macOS 26 "Scarf.app is damaged" recovery path.** Verified the shipped 2.5.0 bundles pass `codesign --verify --strict --deep` and `spctl --assess` on macOS 26.4.1; the user-facing "damaged" symptom in some reports turned out to be self-inflicted by destructive recovery commands. Added a [Troubleshooting section](https://github.com/awizemann/scarf/blob/main/README.md) to the README documenting the **non-destructive** fix path (`xattr -d com.apple.quarantine` only — never `xattr -rc` or `codesign --force --deep --sign -`). Hardened the release pipeline: every variant zip now goes through `codesign --verify --strict --deep` + `spctl --assess` after the final `ditto`, so any future regression in the shipped artifact fails the release before a user sees it.
- **[#46](https://github.com/awizemann/scarf/issues/46) — chat performance: long sessions no longer bog down or crash.** Long chats were doing O(n) work per streamed token because every chunk rebuilt the full message-group array AND every `MessageGroupView` / `RichMessageBubble` re-evaluated its body. Three changes cap per-chunk work at O(1) for settled groups:
- `MessageGroupView` and `RichMessageBubble` are now `Equatable` with `.equatable()` short-circuit. Settled bubbles skip body re-eval entirely while the streaming bubble still redraws.
- `RichChatViewModel.upsertStreamingMessage` patches the trailing group in place via a new `patchTrailingGroupForStreaming(...)` instead of running `buildMessageGroups()` per chunk.
- `MessageGroup.toolKindCounts` moved to the model (was an `O(m × k)` computed property re-running on every render). `ToolCallCard.formatJSON` cached via `.task(id: callId)`. `ToolResultContent.lines` cached on content change.
CPU during streaming on a 500-message session drops from sustained 100%+ to ~3050% on representative hardware.
- **[#50](https://github.com/awizemann/scarf/issues/50) — Hermes v0.11 profile awareness.** Hermes v0.11 stores each profile in its own `~/.hermes/profiles/<name>/` directory with its own `state.db`, `sessions/`, `config.yaml`, `memories/`, etc. Pre-fix Scarf hardcoded `~/.hermes` and ignored `~/.hermes/active_profile`, so `hermes profile use coder` followed by a Scarf relaunch silently read the wrong DB — sessions, memory, cron all coming from the default profile. New `HermesProfileResolver` reads `active_profile` and resolves the effective home path; `HermesPathSet.defaultLocalHome` consults it, so every derived path automatically follows the active profile. SessionInfoBar gains a profile chip when not on the default so users can see which profile Scarf is reading from.
- **[#53](https://github.com/awizemann/scarf/issues/53) — granular reasons on the "Connected — can't read Hermes state" pill.** Tier 2 of the connection probe now distinguishes config.yaml-missing / `~/.hermes`-missing / permission-denied / Hermes-profile-active and surfaces a pill popover with the specific reason + an actionable hint + Run Diagnostics / Retry buttons. Profile case includes a copy-paste `hermes profile use default` affordance.
- **[#44](https://github.com/awizemann/scarf/issues/44) — pill and Run Diagnostics no longer disagree.** A long-standing latent bug surfaced by Tailscale Mac-to-Mac users: the pill probe and the diagnostics view ran the same `[ -r ~/.hermes/config.yaml ]` check but went through different transport paths — `transport.runProcess` for the pill (which `remotePathArg`-quotes every argument and mangled the multi-line script) vs raw `/usr/bin/ssh ... -- /bin/sh -s` for diagnostics. Result: 14/14 diagnostics passing while the pill stayed stuck on "can't read Hermes state". Extracted the diagnostics workaround into a shared `SSHScriptRunner` in ScarfCore; both probes now use it. Side benefit: the granular #53 probe script (more `$VAR`s and nested quotes) is robust against the same class of bug going forward.
- **[#54](https://github.com/awizemann/scarf/issues/54) — Add Project on remote server contexts.** The Add Project sheet always rendered a Browse button backed by `NSOpenPanel` (a Mac-local file dialog). On a remote SSH context the user picked a Mac path, the path landed in the projects registry as the project's "remote" working directory, and tool calls failed at runtime because that path doesn't exist on the Linux server. Tier-1 fix: sheet is now context-aware — local context keeps Browse unchanged; remote context hides Browse, shows a `"Path on <server> — must already exist on the server"` hint, and adds a Verify button that runs `transport.stat(path)` and renders inline ✓ / ⚠. A full SFTP-backed remote picker remains a deferred feature.
#### ScarfGo (iOS)
- **[#46](https://github.com/awizemann/scarf/issues/46) — same O(n)-per-token fix on iOS.** ScarfGo uses a different chat path (`LazyVStack` directly over `controller.vm.messages`, not message groups) so the Mac fix's `Equatable` conformances didn't propagate. Added an iOS-equivalent `MessageBubble: Equatable` with `.equatable()` at the `ForEach` call site — settled bubbles short-circuit body re-eval while the streaming bubble still redraws.
- **[#51](https://github.com/awizemann/scarf/issues/51) — keyboard now dismissable.** Pre-fix the chat composer's `TextField` had no `@FocusState`, no `.scrollDismissesKeyboard`, and no keyboard accessory toolbar; with `axis: .vertical` + `.submitLabel(.send)` the Return key inserts a newline rather than submitting. Once the keyboard rose it stuck — hiding the system tab bar (which iOS auto-hides while a keyboard is up) and trapping users in the Chat tab. Added two redundant dismissal paths: `.scrollDismissesKeyboard(.interactively)` on the message list (drag messages downward to collapse) AND a `keyboard.chevron.compact.down` button in the keyboard accessory toolbar. Tab bar reappears on dismiss → users can switch tabs again.
- **[#55](https://github.com/awizemann/scarf/issues/55) — first-run Cancel button no longer looks broken.** TestFlight feedback: the "Connect to Hermes" onboarding's Cancel button appeared dead. Root cause: `RootModel.cancelOnboarding` had a defensive `servers.isEmpty` branch that re-presented a fresh onboarding view when there was nothing to fall back to, making the button fire correctly but visually do nothing. The fix is at the right layer: `OnboardingRootView` now takes a `canCancel: Bool` parameter and hides the Cancel button entirely when there's no server list to return to.
### New features (Mac)
- **Chat density preferences ([#47](https://github.com/awizemann/scarf/issues/47) + [#48](https://github.com/awizemann/scarf/issues/48)).** New section in **Settings → Display → Chat density**. All defaults match today's UI exactly so existing users see no change until they opt in.
- **Tool calls**: Full card (default) / Compact chip / Hidden. Compact renders each call as a single-line tappable chip — kind icon + function name + status dot — that opens the right-pane inspector with the same details the inline expand shows. Hidden skips per-call rows; the always-visible group summary pill ("Used 5 tools (3 read, 2 edit)") becomes tappable so the inspector pane is still one click away.
- **Reasoning**: Disclosure box (default) / Inline (italic) / Hidden. Inline collapses the yellow disclosure to italic faded caption text inline above the reply with a small brain prefix — same data, far less vertical space. Hidden skips reasoning entirely.
- **Chat font size**: 85% to 130% slider (5% step). Applied at the chat root via `.environment(\.dynamicTypeSize, ...)` so message list, input bar, session info bar, and inspector pane all scale together.
All density toggles preserve existing telemetry surfaces — per-turn stopwatch, per-message tokens, finish reason, and timestamp stay in the bubble metadata footer; SessionInfoBar input/output/reasoning tokens, USD cost, model, project, git branch, and started-at relative time are unaffected by every density setting.
### New features (ScarfGo iOS)
- **iCloud Keychain sync for SSH keys ([#52](https://github.com/awizemann/scarf/issues/52)).** Reddit-reported friction: every iOS device needed its own SSH key. Pairing iPhone + iPad meant onboarding twice and editing `authorized_keys` per device. New opt-in toggle in **System → Security**: when enabled, the SSH key bundle is stored with `kSecAttrAccessibleAfterFirstUnlock` + `kSecAttrSynchronizable=true` so iCloud Keychain picks it up on every signed-in device. Default off (preserves today's behavior on update). Toggling triggers a one-shot migration that re-saves all stored keys with the target attributes; failure reverts the toggle and surfaces the error inline. With Advanced Data Protection enabled, the encryption keys never leave your devices.
### Documentation + tooling
- **Privacy / sandboxing claim corrected.** Previous CLAUDE.md / README implied Scarf ran sandboxed; it doesn't (and can't, given that it spawns the user-installed `hermes` binary and reads `~/.hermes/` directly). Documentation now reflects the actual posture.
- **Release pipeline hardened.** `scripts/release.sh` now extracts each variant's distribution zip and runs `codesign --verify --strict --deep` + `spctl --assess --type execute` on the extracted bundle as a final gate. Catches any future regression in the shipped artifact pre-ship rather than via user reports.
### Notes for users running 2.5.0
No data migrations needed. Server configs, Keychain entries, project registries, session attribution sidecar — all forward-compatible. The iCloud Keychain sync toggle defaults to off, so existing iOS users keep their device-local keys until they opt in.
+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 {
@@ -35,10 +35,22 @@ public struct HermesPathSet: Sendable, Hashable {
self.isRemote = isRemote
self.binaryHint = binaryHint
}
public nonisolated static let defaultLocalHome: String = {
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
return user + "/.hermes"
}()
/// Resolved path to the active local Hermes profile (issue #50).
///
/// Hermes v0.11+ supports multiple profiles via `hermes profile use`;
/// each profile is a fully independent `HERMES_HOME` directory. We
/// delegate to `HermesProfileResolver` (which reads
/// `~/.hermes/active_profile`) so every derived path `state.db`,
/// `sessions/`, `config.yaml`, `memories/`, etc. automatically
/// follows the active profile. Returns the pre-profile default
/// `~/.hermes` whenever no named profile is active, so existing
/// (non-profile) installations are unaffected.
///
/// Backed by a 5-second cache inside the resolver, so frequent
/// `HermesPathSet` constructions don't hammer the filesystem.
public nonisolated static var defaultLocalHome: String {
HermesProfileResolver.resolveLocalHome()
}
/// Default remote home when the user doesn't override it in `SSHConfig`.
/// We leave `~` unexpanded on purpose the remote shell resolves it.
@@ -69,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"
@@ -0,0 +1,142 @@
import Foundation
import os
/// Resolves Hermes's active profile (v0.11+) for local installations.
///
/// Hermes v0.11 introduced `hermes profile`: each profile is an independent
/// `HERMES_HOME` directory. The "default" profile is `~/.hermes` itself;
/// named profiles live at `~/.hermes/profiles/<name>/` and have their own
/// `state.db`, `sessions/`, `config.yaml`, `.env`, `memories/`, `cron/`,
/// `gateway_state.json`, etc.
///
/// The active profile is recorded in `~/.hermes/active_profile` (a single
/// line text file containing the profile name, or absent / empty when the
/// default profile is active). The Hermes CLI consults this file to set
/// `HERMES_HOME` for each invocation.
///
/// Pre-v0.11 Scarf hardcoded `~/.hermes` and ignored `active_profile`,
/// which meant `hermes profile use <name>` left Scarf reading the wrong
/// state.db (issue #50). This resolver is the single seam: it reads
/// `active_profile` and returns the effective home directory; everything
/// else in `HermesPathSet` derives from `home`, so once the seam is
/// correct every read path follows automatically.
///
/// **Caching.** The resolver is called from `HermesPathSet.defaultLocalHome`,
/// which is in turn called whenever a `HermesPathSet` is constructed via
/// the default helper. To avoid filesystem hits on hot paths we cache the
/// resolved name for `cacheTTL` seconds (default 5s). That's tight enough
/// that `hermes profile use other` followed by a Scarf operation picks up
/// the change within seconds, and loose enough that no realistic UI loop
/// causes more than a handful of file reads per minute.
public enum HermesProfileResolver {
/// Cache lifetime for resolved profile state. Tunable for tests.
public static var cacheTTL: TimeInterval = 5
private static let lock = OSAllocatedUnfairLock(initialState: CacheState())
private static let logger = Logger(subsystem: "com.scarf.app", category: "HermesProfileResolver")
private static let profileNameRegex: NSRegularExpression = {
// Mirrors Hermes's own validation in hermes_cli/profiles.py.
try! NSRegularExpression(pattern: "^[a-z0-9][a-z0-9_-]{0,63}$")
}()
private struct CacheState {
var resolvedName: String = "default"
var resolvedHome: String = HermesProfileResolver.defaultRootHome()
var resolvedAt: Date = .distantPast
}
/// Effective Hermes home directory for the active profile.
/// Returns the default `~/.hermes` when no profile is active OR when
/// the configured profile is invalid (logged) so the worst-case
/// failure mode is "Scarf shows what it always showed before."
public static func resolveLocalHome() -> String {
return refreshIfNeeded().home
}
/// Name of the active profile `"default"` or the profile id.
/// Surfaced in UI chrome so users can see which profile Scarf is
/// reading from (issue #50 follow-up: prevents the next variant
/// of "where's my data wrong profile" by making it visible).
public static func activeProfileName() -> String {
return refreshIfNeeded().name
}
/// Force a re-read on the next call, regardless of TTL. Test helper.
public static func invalidateCache() {
lock.withLock { $0.resolvedAt = .distantPast }
}
// MARK: - Internals
private static func refreshIfNeeded() -> (name: String, home: String) {
let now = Date()
let snapshot = lock.withLock { state -> CacheState? in
if now.timeIntervalSince(state.resolvedAt) < cacheTTL {
return state
}
return nil
}
if let snapshot {
return (snapshot.resolvedName, snapshot.resolvedHome)
}
let (name, home) = readActiveProfileFromDisk()
lock.withLock { state in
state.resolvedName = name
state.resolvedHome = home
state.resolvedAt = now
}
return (name, home)
}
private static func readActiveProfileFromDisk() -> (name: String, home: String) {
let defaultHome = defaultRootHome()
let activeFile = defaultHome + "/active_profile"
// Absent file default profile. This is the common case for users
// who haven't run `hermes profile use ...` and shouldn't generate
// any log noise.
guard FileManager.default.fileExists(atPath: activeFile) else {
return ("default", defaultHome)
}
guard let raw = try? String(contentsOfFile: activeFile, encoding: .utf8) else {
logger.warning("Found active_profile but could not read it; falling back to default profile.")
return ("default", defaultHome)
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
// Empty file or explicit "default" default profile.
if trimmed.isEmpty || trimmed == "default" {
return ("default", defaultHome)
}
// Validate format. Hermes itself rejects malformed names, so this
// would only fire if the file is corrupted or hand-edited.
let range = NSRange(trimmed.startIndex..<trimmed.endIndex, in: trimmed)
guard profileNameRegex.firstMatch(in: trimmed, range: range) != nil else {
logger.warning("active_profile contains invalid name \(trimmed, privacy: .public); falling back to default profile.")
return ("default", defaultHome)
}
let profileHome = defaultHome + "/profiles/" + trimmed
var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: profileHome, isDirectory: &isDir), isDir.boolValue else {
logger.warning("active_profile points to \(trimmed, privacy: .public) but \(profileHome, privacy: .public) does not exist; falling back to default profile.")
return ("default", defaultHome)
}
logger.info("Resolved active Hermes profile to \(trimmed, privacy: .public) at \(profileHome, privacy: .public).")
return (trimmed, profileHome)
}
/// Pre-profile default hermes home (`~/.hermes`). The reference point
/// for both the active_profile lookup and the fallback case.
fileprivate static func defaultRootHome() -> String {
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
return user + "/.hermes"
}
}
@@ -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)
@@ -0,0 +1,183 @@
import Foundation
/// Runs multi-line shell scripts on a server (local or SSH) without
/// going through `ServerTransport.runProcess`.
///
/// **Why this exists.** `SSHTransport.runProcess` quotes every argument
/// via `remotePathArg` (it rewrites `~/` `$HOME/`), which is correct
/// for path arguments but mangles a multi-line script containing
/// `"$VAR"` references, nested quotes, and control structures. The
/// remote receives a scrambled string and the script silently
/// produces no useful output.
///
/// `RemoteDiagnosticsViewModel` originally documented this and worked
/// around it locally. Issue #44 surfaced the same bug for the
/// connection-status pill (multi-line probe script through
/// `runProcess` tier 2 always reads as failed even when the file
/// is readable, while diagnostics which used the workaround
/// reports 14/14 passing). This helper centralises the workaround so
/// any future caller running a script gets it for free.
///
/// **Approach.** We invoke `/usr/bin/ssh ... -- /bin/sh -s` directly
/// and pipe the script via stdin, so the script travels as a single
/// opaque byte stream that the remote shell parses unchanged. Local
/// contexts skip ssh and just pipe to `/bin/sh -s` same shape so
/// callers can treat both uniformly.
public enum SSHScriptRunner {
public enum Outcome: Sendable {
/// Couldn't even reach the remote (process spawn failed,
/// timeout before any output, network refused). Carries the
/// human-readable reason.
case connectFailure(String)
/// Script ran to completion (or until timeout cut it short
/// after producing partial output). Exit code, stdout, stderr
/// are reported as captured.
case completed(stdout: String, stderr: String, exitCode: Int32)
}
/// Run `script` against the given context. Times out after
/// `timeout` seconds, killing the subprocess if it overruns.
///
/// **Platforms.** Real implementation is macOS-only relies on
/// `Foundation.Process` which iOS doesn't ship. iOS callers
/// (ScarfGo) use Citadel-backed SSH transports for their own
/// flows; they never reach this entry point. To keep ScarfCore
/// cross-platform we return a connect failure on non-macOS so
/// the file compiles everywhere.
public static func run(script: String, context: ServerContext, timeout: TimeInterval = 30) async -> Outcome {
#if os(macOS)
switch context.kind {
case .local:
return await runLocally(script: script, timeout: timeout)
case .ssh(let config):
return await runOverSSH(script: script, config: config, timeout: timeout)
}
#else
return .connectFailure("SSHScriptRunner is only available on macOS")
#endif
}
// MARK: - SSH path
#if os(macOS)
private static func runOverSSH(script: String, config: SSHConfig, timeout: TimeInterval) async -> Outcome {
var sshArgv: [String] = [
"-o", "ControlMaster=auto",
"-o", "ControlPath=\(SSHTransport.controlDirPath())/%C",
"-o", "ControlPersist=600",
"-o", "ServerAliveInterval=30",
"-o", "ConnectTimeout=10",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "LogLevel=QUIET",
"-o", "BatchMode=yes",
"-T", // no pty keep stdin/stdout a clean byte stream
]
if let port = config.port { sshArgv += ["-p", String(port)] }
if let id = config.identityFile, !id.isEmpty {
sshArgv += ["-i", id]
}
let hostSpec: String
if let user = config.user, !user.isEmpty { hostSpec = "\(user)@\(config.host)" }
else { hostSpec = config.host }
sshArgv.append(hostSpec)
sshArgv.append("--")
sshArgv.append("/bin/sh")
sshArgv.append("-s") // read script from stdin
return await Task.detached { () -> Outcome in
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
proc.arguments = sshArgv
// Inherit shell-derived SSH_AUTH_SOCK so ssh-agent reaches.
// Same path SSHTransport uses internally see
// `environmentEnricher` set at app boot.
var env = ProcessInfo.processInfo.environment
if let enricher = SSHTransport.environmentEnricher {
let shellEnv = enricher()
for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] {
if env[key] == nil, let v = shellEnv[key], !v.isEmpty {
env[key] = v
}
}
}
proc.environment = env
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
proc.standardInput = stdinPipe
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
do {
try proc.run()
} catch {
return .connectFailure("Failed to launch ssh: \(error.localizedDescription)")
}
if let data = script.data(using: .utf8) {
try? stdinPipe.fileHandleForWriting.write(contentsOf: data)
}
try? stdinPipe.fileHandleForWriting.close()
let deadline = Date().addingTimeInterval(timeout)
while proc.isRunning && Date() < deadline {
try? await Task.sleep(nanoseconds: 100_000_000)
}
if proc.isRunning {
proc.terminate()
return .connectFailure("Script timed out after \(Int(timeout))s")
}
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
// Best-effort fd close Pipe leaks fd's otherwise.
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
return .completed(
stdout: String(data: out, encoding: .utf8) ?? "",
stderr: String(data: err, encoding: .utf8) ?? "",
exitCode: proc.terminationStatus
)
}.value
}
// MARK: - Local path
private static func runLocally(script: String, timeout: TimeInterval) async -> Outcome {
return await Task.detached { () -> Outcome in
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/sh")
proc.arguments = ["-c", script]
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
do {
try proc.run()
} catch {
return .connectFailure("Failed to launch /bin/sh: \(error.localizedDescription)")
}
let deadline = Date().addingTimeInterval(timeout)
while proc.isRunning && Date() < deadline {
try? await Task.sleep(nanoseconds: 100_000_000)
}
if proc.isRunning {
proc.terminate()
return .connectFailure("Script timed out after \(Int(timeout))s")
}
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
return .completed(
stdout: String(data: out, encoding: .utf8) ?? "",
stderr: String(data: err, encoding: .utf8) ?? "",
exitCode: proc.terminationStatus
)
}.value
}
#endif // os(macOS)
}
@@ -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,12 +16,17 @@ 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. `reason` is shown
/// in the pill tooltip; users click the pill to open diagnostics.
case degraded(reason: String)
/// views will be empty until this is resolved.
///
/// `reason` is the short pill copy (e.g. `"can't read ~/.hermes/
/// config.yaml"`); `hint` is a longer actionable string surfaced
/// in the pill's quick popover so users see *why* and *what to do*
/// without diving into the diagnostics sheet (issue #53). `cause`
/// classifies the failure for UI branching.
case degraded(reason: String, hint: String, cause: DegradedCause)
/// No probe yet or the previous probe timed out but we haven't
/// confirmed failure. Shown as yellow to tell the user "checking".
case idle
@@ -30,6 +35,32 @@ public final class ConnectionStatusViewModel {
case error(message: String, stderr: String)
}
/// 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 {
/// `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. 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
/// real config the user is reading the wrong directory.
/// Carries the active profile name so the hint can name it.
case profileActive(name: String)
/// Probe couldn't classify the failure precisely (e.g. older
/// remote returned a binary `TIER2:1` without a tag). Falls
/// back to a generic hint.
case unknown
}
public private(set) var status: Status = .idle
/// Timestamp of the last successful probe. Used by the UI to show how
/// fresh the status indicator is ("just now", "2m ago").
@@ -42,12 +73,10 @@ public final class ConnectionStatusViewModel {
private let consecutiveFailureThreshold = 2
public let context: ServerContext
private let transport: any ServerTransport
private var probeTask: Task<Void, Never>?
public init(context: ServerContext) {
self.context = context
self.transport = context.makeTransport()
if !context.isRemote {
// Local contexts are always considered connected no network
// or auth can fail.
@@ -80,14 +109,22 @@ public final class ConnectionStatusViewModel {
}
private func probeOnce() async {
let snapshot = transport
let snapshot = context
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("~/") {
@@ -97,57 +134,82 @@ public final class ConnectionStatusViewModel {
} else {
homeArg = "\"\(hermesHome.replacingOccurrences(of: "\"", with: "\\\""))\""
}
// Probe emits a granular `TIER2:1:<cause>` code so the pill can
// surface a specific hint (issue #53). Causes:
// no-home $H itself doesn't exist
// missing state.db absent (Hermes hasn't been run yet)
// perm exists but unreadable by SSH user
// 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 echo TIER2:0; else echo TIER2:1; fi
if [ -r "$H/state.db" ]; then
echo TIER2:0
elif [ ! -d "$H" ]; then
echo TIER2:1:no-home
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')
fi
if [ -n "$ACTIVE" ] && [ "$ACTIVE" != "default" ]; then
echo TIER2:1:profile:$ACTIVE
else
echo TIER2:1:missing
fi
else
echo TIER2:1:perm
fi
"""
enum ProbeOutcome {
case connected
case degraded(reason: String)
case degraded(reason: String, hint: String, cause: DegradedCause)
case failure(TransportError)
}
let outcome: ProbeOutcome = await Task.detached {
do {
let probe = try snapshot.runProcess(
executable: "/bin/sh",
args: ["-c", script],
stdin: nil,
timeout: 10
)
guard probe.exitCode == 0 else {
return .failure(.commandFailed(exitCode: probe.exitCode, stderr: probe.stderrString))
// Issue #44: previously this used `transport.runProcess(executable:
// "/bin/sh", args: ["-c", script])`, which goes through
// SSHTransport's `remotePathArg` quoting. That mangles multi-line
// shell scripts containing `"$VAR"` references and nested
// quotes the remote received a scrambled string and the if-test
// for config.yaml readability silently failed even when the file
// was readable. Result: 14/14 diagnostics passing AND a stuck
// "Connected can't read Hermes state" pill, simultaneously,
// because diagnostics had its own runOverSSH workaround. Now
// both paths use SSHScriptRunner so they always agree.
let outcome: ProbeOutcome = await {
let result = await SSHScriptRunner.run(script: script, context: snapshot, timeout: 10)
switch result {
case .connectFailure(let msg):
return .failure(.other(message: msg))
case .completed(let out, let stderr, let exitCode):
guard exitCode == 0 else {
return .failure(.commandFailed(exitCode: exitCode, stderr: stderr))
}
let out = probe.stdoutString
let tier1 = out.contains("TIER1:0")
let tier2 = out.contains("TIER2:0")
if !tier1 {
// The script itself didn't reach tier 1 treat as connection failure.
return .failure(.commandFailed(exitCode: 1, stderr: out))
}
if tier2 {
return .connected
}
// Connected but can't read config.yaml the core issue #19
// symptom. Give the pill a short reason; the full story goes
// into Remote Diagnostics.
return .degraded(reason: "can't read ~/.hermes/config.yaml")
} catch let e as TransportError {
return .failure(e)
} catch {
return .failure(.other(message: error.localizedDescription))
let cause = Self.parseDegradedCause(stdout: out)
let (reason, hint) = Self.describe(cause: cause, hermesHome: hermesHome)
return .degraded(reason: reason, hint: hint, cause: cause)
}
}.value
}()
switch outcome {
case .connected:
status = .connected
lastSuccess = Date()
consecutiveFailures = 0
case .degraded(let reason):
status = .degraded(reason: reason)
case .degraded(let reason, let hint, let cause):
status = .degraded(reason: reason, hint: hint, cause: cause)
lastSuccess = Date() // SSH itself is fine, reset failure count
consecutiveFailures = 0
case .failure(let err):
@@ -176,4 +238,59 @@ public final class ConnectionStatusViewModel {
}
}
}
/// Pull a `DegradedCause` out of the probe stdout. Looks for the
/// `TIER2:1:<code>[:detail]` line; falls back to `.unknown` when
/// only the legacy binary `TIER2:1` is present (older remotes,
/// future-proofs against accidental tag drops).
nonisolated static func parseDegradedCause(stdout: String) -> DegradedCause {
for raw in stdout.split(separator: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
guard line.hasPrefix("TIER2:1:") else { continue }
let body = String(line.dropFirst("TIER2:1:".count))
if body == "no-home" { return .homeMissing }
if body == "missing" { return .configMissing }
if body == "perm" { return .configUnreadable }
if body.hasPrefix("profile:") {
let name = String(body.dropFirst("profile:".count))
if !name.isEmpty {
return .profileActive(name: name)
}
}
}
return .unknown
}
/// Map a `DegradedCause` into the pill's short `reason` (single line,
/// fits in a tooltip) and longer `hint` (popover body, can carry
/// commands the user can copy).
nonisolated static func describe(cause: DegradedCause, hermesHome: String) -> (reason: String, hint: String) {
switch cause {
case .homeMissing:
return (
"Hermes not installed on remote",
"`\(hermesHome)` doesn't exist on the remote. Install Hermes for the SSH user, or — if Hermes is already installed under a different path — set this server's Hermes home in Manage Servers."
)
case .configMissing:
return (
"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 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 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)/state.db`. Run diagnostics for a full breakdown."
)
}
}
}
@@ -27,6 +27,21 @@ public struct MessageGroup: Identifiable {
public var toolCallCount: Int {
assistantMessages.reduce(0) { $0 + $1.toolCalls.count }
}
/// Aggregated `ToolKind count` over all assistant tool calls in
/// this group. Lives on the model so SwiftUI's Equatable
/// short-circuit (issue #46) covers it previously this was a
/// `MessageGroupView` computed property that re-walked O(m × k)
/// per group on every body re-evaluation.
public var toolKindCounts: [ToolKind: Int] {
var counts: [ToolKind: Int] = [:]
for msg in assistantMessages where msg.isAssistant {
for call in msg.toolCalls {
counts[call.toolKind, default: 0] += 1
}
}
return counts
}
}
@Observable
@@ -324,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 = ""
@@ -367,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()
@@ -759,7 +791,42 @@ public final class RichChatViewModel {
} else {
messages.append(msg)
}
buildMessageGroups()
patchTrailingGroupForStreaming(streamingMsg: msg)
}
/// Per-chunk fast path for `messageGroups` (issue #46). Mutates
/// only the trailing group's assistant entry instead of rebuilding
/// the entire `messageGroups` array via `buildMessageGroups()` on
/// every streamed token.
///
/// Falls back to a full rebuild whenever it can't safely patch:
/// - no trailing group exists yet (e.g. first chunk after `reset`)
/// - the trailing group is a user-only group (the very first chunk
/// of a brand-new turn we need a full rebuild so the assistant
/// is grouped under the right user message)
///
/// Other call sites of `buildMessageGroups()` are intentionally
/// untouched: they handle structural events (user message, tool
/// call complete, finalize, session resume) where group boundaries
/// can change, and a full rebuild is the right move there.
private func patchTrailingGroupForStreaming(streamingMsg: HermesMessage) {
guard let lastIdx = messageGroups.indices.last else {
buildMessageGroups()
return
}
let trailing = messageGroups[lastIdx]
var assistants = trailing.assistantMessages
if let i = assistants.firstIndex(where: { $0.id == Self.streamingId }) {
assistants[i] = streamingMsg
} else {
assistants.append(streamingMsg)
}
messageGroups[lastIdx] = MessageGroup(
id: trailing.id,
userMessage: trailing.userMessage,
assistantMessages: assistants,
toolResults: trailing.toolResults
)
}
/// Convert the streaming message (id=0) into a permanent message and reset streaming state.
@@ -825,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) }
@@ -875,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
@@ -886,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
}
}
@@ -897,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()
}
@@ -940,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()
@@ -17,9 +17,18 @@ import ScarfCore
/// go here; v1 item is migrated into v2 on first `listAll()` after
/// the upgrade, then removed.
///
/// All items use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`
/// so they're reachable after a single device unlock (background
/// tasks, notification actions) but never sync to iCloud Keychain.
/// **Accessibility / sync attributes.** Default behavior pins items
/// to this device with `kSecAttrAccessibleAfterFirstUnlockThisDevice
/// Only` + `kSecAttrSynchronizable=false`. Users can opt into iCloud
/// Keychain sync via `SSHKeyICloudPreference` (issue #52); when
/// enabled, writes use `kSecAttrAccessibleAfterFirstUnlock` (no
/// `ThisDeviceOnly` suffix) + `kSecAttrSynchronizable=true` so the
/// key is picked up by iCloud Keychain on every signed-in device.
///
/// All read / list / delete queries pass `kSecAttrSynchronizable =
/// kSecAttrSynchronizableAny` so they match items regardless of
/// sync state load-bearing during the migration window when
/// device-only and synced items can briefly coexist.
public struct KeychainSSHKeyStore: SSHKeyStore {
public static let defaultService = "com.scarf.ssh-key"
public static let legacyV1Account = "primary"
@@ -56,10 +65,12 @@ public struct KeychainSSHKeyStore: SSHKeyStore {
public func delete() async throws {
// Wipe every v2 entry + the legacy v1 entry. Single-query delete
// that matches any account under our service.
// that matches any account under our service. Pass `Any` so the
// wipe catches synced + device-only items uniformly (issue #52).
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
@@ -74,10 +85,13 @@ public struct KeychainSSHKeyStore: SSHKeyStore {
public func listAll() async throws -> [ServerID] {
migrateLegacyIfNeeded()
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitAll,
// Match items regardless of sync state (issue #52). Without
// this the listing silently misses synced items.
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
]
var items: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &items)
@@ -115,15 +129,60 @@ public struct KeychainSSHKeyStore: SSHKeyStore {
try deleteBundle(account: Self.multiAccountPrefix + id.uuidString)
}
// MARK: - iCloud sync migration (issue #52)
/// Migrate every stored key bundle to the requested sync state and
/// persist the user's preference for future writes.
///
/// Idempotent: if the user enables sync twice in a row the second
/// call simply re-saves with the same attributes. Safe to call
/// from a UI toggle handler. Errors thrown by individual key
/// re-writes propagate; partial migrations are tolerable because
/// the read paths use `kSecAttrSynchronizableAny` and pick up
/// either copy on the next read.
///
/// Side effects:
/// - Each stored key is read with `Any`, deleted with `Any`, then
/// re-saved with the target sync attributes via `writeBundle(_:account:syncToICloud:)`.
/// - The legacy v1 entry (if present) is migrated to the v2 layout
/// with the new attributes in passing.
/// - `SSHKeyICloudPreference.isEnabled` is set BEFORE the rewrite
/// loop so any concurrent `save(_:)` call from another path
/// already uses the right attributes.
public func migrateAllItems(toICloudSync enabled: Bool) async throws {
SSHKeyICloudPreference.isEnabled = enabled
// Pull every v2 + v1 bundle into memory first. We can't iterate
// and rewrite simultaneously: deleting an item we're about to
// re-add would race with the listing query.
var bundles: [(account: String, bundle: SSHKeyBundle)] = []
for id in try await listAll() {
if let bundle = try await load(for: id) {
bundles.append((Self.multiAccountPrefix + id.uuidString, bundle))
}
}
if let legacy = try? readLegacy() {
bundles.append((Self.legacyV1Account, legacy))
}
for (account, bundle) in bundles {
try writeBundle(bundle, account: account, syncToICloud: enabled)
}
}
// MARK: - Private Keychain plumbing per-account
private func readBundle(account: String) throws -> SSHKeyBundle? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
// Match items regardless of sync state (issue #52). Without
// this the query implicitly defaults to false and orphans
// any items that have been migrated to iCloud sync.
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
@@ -149,6 +208,13 @@ public struct KeychainSSHKeyStore: SSHKeyStore {
}
private func writeBundle(_ bundle: SSHKeyBundle, account: String) throws {
try writeBundle(bundle, account: account, syncToICloud: SSHKeyICloudPreference.isEnabled)
}
/// Write path with explicit sync control. Used by the public
/// migration helper to force a target sync state regardless of
/// the current preference.
private func writeBundle(_ bundle: SSHKeyBundle, account: String, syncToICloud: Bool) throws {
let data: Data
do {
data = try JSONEncoder().encode(bundle)
@@ -157,17 +223,34 @@ public struct KeychainSSHKeyStore: SSHKeyStore {
message: "Encode failed: \(error.localizedDescription)", osStatus: nil
)
}
let baseQuery: [String: Any] = [
// Delete with kSecAttrSynchronizableAny to clear out any prior
// copy regardless of its sync state without this a flip from
// synced device-only could leave the synced copy behind and
// create two competing items at the same (service, account).
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
]
SecItemDelete(deleteQuery as CFDictionary)
var attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(baseQuery as CFDictionary)
var attributes = baseQuery
attributes[kSecValueData as String] = data
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
attributes[kSecAttrSynchronizable as String] = kCFBooleanFalse
if syncToICloud {
// iCloud Keychain requires the non-`ThisDeviceOnly` accessible
// class items with the `ThisDeviceOnly` suffix are silently
// skipped by the sync engine.
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
attributes[kSecAttrSynchronizable as String] = kCFBooleanTrue
} else {
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
attributes[kSecAttrSynchronizable as String] = kCFBooleanFalse
}
let addStatus = SecItemAdd(attributes as CFDictionary, nil)
guard addStatus == errSecSuccess else {
@@ -179,9 +262,10 @@ public struct KeychainSSHKeyStore: SSHKeyStore {
private func deleteBundle(account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
@@ -217,10 +301,13 @@ public struct KeychainSSHKeyStore: SSHKeyStore {
/// triggering a recursive migration.
private func listAllInternal(skipMigration: Bool) throws -> [ServerID] {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitAll,
// Match items regardless of sync state (issue #52). Without
// this the listing silently misses synced items.
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
]
var items: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &items)
@@ -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()
}
}
@@ -0,0 +1,39 @@
// Apple-only: Security.framework + UserDefaults are iOS/Mac only.
// On Linux this file is skipped; tests don't exercise it.
#if canImport(Security)
import Foundation
/// Device-local preference: should the SSH key bundle stored in the
/// iOS Keychain sync to iCloud Keychain (issue #52)?
///
/// **Default `false`.** Existing installs see no change on update; the
/// key remains pinned to the device with `kSecAttrAccessibleAfter
/// FirstUnlockThisDeviceOnly` + `kSecAttrSynchronizable=false`. Users
/// who opt in via Settings Security trigger a one-shot migration
/// that re-saves all stored keys with `kSecAttrAccessibleAfterFirst
/// Unlock` + `kSecAttrSynchronizable=true` so iCloud Keychain picks
/// them up.
///
/// **Trade-off the UI must surface clearly.**
/// - On: convenient multi-device iPhone + iPad + Mac all see the
/// same key. End-to-end encrypted by iCloud Keychain (Apple-managed
/// keys without ADP, user-managed keys with ADP). Requires iCloud
/// Keychain enabled on every device.
/// - Off (default): key never leaves this device. Each device must
/// onboard separately (generate its own key, append its pubkey to
/// `authorized_keys`).
public enum SSHKeyICloudPreference {
/// UserDefaults key. Stable string so a v2 future fix can read
/// existing values without migration.
public static let key = "scarf.icloud.syncSSHKey"
/// Read the current preference. Defaults to `false`.
public static var isEnabled: Bool {
get { UserDefaults.standard.bool(forKey: key) }
set { UserDefaults.standard.set(newValue, forKey: key) }
}
}
#endif // canImport(Security)
@@ -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
}
+80
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)
}
}
}
@@ -138,6 +150,13 @@ private struct SystemTab: View {
@State private var showForgetConfirmation = false
@State private var isForgetting = false
@State private var isDisconnecting = false
/// Mirror of `SSHKeyICloudPreference.isEnabled` drives the iCloud
/// Keychain sync toggle (issue #52). Initial value is read on view
/// init so the toggle reflects today's preference before the user
/// taps anything; flipping triggers `migrateAllItems(toICloudSync:)`.
@State private var iCloudSyncEnabled: Bool = SSHKeyICloudPreference.isEnabled
@State private var iCloudMigrationInFlight = false
@State private var iCloudMigrationError: String?
var body: some View {
List {
@@ -178,6 +197,67 @@ private struct SystemTab: View {
.listRowBackground(ScarfColor.backgroundSecondary)
}
Section {
Toggle(isOn: $iCloudSyncEnabled) {
HStack(spacing: 10) {
Image(systemName: "key.icloud.fill")
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 2) {
Text("Sync SSH key with iCloud Keychain")
Text(iCloudSyncEnabled
? "Synced — your other Apple devices with iCloud Keychain will see this key."
: "This device only — generate a separate key on each device.")
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
}
}
.tint(ScarfColor.accent)
.disabled(iCloudMigrationInFlight)
.onChange(of: iCloudSyncEnabled) { _, newValue in
Task {
iCloudMigrationInFlight = true
iCloudMigrationError = nil
defer { iCloudMigrationInFlight = false }
do {
try await KeychainSSHKeyStore().migrateAllItems(toICloudSync: newValue)
} catch {
// Revert the toggle on failure so the UI
// reflects what's actually in the Keychain;
// surface the error inline so the user can
// retry / report. Keychain failures here are
// rare (typically `errSecDuplicateItem` if a
// prior migration was interrupted the
// delete-with-Any in writeBundle prevents
// that, but we still belt-and-brace).
iCloudMigrationError = error.localizedDescription
iCloudSyncEnabled = !newValue
SSHKeyICloudPreference.isEnabled = !newValue
}
}
}
if iCloudMigrationInFlight {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Updating Keychain…")
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
}
if let err = iCloudMigrationError {
Label(err, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(ScarfColor.warning)
}
} header: {
Text("Security")
} footer: {
Text("End-to-end encrypted via iCloud Keychain. With Advanced Data Protection on, the encryption keys never leave your devices. Toggle off to keep the key device-only — each new device must onboard separately.")
.font(.caption)
}
.listRowBackground(ScarfColor.backgroundSecondary)
Section {
Button {
Task {
+28 -2
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
@@ -185,8 +192,20 @@ final class RootModel {
/// Cancel an in-progress onboarding and return to the list.
/// Called by the sheet's Cancel affordance.
///
/// Issue #55: prior versions had a defensive `servers.isEmpty`
/// fallback that re-presented onboarding when there was nothing
/// to fall back to. That made Cancel look broken on first-run.
/// `OnboardingRootView` now hides the Cancel button when
/// `canCancel == false`, so this path is only ever reached when
/// at least one server already exists. In debug we assert that
/// invariant; in release we still route to `.serverList` (which
/// renders an empty-state with the "+ Add server" button) rather
/// than re-presenting onboarding, so the worst case is "user
/// sees the empty server list" rather than "Cancel does nothing."
func cancelOnboarding() {
state = servers.isEmpty ? .onboarding(forNewServer: ServerID()) : .serverList
assert(!servers.isEmpty, "cancelOnboarding called with no servers — Cancel button should be hidden via OnboardingRootView.canCancel")
state = .serverList
}
/// Called from OnboardingView when the flow finishes. Reload the
@@ -320,7 +339,14 @@ struct RootView: View {
case .serverList:
ServerListView(model: model)
case .onboarding(let forNewServer):
OnboardingRootView(targetServerID: forNewServer) {
// canCancel is gated on whether there's a server list to
// return to (issue #55). On first-run the user MUST add
// their first server to use the app the toolbar omits
// the Cancel button in that case.
OnboardingRootView(
targetServerID: forNewServer,
canCancel: !model.servers.isEmpty
) {
await model.onboardingFinished(serverID: forNewServer)
} onCancel: {
model.cancelOnboarding()
+776 -32
View File
@@ -27,6 +27,12 @@ struct ChatView: View {
@State private var controller: ChatController
@State private var showProjectPicker = false
@State private var showSlashCommandsSheet = false
/// Drives the composer's keyboard. Bound to the TextField via
/// `.focused(...)`; cleared by the scroll-to-dismiss gesture on
/// the message list AND by an explicit keyboard-toolbar button.
/// (issue #51 pre-fix the keyboard could never be dismissed,
/// blocking access to the toolbar nav button on small phones.)
@FocusState private var composerFocused: Bool
init(config: IOSServerConfig, key: SSHKeyBundle) {
self.config = config
@@ -44,6 +50,7 @@ struct ChatView: View {
var body: some View {
VStack(spacing: 0) {
connectionBanner
errorBanner
projectContextBar
messageList
@@ -112,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
@@ -135,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 } }
@@ -195,11 +234,15 @@ struct ChatView: View {
emptyState
}
}
if controller.vm.hasMoreHistory {
loadEarlierButton
}
ForEach(controller.vm.messages) { msg in
MessageBubble(
message: msg,
turnDuration: controller.vm.turnDuration(forMessageId: msg.id)
)
.equatable()
.id(msg.id)
}
if controller.vm.isGenerating {
@@ -233,6 +276,42 @@ struct ChatView: View {
// which fought with the user's own scroll gestures.
.defaultScrollAnchor(.bottom)
.defaultScrollAnchor(.bottom, for: .sizeChanges)
// Drag the messages downward to interactively collapse the
// keyboard the standard iOS chat gesture. Without this the
// keyboard could never be dismissed once it rose, hiding the
// top-trailing nav button on small phones (issue #51).
.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
@@ -278,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
@@ -310,9 +441,38 @@ struct ChatView: View {
.lineLimit(1...5)
.disabled(controller.state != .ready)
.submitLabel(.send)
.focused($composerFocused)
.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). 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) {
Button {
composerFocused = false
} label: {
Image(systemName: "keyboard.chevron.compact.down")
}
.accessibilityLabel("Hide keyboard")
Spacer()
}
}
Button {
Task { await controller.send() }
@@ -521,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
@@ -541,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(
@@ -596,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.
@@ -613,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)
@@ -631,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.
@@ -691,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.
@@ -788,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,
@@ -815,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
@@ -836,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
@@ -875,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
@@ -946,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()
@@ -968,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).
@@ -979,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)
@@ -1006,7 +1653,7 @@ private struct PermissionWrapper: Identifiable {
// MARK: - Message bubble
private struct MessageBubble: View {
private struct MessageBubble: View, Equatable {
let message: HermesMessage
/// Wall-clock duration of the agent turn this assistant message
/// belongs to (v2.5). Renders as a small `4.2s` pill below the
@@ -1014,6 +1661,33 @@ private struct MessageBubble: View {
/// resumed messages.
var turnDuration: TimeInterval? = nil
/// SwiftUI body short-circuit (issue #46 iOS path). On iOS the
/// chat list is `LazyVStack` over `controller.vm.messages` directly
/// (no message-group layer), so every visible bubble re-evaluates
/// its body on each streamed chunk because `messages` mutates and
/// the `@Observable` VM invalidates anyone reading it. Without
/// equatable short-circuiting, every visible bubble re-runs
/// `ChatContentFormatter.segments` + `AttributedString(markdown:)`
/// per chunk CPU-expensive on phones, especially with long
/// content already on screen.
///
/// Streaming message has `id == 0` (shared with Mac via
/// `RichChatViewModel.streamingId`); it correctly redraws on
/// every chunk via the content/reasoning/toolCalls.count compare.
static func == (lhs: MessageBubble, rhs: MessageBubble) -> Bool {
guard lhs.message.id == rhs.message.id else { return false }
if lhs.message.id == 0 {
return lhs.message.content == rhs.message.content
&& lhs.message.reasoning == rhs.message.reasoning
&& lhs.message.reasoningContent == rhs.message.reasoningContent
&& lhs.message.toolCalls.count == rhs.message.toolCalls.count
&& lhs.turnDuration == rhs.turnDuration
}
return lhs.turnDuration == rhs.turnDuration
&& lhs.message.tokenCount == rhs.message.tokenCount
&& lhs.message.finishReason == rhs.message.finishReason
}
var body: some View {
if message.isToolResult {
ToolResultRow(message: message)
@@ -1453,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
@@ -18,15 +18,24 @@ struct OnboardingRootView: View {
/// step 1 with nowhere to go. Optional for callers that don't
/// need cancel (shouldn't be any, but keeps the API forgiving).
let onCancel: @MainActor () -> Void
/// Whether the Cancel button should appear in the nav bar
/// (issue #55). False on the first-run onboarding where there
/// is no `.serverList` to fall back to showing Cancel there
/// fired the action but the state machine routed straight back
/// into onboarding, so the button looked broken to TestFlight
/// users.
let canCancel: Bool
@State private var vm: OnboardingViewModel
init(
targetServerID: ServerID,
canCancel: Bool = true,
onFinished: @escaping @MainActor () async -> Void,
onCancel: @escaping @MainActor () -> Void = {}
) {
self.targetServerID = targetServerID
self.canCancel = canCancel
self.onFinished = onFinished
self.onCancel = onCancel
let service = CitadelSSHService()
@@ -63,9 +72,16 @@ struct OnboardingRootView: View {
// to cancel. Hiding the button then also keeps
// users from accidentally wiping a just-saved
// server mid-race.
//
// Also hidden on first-run onboarding (issue #55):
// there is no server list to return to, so Cancel
// would either be inert (state machine looping
// back into onboarding) or confusing (an empty
// server list with no path forward). Better to
// not show the affordance at all.
if case .connected = vm.step {
EmptyView()
} else {
} else if canCancel {
Button("Cancel") {
onCancel()
}
+3 -2
View File
@@ -54,7 +54,8 @@ If you join the ScarfGo beta via TestFlight, Apple shares anonymized crash repor
- iOS Keychain storage uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` so credentials are unreachable while the device is locked and never synced to iCloud.
- SSH connections use the same protocol stack as `ssh(1)` — strict host-key verification on first connect, key-based auth (no passwords are sent over the wire), and Citadel's pure-Swift implementation on iOS.
- The macOS app is sandboxed where possible and notarized via Apple's standard Developer ID flow.
- The macOS app is notarized via Apple's standard Developer ID flow (signed + stapled by `xcrun notarytool` on every release). It is not App-Sandboxed — Scarf needs direct read access to `~/.hermes/` and the ability to spawn the `hermes` CLI, both of which the App Sandbox forbids. That's why Scarf is distributed via GitHub Releases + Sparkle rather than the Mac App Store.
- ScarfGo on iOS runs inside the standard iOS app sandbox — no special entitlements beyond Keychain access for the SSH key.
## Children's privacy
@@ -65,7 +66,7 @@ Neither app is directed at children under 13 and we do not knowingly collect any
Because we don't collect any data on developer-controlled servers, there is nothing for you to opt out of, request deletion of, or export. To remove all app-stored data from your device:
- **ScarfGo**: delete the app. iOS purges the Keychain group + app container.
- **Scarf**: delete the app and the `~/Library/Containers/com.scarf` directory (the app is sandboxed; this is the only on-disk data).
- **Scarf**: delete `Scarf.app` from `/Applications`, then optionally remove `~/Library/Caches/scarf/` (remote SQLite snapshots), `~/Library/Preferences/com.scarf.app.plist` (server registry + preferences), and `~/Library/Application Support/com.scarf/` (skill snapshots).
Your Hermes host's data (`~/.hermes/`) stays untouched — that's yours to manage.
+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 = 26;
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.0;
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 = 26;
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.0;
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 = 26;
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 = 26;
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 = 26;
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 = 26;
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 = 26;
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.0;
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 = 26;
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.0;
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 = 26;
CURRENT_PROJECT_VERSION = 28;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.5.0;
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 = 26;
CURRENT_PROJECT_VERSION = 28;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.5.0;
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 = 26;
CURRENT_PROJECT_VERSION = 28;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 2.5.0;
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 = 26;
CURRENT_PROJECT_VERSION = 28;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 2.5.0;
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 {
@@ -0,0 +1,109 @@
import SwiftUI
/// Scarf-local chat rendering preferences (issues #47 / #48).
///
/// **Scope vs. Hermes config.** These three keys control how Scarf
/// *renders* the chat transcript on screen they do not affect what
/// Hermes emits over ACP. The companion Hermes flags (`display.compact`,
/// `showReasoning`, `showCost`) live on the Settings Display tab's
/// "Output" section and gate emission. Two separate concerns; both can
/// be on at once.
///
/// **Defaults match today's UI exactly.** Existing users see no change
/// until they opt in via Settings Display Chat density.
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.
enum ToolCardStyle: String, CaseIterable, Identifiable {
/// Today's behavior: full expandable card per call with arguments
/// preview and inline result.
case full
/// Single-line chip per call (icon + name + status dot). Tap opens
/// the right-pane inspector with the same details the inline expand
/// shows. Saves significant vertical space when the assistant
/// chains many tool calls.
case compact
/// No per-call rows. The `MessageGroupView.toolSummary` pill stays
/// visible (showing aggregate counts) and is tappable clicking it
/// opens the inspector on the first call so per-call telemetry
/// (duration, exit code) remains reachable.
case hidden
var id: String { rawValue }
var displayName: String {
switch self {
case .full: return "Full card"
case .compact: return "Compact chip"
case .hidden: return "Hidden"
}
}
}
/// How `RichMessageBubble` renders the assistant's reasoning channel.
enum ReasoningStyle: String, CaseIterable, Identifiable {
/// Today's behavior: yellow tinted DisclosureGroup with a brain
/// icon, "REASONING" label, and reasoning-token chip in the label.
case disclosure
/// Italic foregroundFaint caption inline above the reply, with a
/// 9pt brain prefix. No box, no border, no toggle just the text.
/// Reasoning token count moves into the bubble's metadataFooter
/// (`· N reasoning tok`) so it isn't lost.
case inline
/// Reasoning is not rendered. Token count still appears in the
/// metadataFooter so user retains visibility into reasoning cost.
case hidden
var id: String { rawValue }
var displayName: String {
switch self {
case .disclosure: return "Disclosure box"
case .inline: return "Inline (italic)"
case .hidden: return "Hidden"
}
}
}
/// Convenience helpers for translating the user's chat font scale into
/// SwiftUI's `DynamicTypeSize`. Applied once at the `RichChatView` root
/// so all of message list / input bar / session info bar scale together.
enum ChatFontScale {
static let min: Double = 0.85
static let max: Double = 1.30
static let step: Double = 0.05
static let `default`: Double = 1.0
/// Map the slider value to the closest `DynamicTypeSize`. We avoid
/// the accessibility sizes deliberately the Mac chat layout has
/// fixed-width side panes and accessibility-XXL would push tool
/// chips into truncation. Users who need larger text should also
/// resize the window.
static func dynamicTypeSize(for scale: Double) -> DynamicTypeSize {
switch scale {
case ..<0.92: return .xSmall
case ..<1.00: return .small
case ..<1.08: return .medium
case ..<1.18: return .large
case ..<1.25: return .xLarge
default: return .xxLarge
}
}
/// Display percentage for the slider's value chip.
static func percentLabel(for scale: Double) -> String {
let pct = Int((scale * 100).rounded())
return "\(pct)%"
}
}
@@ -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,8 +64,33 @@ 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()
.id("group-\(group.id)")
}
@@ -136,7 +168,7 @@ struct RichChatMessageList: View {
}
}
struct MessageGroupView: View {
struct MessageGroupView: View, Equatable {
let group: MessageGroup
/// Wall-clock turn durations keyed by assistant-message id (v2.5).
/// Forwarded into `RichMessageBubble` so the metadata footer can
@@ -144,10 +176,57 @@ struct MessageGroupView: View {
/// that haven't been updated yet still compile.
var turnDurations: [Int: TimeInterval] = [:]
@Environment(ChatViewModel.self) private var chatViewModel
/// Read here so the toolSummary pill knows whether to render as
/// always-visible (today's behavior) or as a tappable inspector
/// shortcut when per-call tool cards are hidden (issue #47).
@AppStorage(ChatDensityKeys.toolCardStyle)
private var toolCardStyleRaw: String = ToolCardStyle.full.rawValue
private var toolCardStyle: ToolCardStyle {
ToolCardStyle(rawValue: toolCardStyleRaw) ?? .full
}
/// Equatable short-circuit for SwiftUI: when the trailing group's
/// streaming bubble grows, only that group's `==` returns false.
/// All earlier groups skip body re-evaluation, dropping per-chunk
/// render work from O(n) to O(1) for settled groups (issue #46).
///
/// What participates:
/// - `group.id` (primary key stable sequential index).
/// - assistant-message id list (additions / finalize-id-flip).
/// - For the streaming message (id == 0): content, reasoning,
/// reasoningContent, toolCalls.count the only fields that
/// mutate while streaming.
/// - `turnDurations[msg.id]` for assistants in this group only
/// the dict is large and shared across groups, but each group
/// only renders its own entries.
/// - `group.toolResults.count` append-only within a group.
static func == (lhs: MessageGroupView, rhs: MessageGroupView) -> Bool {
guard lhs.group.id == rhs.group.id else { return false }
guard lhs.group.userMessage?.id == rhs.group.userMessage?.id else { return false }
guard lhs.group.userMessage?.content == rhs.group.userMessage?.content else { return false }
guard lhs.group.assistantMessages.count == rhs.group.assistantMessages.count else { return false }
for (l, r) in zip(lhs.group.assistantMessages, rhs.group.assistantMessages) {
if l.id != r.id { return false }
if l.id == 0 {
if l.content != r.content { return false }
if l.reasoning != r.reasoning { return false }
if l.reasoningContent != r.reasoningContent { return false }
if l.toolCalls.count != r.toolCalls.count { return false }
}
}
if lhs.group.toolResults.count != rhs.group.toolResults.count { return false }
for msg in lhs.group.assistantMessages where msg.isAssistant && msg.id != 0 {
if lhs.turnDurations[msg.id] != rhs.turnDurations[msg.id] { return false }
}
return true
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let user = group.userMessage {
RichMessageBubble(message: user, toolResults: [:])
.equatable()
}
// Identify by array offset rather than `message.id`. The
@@ -166,9 +245,19 @@ struct MessageGroupView: View {
toolResults: group.toolResults,
turnDuration: turnDurations[message.id]
)
.equatable()
}
if group.toolCallCount > 1 {
// When per-call tool cards are visible, the summary pill
// is informational only. When tool cards are hidden
// (issue #47), this pill becomes the only chrome surfacing
// tool activity AND the only path back into the inspector
// pane render it on every group with calls (not just >1)
// and make it tappable to focus the first call.
let showSummary = (toolCardStyle == .hidden)
? group.toolCallCount > 0
: group.toolCallCount > 1
if showSummary {
toolSummary
}
}
@@ -176,28 +265,44 @@ struct MessageGroupView: View {
@ViewBuilder
private var toolSummary: some View {
let kinds = toolKindCounts
let kinds = group.toolKindCounts
if !kinds.isEmpty {
HStack(spacing: 4) {
Image(systemName: "wrench")
.font(.caption2)
Text(summaryText(kinds))
.font(.caption2)
let firstCallId = group.assistantMessages
.flatMap(\.toolCalls)
.first?.callId
let isInteractive = (toolCardStyle == .hidden) && firstCallId != nil
Group {
if isInteractive, let firstCallId {
Button {
chatViewModel.focusedToolCallId = firstCallId
} label: {
toolSummaryPill(kinds, interactive: true)
}
.buttonStyle(.plain)
.help("Click to inspect tool calls")
} else {
toolSummaryPill(kinds, interactive: false)
}
}
.foregroundStyle(.tertiary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 2)
}
}
private var toolKindCounts: [ToolKind: Int] {
var counts: [ToolKind: Int] = [:]
for msg in group.assistantMessages where msg.isAssistant {
for call in msg.toolCalls {
counts[call.toolKind, default: 0] += 1
@ViewBuilder
private func toolSummaryPill(_ kinds: [ToolKind: Int], interactive: Bool) -> some View {
HStack(spacing: 4) {
Image(systemName: "wrench")
.font(.caption2)
Text(summaryText(kinds))
.font(.caption2)
if interactive {
Image(systemName: "arrow.up.right.square")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
return counts
.foregroundStyle(.tertiary)
}
private func summaryText(_ kinds: [ToolKind: Int]) -> String {
@@ -22,14 +22,32 @@ struct RichChatView: View {
@Environment(HermesFileWatcher.self) private var fileWatcher
@Environment(ChatViewModel.self) private var chatViewModel
/// User-controlled font scale for the chat surface (issue #48).
/// Applied via `.environment(\.dynamicTypeSize, ...)` so message
/// list, input bar, session info bar, and the inspector pane all
/// scale together. Default 1.0 = today's UI.
@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,
@@ -37,11 +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 {
@@ -2,7 +2,7 @@ import SwiftUI
import ScarfCore
import ScarfDesign
struct RichMessageBubble: View {
struct RichMessageBubble: View, Equatable {
let message: HermesMessage
let toolResults: [String: HermesMessage]
/// Wall-clock duration of the agent turn this assistant message
@@ -14,6 +14,44 @@ struct RichMessageBubble: View {
@Environment(ChatViewModel.self) private var chatViewModel
/// Scarf-local chat density preferences (issues #47 / #48). All
/// three default to today's UI. Read here so the reasoning + tool-
/// call switches don't have to thread the values through every
/// layer; the AppStorage seam is one line per dependency.
@AppStorage(ChatDensityKeys.toolCardStyle)
private var toolCardStyleRaw: String = ToolCardStyle.full.rawValue
@AppStorage(ChatDensityKeys.reasoningStyle)
private var reasoningStyleRaw: String = ReasoningStyle.disclosure.rawValue
private var toolCardStyle: ToolCardStyle {
ToolCardStyle(rawValue: toolCardStyleRaw) ?? .full
}
private var reasoningStyle: ReasoningStyle {
ReasoningStyle(rawValue: reasoningStyleRaw) ?? .disclosure
}
/// SwiftUI body short-circuit (issue #46). Settled bubbles
/// (`message.id != 0`) are immutable id equality plus a couple
/// of cheap stored-field comparisons is sufficient. The streaming
/// bubble (id == 0) gets a content + reasoning + toolCalls.count
/// comparison so it correctly redraws on every chunk.
/// `toolResults` is compared by count: results are append-only
/// within a group, so a count change implies a new tool result.
static func == (lhs: RichMessageBubble, rhs: RichMessageBubble) -> Bool {
guard lhs.message.id == rhs.message.id else { return false }
if lhs.message.id == 0 {
return lhs.message.content == rhs.message.content
&& lhs.message.reasoning == rhs.message.reasoning
&& lhs.message.reasoningContent == rhs.message.reasoningContent
&& lhs.message.toolCalls.count == rhs.message.toolCalls.count
&& lhs.turnDuration == rhs.turnDuration
&& lhs.toolResults.count == rhs.toolResults.count
}
return lhs.turnDuration == rhs.turnDuration
&& lhs.toolResults.count == rhs.toolResults.count
&& lhs.message.tokenCount == rhs.message.tokenCount
&& lhs.message.finishReason == rhs.message.finishReason
}
var body: some View {
if message.isUser {
userBubble
@@ -79,13 +117,13 @@ struct RichMessageBubble: View {
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
if message.hasReasoning {
if message.hasReasoning, reasoningStyle != .hidden {
reasoningSection
}
if !message.content.isEmpty {
contentView
}
if !message.toolCalls.isEmpty {
if !message.toolCalls.isEmpty, toolCardStyle != .hidden {
toolCallsSection
}
}
@@ -125,7 +163,24 @@ struct RichMessageBubble: View {
// MARK: - Reasoning
/// Reasoning is rendered in one of three styles, controlled by
/// `Settings Display Chat density Reasoning` (issue #48).
/// Token count for the reasoning-bearing message is kept in the
/// metadataFooter (always-visible), so collapsing or hiding the
/// box doesn't drop telemetry.
@ViewBuilder
private var reasoningSection: some View {
switch reasoningStyle {
case .disclosure:
reasoningDisclosure
case .inline:
reasoningInline
case .hidden:
EmptyView()
}
}
private var reasoningDisclosure: some View {
DisclosureGroup {
Text(message.preferredReasoning ?? "")
.font(ScarfFont.monoSmall)
@@ -158,9 +213,44 @@ struct RichMessageBubble: View {
)
}
/// Inline reasoning: italic foregroundFaint caption with a 9pt
/// brain prefix, no box / border / disclosure. Same data, far less
/// vertical space addresses the #48 complaint.
private var reasoningInline: some View {
HStack(alignment: .firstTextBaseline, spacing: 5) {
Image(systemName: "brain")
.font(.system(size: 9))
.foregroundStyle(ScarfColor.warning)
Text(message.preferredReasoning ?? "")
.font(ScarfFont.caption)
.italic()
.foregroundStyle(ScarfColor.foregroundFaint)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: - Tool Calls
/// Tool calls render in one of three styles, controlled by
/// `Settings Display Chat density Tool calls` (issue #47).
/// `.hidden` is handled by the caller (skips this view entirely)
/// AND by the parent `MessageGroupView`, which makes its
/// always-visible toolSummary pill tappable so the inspector pane
/// remains reachable in both compact and hidden modes.
@ViewBuilder
private var toolCallsSection: some View {
switch toolCardStyle {
case .full:
toolCallsFull
case .compact:
toolCallsCompact
case .hidden:
EmptyView()
}
}
private var toolCallsFull: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(message.toolCalls) { call in
ToolCallCard(
@@ -173,6 +263,78 @@ struct RichMessageBubble: View {
}
}
/// One-line tappable chip per call. Click sets focus so the right-
/// pane inspector opens with the same data the inline expand
/// shows. Status dot mirrors the full-card status icon: in-flight
/// progress / success check / non-zero exit code danger.
private var toolCallsCompact: some View {
VStack(alignment: .leading, spacing: 3) {
ForEach(message.toolCalls) { call in
let result = toolResults[call.callId]
let isFocused = chatViewModel.focusedToolCallId == call.callId
let color = compactToolColor(for: call.toolKind)
Button {
chatViewModel.focusedToolCallId = call.callId
} label: {
HStack(spacing: 6) {
Image(systemName: call.toolKind.icon)
.font(.system(size: 10))
.foregroundStyle(color)
Text(call.functionName)
.font(ScarfFont.monoSmall)
.fontWeight(.medium)
.foregroundStyle(ScarfColor.foregroundPrimary)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 6)
compactStatusIcon(call: call, result: result)
}
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(color.opacity(isFocused ? 0.16 : 0.08))
.overlay(
RoundedRectangle(cornerRadius: 5)
.strokeBorder(
color.opacity(isFocused ? 0.45 : 0.20),
lineWidth: isFocused ? 1.2 : 1
)
)
)
}
.buttonStyle(.plain)
.help("Click to inspect this tool call")
}
}
}
@ViewBuilder
private func compactStatusIcon(call: HermesToolCall, result: HermesMessage?) -> some View {
if let exit = call.exitCode {
Image(systemName: exit == 0 ? "checkmark.circle.fill" : "xmark.circle.fill")
.font(.system(size: 10))
.foregroundStyle(exit == 0 ? ScarfColor.success : ScarfColor.danger)
} else if result != nil {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 10))
.foregroundStyle(ScarfColor.success)
} else {
ProgressView().controlSize(.mini)
}
}
private func compactToolColor(for kind: ToolKind) -> Color {
switch kind {
case .read: return ScarfColor.success
case .edit: return ScarfColor.info
case .execute: return ScarfColor.warning
case .fetch: return ScarfColor.Tool.web
case .browser: return ScarfColor.Tool.search
case .other: return ScarfColor.foregroundMuted
}
}
// MARK: - Metadata Footer
private var metadataFooter: some View {
@@ -21,9 +21,28 @@ struct SessionInfoBar: View {
/// git repos.
var gitBranch: String? = nil
/// Active Hermes profile name (issue #50). Resolved on each body
/// re-evaluation; the resolver caches for 5s so this is cheap.
/// Chip renders only when not "default" so existing (non-profile)
/// installations see no change in the bar.
private var activeProfile: String {
HermesProfileResolver.activeProfileName()
}
var body: some View {
HStack(spacing: 16) {
if let session {
// Profile chip leftmost surfaces which Hermes profile
// Scarf is reading (issue #50). Without this users couldn't
// tell whether the visible session list came from the
// profile they thought they switched to.
if activeProfile != "default" {
Label(activeProfile, systemImage: "person.crop.square")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.warning)
.lineLimit(1)
.help("Scarf is reading from Hermes profile \"\(activeProfile)\". Switch profiles with `hermes profile use <name>` and relaunch Scarf.")
}
// Project indicator first visually anchors the session
// as "scoped to project X" before the working dot and
// title. Hidden for non-project chats so the bar looks
@@ -16,6 +16,12 @@ struct ToolCallCard: View {
var onFocus: (() -> Void)? = nil
@State private var expanded = false
/// Pretty-printed `call.arguments`. Computed once per `call.callId`
/// via `.task(id:)` instead of on every card re-render (issue #46).
/// Seeded with the raw arguments so the first frame after expand
/// shows readable text instead of a flicker of empty space while
/// the task runs.
@State private var formattedArgs: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 6) {
@@ -77,7 +83,7 @@ struct ToolCallCard: View {
Text("ARGUMENTS")
.scarfStyle(.captionUppercase)
.foregroundStyle(ScarfColor.foregroundMuted)
Text(formatJSON(call.arguments))
Text(formattedArgs.isEmpty ? call.arguments : formattedArgs)
.font(ScarfFont.monoSmall)
.foregroundStyle(ScarfColor.foregroundPrimary)
.textSelection(.enabled)
@@ -102,6 +108,9 @@ struct ToolCallCard: View {
.padding(.leading, 4)
}
}
.task(id: call.callId) {
formattedArgs = formatJSON(call.arguments)
}
}
private var toolLabel: String {
@@ -141,13 +150,18 @@ struct ToolResultContent: View {
let content: String
@State private var showAll = false
private var lines: [String] { content.components(separatedBy: "\n") }
private var isLong: Bool { lines.count > 8 }
/// Cached line split. The previous computed-property pair
/// (`lines` + `isLong`) split `content` twice on every render
/// once for the count check, once for the prefix join. With long
/// tool outputs (file contents, command output) this was O(n)
/// per render, repeated for every settled card on every chunk
/// (issue #46). Now split once per content change via `.task(id:)`.
@State private var lines: [String] = []
@State private var preview: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(showAll ? content : lines.prefix(8).joined(separator: "\n"))
Text(showAll ? content : preview)
.font(ScarfFont.monoSmall)
.foregroundStyle(ScarfColor.foregroundPrimary)
.textSelection(.enabled)
@@ -162,7 +176,7 @@ struct ToolResultContent: View {
)
)
if isLong {
if lines.count > 8 {
Button(showAll ? "Show less" : "Show all \(lines.count) lines") {
withAnimation { showAll.toggle() }
}
@@ -171,5 +185,10 @@ struct ToolResultContent: View {
.buttonStyle(.plain)
}
}
.task(id: content) {
let split = content.components(separatedBy: "\n")
lines = split
preview = split.prefix(8).joined(separator: "\n")
}
}
}
@@ -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
}
}
@@ -306,7 +306,7 @@ struct ProjectsView: View {
onAddProject: { showingAddSheet = true }
)
.sheet(isPresented: $showingAddSheet) {
AddProjectSheet { name, path in
AddProjectSheet(context: serverContext) { name, path in
viewModel.addProject(name: name, path: path)
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
}
@@ -593,28 +593,38 @@ struct AddProjectSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var projectName = ""
@State private var projectPath = ""
/// Inline verification result for remote contexts (issue #54).
/// Renders alongside the path field as a green check / red x so
/// users learn whether a remote path is valid BEFORE they hit Add
/// and the agent's tool calls fail at runtime.
@State private var remoteVerification: RemoteVerification = .idle
/// Active server context. On remote contexts the local Browse
/// button is hidden (NSOpenPanel browses the Mac filesystem,
/// useless when the project lives on a remote host) and replaced
/// with a Verify button driven by the SSH transport's `stat`.
let context: ServerContext
let onAdd: (String, String) -> Void
private enum RemoteVerification: Equatable {
case idle
case verifying
case ok(String) // green: "Directory exists (1.2k items)" etc.
case warn(String) // red: missing / not a dir / unreadable
}
var body: some View {
VStack(spacing: 16) {
Text("Add Project")
.font(.headline)
TextField("Project Name", text: $projectName)
.textFieldStyle(.roundedBorder)
HStack {
TextField("Project Path", text: $projectPath)
.textFieldStyle(.roundedBorder)
Button("Browse...") {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
projectPath = url.path
if projectName.isEmpty {
projectName = url.lastPathComponent
}
}
VStack(alignment: .leading, spacing: 6) {
pathInputRow
if context.isRemote {
Text("Path on \(context.displayName) — must already exist on the server. Tool calls run with this directory as their working directory.")
.font(.caption)
.foregroundStyle(.secondary)
verificationBadge
}
}
HStack {
@@ -631,6 +641,102 @@ struct AddProjectSheet: View {
}
}
.padding()
.frame(width: 400)
.frame(width: 440)
}
@ViewBuilder
private var pathInputRow: some View {
HStack {
TextField("Project Path", text: $projectPath)
.textFieldStyle(.roundedBorder)
.onChange(of: projectPath) { _, _ in
// Stale verification once the path edits reset to
// idle so users don't see a green check for a path
// they've since changed.
if remoteVerification != .idle {
remoteVerification = .idle
}
}
if context.isRemote {
Button("Verify") {
Task { await verifyRemotePath() }
}
.disabled(projectPath.isEmpty || remoteVerification == .verifying)
} else {
Button("Browse...") {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
projectPath = url.path
if projectName.isEmpty {
projectName = url.lastPathComponent
}
}
}
}
}
}
@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)
}
}
}
/// Verify the entered path on the remote via the existing SSH
/// transport. Uses `stat` (not just `fileExists`) so we can reject
/// files-that-aren't-dirs without a separate round trip.
private func verifyRemotePath() async {
let path = projectPath.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).")
}
guard let stat = transport.stat(path) else {
// Stat failed even though `test -e` passed typically
// a permission issue on the parent dir. Surface as a
// warning so the user knows the path is reachable but
// not introspectable.
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
}
}
@@ -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] = []
@@ -123,7 +143,11 @@ final class RemoteDiagnosticsViewModel {
finishedAt = nil
let script = Self.buildScript(hermesHome: context.paths.home)
let captured = await Self.execute(script: script, context: context)
// Use the shared SSHScriptRunner so this view model and the
// ConnectionStatusViewModel pill always agree on what the
// remote sees (issue #44 the prior local copies of the
// workaround drifted from each other).
let captured = await SSHScriptRunner.run(script: script, context: context, timeout: 30)
switch captured {
case .connectFailure(let msg):
@@ -131,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
@@ -147,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
@@ -206,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
@@ -234,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"
@@ -256,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
@@ -272,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
@@ -282,176 +363,24 @@ final class RemoteDiagnosticsViewModel {
"""#
}
enum Captured {
case connectFailure(String)
case completed(stdout: String, stderr: String, exitCode: Int32)
}
private static func execute(script: String, context: ServerContext) async -> Captured {
// Can't use `transport.runProcess(executable: "/bin/sh", args: ["-c", script])`
// here: SSHTransport.runProcess pipes every argument through
// `remotePathArg` (which double-quotes to rewrite `~/` `$HOME/`),
// which mangles a multi-line shell script containing `"$1"`,
// nested quotes, and `printf` escape sequences. The result on the
// remote is a scrambled string and every probe fails to emit.
//
// Mirror TestConnectionProbe's approach: build the ssh argv
// directly so the script travels as a single opaque argv entry
// that ssh forwards to the remote shell unchanged.
switch context.kind {
case .local:
return await runLocally(script: script)
case .ssh(let config):
return await runOverSSH(script: script, config: config)
}
}
/// Direct ssh invocation. Pipes the script into `sh` on stdin rather
/// than passing it as `sh -c <script>` argv because ssh concatenates
/// argv with spaces and sends that as a single command string to the
/// remote's LOGIN shell, which then parses newlines as command
/// separators. A multi-line `sh -c <script>` would run only the first
/// line inside the `sh` subprocess (any variables set there die when
/// `sh` exits), and the rest would run in the login shell with no
/// access to those variables. Symptom: `$H=""` everywhere downstream.
///
/// Feeding the script via stdin avoids the split entirely `sh -s`
/// consumes the whole stream in one process, so variable scope is
/// preserved and the script runs exactly the same way it would from
/// a local `cat script.sh | sh`.
private static func runOverSSH(script: String, config: SSHConfig) async -> Captured {
var sshArgv: [String] = [
"-o", "ControlMaster=auto",
"-o", "ControlPath=\(controlDirPath())/%C",
"-o", "ControlPersist=600",
"-o", "ServerAliveInterval=30",
"-o", "ConnectTimeout=10",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "LogLevel=QUIET",
"-o", "BatchMode=yes",
"-T" // no pty keep stdin/stdout a clean byte stream
]
if let port = config.port { sshArgv += ["-p", String(port)] }
if let id = config.identityFile, !id.isEmpty {
sshArgv += ["-i", id]
}
let hostSpec: String
if let user = config.user, !user.isEmpty { hostSpec = "\(user)@\(config.host)" }
else { hostSpec = config.host }
sshArgv.append(hostSpec)
sshArgv.append("--")
sshArgv.append("/bin/sh")
sshArgv.append("-s") // read script from stdin
return await Task.detached { () -> Captured in
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
proc.arguments = sshArgv
// Inherit the shell's SSH_AUTH_SOCK so ssh can reach the
// agent same pattern as SSHTransport + TestConnectionProbe.
var env = ProcessInfo.processInfo.environment
let shellEnv = HermesFileService.enrichedEnvironment()
for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] {
if env[key] == nil, let v = shellEnv[key], !v.isEmpty {
env[key] = v
}
}
proc.environment = env
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
proc.standardInput = stdinPipe
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
do {
try proc.run()
} catch {
return .connectFailure("Failed to launch ssh: \(error.localizedDescription)")
}
// Write the script to ssh's stdin, then close the write end so
// remote sh sees EOF and exits after executing the whole script.
if let data = script.data(using: .utf8) {
try? stdinPipe.fileHandleForWriting.write(contentsOf: data)
}
try? stdinPipe.fileHandleForWriting.close()
let deadline = Date().addingTimeInterval(30)
while proc.isRunning && Date() < deadline {
try? await Task.sleep(nanoseconds: 100_000_000)
}
if proc.isRunning {
proc.terminate()
return .connectFailure("Diagnostics timed out after 30s")
}
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
return .completed(
stdout: String(data: out, encoding: .utf8) ?? "",
stderr: String(data: err, encoding: .utf8) ?? "",
exitCode: proc.terminationStatus
)
}.value
}
/// Local Shell invocation runs the diagnostic script against the
/// user's own Mac. Less useful than the remote form (most checks will
/// trivially pass), but lets the same UI work for both contexts.
private static func runLocally(script: String) async -> Captured {
return await Task.detached { () -> Captured in
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/sh")
proc.arguments = ["-c", script]
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
do {
try proc.run()
} catch {
return .connectFailure("Failed to launch /bin/sh: \(error.localizedDescription)")
}
let deadline = Date().addingTimeInterval(10)
while proc.isRunning && Date() < deadline {
try? await Task.sleep(nanoseconds: 100_000_000)
}
if proc.isRunning {
proc.terminate()
return .connectFailure("Local diagnostics timed out (should be <1s)")
}
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
return .completed(
stdout: String(data: out, encoding: .utf8) ?? "",
stderr: String(data: err, encoding: .utf8) ?? "",
exitCode: proc.terminationStatus
)
}.value
}
/// Same cache directory used by SSHTransport shared so the diagnostic
/// probe reuses the connection's ControlMaster socket when it already
/// exists (no second TCP handshake, no second auth).
private static func controlDirPath() -> String {
SSHTransport.controlDirPath()
}
private static func parse(stdout: String, stderr: String, exitCode: Int32) -> [Probe] {
var results: [ProbeID: Probe] = [:]
for line in stdout.split(whereSeparator: { $0 == "\n" || $0 == "\r" }) {
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
)
}
@@ -469,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)
@@ -10,6 +10,7 @@ import ScarfDesign
struct ConnectionStatusPill: View {
let status: ConnectionStatusViewModel
@State private var showDetails = false
@State private var showDegraded = false
@State private var showDiagnostics = false
var body: some View {
@@ -18,9 +19,10 @@ struct ConnectionStatusPill: View {
case .error:
showDetails = true
case .degraded:
// Yellow "can't read" state open the diagnostics sheet
// so the user can see exactly which files fail and why.
showDiagnostics = true
// Show the granular reason + hint inline first (issue
// #53). The user can drill into the full diagnostics
// sheet from the popover if the hint isn't enough.
showDegraded = true
case .connected, .idle:
status.retry()
}
@@ -45,6 +47,9 @@ struct ConnectionStatusPill: View {
.popover(isPresented: $showDetails, arrowEdge: .bottom) {
errorDetails.frame(width: 400)
}
.popover(isPresented: $showDegraded, arrowEdge: .bottom) {
degradedDetails.frame(width: 440)
}
.sheet(isPresented: $showDiagnostics) {
RemoteDiagnosticsView(context: status.context)
}
@@ -75,7 +80,7 @@ struct ConnectionStatusPill: View {
private var labelText: Text {
switch status.status {
case .connected: return Text("Connected")
case .degraded: return Text("Connected — can't read Hermes state")
case .degraded(let reason, _, _): return Text("Connected — \(reason)")
case .idle: return Text("Checking…")
case .error(let message, _): return Text(verbatim: message)
}
@@ -89,13 +94,75 @@ struct ConnectionStatusPill: View {
return Text("Last probe: \(fmt.localizedString(for: ts, relativeTo: Date()))")
}
return Text("Connected")
case .degraded(let reason):
return Text("SSH works but \(reason). Click for diagnostics.")
case .degraded(let reason, _, _):
return Text("SSH works but \(reason). Click for details.")
case .idle: return Text("Waiting for first probe")
case .error: return Text("Click for details")
}
}
@ViewBuilder
private var degradedDetails: some View {
if case .degraded(let reason, let hint, let cause) = status.status {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
Label(reason, systemImage: "stethoscope")
.foregroundStyle(ScarfColor.warning)
.scarfStyle(.headline)
Spacer()
}
Divider()
Text(hint)
.font(.callout)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
if case .profileActive(let name) = cause {
// Specific copy-paste affordance for the profile case
// the most actionable hint, surfaced inline.
profileFixCommand(name: name)
}
HStack {
Button("Run diagnostics") {
showDegraded = false
showDiagnostics = true
}
.buttonStyle(ScarfSecondaryButton())
Spacer()
Button("Retry") {
status.retry()
showDegraded = false
}
.buttonStyle(ScarfPrimaryButton())
}
}
.padding(14)
.frame(width: 440)
}
}
@ViewBuilder
private func profileFixCommand(name _: String) -> some View {
let command = "hermes profile use default"
VStack(alignment: .leading, spacing: 6) {
Text("Or run this on the remote to switch back to the default profile:")
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Text(command)
.font(.system(size: 11, design: .monospaced))
.textSelection(.enabled)
.padding(6)
.background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 4))
Spacer()
Button("Copy") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(command, forType: .string)
}
.buttonStyle(.borderless)
}
}
}
@ViewBuilder
private var errorDetails: some View {
if case .error(let message, let stderr) = status.status {
@@ -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
}
}
@@ -1,11 +1,46 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Display tab streaming, reasoning, cost, skin, compact mode, inline diffs, bell, etc.
struct DisplayTab: View {
@Bindable var viewModel: SettingsViewModel
/// Scarf-local chat density preferences (issues #47 / #48).
/// Independent of the Hermes config flags rendered in the
/// "Output" section below those control what Hermes EMITS,
/// these control how Scarf RENDERS what was emitted.
@AppStorage(ChatDensityKeys.toolCardStyle)
private var toolCardStyle: String = ToolCardStyle.full.rawValue
@AppStorage(ChatDensityKeys.reasoningStyle)
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") {
DensityPickerRow(
label: "Tool calls",
selection: $toolCardStyle,
options: ToolCardStyle.allCases.map { ($0.rawValue, $0.displayName) }
)
DensityPickerRow(
label: "Reasoning",
selection: $reasoningStyle,
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()
}
SettingsSection(title: "Output", icon: "doc.plaintext") {
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
@@ -32,3 +67,82 @@ struct DisplayTab: View {
}
}
}
// MARK: - Density-section primitives
/// Segmented picker over (rawValue, displayName) tuples keeps the
/// existing `PickerRow` simple-string contract while still letting us
/// render distinct user-facing labels for each density enum case.
/// Cannot reuse the generic `PickerRow` in `SettingsComponents.swift`:
/// that one is `.menu` style and doesn't accept a separate display
/// name per option.
private struct DensityPickerRow: View {
let label: String
@Binding var selection: String
let options: [(rawValue: String, displayName: String)]
var body: some View {
HStack {
Text(label)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.frame(width: 160, alignment: .trailing)
Picker("", selection: $selection) {
ForEach(options, id: \.rawValue) { option in
Text(option.displayName).tag(option.rawValue)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(maxWidth: 320)
Spacer()
}
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 6)
.background(ScarfColor.backgroundTertiary.opacity(0.5))
}
}
private struct FontScaleRow: View {
@Binding var scale: Double
var body: some View {
HStack {
Text("Chat font size")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.frame(width: 160, alignment: .trailing)
Slider(
value: $scale,
in: ChatFontScale.min...ChatFontScale.max,
step: ChatFontScale.step
)
.frame(maxWidth: 240)
Text(ChatFontScale.percentLabel(for: scale))
.font(ScarfFont.monoSmall)
.foregroundStyle(ScarfColor.foregroundMuted)
.frame(width: 48, alignment: .leading)
Button("Reset") {
scale = ChatFontScale.default
}
.buttonStyle(.borderless)
.controlSize(.small)
.disabled(abs(scale - ChatFontScale.default) < 0.001)
Spacer()
}
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 6)
.background(ScarfColor.backgroundTertiary.opacity(0.5))
}
}
private struct DensityFootnote: View {
var body: some View {
Text("Controls how Scarf renders the chat. Use Output → Show Reasoning to control what Hermes sends.")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 6)
}
}
@@ -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?" : {
},
+15
View File
@@ -188,6 +188,21 @@ build_variant() {
log "[$label] Package $(basename "$out_zip")"
ditto -c -k --keepParent "$app_path" "$out_zip"
# Post-package verification: extract the actual distribution zip and confirm
# codesign + Gatekeeper still accept it. Catches any regression introduced by
# ditto / staple / future pipeline tweaks before users see "damaged" errors.
# See issue #49 — without this, a broken seal in Sparkle.framework or the
# outer bundle would only surface in user reports.
log "[$label] Post-package signature + Gatekeeper verification"
local verify_dir
verify_dir="$(mktemp -d)"
ditto -xk "$out_zip" "$verify_dir"
codesign --verify --strict --deep --verbose=4 "$verify_dir/Scarf.app" \
|| die "[$label] codesign --verify failed on packaged zip"
spctl --assess --type execute --verbose "$verify_dir/Scarf.app" \
|| die "[$label] spctl --assess failed on packaged zip"
rm -rf "$verify_dir"
}
UNIVERSAL_ZIP="$RELEASE_DIR/Scarf-v${VERSION}-Universal.zip"