Compare commits

...

56 Commits

Author SHA1 Message Date
Alan Wizemann 6a7ac21ebe chore: Bump version to 2.6.5 2026-05-03 22:15:05 +02:00
Alan Wizemann 5be67282d8 test(layer-b): full Install → Configure → Open → Uninstall journey XCUITest (#73)
Closes the deferred Layer B install-drive that v2.7's smoke test
left as future work. The new test
(`testFullCatalogToInstallToDashboardJourney`) drives the full
install/uninstall pipeline end-to-end and validates 9 assertion
points along the way:

- Window surfaces under `--scarf-test-mode`
- Sidebar navigation to Projects
- Install sheet appears (URL handoff via launch arg)
- Parent-dir field accepts custom path + Continue
- Configure sheet renders + commit clicks
- Confirm Install runs the install pipeline
- Open Project advances to success view
- Project row appears in sidebar with uniquified name
- Right-click Uninstall + confirm Remove + Done removes the row

Runs in ~30s green on the dev Mac.

## What needed wiring up

**SwiftUI Menu / NSToolbarItem accessibility-bridging.** macOS
toolbar Menus don't propagate `.accessibilityIdentifier` through to
XCUITest — neither the menu trigger NOR the popup contents are
queryable by ID. Verified by tree-dump diagnostics. The test
sidesteps this entirely by routing the install URL through a new
`--scarf-test-install-url <https-url>` launch arg that calls
`TemplateURLRouter.shared.handle(scarf://install?url=...)` at App
init, gated on `TestModeFlags.shared.isTestMode`. Production
launches (no flag) untouched.

**Accessibility IDs added** on the new install/uninstall path:
- `templateConfig.commitButton`, `templateConfig.cancelButton`
- `projects.row.<name>`, `sidebar.section.<rawValue>`
- `projects.contextMenu.uninstallTemplate`
- `templateUninstall.confirmRemove`
- `templateInstall.success.openProject`
- `templateUninstall.success.done`

**Sandboxed-runner caveat.** The XCUITest runner's `/tmp` is
sandbox-protected (createDirectory throws EPERM); we use
`NSTemporaryDirectory()` which resolves to the runner's container
tmp (`~/Library/Containers/com.scarfUITests.xctrunner/Data/tmp/`),
which the unsandboxed Scarf app can read since it has full disk
access.

## Known cohabitation hazard (pre-existing uninstaller bug)

If the dev Mac already has a project from the same template
installed, the install pipeline uniquifies the new project's name
("HackerNews Daily Digest 2") but BOTH projects' cron jobs get
registered under the same `[tmpl:awizemann/hackernews-digest] Daily
HN digest` name. `ProjectTemplateUninstaller.loadUninstallPlan`
resolves cron jobs to remove by NAME and can target the wrong
project's job. The Layer B test surfaces this — manifests as: test
passes, the dev's real project's cron job disappears.

**Fix (separate work):** store cron-job IDs in
`<project>/.scarf/template.lock.json` at install time and resolve
by ID at uninstall time. Until then, the test docstring warns
about cohabitation; recovery is `hermes cron create` to recreate
the lost job.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:09:50 +02:00
Alan Wizemann c661945a1f feat(cron): auth-error banner + running indicator + per-job log tail (#72)
Cron rows now surface the same OAuth-refresh-revoked recovery flow as
chat instead of a generic red dot, plus three previously-missing
observability cues:

- ACPErrorHint.classify is reused on `job.lastError`. When it returns
  `oauthRefreshRevoked(provider)` the detail pane shows the human hint
  + a "Re-authenticate" button that drops the user into Credential
  Pools via `coordinator.pendingOAuthReauth = provider` — same wiring
  ChatView's banner uses. Unrecognized errors fall back to the legacy
  red `lastError` text (no regression).
- Row dot turns blue + pulses when `state == "running"` (taking
  precedence over disabled / error / success); the detail header gains
  a `ScarfBadge("running…", kind: .info)` next to active/paused. No new
  polling — `HermesFileWatcher.lastChangeDate` (already wired into
  ActivityView/Logs) drives `CronViewModel.load()` so state flips
  surface within a watcher tick.
- "LAST RUN OUTPUT" replaces the inline `LAST OUTPUT` block with a
  collapsible panel: a one-line summary (`<timestamp> — ok|error|running…`)
  always visible, full monospaced terminal-style scroll view on
  expand, auto-scrolls to bottom when new runs land.

Also fixes a pre-existing bug in `HermesFileService.loadCronOutput`:
Hermes nests per-run output under `~/.hermes/cron/output/<jobId>/<ts>.md`
but the loader treated the dir as flat, so the cron output panel never
rendered any content. The fix walks the per-job subdir + keeps the
legacy flat-file fallback for older Hermes layouts.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:09:21 +02:00
Alan Wizemann f5f8dc30b6 Dogfooding templates: HN Digest + in-app catalog browser + test harness (#71)
* feat(templates): hackernews-digest template + dogfooding test harness

First pass of the dogfooding-templates initiative. Each pre-release cycle
ships one new official `.scarftemplate` and uses installing/exercising
that template as the regression test. v1 lands the harness scaffolding
plus the first template under it.

- HackerNews Daily Digest template (`templates/awizemann/hackernews-digest/`):
  config-driven (min_score / max_items / topics) cron-only template.
  No secrets — keeps the harness minimal until the fake-Keychain shim
  lands. Bundle validates against `tools/build-catalog.py`; entry added
  to `templates/catalog.json`.
- `SCARF_HERMES_HOME` env-var override at `HermesProfileResolver` —
  the seam every Layer-B test relies on to drive Scarf against an
  isolated Hermes home. Bypasses cache + active_profile lookup; rejects
  relative paths. 5 unit tests + 3 ServerContext integration tests.
- `TestModeFlags.shared.isTestMode` — reads `--scarf-test-mode` once
  from `CommandLine.arguments`. Wiring only; gating sites (Sparkle,
  capability probe, first-run walkthrough) land as Layer-B exercises
  them.
- Layer A (`scarf/scarfTests/TemplateE2ETests.swift`): parses + plans
  the shipped HN bundle the way the app does at install time;
  asserts manifest, config schema, dashboard widgets, and cron prompt
  contract. Mirrors the existing site-status-checker coverage.
- Layer B scaffold (`scarf/scarfUITests/TemplateInstallUITests.swift`):
  proves the launch-arg + env-var plumbing reaches Scarf. Full install
  click-through deferred until fixture-Hermes-home and accessibility
  IDs land.

Wiki pages added separately on the `.wiki-worktree` branch:
- `Template-Ideas.md` — backlog of 9 v1-feasible templates +
  full-spec v3 epic for Project-Site-as-Living-Surface (eBay listings
  use case).
- `Test-Harness.md` — contributor guide for extending the harness.

Verification: scarfTests 124/124, ScarfCore 220/220, new Layer A 3/3,
Layer B scaffold 1/1, build-catalog.py + its 28 unit tests all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(test-harness): Layer B pivot to real ~/.hermes + a11y IDs + Sparkle gating

Discovered during Layer B work that XCUITest runners are sandboxed:
they can read ~/.hermes/ but writes throw NSFileWriteNoPermissionError.
That kills the SCARF_HERMES_HOME-based isolation pattern for UI tests —
snapshot/restore from inside the runner can't work. Pivot:

- Layer B drives the real ~/.hermes the dev Mac is already running
  against. The harness assumes a working Hermes install (XCTSkip if
  the binary isn't there). Cleanup is via the app's own UI flows
  (which have full disk access), not direct file I/O. Layer A keeps
  its env-var seam — those tests run inside the host app's address
  space and write freely.
- SwiftUI's WindowGroup(for: ServerID.self) doesn't auto-surface a
  window on a fresh XCUIApplication.launch(). The harness sends ⌘1
  (the "Open Server → Local" menu shortcut wired in scarfApp.swift's
  OpenServerCommands) to take the same code path real users hit via
  Dock click.
- Real user home resolved via getpwuid(getuid()) rather than
  NSHomeDirectory(), which inside the sandboxed runner returns
  ~/Library/Containers/com.scarfUITests.xctrunner/Data.
- 8 accessibility IDs added on the install path so the next iteration
  can drive the full Templates → Install from URL → Parent dir →
  Confirm Install flow without depending on view-tree label scraping:
  templates.toolbar.menu, templates.installFromFile,
  templates.installFromURL, templates.installURL.field,
  templates.installURL.confirm, templateInstall.parentDir.field,
  templateInstall.parentDir.continue, templateInstall.confirmInstall.
- TestModeFlags.shared.isTestMode now gates UpdaterService —
  --scarf-test-mode launches Sparkle inert so update prompts don't
  pop on top of an XCUITest-driven window. Production launches
  unchanged.

FixtureHermesHome.swift removed — the fixture-tmpdir approach is
abandoned in favour of using the real installation. Layer A's
SCARF_HERMES_HOME tests still pass; they just don't need a populated
home to exercise path derivation.

Verification: scarfTests 124/124, ScarfCore 220/220, Layer B smoke
1/1 (after fresh build — XCUITest is sensitive to stale binaries).
catalog.py --check still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): clip placeholder to TextEditor bounds and clear it on focus

Two related bugs in the Mac chat composer's placeholder overlay:

* The "Message Hermes… / for commands · drag images to attach" hint had
  no width constraint, so on narrower window geometries it visibly
  overflowed past the rounded TextEditor boundary. Add `lineLimit(1)`,
  `truncationMode(.tail)`, and `frame(maxWidth: .infinity, alignment:
  .leading)` so it ellipsizes inside the field instead.
* The opacity formula `text.isEmpty ? 1 : 0` only hid the placeholder
  once content was typed, not when the field gained focus. Standard
  NSTextField / UITextField semantics clear the placeholder on focus.
  Switch to `(text.isEmpty && !isFocused) ? 1 : 0` so the hint
  disappears the moment the user clicks into the field.

The opaque-background ghosting mitigation from #65 is preserved
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): surface OAuth refresh-revoked errors with in-app re-auth

When an OAuth provider's refresh token was revoked, Hermes printed
"Refresh session has been revoked. Run `hermes model` to re-authenticate."
to stderr but Scarf swallowed it — the user saw a typing indicator that
silently disappeared with no banner, no system message, no actionable
hint. The error classifier had no pattern for OAuth revocation.

- `ACPErrorHint.classify` now returns a `Classification` struct
  carrying the hint plus an optional `oauthProvider` name. New
  patterns match "Refresh session has been revoked", "re-authenticate",
  and 401-with-OAuth-provider-name (whole-word so `anthropicapi`
  doesn't false-match `anthropic`). Provider extraction lets the UI
  dispatch the right re-auth flow.
- Chat error banner ([ChatView.swift]) gains a "Re-authenticate" button
  when an OAuth provider was identified — sets
  `AppCoordinator.pendingOAuthReauth` and routes to Credential Pools.
- Credential Pools view consumes the hand-off slot to auto-present
  AddCredentialSheet seeded with the affected provider, AND adds a
  per-row "Re-authenticate" button on every OAuth provider so users
  who go straight there don't have to retype the provider name.
- `AddCredentialSheet` accepts an optional `initialProvider` that
  pre-fills providerID + authType=.oauth; the existing Nous-vs-PKCE-
  vs-CLI gate dispatches re-auth identically to first-time setup —
  reuses the same `OAuthFlowController` / `NousSignInSheet` plumbing,
  no new flow code.

Verification: ScarfCore 221/221 (incl. new
errorHintsClassifyOAuthRefreshRevoked covering the four patterns +
word-boundary guard); Mac app builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(catalog): in-app template catalog browser + sentinel-marker test isolation

The v2.8 catalog browser surfaces every shipped .scarftemplate from
awizemann.github.io/scarf/templates/catalog.json directly in Scarf.
Users now discover and install templates without leaving the app.
Closes the gap that publishing the catalog updated the website but
nothing inside Scarf.

Architecture mirrors NousModelCatalogService 1:1: cache-first fetch,
24h TTL at ~/.hermes/scarf/catalog_cache.json, result enum (fresh /
cache / fallback) with bundled fallback so a fresh-install / offline
user still sees something. Search + category filter + sort
(awizemann official first). Detail page renders entry.config schema
preview without separate README fetch — what's in catalog.json is
what we render. Install hands the HTTPS URL to the existing
TemplateInstallerViewModel.openRemoteURL flow; nothing about the
installer itself changes.

Files:
- Core/Models/CatalogEntry.swift — Decodable mirror of catalog.json
  per-template shape. Identity-based Equatable/Hashable on `id`.
- Core/Services/CatalogService.swift — fetch + cache + fallback
- Core/Services/InstalledTemplatesIndex.swift — walks projects.json +
  template.lock.json to build [templateId: version] map; classify()
  helper for Installed / Update available / Not installed badges
- Features/Templates/ViewModels/CatalogViewModel.swift — @Observable
- Features/Templates/Views/{CatalogView,CatalogRowView,CatalogDetailView,CatalogCategoryFilter}.swift
- Packages/ScarfCore/.../HermesPathSet.swift — adds catalogCache path
- Features/Projects/Views/ProjectsView.swift — Templates toolbar
  menu now opens with "Browse Catalog…"; sheet binding.

Tests (20 new, all passing in isolation):
- CatalogServiceTests (6) — live catalog.json snapshot, cache lifecycle,
  staleness boundary, schema-version mismatch rejection, bundled fallback
- InstalledTemplatesIndexTests (5) — empty registry, templated project,
  ad-hoc project skip, corrupt lock skip, classify() branches
- CatalogViewModelTests (6) — search filter, category filter, official-first
  sort, deduped categories, install state, install URL pass-through

Accessibility IDs (6, on the catalog path): templates.browseCatalog,
catalog.searchField, catalog.refreshButton, catalog.row.<detailSlug>,
catalog.categoryFilter, catalogDetail.installButton.

## Sentinel-marker hardening on SCARF_HERMES_HOME (incident response)

While iterating on v2.8 tests, the env-var override pattern racing
under Swift Testing's parallel-suite scheduler caused
~/.hermes/scarf/projects.json to be overwritten with fixture data
from ProjectsViewModelTests. Recovered the user's projects from the
on-disk dirs they referenced + cron-job prompt paths (6 projects
restored).

To make this class of incident impossible going forward:
HermesProfileResolver.scarfHermesHomeOverride() now requires the
override path to contain a sentinel marker file
(`.scarf-test-home-marker`). Without the marker, the override is
ignored and Scarf falls through to the real ~/.hermes/. Even if a
test crashes mid-teardown leaving the env var set, even if the var
leaks to a non-test process, even if a misconfigured launchctl plist
exports it — the override only activates against directories that
explicitly opt in by carrying the marker. Tests drop the marker in
their tmpdir setUp; production never carries it.

HermesProfileResolverTests gains overrideIsIgnoredWhenMarkerMissing
which verifies the guard is load-bearing. All test files using
SCARF_HERMES_HOME (CatalogServiceTests, CatalogViewModelTests,
InstalledTemplatesIndexTests, TemplateE2ETests) now drop the marker
before setenv.

Verification: 20/20 v2.8 + v2.7 hardened tests pass; 45/45 adjacent
existing tests pass; ScarfCore package tests pass (221/221); catalog
validator clean (3 templates); wiki secret-scan clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(swift6): retroactive conformance + verbatim help text + xcstrings refresh

Three small Swift 6 compile-cleanups that landed during the
dogfooding-templates iteration:

- MessageSpeechService — drop `@preconcurrency` on the
  AVSpeechSynthesizerDelegate conformance now that the protocol's
  Sendable annotations are upstreamed.
- ChatView — mark `RichChatViewModel.PendingPermission: Identifiable`
  as `@retroactive`. We don't own either the type or the protocol; the
  Swift 6 compiler flags this so downstream breakage is loud if
  ScarfCore ever adds the conformance upstream.
- CredentialPoolsView — wrap the `.help(...)` string in
  `Text(verbatim:)` so the backticks render literally instead of being
  interpreted as markdown inline-code by the LocalizedStringKey
  overload (which `.help(_:)` rejects styled).

Localizable.xcstrings: auto-generated catalog refresh picking up the
new active-profile + chat error-hint strings landed in earlier
commits on this branch (acd3692, 301806d).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(catalog): error logging + MainActor I/O + semver pre-release + decoder fault tolerance

- InstalledTemplatesIndex: replace bare `try?` reads/decodes with logged
  do/catch so corrupt registry/lock files leave a breadcrumb instead of a
  silent nil.
- InstalledTemplatesIndex.isVersionNewer: handle pre-release suffixes per
  semver §11 — `1.0.0-beta` no longer reports as newer than `1.0.0`,
  preventing a ghost "Update available" that would downgrade users.
- CatalogViewModel.refresh: dispatch the synchronous index walk through
  `Task.detached` so registry + N lock-file reads don't run on
  @MainActor.
- Catalog decoder: per-element fault tolerance via custom `init(from:)` —
  one malformed catalog entry is dropped with a logged warning instead
  of failing the whole catalog decode (honors the per-entry doc-comment
  contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:04:13 +02:00
Alan Wizemann 34d315793b fix(chat): clip placeholder to TextEditor bounds and clear it on focus
Two related bugs in the Mac chat composer's placeholder overlay:

* The "Message Hermes… / for commands · drag images to attach" hint had
  no width constraint, so on narrower window geometries it visibly
  overflowed past the rounded TextEditor boundary. Add `lineLimit(1)`,
  `truncationMode(.tail)`, and `frame(maxWidth: .infinity, alignment:
  .leading)` so it ellipsizes inside the field instead.
* The opacity formula `text.isEmpty ? 1 : 0` only hid the placeholder
  once content was typed, not when the field gained focus. Standard
  NSTextField / UITextField semantics clear the placeholder on focus.
  Switch to `(text.isEmpty && !isFocused) ? 1 : 0` so the hint
  disappears the moment the user clicks into the field.

The opaque-background ghosting mitigation from #65 is preserved
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:48:32 +02:00
Alan Wizemann acd3692faf fix(profiles): switch-and-relaunch flow + active-profile chip + structured logs
Profile selection had no apparent effect on Webhooks/Sessions/SOUL.md/Memory
even after restart in some user setups. The path-resolution code reads
~/.hermes/active_profile correctly on paper, so the failure mode is likely
environment-specific (HERMES_HOME exported in the shell, in-process state
that didn't reset on what the user perceived as a restart, etc). Layer a
defense that's correct regardless of root cause:

* New AppRelauncher helper spawns a fresh `open -n <bundleURL>` and asks
  the current process to terminate after a 250ms delay. Refuses to fire
  from Xcode/DerivedData (the .debugBuild guard) so debug sessions don't
  lose their attached debugger.
* ProfilesViewModel.switchAndRelaunch runs `hermes profile use`, calls
  HermesProfileResolver.invalidateCache(), then relaunches via the helper.
  Existing switchTo() also gains the cache-invalidation step so the
  context-menu "Set Active (no relaunch)" path stays self-consistent.
* ProfilesView replaces the passive "Restart Scarf after switching" text
  with a confirmation-gated `Switch & Relaunch` primary button on the
  detail pane plus the same item in each row's context menu. Confirmation
  dialog flags that all Scarf windows will close.
* SidebarView header gains a brand-tinted ScarfBadge showing the
  currently-active profile on local contexts. Click to jump to the
  Profiles tab. The chip refreshes on `selectedSection` change so a
  terminal-side `hermes profile use` is visible after the next nav.
* HermesProfileResolver success logs gain `name=…, home=…, source=…`
  key=value structure across all three resolution paths (file / file-default /
  default-no-file). `log show … | grep ProfileResolver` now answers
  "what did the resolver decide?" unambiguously for support requests.

Closes #70

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:18:10 +02:00
Alan Wizemann ab615f0c28 feat(ios-chat): redesign composer with HIG touch targets and clear disabled state
Send button is now a 44pt circular target with an explicit color swap
(rust accent → background-tertiary) on disable, instead of relying on
SwiftUI's default opacity dim — addresses the "first tap doesn't
register" complaint by making the inactive state visibly different in
both light and dark mode. Paperclip and text field both gain a 44pt
minimum height so the row feels modern and roomy.

The text field swaps `.roundedBorder` for a plain field with a
ScarfRadius.xl rounded fill (ScarfColor.backgroundSecondary) and a
borderStrong stroke. Outer paddings and HStack spacing migrate from
magic numbers to ScarfSpace tokens.

Preserves verbatim: the `.toolbar { ToolbarItemGroup(placement: .keyboard) }`
keyboard-dismiss chevron (issue #51), draft persistence, .submitLabel,
@FocusState, photo-picker wiring, attachment-strip rendering, and every
.disabled() predicate.

Closes #69

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:14:09 +02:00
Alan Wizemann 982ed7da92 chore: bump iOS build to 30 for TestFlight
iOS-only patch carrying the rotation lock + chat-start preflight
off-MainActor fixes from cb164f0. Mac side stays on the v2.6.0
binary already shipped (build 29 archive); this build number bump
only affects future Mac archives, not the one already notarized.

Uploaded to App Store Connect via altool — Apple processing now,
will land in TestFlight once the binary clears the post-upload
scan (typically 5–15 min).
2026-05-01 16:20:13 +02:00
Alan Wizemann cb164f07f9 fix(ios): lock iPhone to portrait + move chat-start preflight off MainActor
Two iOS-specific crash classes from the v2.5.1 TestFlight feedback
round:

**Rotation crash** — locked the iPhone target to
`UIInterfaceOrientationPortrait` only (was Portrait + LandscapeLeft
+ LandscapeRight). The phone can't rotate the app at all anymore,
so any layout path that wasn't audited for size-class transitions
is no longer reachable. iPad orientation list left alone (target
device family is iPhone-only anyway).

**"Crash while typing" / "trying to continue an existing
conversation"** — `ChatController.passModelPreflight()` was doing
a synchronous SSH read (`context.readText(configYAML)`) on
`@MainActor` during chat-start. On a remote ScarfGo context that
blocks the main thread for seconds; iOS's non-responsive-app
watchdog kills the process around 10s. To the user this surfaces
as a "crash" while they're typing — they kept tapping the keyboard
while the connect was hung. Move the read to `Task.detached` and
await it; the UI stays responsive while the SSH I/O drains. Three
callers (`start`, `start(projectPath:)`, `startResuming`) updated
to `await passModelPreflight(...)` — they were already async.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:03:28 +02:00
Alan Wizemann 1dbdf9d079 chore: ignore local crashes/ triage directory
TestFlight feedback / crash JSONs land here while we're working
through an iOS fix round. They carry tester PII (emails, carriers,
locales) and aren't meant for the public repo. Kept local-only;
deleted after the round closes.
2026-05-01 15:57:41 +02:00
Alan Wizemann 101488cd0d docs(readme): bump What's New to v2.6.0 + Hermes v0.12 catch-up
Replaces the 2.5 "What's New" block with a 2.6 summary that
covers the Hermes v0.12 surfaces (Curator, multimodal images, 5
new providers, Teams + Yuanbao, Kanban, Skills v0.12, cron
--workdir, settings deltas, ScarfGo Webhooks/Plugins/Profiles)
and the post-merge chat fix round (#67/#68/#65/#62/#63/#64/#66/
#61). Verified-versions table gains v0.12.0 as the current target;
recommended-Hermes line points at v0.12.0+ for full feature
support. ScarfGo block kept but de-emphasised since it shipped
in 2.5.
2026-05-01 15:55:16 +02:00
Alan Wizemann 03c996ee80 chore: Bump version to 2.6.0 2026-05-01 15:42:48 +02:00
Alan Wizemann 8428cbff10 docs(v2.6.0): document post-merge issue fixes in RELEASE_NOTES
Adds a "Chat composer + transcript (post-merge round)" subsection
to the bug-fixes block covering #67, #68, #65, #62, #63, #64,
#66, and the partial #61 ACP-timeout bump. The pre-merge
test-target / iOS-build fixes stay grouped under "Pre-merge".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:41:48 +02:00
Alan Wizemann 381adfd925 fix(acp): bump control-message timeout 30s→60s for db-contended hosts (#61)
Field-reported (#61): under realistic concurrency where the
Hermes gateway is also running, state.db lock contention
(Discord sync / skill registration / cron scheduling all
holding write locks) stalls ACP's `initialize` / `session/new` /
`session/load` past the previous 30s watchdog, surfacing as
"Starting…" indefinitely or an opaque timeout error.

SQLite contention on a healthy host clears in seconds, so 60s
gives the lock-resolution path room to breathe while still
surfacing genuinely broken transports promptly. `session/prompt`
remains untimed (it streams events and can run for minutes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:40:33 +02:00
Alan Wizemann 254af46e93 feat(chat): per-message TTS playback in assistant bubbles (#66)
Adds a small speaker glyph to the metadata footer of each settled
assistant bubble. Tap to read the reply aloud through
`AVSpeechSynthesizer`; tap again (or any other bubble's button) to
stop. Picks up the user's macOS Spoken Content default voice
automatically — no Hermes dependency, works offline.

- New `MessageSpeechService` (`Core/Services/`) — shared
  `@Observable` synthesizer; `playingMessageId` drives icon
  state. Markdown control characters (asterisks, backticks,
  link syntax) are stripped before speech so the user doesn't
  hear "asterisk asterisk bold".
- `SpeakMessageButton` lives outside `RichMessageBubble.==` so
  the bubble's Equatable short-circuit doesn't freeze the icon
  when playback flips between messages.

The full Hermes-provider TTS pipeline (Edge / ElevenLabs /
OpenAI / NeuTTS / Piper from Settings → Voice) is a much bigger
follow-up — wiring per-provider audio fetching, caching, and
streamed playback is its own quarter. v2.6.0 ships the immediate
"listen while doing something else" affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:38:22 +02:00
Alan Wizemann 596c844da5 feat(chat): notify when Hermes finishes a prompt in the background (#64)
Sending a long prompt and switching to other work — the canonical
async-agent flow — required polling the chat to know when the
response landed. Wire a local UNUserNotificationCenter notification
to fire when an ACP prompt completes while Scarf isn't the
foreground app.

- New `ChatNotificationService` (Core/Services) handles lazy
  authorization, foreground gating, and post.
- `ChatViewModel.sendViaACP` calls it on successful prompt
  completion with the assistant's first-line preview and the
  active session title.
- Settings → Display → Feedback adds a "Notify when Hermes finishes"
  toggle, default on. Skipped for `/steer`-style mid-run sends —
  those don't end a turn.

Dock badges and per-session unread state from the issue are
worthwhile follow-ups but out of scope for v2.6.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:35:55 +02:00
Alan Wizemann ec47d191a1 fix(chat): preserve local user messages across resume cycles (#63)
When a user sent a prompt and immediately switched to a different
session before Hermes flushed the row to state.db, `resumeSession`
ran `reset()` (which clears `messages`) and then
`loadSessionHistory` read the un-persisted DB and replaced the
array with an empty result. The user's bubble came back blank or
disappeared on return.

Hold local-only user messages (negative ids) in a per-session
cache that survives `reset()`. `loadSessionHistory` re-injects any
still-pending entries for the loaded session, dedups against any
DB row that finally caught up (matching content with persisted id
≥ 0), and clears the cache as the DB confirms each entry.

Cache is bounded by sessions sent-in during one app run; entries
clean themselves out as Hermes persists, and orphaned entries
(deleted sessions etc.) are tiny and never re-surface since
session ids are unique per session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:33:37 +02:00
Alan Wizemann 31e6c31acf fix(chat): scope composer state to active session id (#62)
`RichChatInputBar`'s `@State` `text` and `attachments` survived
session switches because the surrounding view tree is structurally
identical across sessions — SwiftUI happily reused the same
instance and leaked the previous session's unsent draft into the
new one.

Bind the composer's identity to `richChat.sessionId` so SwiftUI
rebuilds the view (and its `@State`) on session change. A stable
fallback string covers the brief "no session selected" window;
using `UUID()` here would mint a fresh id on every render and
trash the composer per body re-eval.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:28:59 +02:00
Alan Wizemann fcfe1c89d6 fix(chat): stop placeholder ghosting in chat composer (#65)
`TextEditor`'s NSTextView surfaces a typed glyph one frame before
the SwiftUI binding propagates, so the bare `if text.isEmpty`
overlay rendered the translucent placeholder text directly on top
of the just-typed character — the "behind or around" ghost the
reporter described.

Two mitigations:

- Pin an opaque `ScarfColor.backgroundSecondary` rect behind the
  placeholder Text. During any single-frame binding lag the user
  now sees a clean placeholder rather than layered glyphs.
- Switch the conditional to `.opacity(text.isEmpty ? 1 : 0)` so the
  view tree stays stable per keystroke. Pairs with the composer
  perf fix from #67.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:27:53 +02:00
Alan Wizemann df1b9caabf fix(chat): scale rich chat content with the font-size slider (#68)
The chat font-size slider only set `\.dynamicTypeSize` on the chat
root, but ScarfFont tokens are fixed-point (`Font.system(size: 14, …)`)
so dynamic type didn't reach bubble text, reasoning, tool chips, code
blocks, or markdown headings. Slider moved between 85%–130% with
little visible effect.

Plumb a separate `\.chatFontScale: Double` env value from
`RichChatView` and have the chat content views read it:

- `RichMessageBubble` — user bubble body, reasoning (disclosure +
  inline), REASONING label, token chip, tool-chip name, metadata
  footer.
- `MarkdownContentView` — paragraphs (now pinned to a scaled body
  font instead of inheriting), headings (1..5), inline-rendered code
  blocks, code-language label.
- `CodeBlockView` — code body and language label.

`ChatFontScale.{body, callout, caption, captionStrong, caption2,
mono, monoSmall, codeBlock, codeInline}(_ scale:)` helpers mirror
`ScarfFont`'s base sizes so scale = 1.0 is byte-for-byte identical
to today's UI; the slider now actually moves the visible chat text.

Other surfaces (settings, sidebar, etc.) still use the static
ScarfFont tokens — chat scaling stays scoped to the chat surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:24:45 +02:00
Alan Wizemann a41c81c048 fix(chat): coalesce composer onChange writes to stop typing lag (#67)
Typing in the chat composer became unusably laggy because
`updateMenuState()` ran on every keystroke and unconditionally wrote
both `showMenu` and `selectedIndex`. Two state writes inside one
`onChange(of: text)` handler tripped SwiftUI's "action tried to
update multiple times per frame" warning, and each redundant write
forced a full body re-eval — visible as the slow-HID stalls and the
main-thread layout churn the reporter captured in sampling.

Two changes:

- Compute the new selection up front and write only the deltas. Same
  semantics; no spurious mutations.
- Short-circuit the whole handler when the user is composing normal
  text (no `/` prefix) and the menu is already hidden — the common
  case. Stops paying for `SlashCommandMenu.filter` on every keystroke
  of regular prose.
- Replace `.onChange(of: commands.map(\.id))` with
  `.onChange(of: commands.count)`. The mapped form allocated a fresh
  `[String]` on every body re-eval; counting is one int read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:20:15 +02:00
Alan Wizemann 88add62997 Merge branch 'v12-updates'
Hermes v2026.4.30 (v0.12.0) compatibility — autonomous Curator (Mac +
iOS), multimodal image input in chat, 5 new inference providers,
Microsoft Teams + Yuanbao gateway platforms, read-only Kanban view,
Skills v0.12 surface (URL install / reload / pin / disable), Cron
--workdir flag, Settings deltas (cache TTL, redaction, runtime footer,
Piper, Vercel), iOS read-only Webhooks/Plugins/Profiles, and a
pre-v0.12 Hermes-version banner. All new surfaces capability-gated so
older Hermes hosts see the v2.5 surface unchanged.

Release notes: releases/v2.6.0/RELEASE_NOTES.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:17:36 +02:00
Alan Wizemann 80589b3f23 chore(i18n): pick up autogenerated v0.12 string keys
Xcode-autogenerated strings for the v12 surface — curator chip labels,
image attachment button + counter, archived-skill banner — that the
extractor produced while the v12-updates branch was being authored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:17:11 +02:00
Alan Wizemann 13f89e309b docs(claude-md): correct Hermes v0.12 surface drift after review fixes
CLAUDE.md was rewritten in 3d85b91 to describe the new v0.12 surfaces
but several claims drifted from what actually shipped (or have since
walked back during the review-fix pass):

- Curator iOS panel was described as "read-only"; it ships Run Now /
  Pause / Resume actions and inline pin toggles.
- Curator path symbols were named `curatorReportJSON` / `curatorReportMD`;
  the actual additions to `HermesPathSet` are `curatorLogsDir` and
  `curatorStateFile`, with the per-cycle `run.json` / `REPORT.md`
  resolved at runtime via the state file's `last_report_path`.
- The `flush_memories` bullet claimed Scarf had dropped the field; it's
  preserved on pre-v0.12 hosts via `hasFlushMemoriesAux` (restored in
  commit 33022ae).
- The cron `--workdir` bullet didn't mention the capability gating that
  landed in commit 4a2ef74, nor the empty-string clear gesture from
  commit 46cec81.
- The v0.12 surface list omitted the iOS Phase H catch-up
  (Webhooks/Plugins/Profiles read-only tabs + HermesVersionBanner)
  shipped in commit 799332f.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:15:34 +02:00
Alan Wizemann c055081ba3 perf(chat-ios): ingest picker items in parallel via TaskGroup
`ingestPickerItems` ran loadTransferable + encode sequentially per
selected image. PhotosPickerItem.loadTransferable is async and hops
off MainActor (nonisolated), but for 5+ iCloud-backed PHAssets the
sequential pipeline meant five round-trips back-to-back instead of
five concurrent ones.

Switched to `withTaskGroup` keyed by selection index so:
- Slot cap is computed once up front and items past the cap are
  dropped (previously we mid-loop-broke after the first overage).
- Each item's loadTransferable + ImageEncoder runs concurrently.
- Results land back in selection order via index sort, so the
  attachment chip row matches what the user picked.

Errors carry a Sendable `String` message rather than the raw `Error`,
which isn't Sendable under strict concurrency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:12:41 +02:00
Alan Wizemann bd05e01d1c fix(webhooks-ios): surface parse failure in lastError
The post-load assignment was a true no-op:
`self.lastError = parsed.isEmpty && !result.isEmpty ? nil : nil` —
both ternary branches assigned `nil`. The intent (visible from the
condition shape) was to set an error message when the CLI returned
text but the parser produced no webhooks.

Now that branch sets a "Couldn't parse webhook list output" message
which the existing banner at line 33 renders. Normal flow (parse
succeeds, or empty output) still clears the error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:11:25 +02:00
Alan Wizemann b66ed7e8d7 fix(kanban): show stderr-only in error banner, parse stdout-only as JSON
`KanbanViewModel.load` previously assigned the combined stdout+stderr
output of `runHermesCLI` into both the JSON-parse `data` and the
`stderr` slot of its result tuple. Two consequences:

- On non-zero exit, the error banner showed combined output (often
  stdout usage text concatenated with the actual error), reducing the
  signal-to-noise ratio when troubleshooting.
- On non-zero exit with mixed output, JSON decoding could fail because
  stderr text was prepended to the JSON body.

Added `HermesFileService.runHermesCLISplit` — a sibling of `runHermesCLI`
that returns `(exitCode, stdout, stderr)` separately, leaning on the
already-separated `stdoutString` / `stderrString` from the transport
layer. KanbanViewModel now uses it: stdout is the JSON parse target,
stderr is the error-banner source. Existing `runHermesCLI` callers are
untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:29:16 +02:00
Alan Wizemann 46cec816ec fix(cron): allow clearing an existing workdir on edit
`updateJob` only emitted `--workdir <path>` when the value was non-empty,
so once a workdir was set on a job, the user had no way to remove it
through Scarf — clearing the TextField and saving was a silent no-op.

Hermes' `cron edit --workdir` argparse documents passing an empty string
as the explicit clear gesture (mirroring the existing `--script` shape,
which already passes empty through here). Drop the `!isEmpty` predicate
so a non-nil value — including "" — reaches the CLI.

The previous capability gate keeps this safe on pre-v0.12 hosts: CronView
passes `workdir: nil` there, so the flag is omitted and v0.11 argparse
is never asked about an unknown arg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:27:49 +02:00
Alan Wizemann 681fa40c3c fix(skills): use ScarfFont token for OFF pill badge
The disabled-skill row's "OFF" pill used `.font(.system(size: 9, weight:
.semibold))`, which the project CLAUDE.md flags as a code smell ("bypass
the type scale… is a code smell"). The design system documents
`scarfStyle(.captionUppercase)` as the canonical badge font; switching
to it picks up the matching tracking + uppercase casing as a bonus.

The pin glyph above (`Image(systemName: "pin.fill").font(.system(size:
9))`) is left as-is — that's intentional glyph sizing on an `Image`,
which the design rule explicitly excludes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:27:07 +02:00
Alan Wizemann 15642d37cf fix(skills): parse equal-indent disabled list in skills config
`readDisabledSkillNames` broke out of the loop on `leading <= baseIndent`,
but PyYAML's default `yaml.dump` (what Hermes uses to write the disabled
list) emits list items at the SAME indent as the parent key:

    skills:
      disabled:
      - foo
      - bar

Here `disabled:` is at indent 2 and `- foo` is also at indent 2, so the
old check terminated before any item was appended — every disabled skill
written by Hermes would have appeared enabled in the UI.

Now the loop only breaks when the indent is strictly shallower than the
`disabled:` line, or when a same-indent line isn't a list item (sibling
key — that's still the end of the block). The deeper-indent layout still
parses correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:23:01 +02:00
Alan Wizemann 33022aeb92 fix(settings): restore flush_memories aux row on pre-v0.12 hosts
Phase B removed the `flushMemories` field from `AuxiliarySettings`,
the `aux("flush_memories")` reader from the YAML parser, and the
"Flush Memories" row from `AuxiliaryTab.tasks` outright. But
`HermesCapabilities.hasFlushMemoriesAux` still claims (with inverse
semantics) that the row should stay visible on pre-v0.12 hosts where
the task is alive. Project CLAUDE.md documents the same contract.

Restored:
- `AuxiliarySettings.flushMemories: AuxiliaryModel` (and `.empty`).
- `aux("flush_memories")` in both YAML readers
  (`HermesConfig+YAML.swift` and the `HermesFileService` mirror).
- `AuxiliaryTab.tasks` appends the Flush Memories row when
  `hasFlushMemoriesAux` is true, mirroring how `curator` is appended
  on the v0.12+ branch.

On v0.12+ hosts the flag is `false` so the field stays `.empty` and
the row is hidden — no behaviour change for current users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:22:41 +02:00
Alan Wizemann 4a2ef74b74 fix(cron): gate --workdir flag on hasCronWorkdir capability
`HermesCapabilities.hasCronWorkdir` was added but never consumed: the
editor sheet always rendered the Workdir TextField and the view model
unconditionally appended `--workdir <path>` whenever the field was
non-empty. On a pre-v0.12 host argparse rejects the unknown flag and
the entire `cron create`/`cron edit` call fails.

Two-layer gate:
- CronJobEditor takes a `supportsWorkdir` flag and hides the field on
  pre-v0.12 hosts.
- CronView reads `\.hermesCapabilities` and forces the workdir argument
  to "" / nil when the capability is absent, so an editing-an-existing-
  job path that hydrates `form.workdir` from a pre-existing value can't
  smuggle the flag through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:21:35 +02:00
Alan Wizemann 11bb2bd0c3 fix(chat): detach NSOpenPanel image read off MainActor
`presentImagePicker()` ran `Data(contentsOf: url)` synchronously on
MainActor inside the URL loop before the detached `encode()`. A 24 MP
HEIC at 8-15 MB stalled the chat composer per file. The drag/drop and
paste paths already read off-main via `loadObject`/`loadDataRepresentation`
callbacks; this brings the open-panel branch in line by capturing the
URLs into a `Task.detached` and reading bytes there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:20:50 +02:00
Alan Wizemann 3d85b91392 docs(hermes-v12): release notes + CLAUDE.md polish (Phase I)
Adds releases/v2.6.0/RELEASE_NOTES.md covering every Phase A-H surface
(Curator, multimodal image input, 5 new providers, Skills v0.12,
Settings deltas, Cron workdir, Teams + Yuanbao, read-only Kanban, iOS
read-only Webhooks/Plugins/Profiles, version banner, internal
capability detector). Drops a paragraph at the top noting Hermes
v0.11 hosts continue to work — every new surface is gated on
HermesCapabilities so v2.6 against v0.11 looks identical to v2.5.2
against v0.11.

Polishes CLAUDE.md inaccuracies introduced in Phase A's first pass:

- ACP image wire shape: corrected to {"type":"image","data":...,"mimeType":...}
  (matches acp.schema.ImageContentBlock); previous Anthropic-style
  source: {type: base64, ...} sketch was wrong.
- Cron --context-from: clarified that Hermes hasn't exposed it as a
  CLI flag yet (read-only via HermesCronJob.contextFrom), only
  --workdir is writable.
- hermes memory setup: noted that the interactive verb stays in
  Terminal (no in-app shellout); Settings → Memory just exposes the
  provider picker.
- Skills surface: more precise about which CLI verbs back the Mac UI
  affordances and why the disable-toggle is deferred to v2.7.

215 ScarfCore tests green; both Mac and iOS schemes build clean. Wiki
update + the actual release.sh ship are deferred to the user's
typical release-prep flow (the wiki repo is a separate worktree
that needs scripts/wiki.sh pull/commit/push, and release.sh expects
a clean working tree pointed at main).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:01:43 +02:00
Alan Wizemann 799332fbcd feat(hermes-v12): iOS catch-up — Webhooks/Plugins/Profiles read-only + version banner (Phase H)
Closes the iOS read-only inspection gap on three CLI-driven Hermes
surfaces and adds a Hermes-version banner so mobile users on remote
v0.11 hosts see the upgrade nudge inline.

Components:

- Scarf iOS/Components/HermesVersionBanner.swift — yellow banner shown
  on the Dashboard when the active server's HermesCapabilities returns
  detected==true && hasCurator==false. One-tap session dismiss; comes
  back on next app open. Lists the v0.12 capabilities the user is
  missing out on (curator, multimodal, new providers).

- Scarf iOS/Webhooks/WebhooksView.swift — read-only list rendered from
  `hermes webhook list`. Tolerant block parser mirrors the Mac
  WebhooksViewModel shape so future drift fixes in one canonical place
  if/when promoted into ScarfCore. Detects the "platform not enabled"
  state and shows a setup-required pane instead of synthesizing rows
  from instructional text.

- Scarf iOS/Plugins/PluginsView.swift — filesystem-first scan over
  `~/.hermes/plugins/<name>/` with plugin.json / plugin.yaml manifest
  reads (mirrors the Mac VM). Enabled/disabled badge, version, source.
  Uses HermesYAML.parseNestedYAML / stripYAMLQuotes from ScarfCore
  (already public).

- Scarf iOS/Profiles/ProfilesView.swift — `hermes profile list` text
  parser with active-profile highlighting from
  `~/.hermes/active_profile`. Defensively handles both Rich box-drawn
  table output and plain-text fallback.

ScarfGoTabRoot's System tab gains an "Inspect" section with the three
new NavigationLinks. None are capability-gated — the underlying
list verbs exist on both v0.11 and v0.12, so the read views work
against either Hermes version without surprises.

Tests: 215 ScarfCore tests pass; both Mac and iOS schemes build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:58:28 +02:00
Alan Wizemann 7a833b6c5a feat(hermes-v12): Cron workdir + Microsoft Teams + Yuanbao + read-only Kanban (Phase G)
Mac-only Phase G surfaces. Three additions:

Cron — `--workdir` flag (v0.12+):

- HermesCronJob carries `workdir: String?` and `contextFrom: [String]?`
  fields (the latter is read-only from CLI today; YAML-only chaining).
- FormState.workdir; CronJobEditor adds an absolute-path field;
  CronViewModel.createJob/updateJob forward `--workdir` when set,
  omit it when blank so v0.11 hosts (which don't know the flag) keep
  working unchanged.

Platforms — Microsoft Teams + Yuanbao (v0.12+):

- KnownPlatforms gains the two new platform identifiers + icons.
- PlatformsView adds inline read-only setup panels for each since the
  full setup flow lives outside Scarf (OAuth dance for Yuanbao, plugin
  install for Teams). Both panels surface the type, the recommended
  setup command, and the current configured/connected status the
  existing connectivity probe already understands.

Kanban — read-only list (v0.12+):

- HermesKanbanTask Sendable Codable model mirroring
  `_task_to_dict` in hermes_cli/kanban.py.
- KanbanViewModel polls `hermes kanban list --json` every 5s while the
  view is foregrounded; status filter dropdown maps to `--status`.
  Empty list and "no matching tasks" text outputs both render the
  empty state cleanly.
- KanbanView: page header + status badges + meta chips
  (id/assignee/workspace/skills) per row. No create/claim/dispatch UI
  — multi-profile collaboration was reverted upstream while the
  design is reworked, so v2.6 ships read-only and defers the editor
  to v2.7+.
- AppCoordinator.SidebarSection.kanban + ContentView routing.
  SidebarView's capability-aware `sections` filters out the row when
  `HermesCapabilities.hasKanban` is false.

Tests: 215 ScarfCore tests pass; both Mac and iOS schemes build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:54:38 +02:00
Alan Wizemann 6954f0276a feat(hermes-v12): Settings deltas — cache TTL, redaction, runtime footer, Piper, Vercel (Phase F)
Surfaces the v0.12 config knobs that landed without their own dedicated
UI elsewhere:

- prompt_caching.cache_ttl picker (5m default, 1h opt-in) — reduces
  cache writes on long agent loops with stable system prompts.
- redaction.enabled toggle — Hermes flipped this off by default in
  v0.12 because the substitution corrupted patches; security-sensitive
  users can flip it back on here.
- agent.runtime_metadata_footer toggle — opt-in compact footer on each
  final reply (provider/model/cost/turn count).
- TTS provider list gains "piper" — native local TTS engine new in
  v0.12.
- Terminal backend list gains "vercel" — Vercel Sandbox backend for
  execute_code/terminal added in v0.12.

The new "Caching & Redaction" section in AdvancedTab is gated on
HermesCapabilities.hasPromptCacheTTL — pre-v0.12 hosts don't see
toggles that would write keys Hermes ignores. The Piper + Vercel
options ride along unconditionally because Hermes silently accepts
unknown values and falls back to safe defaults.

Model + parser:

- HermesConfig grows three optional scalar fields (cacheTTL: String,
  redactionEnabled: Bool, runtimeMetadataFooter: Bool). All three
  have init defaults so existing call sites — including
  HermesConfig.empty — keep compiling.
- Both YAML readers (HermesFileService for Mac, HermesConfig+YAML for
  the package) now parse the new keys with v0.12-defaults.

Tests: 215 ScarfCore tests pass; both Mac and iOS schemes build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:47:54 +02:00
Alan Wizemann ee3791a1b2 feat(hermes-v12): Skills v0.12 surface — URL install + reload + pin/disable badges (Phase E)
Hermes v0.12 added three skills surfaces Scarf can now reach:

- Direct-URL install: `hermes skills install <https://...>` lets users
  pull a one-off skill without going through a registry. Mac SkillsView
  grew an "Install from URL…" toolbar button (capability-gated on
  HermesCapabilities.hasSkillURLInstall) opening a sheet with the URL
  field plus optional --category / --name overrides.
- Reload: `hermes skills audit` rescans `~/.hermes/skills/` and refreshes
  the agent's view of available skills without restarting. Wired to a
  "Reload" toolbar button next to the install button on Mac.
- Enabled state: skills.disabled in config.yaml is now read at scan time
  (SkillsViewModel.readDisabledSkillNames). Disabled skills render
  strikethrough + an "OFF" pill on Mac and iOS rows so users see what
  Hermes won't load. iOS detail view explains the state in plain text.
- Curator pin badge: pinned-skill names from
  `~/.hermes/skills/.curator_state` (SkillsViewModel.readPinnedSkillNames)
  surface as a pin glyph on each row. Mac sidebar + iOS list both show
  it; iOS detail view explains "pinned by curator — won't auto-archive."

Model + scanner:

- HermesSkill gains `enabled: Bool` (default true) and `pinned: Bool`
  (default false). Both default to backwards-compatible values so
  unmodified call sites keep compiling.
- SkillsScanner.scan now takes optional `disabledNames` and
  `pinnedNames` sets and applies them per skill at scan time.
- SkillsViewModel.load auto-fetches both sets internally so Mac/iOS
  callers don't have to plumb curator state manually; an opt-in
  `pinnedNames` override is available for the Curator screen which
  has a fresher snapshot in hand.

Tests: 215 ScarfCore tests pass; both Mac and iOS schemes build clean.

Note: the disable-toggle path (writing the array back into
config.yaml) is deferred to v2.7 — Hermes ships
`hermes skills config` as an interactive verb only, and we'd rather
read accurately than risk clobbering the user's list with a
half-tested write path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:44:15 +02:00
Alan Wizemann 686fb37630 feat(hermes-v12): Curator feature module on Mac + iOS (Phase D)
Hermes v0.12 ships an autonomous Curator that prunes / consolidates
agent-created skills on a 7-day cycle. This phase brings that surface
into Scarf so users can see status, trigger runs, pin protected skills,
and restore archived ones.

Pipeline:

- HermesCuratorStatus + HermesCuratorSkillRow: Sendable value types for
  parsed status + per-skill leaderboard rows.
- HermesCuratorStatusParser: pure text parser for `hermes curator status`
  stdout (no `--json` flag exists upstream). Tolerates Hermes's
  whitespace-padded leaderboard layout (`activity=  0` with N spaces
  between `=` and the value) by slicing between known key positions
  rather than splitting on whitespace. State-file JSON overrides
  text-parsed values for last_run_at / last_run_summary /
  last_report_path because the file carries full ISO timestamps the
  text output may have rounded.
- CuratorViewModel: @Observable @MainActor, drives the CLI verbs
  (status / run / pause / resume / pin / unpin / restore) via
  transport.runProcess so it works equally over local and Citadel SSH.
- HermesPathSet: adds curatorLogsDir + curatorStateFile (the latter
  is `.curator_state` with no extension despite holding JSON).

Mac:

- Features/Curator/Views/CuratorView.swift — page-header + status card
  + skill counts + pinned chips + 3 leaderboard tables (least recent,
  most active, least active) with inline pin toggles and a
  per-skill counter chip row. "Run Now" button + a kebab menu for
  Pause/Resume + Restore Archived.
- Features/Curator/Views/CuratorRestoreSheet.swift — name-entry sheet
  for `hermes curator restore <skill>`. Free-form text field; Hermes
  doesn't ship a `curator list-archived` yet so we don't synthesize a
  picker.
- Sidebar: AppCoordinator + SidebarView gain a `.curator` case under
  Interact (between Memory and Skills); the row is filtered out by
  SidebarView's capability-aware `sections` computed property when
  `HermesCapabilities.hasCurator` is false. ContentView routes
  `.curator` to CuratorView. Pre-v0.12 hosts see the v0.11 sidebar
  unchanged.

iOS:

- Scarf iOS/Curator/CuratorView.swift — read-mostly List with the same
  status / skill counts / pinned / leaderboards + inline pin toggles.
  Run Now / Pause / Resume actions in the section footer.
- ScarfGoTabRoot's System tab gains a Curator NavigationLink under
  Features, gated on `hasCurator`. Uses a stable
  `systemTabContextID` so the SSH transport pool reuses the cached
  Citadel connection keyed by that id.

Tests: 6 new parser tests (215 total, all green). Locks the empty-state
output captured from a real v0.12.0 install + paused-state + state-file
override + multi-word-name-row parsing. Both Mac and iOS schemes build
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:37:48 +02:00
Alan Wizemann 1354568992 feat(hermes-v12): ACP multimodal image input on Mac + iOS (Phase C)
Hermes v0.12 advertises `prompt_capabilities.image = true` and accepts
image content blocks in `session/prompt`. This wires a producer flow on
both targets so users can attach images alongside text and have them
routed to the vision-capable model automatically.

Pipeline:

- ChatImageAttachment: Sendable value type holding base64 payload +
  thumbnail, MIME type, source filename, and approximate byte count.
- ImageEncoder: detached-only Sendable service that downsamples to
  Anthropic's 1568px long-edge cap, JPEG-encodes at q=0.85, and
  produces a small inline thumbnail for composer chips. Cross-platform
  (NSImage on Mac, UIImage on iOS, JPEG-passthrough on Linux/CI).
- ACPClient.sendPrompt(sessionId:text:images:) overload emits a content
  array `[{type: "text"...}, {type: "image", data, mimeType}]` matching
  the wire shape in hermes-agent/acp_adapter/server.py. The
  zero-arg-images convenience overload preserves the v0.11 wire shape
  for any unmodified callers.

Mac UI:

- RichChatInputBar grew an `attachments: [ChatImageAttachment]` state
  array, a paperclip button (NSOpenPanel multi-pick), drag-drop and
  paste handlers, and a horizontal preview chip strip. The "send"
  callback's signature is `(String, [ChatImageAttachment]) -> Void`
  threaded through RichChatView -> ChatTranscriptPane -> ChatView ->
  ChatViewModel.sendText(text, images:). Image-only prompts are
  permitted ("describe this") once at least one attachment is queued.

iOS UI:

- ChatView's composer adopts a paperclip + PhotosPicker flow with the
  same chip strip and 5-attachment cap. Attachments live on
  ChatController so they survive across PhotosPicker presentations.
  loadTransferable(type: Data.self) feeds raw bytes into the same
  ImageEncoder; encode work runs detached so MainActor stays
  responsive on cellular.

Capability gating:

- Both composers hide the entire attachment surface when
  HermesCapabilities.hasACPImagePrompts is false (pre-v0.12 hosts).
  No paperclip button, no drop target, no paste accept — the input bar
  is byte-for-byte the v0.11 surface against an older Hermes.

Tests: 209 ScarfCore tests pass; both Mac and iOS schemes build clean.
The encoder's pixel work is hard to unit-test at the package level
(no NSImage/UIImage in plain Swift CI) — manual end-to-end testing
is the verification path here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:28:41 +02:00
Alan Wizemann da721fa276 feat(hermes-v12): provider catalog + auxiliary swap (Phase B)
Adds the five v0.12 inference providers to ModelCatalogService.overlayOnlyProviders
so the model picker reaches them. IDs match HERMES_OVERLAYS verbatim:

- gmi → GMI Cloud (api_key)
- azure-foundry → Azure AI Foundry (api_key)
- lmstudio → LM Studio (api_key, promoted from custom-endpoint alias)
- minimax-oauth → MiniMax (OAuth, oauth_external)
- tencent-tokenhub → Tencent TokenHub (api_key)

Auxiliary tasks: drop the `flush_memories` row (Hermes removed it
entirely in v0.12) and add `auxiliary.curator` so users can configure
the model the autonomous curator's review fork uses. The Curator row is
gated on HermesCapabilities.hasCuratorAux, so v0.11 hosts don't see a
control that writes a key Hermes ignores. AuxiliarySettings, the YAML
parser, and HealthViewModel's Tool Gateway breakdown are all updated.

Side fixes:

- CredentialPoolsGatingTests was missing `import ScarfCore` after
  ModelCatalogService moved to the package (broke the test target's
  compile against pure-Mac scarf).
- Promoted `ModelCatalogService.overlayOnlyProviders` to public so the
  new `v012OverlayProvidersCarryCorrectAuthTypes` lock-in test can
  reach it.

Tests: 14 ToolGateway tests pass; 209 ScarfCore tests pass; both Mac
and iOS schemes build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:16:37 +02:00
Alan Wizemann a90a29add8 feat(hermes-v12): version-aware capability detection (Phase A)
Introduces `HermesCapabilities` (parsed from `hermes --version`) and a
per-server `HermesCapabilitiesStore` injected into Mac `ContextBoundRoot`
and iOS `ScarfGoTabRoot` via `.environment(_:)` and `.hermesCapabilities`.
Subsequent v0.12-targeted UI (Curator, Kanban, ACP image input,
auxiliary.curator, prompt cache TTL, etc.) can branch on these flags so
older Hermes installs degrade silently instead of throwing on unknown CLI
subcommands.

Adds `curatorReportJSON` / `curatorReportMD` paths to `HermesPathSet`.

Bumps the Hermes version target in CLAUDE.md from v2026.4.23 (v0.11.0) to
v2026.4.30 (v0.12.0) and lists the v0.12 surfaces Scarf will consume.

Side fixes:

- `M5FeatureVMTests.ScriptedTransport` was missing
  `cachedSnapshotPath` after that property was added in 7b864d7;
  added `URL? { nil }` stub.
- `M0dViewModelsTests` referenced `.degraded(reason:)` after the case
  gained `hint` + `cause`; updated.
- `RemoteBackupService.zipDirectory` and `RemoteRestoreService.unzipArchive`
  used `Foundation.Process` unconditionally, breaking the iOS build
  (Process is unavailable on iOS). Wrapped in `#if !os(iOS)` with iOS
  stubs that throw — the backup/restore flow is Mac-only by design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:10:06 +02:00
Alan Wizemann 421e6030df fix(dashboard): shadow Hermes-home consolidation actually clears the warning
The "Project-local Hermes home shadowing global setup" banner has a
"Copy fix command" button that produced a one-liner the user could
paste on the remote. The old command only `cp`'d the project's
`auth.json` into the global `~/.hermes/`; it never touched the
project-local `.hermes/` directory. Hermes' CLI binds to the
*closest* `.hermes/` as `$HERMES_HOME`, so the directory still being
there meant it still shadowed — the detector's
`fileExists(<project>/.hermes)` correctly kept returning true and
the warning didn't go away after the user "fixed" it. They got
stuck.

Fix: rename the project-local `.hermes/` to
`.hermes.scarf-bak.<UTC-stamp>/` after the auth copy. Hermes scans
for a directory literally named `.hermes`, so the rename is enough
to stop binding without losing user data — `state.db`, sessions,
skills all survive untouched in the renamed folder. The user can
inspect / delete the `.bak` later when confident. `mv` over
`rm -rf` because a project's shadow can hold uncommitted session
history; deletion would be unrecoverable, the rename is reversible.

Also removes the `if shadow.hasAuthJSON` gate around the "Copy fix
command" button — a state-only shadow (no creds, just `state.db`)
still binds as `$HERMES_HOME` and needs the same rename to clear
the warning. The button now always shows; the help-tooltip text
branches on `hasAuthJSON` to describe what the command will do.

Help-text now spells out the rename so the user knows where their
data went before they paste anything.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:51:33 +02:00
Alan Wizemann 7b864d77d5 feat(servers): backup + restore for any Scarf server
Adds an end-to-end "back up this server's full Hermes state" flow
with a verifiable archive format and a matching restore that pushes
it onto a fresh droplet. Tested against a 570 MB local Hermes home
+ 5 projects, then iterated against a real DigitalOcean droplet.

Architecture
- `.scarfbackup` is a ZIP containing `manifest.json` (schema v1,
  source server + hermes version + per-tarball SHA-256), one
  `hermes.tar.gz` (gzipped tar of `~/.hermes/`), and one
  `projects/<id>.tar.gz` per registered project. Streams via
  `tar -czf - …` over SSH; never buffers a full archive in memory.
- New `streamRawBytes(executable:args:)` on `ServerTransport`
  (Local + SSH impls) yields binary `Data` chunks. `streamLines`
  splits on `\n` and would corrupt tar output — needed a
  binary-safe sibling.
- `RemoteBackupService` runs preflight (resolves $HOME, probes
  hermes version, enumerates projects via the existing
  `ProjectDashboardService`, sizes each via `du -sb`, checks for
  `sqlite3`), optionally runs `PRAGMA wal_checkpoint(TRUNCATE)`
  to quiesce state.db, streams each tarball with incremental
  SHA-256, then ZIP-bundles via `/usr/bin/zip`. Atomic
  temp-then-rename so a partial archive never appears at the
  user-chosen destination.
- `RemoteRestoreService` unzips into a temp dir, validates the
  manifest's `kind` magic + `schemaVersion`, hash-verifies every
  inner tarball BEFORE pushing any bytes to the target, then
  streams each tarball into `tar -xzf - -C …` over SSH stdin.
  Post-restore: rewrites `~/.hermes/scarf/projects.json` with
  source→target path mappings via a small `python3 -c` script,
  and pauses every cron job (`enabled: false`) so restored jobs
  don't surprise-fire on a fresh droplet.

Defaults + safety
- Excluded from the backup unless explicitly opted in:
  `auth.json` (provider creds), `mcp-tokens/` (per-host OAuth),
  `logs/`. Always excluded: `state.db-{wal,shm}`,
  `gateway_state.json`, and standard project junk
  (`node_modules`, `.venv`, `.git/objects`, `__pycache__`,
  `.next`, `dist`).
- Manifest records `options.includeAuth/includeMcpTokens/
  includeLogs/checkpointedWAL` honestly so restore can warn
  the user about what they'll need to re-establish manually.
- All paths are tilde-expanded against the resolved remote
  `$HOME` before being passed to `tar`/`sqlite3`.
  `tar -C '~/projects'` would otherwise fail with
  "No such file or directory" because `shellQuote` wraps the
  path in single quotes and tar doesn't expand tildes itself.

UI
- Per-row ellipsis menu on `ManageServersView` consolidates
  Back Up… / Restore from Backup… / Diagnostics… / Remove…
  Keeps the row visually clean as actions grow. Local server
  gets Back Up + Restore (no Remove or Diagnostics).
- `BackupServerSheet` walks loading → ready (size + project
  list + auth/logs toggles) → running (byte-counter progress
  per stage) → done (Show in Finder) | failed (Try again).
- `RestoreServerSheet` walks awaitingFile → inspecting →
  ready (source-vs-target preview, projects-root chooser,
  cron-pause toggle, "auth was excluded" notes) → running →
  done | failed.
- Both view models use a `WeakBox` two-step capture pattern so
  the @Sendable progress callback hops back into MainActor
  without the Swift 6 var-self warning on nested closures.

Cleanup folded in
- Drops two no-op `await`s on sync `startReaders()` in
  `ProcessACPChannel` (warning surfaced after the Phase 1 ACP
  changes; cleanest to fix in the same Transport-layer touch).

Verified
- Local round-trip via a Swift CLI harness:
  preflight → backup → unzip listing matches manifest →
  on-disk SHA-256 matches manifest claim for every tarball.
- Real DigitalOcean droplet: backup completes after the
  tilde-expansion fix; restore preserves projects + sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:51:10 +02:00
Alan Wizemann 11946aad67 feat(remote): legible SSH/ACP failures + servers.json export/import
A vanished or misconfigured remote surfaced as an opaque 30s
"ACP request 'initialize' timed out" because the channel's EOF
fired with no exit code or stderr context, and `sh -c` on the
remote couldn't find pipx-installed `hermes` on PATH. This makes
remote failure modes immediately legible and adds a recovery path
for the server registry itself.

- `ACPClientError.processTerminated` now carries exit code + stderr
  tail; `performDisconnectCleanup` reads them from the channel
  before failing pending requests, and `ACPErrorHint.classify`
  recognises Connection refused, Operation timed out, Permission
  denied (publickey), Host key verification failed, Could not
  resolve hostname, and exit 127 / command not found.
- `ProcessACPChannel.terminationHandler` closes the stdout read
  end the moment the OS reaps the child so disconnect cleanup
  fires within ~1s instead of waiting on `availableData`.
  `lastExitCode` reads `Process.terminationStatus` directly to
  avoid an actor-handshake race.
- `SSHTransport.makeProcess` / `streamLines` switch from `sh -c`
  to `bash -lc` so non-interactive SSH shells source the user's
  profile and pick up pipx (`~/.local/bin`), Linuxbrew, asdf,
  and conda PATH entries.
- New `ServerRegistry.exportFile()` / `importEntries(from:)` with
  a `.scarfservers` JSON envelope (schema v1, dedupe by UUID,
  default-server flag preserved). UI in `ManageServersView`'s
  header menu surfaces Export… / Import… via NSSave/OpenPanel.
  No secrets travel — `identityFile` is a path string and SSH
  keys live in `~/.ssh/`, not in `servers.json`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:04:14 +02:00
Alan Wizemann 4140983866 feat(site): marketing landing page for Mac + ScarfGo
Replace the gh-pages root placeholder with a real landing page that
sells both apps. Sources live at site/landing/ and publish through a
new scripts/site.sh that mirrors scripts/catalog.sh and scripts/wiki.sh
(check / build / preview / serve / publish, two-pass secret-scan, only
touches root files + assets/ on gh-pages so appcast.xml and templates/
stay disjoint).

Page is rust-palette tokens mapped from ScarfDesign, semantic HTML,
SEO + AEO infra (OpenGraph, Twitter cards, JSON-LD SoftwareApplication
+ MobileApplication + FAQPage, llms.txt, sitemap, manifest), 12-entry
FAQ, light/dark via prefers-color-scheme + manual toggle that swaps
both site chrome and screenshot variants. tools/og-image.html renders
the 1200x630 OG / 1200x600 Twitter cards via headless Chromium.

Real captures from the live Mac app (9 surfaces x light + dark) +
existing ScarfGo screenshots round out the imagery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:41:37 +02:00
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
184 changed files with 17740 additions and 466 deletions
+5
View File
@@ -61,3 +61,8 @@ releases/v*/appcast-entry.xml
# Wiki helper: personal patterns (hostnames, IPs) blocked from the wiki push.
scripts/wiki-blocklist.txt
# TestFlight feedback / crash JSONs downloaded for triage. PII (emails,
# carriers, locales) and never meant for the public repo — kept local
# while a fix round is in progress, deleted afterward.
crashes/
+21 -2
View File
@@ -113,9 +113,28 @@ Public documentation lives in the GitHub wiki at https://github.com/awizemann/sc
## Hermes Version
Targets Hermes v2026.4.23 (v0.11.0). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
Targets Hermes v2026.4.30 (v0.12.0). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
**v2026.4.23 (v0.11.0)** added (Scarf-relevant subset):
**Capability gating.** Scarf detects the target's Hermes version once per server connection via [HermesCapabilities](scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesCapabilities.swift) (`hermes --version` → semver + `YYYY.M.D` parse). The resulting `HermesCapabilitiesStore` is injected on `ContextBoundRoot` (Mac) and `ScarfGoTabRoot` (iOS) via `.environment(_:)` and `.hermesCapabilities(_:)`; UI that depends on a v0.12+ surface (Curator, Kanban, ACP image input, `auxiliary.curator`, `prompt_caching.cache_ttl`, Piper TTS, Vercel terminal) reads it through the typed environment key. Pre-v0.12 hosts gracefully hide the new affordances rather than throwing on unknown CLI subcommands. Add a new flag at the top of `HermesCapabilities` whenever Scarf gains a release-gated UI surface.
**v2026.4.30 (v0.12.0)** added (Scarf-relevant subset):
- **Autonomous Curator** — `hermes curator` self-prunes / -consolidates the skill library on a 7-day cycle. Reports land at `~/.hermes/logs/curator/run.json` + `REPORT.md`; paths exposed via `HermesPathSet.curatorLogsDir` (`logs/curator`) + `curatorStateFile` (`skills/.curator_state`), with the per-cycle `run.json` / `REPORT.md` resolved at runtime from the `last_report_path` field on the state file. Surfaced in Scarf as a dedicated "Curator" sidebar item under Interact (between Memory and Skills) on Mac, plus a read-mostly iOS panel with Run Now / Pause / Resume actions and inline pin toggles; both gated on `HermesCapabilities.hasCurator`.
- **5 new inference providers** — GMI Cloud, Azure AI Foundry, LM Studio (upgraded to first-class), MiniMax OAuth, Tencent Tokenhub. Mirrored in `ModelCatalogService.overlayOnlyProviders`; the model picker reaches all of them automatically.
- **`flush_memories` aux task removed (server side)** — `auxiliary.flush_memories` is gone from v0.12 Hermes config but remains alive on pre-v0.12 hosts. Scarf preserves `AuxiliarySettings.flushMemories: AuxiliaryModel`, the YAML reader still emits an `aux("flush_memories")` row, and `AuxiliaryTab` only renders the row when `HermesCapabilities.hasFlushMemoriesAux` is `true` (inverse semantics — pre-v0.12 only). v0.12 users never see the row; v0.11 users keep their edit surface.
- **`auxiliary.curator` aux task added** — Curator's review model is configurable independently of the main model. Surfaced in `Settings → Auxiliary` next to the other aux rows.
- **Multimodal ACP `session/prompt`** — ACP advertises and forwards image content blocks. Scarf chat composers (Mac drag/drop + paste; iOS PhotosPicker) attach images that flow through `ACPClient.sendPrompt(sessionId:text:images:)` as `[{"type":"text","text":...}, {"type":"image","data":"<base64>","mimeType":"image/jpeg"}]` — wire shape matches `acp.schema.ImageContentBlock`. `ImageEncoder` downsamples to 1568px long-edge JPEG q=0.85 detached (never blocks MainActor). Gated on `HermesCapabilities.hasACPImagePrompts`.
- **CLI additions:** `hermes -z <prompt>` (non-interactive one-shot), `hermes update --check` (preflight), `hermes fallback` (manage fallback providers), `hermes curator` (status / run / pause / resume / pin / unpin / restore), `hermes kanban` (full task-board CLI; multi-profile collab was reverted upstream so Scarf ships a read-only Kanban view only). All capability-gated.
- **Skills surface:** `hermes skills install <https-url>` direct-URL install (SkillsView "Install from URL…" toolbar button), reload via `hermes skills audit` (Skills "Reload" button — equivalent to the `/reload-skills` slash command for non-ACP contexts), enabled/disabled state read from `skills.disabled` in config.yaml (rendered as strikethrough + "OFF" pill), Curator pin badge from `~/.hermes/skills/.curator_state` (rendered as a pin glyph). The disable-toggle write path is deferred to v2.7 — Hermes only exposes `hermes skills config` as an interactive verb, and Scarf prefers reading accurately to risking a clobbered list.
- **Two new gateway platforms:** Microsoft Teams (19th, plugin-shipped) + Tencent 元宝 / Yuanbao (18th, native). Surfaced in the Mac Platforms tab.
- **Cron upgrades:** per-job `--workdir <abs-path>` (project-aware cwd that pulls AGENTS.md / CLAUDE.md / .cursorrules) is exposed in the editor sheet, gated on `HermesCapabilities.hasCronWorkdir` so pre-v0.12 hosts don't see the field (and a defensive override in `CronView` strips the value before calling `createJob`/`updateJob` even if it was hydrated from a pre-existing job). Pass an empty string on edit to clear an existing workdir, mirroring the `--script` shape. Hermes also added a `context_from` field for chaining cron outputs but only via YAML so far — Scarf reads it (HermesCronJob.contextFrom) but doesn't write it.
- **Settings deltas:** `prompt_caching.cache_ttl` (5m/1h picker), `redaction.enabled` toggle (off-by-default in v0.12 — toggle restores it), `agent.runtime_metadata_footer` toggle, Piper added to TTS provider list, `vercel` added to terminal backend list.
- **Bundled plugins:** Spotify, Google Meet, Langfuse observability, hermes-achievements (visible in Plugins tab).
- **iOS catch-up (Phase H):** read-only Webhooks / Plugins / Profiles tabs (`Scarf iOS/Webhooks/WebhooksView.swift`, `Plugins/PluginsView.swift`, `Profiles/ProfilesView.swift`) parity-match the Mac surfaces but skip mutating CLI verbs. `Scarf iOS/Components/HermesVersionBanner.swift` nudges pre-v0.12 hosts to upgrade (renders only when the connected target is below v0.12).
- **`hermes memory` providers:** honcho, openviking, mem0, hindsight, holographic, retaindb, byterover. `Settings → Memory` lists all providers in the picker; the existing "Run `hermes memory setup` in Terminal" hint stays — `hermes memory setup` is interactive (asks for tokens) so an in-app shellout would surface a frozen UI.
- **Schema is unchanged from v0.11** — same state.db columns (`messages.reasoning_content`, `sessions.api_call_count` introduced in v0.11 remain). No migration needed.
**v2026.4.23 (v0.11.0)** added (historical context, still consumed by Scarf when running against a pre-v0.12 host):
- `/steer <prompt>` — non-interruptive mid-run guidance slash command. Surfaced in Scarf chat menus via `RichChatViewModel.nonInterruptiveCommands`; `ChatViewModel.sendViaACP` (Mac) and `ChatController.send` (iOS) skip the "Agent working…" status flip and show a transient toast instead.
- New CLI subcommands: `hermes plugins` / `profile` / `webhook` / `insights` / `logs` / `memory reset` / `completion` / `dashboard`. Scarf v2.5 adopts **`hermes memory reset`** (toolbar button on MemoryView with destructive confirmation). The other CLIs are documented here for v2.6 — Scarf still reads `~/.hermes/plugins/`, `~/.hermes/profiles/` etc directly today; switching those paths to the canonical CLI is a forward-compatible change to make when bandwidth permits.
+39 -21
View File
@@ -19,11 +19,43 @@
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
</p>
## What's New in 2.5
## What's New in 2.6
### ScarfGo — the iPhone companion ships in public TestFlight
### Hermes v2026.4.30 (v0.12.0) catch-up
Same Hermes server you've been running on your Mac — now reachable from your phone over SSH. Multi-server, project-scoped chat, session resume, memory editor, cron list, skills tree, settings (read), all native iOS. Pure-Swift SSH (Citadel under the hood — no `ssh` binary needed on iOS). Per-project chat writes the same Scarf-managed `AGENTS.md` block the Mac app does, so the agent boots with the same project context regardless of which client opened the session.
The largest single Hermes update Scarf has had to follow since v0.10's Tool Gateway. Every new surface is **capability-gated** through `HermesCapabilities` (parses `hermes --version` once per server) — on a v0.11 host, Scarf 2.6 looks identical to Scarf 2.5.2 and the new affordances are hidden.
- **Autonomous Curator (Mac sidebar + iOS panel).** `hermes curator` self-prunes / -consolidates the skill library on a 7-day cycle. Status panel, **Run Now / Pause / Resume** actions, three leaderboards (least-recently-active / most-active / least-active) with activity / use / view / patch counters, inline pin toggles, restore-archived sheet. Last-run REPORT.md renders inline. New "Curator" sidebar item under Interact (between Memory and Skills); ScarfGo gets a Curator nav row under System.
- **Multimodal image input in chat.** Drag/drop, paste, or NSOpenPanel multi-pick on Mac; PhotosPicker on iOS (up to 5 images per message). `ImageEncoder` downsamples to 1568px long-edge JPEG q=0.85, **detached only** so encoding never blocks MainActor. Hermes routes the prompt to a vision-capable model automatically. Image-only sends are valid — vision models accept "describe this" with no caption.
- **5 new inference providers** in the model picker — GMI Cloud, Azure AI Foundry, LM Studio (now first-class), MiniMax (OAuth), Tencent TokenHub. Provider IDs match `HERMES_OVERLAYS` in `hermes_cli/providers.py` exactly.
- **Microsoft Teams + Yuanbao** as the 18th and 19th gateway platforms in the Platforms tab.
- **Read-only Kanban view (Mac).** Paginated table over `hermes kanban list --json` filtered by status, with status badges, meta chips (id / assignee / workspace / skills), and 5s polling while foregrounded. Create / claim / dispatch UI is deferred until upstream stabilizes the multi-profile collab layer (which was reverted in v0.12).
- **Skills v0.12 surface.** Direct-URL install (`hermes skills install <https-url>`) via a new "Install from URL…" toolbar button on Mac; reload via `hermes skills audit`; `skills.disabled` rendered as strikethrough + an "OFF" pill on Mac and iOS rows; Curator pin badge from `~/.hermes/skills/.curator_state` surfaced as a pin glyph.
- **Cron — `--workdir` field (Mac).** Inject `AGENTS.md` / `CLAUDE.md` / `.cursorrules` from a working directory and pin cwd for terminal/file/code_exec tools. Scarf's CronJobEditor adds the field; both create and edit paths forward the flag.
- **Settings deltas.** New **Caching & Redaction** section under Advanced — prompt cache TTL picker (5m / 1h), redact-secrets-in-patches toggle (now off by default on v0.12; flip back on here), runtime metadata footer toggle. TTS provider list gains **piper** (native local TTS); terminal backend list gains **vercel** (Vercel Sandbox).
- **`auxiliary.curator` aux task.** Curator's review fork can run on a separate model from the main agent. `auxiliary.flush_memories` was removed in v0.12 — Scarf preserves the row on v0.11 hosts (inverse gate) and hides it on v0.12.
- **ScarfGo catch-up.** Read-only Webhooks / Plugins / Profiles tabs parity-match the Mac surfaces (no mutating CLI verbs on the phone). Yellow Hermes-version banner nudges pre-v0.12 hosts to upgrade; renders only when the connected target is below v0.12.
### Chat fixes (post-merge round)
A focused pass over GitHub issue triage:
- **Typing lag in the chat composer ([#67](https://github.com/awizemann/scarf/issues/67))** — `RichChatInputBar.updateMenuState()` was firing on every keystroke and writing two state vars per `.onChange`, tripping SwiftUI's "action tried to update multiple times per frame" warning. Composer now coalesces writes, short-circuits when the slash menu can't apply, and watches `commands.count` instead of allocating `commands.map(\.id)` per keystroke.
- **Chat font-size slider now actually scales rich chat content ([#68](https://github.com/awizemann/scarf/issues/68))** — `\.dynamicTypeSize` couldn't reach the fixed-point ScarfFont tokens. New `\.chatFontScale` env value plumbed through bubbles, markdown, and code blocks.
- **Placeholder ghosting on first keystroke ([#65](https://github.com/awizemann/scarf/issues/65))** — `TextEditor`'s NSTextView surfaces a typed glyph one frame before the SwiftUI binding propagates. Pinned an opaque background behind the placeholder rect; switched the conditional to `.opacity(...)` for view-tree stability.
- **Draft text leaked between conversations ([#62](https://github.com/awizemann/scarf/issues/62))** — composer `@State` survived session switches because the surrounding view tree was structurally identical. Bound `RichChatInputBar`'s identity to `richChat.sessionId`.
- **Sent message rendered blank after navigating away ([#63](https://github.com/awizemann/scarf/issues/63))** — `loadSessionHistory` atomically replaced messages from a state.db that hadn't yet flushed the user's row. New per-session pending-user-messages cache survives `reset()` and re-injects entries until the DB catches up.
- **Background completion notifications ([#64](https://github.com/awizemann/scarf/issues/64))** — new `ChatNotificationService` fires a local UNUserNotificationCenter banner when a prompt completes while Scarf isn't the foreground app. Settings → Display → Feedback → "Notify when Hermes finishes" toggle, default on.
- **Per-message TTS playback ([#66](https://github.com/awizemann/scarf/issues/66))** — small speaker glyph on each settled assistant bubble. Tap to read aloud through `AVSpeechSynthesizer` with the user's macOS Spoken Content default voice.
- **ACP control-message timeout 30s → 60s ([#61](https://github.com/awizemann/scarf/issues/61))** — gives `initialize` / `session/new` / `session/load` headroom against gateway-induced state.db lock contention.
See the full [v2.6.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.6.0).
**Previous releases:** see the [Release Notes Index](https://github.com/awizemann/scarf/wiki/Release-Notes-Index) on the wiki for v2.5, v2.3, v2.2, v2.0, v1.6, and earlier.
## ScarfGo — the iPhone companion
Same Hermes server you've been running on your Mac — reachable from your phone over SSH. Multi-server, project-scoped chat, session resume, memory editor, cron list, skills tree, settings (read), all native iOS. Pure-Swift SSH (Citadel under the hood — no `ssh` binary needed on iOS). Per-project chat writes the same Scarf-managed `AGENTS.md` block the Mac app does, so the agent boots with the same project context regardless of which client opened the session.
**[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.
@@ -39,21 +71,6 @@ Same Hermes server you've been running on your Mac — now reachable from your 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
- **Portable project-scoped slash commands.** Author reusable prompt templates as Markdown files at `<project>/.scarf/slash-commands/<name>.md` with YAML frontmatter (name, description, argumentHint, optional model override). Invoke as `/<name> [args]` from chat — Scarf substitutes `{{argument}}` (with optional `default:` fallback) in the body and sends the expanded prompt to Hermes. Mac authoring tab + iOS read-only browser. Templates carry them via the new `slash-commands/` block in `.scarftemplate` bundles (schemaVersion 3). See [Slash Commands](https://github.com/awizemann/scarf/wiki/Slash-Commands) for the full schema.
- **Hermes v2026.4.23 chat parity.** `/steer` non-interruptive guidance command, per-turn stopwatch on assistant bubbles, numbered keyboard shortcuts (19) on the permission sheet, git branch chip in the chat header. The new `messages.reasoning_content` and `sessions.api_call_count` columns surface as a richer reasoning disclosure + an "API" chip on session rows.
- **Spotify + design-md skills.** Mac ships an in-app Spotify OAuth sheet (mirrors the v2.3 Nous Portal pattern); design-md gets a host-side `npx` prereq check on both platforms. SKILL.md frontmatter (`allowed_tools`, `related_skills`, `dependencies`) renders as chip rows. A "What's New" pill on the Skills tab tells you when remote skills changed since you last looked.
- **Mac global Sessions: project filter + project badges** — parity with ScarfGo's Sessions tab. The list grows a filter Menu (All projects / Unattributed / each registered project) and each row carries a tinted folder chip with the project name when attributed.
- **Human-readable cron schedules everywhere.** New `CronScheduleFormatter` in ScarfCore translates the common cron shapes into English phrases and falls back to the raw expression on anything custom. Mac and iOS render the same.
- **Mac design-system overhaul.** Rust palette, typed token bundle (`ScarfColor`, `ScarfFont`, `ScarfSpace`, `ScarfRadius`), reusable components (`ScarfPageHeader`, `ScarfCard`, `ScarfBadge`, `ScarfTextField`, four button styles), redesigned 3-pane chat. iOS adopts the same tokens with a hybrid Dynamic Type policy so accessibility scaling on body text is preserved. See [Design System](https://github.com/awizemann/scarf/wiki/Design-System) for the full reference.
- **Under the hood** — `SessionAttributionService`, `ProjectContextBlock`, `CronScheduleFormatter`, `GitBranchService`, `SkillPrereqService`, `SkillSnapshotService`, `ProjectSlashCommandService`, and the ACP error triplet (`acpError` / `acpErrorHint` / `acpErrorDetails`) consolidated into ScarfCore so Mac and iOS consume one source of truth. 179 tests across 13 suites, three consecutive green runs. Several `try?` swallows in iOS lifecycle code now surface real failures (Keychain unlock errors no longer drop people into onboarding; partial Forget operations report what failed).
- **iOS push notifications skeleton** — `NotificationRouter` ships with foreground presentation + a lock-screen "Approve / Deny" action category gated by `apnsEnabled = false`. Lights up when Hermes ships a server-side push sender + an APNs cert.
See the full [v2.5.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.5.0).
**Previous releases:** see the [Release Notes Index](https://github.com/awizemann/scarf/wiki/Release-Notes-Index) on the wiki for v2.3, v2.2, v2.0, v1.6, and earlier.
## Connect ScarfGo to your Hermes server
ScarfGo speaks SSH directly — no companion service, no developer-controlled server in between. Onboarding takes about a minute:
@@ -145,7 +162,7 @@ Custom, agent-generated dashboards for any project. Define stat boxes, charts, t
- macOS 14.6+ (Sonoma) for Scarf
- iOS 18.0+ for [ScarfGo](https://github.com/awizemann/scarf/wiki/ScarfGo) (the iPhone companion, public TestFlight from v2.5)
- Xcode 16.0+ to build from source
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` on each target host (v0.11.0+ recommended for full v2.5 feature support — `/steer`, new state.db columns, design-md/spotify skills, SKILL.md frontmatter chips)
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` on each target host (v0.12.0+ recommended for full v2.6 feature support — autonomous Curator, multimodal image input, 5 new providers, Microsoft Teams + Yuanbao gateways, Kanban, Skills v0.12 surface, cron `--workdir`, prompt-cache TTL, Piper TTS, Vercel terminal)
- For remote servers: SSH access (key-based), `sqlite3` on the remote (for atomic DB snapshots), and the `hermes` CLI resolvable from the remote user's `PATH` or at a path you specify per server. ScarfGo requires the same on every Hermes host it connects to.
### Compatibility
@@ -159,9 +176,10 @@ Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`,
| v0.8.0 (2026-04-08) | Verified |
| v0.9.0 (2026-04-13) | Verified |
| v0.10.0 (2026-04-16) | Verified (Tool Gateway introduced) |
| v0.11.0 (2026-04-23) | **Verified — current target (recommended for full v2.5 feature support)** |
| v0.11.0 (2026-04-23) | Verified |
| v0.12.0 (2026-04-30) | **Verified — current target (recommended for full v2.6 feature support)** |
Scarf 2.5 targets Hermes v0.11.0 for `/steer`, the new state.db columns (`messages.reasoning_content`, `sessions.api_call_count`), the new skills (design-md, spotify), the SKILL.md frontmatter chip surfaces, and the `hermes memory reset` toolbar action. Earlier Hermes versions remain supported for monitoring, sessions, file-based features, and ACP chat; v0.11-specific behavior degrades gracefully on older agents (`/steer` is harmless, new columns silently nil out).
Scarf 2.6 targets Hermes v0.12.0 for the autonomous Curator, multimodal ACP image content blocks, the 5 new inference providers, Microsoft Teams + Yuanbao gateways, the read-only Kanban view, the Skills v0.12 surface (URL install / reload / disable badges / curator pin), cron `--workdir`, `auxiliary.curator`, `prompt_caching.cache_ttl`, the redaction toggle, the runtime metadata footer, Piper TTS, and the Vercel terminal backend. Every v0.12 surface is **capability-gated** — Scarf detects the host's Hermes version once per server connection (`hermes --version` → semver + `YYYY.M.D` parse) and hides v0.12-only UI on older hosts. v0.11.0 hosts keep the full v2.5 surface (`/steer`, `messages.reasoning_content`, `sessions.api_call_count`, design-md/spotify skills, SKILL.md frontmatter chips, `hermes memory reset`). Earlier Hermes versions remain supported for monitoring, sessions, file-based features, and ACP chat; new behavior degrades gracefully on older agents.
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
+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.
+134
View File
@@ -0,0 +1,134 @@
## What's in 2.6.0
A major release tracking **Hermes v2026.4.30 (v0.12.0)** — the largest single Hermes update Scarf has had to follow since v0.10's Tool Gateway. Headline additions: the autonomous **Curator**, **multimodal image input** in chat, **5 new inference providers**, **Microsoft Teams + Yuanbao** gateway platforms, a **read-only Kanban** view, and ScarfGo gains read-only Webhooks/Plugins/Profiles plus a Hermes-version banner.
Pre-v0.12 Hermes hosts are fully supported. Every new surface is gated on a runtime capability detector (`hermes --version` → semver), so users on older Hermes installs see the v2.5 surface unchanged. UI doesn't appear until the underlying CLI subcommand exists.
### Curator (Mac + iOS)
Hermes v0.12's autonomous skill curator prunes / consolidates / archives agent-created skills on a 7-day schedule. Scarf adds a dedicated **Curator** sidebar item under Interact (Mac) and a Curator nav row under the System tab (iOS).
- **Status panel** — enabled/paused/disabled badge, last-run timestamp, last summary, run count, scheduling cadence (interval / stale-after / archive-after).
- **Run Now** button triggers `hermes curator run`; pause/resume from the kebab menu.
- **Three leaderboards** — least-recently-active, most-active, least-active. Each row carries activity / use / view / patch counters and an inline pin toggle.
- **Pin / unpin** — pinned skills are protected from auto-archive and rewrites. State pulled from `~/.hermes/skills/.curator_state` and surfaced as a pin glyph everywhere skills appear (Curator screen, Skills sidebar/list, SkillDetailView).
- **Restore archived** sheet calls `hermes curator restore <name>` to bring a previously-archived skill back.
- **Last report Markdown** — when present, the previous run's REPORT.md renders inline in mono.
Capability-gated; sidebar item disappears on pre-v0.12 hosts.
### Multimodal image input in chat (Mac + iOS)
Hermes v0.12 advertises `prompt_capabilities.image = true` on ACP and accepts image content blocks in `session/prompt`. Scarf wires the producer side on both targets:
- **Mac**: paperclip toolbar button on the chat composer opens NSOpenPanel multi-pick. Drag-and-drop and paste also work — drop an image (or a Finder file URL) onto the composer and it attaches. Capability-gated; the entire attachment surface is hidden on pre-v0.12 hosts.
- **iOS**: paperclip button opens PhotosPicker (multi-select up to 5 photos). Same byte-for-byte capability gate.
- **ImageEncoder** downsamples to 1568px long-edge (Anthropic's recommended ceiling) at JPEG q=0.85, so a 12 MP screenshot lands under ~300 KB on the wire. Detached only — never blocks MainActor.
- **Image-only sends are valid** — once at least one attachment is queued, the send button enables even with empty text. Vision models accept "describe this" with no caption.
- **Per-attachment chips** above the input field with thumbnail + filename tooltip + X to remove. 5-image-per-message cap; total payload stays under ~2 MB so cellular sends don't time out.
Hermes routes the resulting prompt to a vision-capable model automatically — no extra Scarf-side work to pick the right aux model.
### 5 new inference providers (Mac + iOS)
Five overlay-only providers added to `ModelCatalogService.overlayOnlyProviders`. The model picker reaches all of them; provider IDs match `HERMES_OVERLAYS` in `hermes_cli/providers.py` exactly so a typo here doesn't strand users with an unreachable provider.
- **GMI Cloud** (api_key) — `https://api.gmi-serving.com/v1`
- **Azure AI Foundry** (api_key) — base URL resolved from `AZURE_FOUNDRY_BASE_URL` per tenant
- **LM Studio** (api_key, first-class) — promoted from custom-endpoint alias to a real provider; defaults to `http://127.0.0.1:1234/v1`
- **MiniMax (OAuth)** (oauth_external) — `https://api.minimax.io/anthropic`
- **Tencent TokenHub** (api_key) — base URL resolved from `TOKENHUB_BASE_URL`
### `auxiliary.curator` aux task (Mac)
Hermes removed `auxiliary.flush_memories` entirely in v0.12 (the underlying memory pipeline was rewritten) and added `auxiliary.curator` so the curator's review fork can run on a separate model from the main agent. Settings → Auxiliary now surfaces a Curator row when the active host is v0.12+ (gated on `HermesCapabilities.hasCuratorAux`); the obsolete Flush Memories panel is gone.
The Tool Gateway health view in HealthView lost the flushMemories-routes-through-Nous row and gained a curator row, matching the new aux task list.
### Skills v0.12 surface (Mac + iOS)
Three new capabilities Scarf can now reach:
- **Direct-URL install** — `hermes skills install <https-url>` lets users pull a one-off skill without going through a registry. Mac SkillsView gains an "Install from URL…" toolbar button (capability-gated) opening a sheet with the URL field plus optional `--category` / `--name` overrides.
- **Reload** — `hermes skills audit` rescans the skills directory and refreshes the agent's view without a session restart. Wired to a "Reload" toolbar button next to the install button on Mac.
- **Enabled / disabled state** — `skills.disabled` in config.yaml is read at scan time. Disabled skills render strikethrough + an "OFF" pill on Mac and iOS rows; iOS detail view explains the state in plain text.
- **Curator pin badge** — pinned-skill names from `~/.hermes/skills/.curator_state` surface as a pin glyph on each row across Mac sidebar and iOS list, plus an explanatory chip on iOS detail view.
The disable-toggle write path is deferred to v2.7 — Hermes only exposes `hermes skills config` as an interactive verb today, and we'd rather read accurately than risk clobbering the user's list with a half-tested write.
### Cron — `--workdir` flag (Mac)
Hermes v0.12 cron jobs accept `--workdir <absolute-path>` to inject AGENTS.md / CLAUDE.md / .cursorrules from that directory and pin cwd for terminal/file/code_exec tools. Scarf's CronJobEditor now has a Workdir field; both create and edit paths forward the flag. Existing v0.11 jobs keep the no-cwd behaviour by leaving the field blank.
The `context_from` chaining field is read-only from Scarf this round (Hermes hasn't exposed a `--context-from` CLI flag yet, only YAML).
### Microsoft Teams + Yuanbao (Mac)
Two new gateway platforms. Microsoft Teams (the 19th platform) ships as a plugin; Yuanbao 元宝 (the 18th) is a native gateway adapter. Both surface in the Platforms tab with read-only setup panels — the OAuth dance for Yuanbao and the plugin install for Teams happen outside Scarf.
### Read-only Kanban (Mac)
Hermes v0.12 ships a SQLite-backed multi-tenant task board with a full CLI (`hermes kanban create / list / claim / dispatch / …`). The multi-profile *collaboration* layer was reverted upstream while the design is reworked, so v2.6 ships a **read-only** Kanban view: paginated table of `hermes kanban list --json` filtered by status, with status badges, meta chips (id / assignee / workspace / skills), and per-row metadata. 5-second polling while the view is foregrounded; suspended on disappear.
Create / claim / dispatch UI is deferred until upstream stabilizes — building the editor now would risk rework on a quarter-out timeline.
### Settings deltas (Mac)
A new **Caching & Redaction** section under Settings → Advanced with three v0.12 knobs (gated on capability):
- **Prompt cache TTL** picker — 5m default / 1h opt-in. Reduces cache writes on long agent loops with stable system prompts.
- **Redact secrets in patches** toggle — Hermes flipped this off by default in v0.12 because the substitution corrupted patches; security-sensitive users can flip it back on here.
- **Runtime metadata footer** toggle — opt-in compact footer on each final reply (provider/model/cost/turn count).
TTS provider list gains **piper** (native local TTS engine new in v0.12). Terminal backend list gains **vercel** (Vercel Sandbox backend for execute_code/terminal). Both ride along unconditionally — Hermes silently falls back when an older host doesn't recognize the value.
### iOS catch-up — Webhooks / Plugins / Profiles (read-only)
Three new System-tab nav rows in ScarfGo, all read-only:
- **Webhooks** — list of `hermes webhook list` output with description / deliver / events / route per row. "Platform not enabled" detection so a freshly-installed Hermes shows setup guidance instead of error noise.
- **Plugins** — filesystem-first scan over `~/.hermes/plugins/` with manifest reads (plugin.json or plugin.yaml). Enabled/disabled badge, version, source, path.
- **Profiles** — `hermes profile list` with active-profile highlighting from `~/.hermes/active_profile`. Tolerant of both Rich box-drawn and plain-text outputs.
None of the three are capability-gated — the underlying list verbs work on both v0.11 and v0.12. Create / edit / delete remain Mac-only since they touch enough state we keep them off the phone.
### Hermes-version banner (iOS)
Yellow banner at the top of the Dashboard tab when the active server is pre-v0.12. Lists the v0.12 capabilities the user is missing out on (curator, multimodal image input, new providers); one-tap session-dismiss; reappears on next app open. Hidden entirely on v0.12+ hosts.
### Internal — version-aware capability detection
The foundation of every gated surface above:
- `HermesCapabilities` value type parses `Hermes Agent v0.12.0 (2026.4.30)` from `hermes --version` output. Exposes booleans for each release-gated UI surface (`hasCurator`, `hasACPImagePrompts`, `hasKanban`, `hasOneShot`, `hasSkillURLInstall`, `hasFallbackCommand`, `hasUpdateCheck`, `hasPiperTTS`, `hasVercelTerminal`, `hasCuratorAux`, `hasTeamsPlatform`, `hasYuanbaoPlatform`, `hasCronWorkdir`, `hasPromptCacheTTL`, `hasRedactionToggle`, `hasFlushMemoriesAux`).
- `HermesCapabilitiesStore` (`@Observable @MainActor`) caches per-server capabilities. Injected on `ContextBoundRoot` (Mac) and `ScarfGoTabRoot` (iOS) via `.environment(_:)` and `.hermesCapabilities(_:)`.
- 12 parser tests + 6 curator-output parser tests lock the v0.12 / v0.11 / fallback flag matrices.
### Bug fixes
#### Chat composer + transcript (post-merge round)
- **Typing lag in the chat composer (#67)** — `RichChatInputBar.updateMenuState()` ran on every keystroke and unconditionally wrote both `showMenu` and `selectedIndex`, tripping SwiftUI's "action tried to update multiple times per frame" warning and stalling input. Composer now coalesces writes to deltas, short-circuits when not in slash mode (the common case), and watches `commands.count` instead of re-allocating `commands.map(\.id)` per keystroke.
- **Chat font-size slider had no visible effect (#68)** — `RichChatView` only set `\.dynamicTypeSize`, but `ScarfFont` tokens are fixed-point (`Font.system(size: 14, …)`) so dynamic type didn't reach bubble text, reasoning, tool chips, code blocks, or markdown headings. New `\.chatFontScale` env value plumbed through `RichMessageBubble`, `MarkdownContentView`, and `CodeBlockView`; `ChatFontScale.{body, caption, captionStrong, caption2, mono, monoSmall, codeBlock, codeInline}(_:)` helpers mirror the ScarfFont base sizes so 100% is byte-for-byte identical to today's UI.
- **Placeholder ghosting on first keystroke (#65)** — `TextEditor`'s NSTextView surfaces a typed glyph one frame before the SwiftUI binding propagates, so the bare `if text.isEmpty` overlay rendered the translucent placeholder text on top of the just-typed character. Pinned an opaque background behind the placeholder rect and switched the conditional to `.opacity(...)` so the view tree stays stable per keystroke.
- **Draft text leaked between conversations (#62)** — composer `@State` survived session switches because the surrounding view tree was structurally identical. Bound `RichChatInputBar`'s identity to `richChat.sessionId` so SwiftUI rebuilds the view (and its `@State`) on session change. Stable fallback string for the "no session selected" window — `UUID()` would have minted a new id per body re-eval and trashed the composer mid-typing.
- **Sent message rendered blank after navigating away (#63)** — when a user sent a prompt and immediately resumed a different session before Hermes flushed the row to state.db, `resumeSession`'s `reset()` cleared `messages` and `loadSessionHistory` then read an as-yet-empty DB. New per-session pending-user-messages cache survives `reset()` and re-injects still-pending entries on load; entries clear themselves as soon as a matching DB row catches up.
- **No completion notification (#64)** — sending a long prompt and switching to other work required polling the chat to know when the response landed. New `ChatNotificationService` fires a local `UNUserNotificationCenter` banner on prompt completion when Scarf isn't the foreground app. Settings → Display → Feedback → "Notify when Hermes finishes" toggle, default on.
- **Per-message TTS playback (#66)** — small speaker glyph in each settled assistant bubble's metadata footer; uses `AVSpeechSynthesizer` with the user's macOS Spoken Content default voice, picks up offline. Markdown control characters stripped before speech. The deeper Settings → Voice provider integration (Edge / ElevenLabs / OpenAI / NeuTTS / Piper) is queued as a v2.7 follow-up.
- **ACP control-message timeout under gateway concurrency (#61)** — bumped 30s → 60s. State.db lock contention on a healthy host clears in seconds, but the previous 30s watchdog tripped under realistic gateway+ACP concurrency (Discord sync / skill registration / cron scheduling holding write locks during ACP `initialize` / `session/new` / `session/load`). 60s gives lock resolution headroom while still surfacing genuinely broken transports.
#### Pre-merge
- **Test target compile** — `M5FeatureVMTests.ScriptedTransport` had drifted off the `ServerTransport` protocol after `cachedSnapshotPath` landed in v2.5.2; added the missing stub. `M0dViewModelsTests` got the `ConnectionStatusViewModel.Status.degraded` argument-name update. `CredentialPoolsGatingTests` got the missing `import ScarfCore`. The full `swift test` suite now runs (and passes — 215 tests across 17 suites).
- **iOS package compile** — `RemoteBackupService.zipDirectory` and `RemoteRestoreService.unzipArchive` used `Foundation.Process` unconditionally, breaking the iOS build entirely (Process is unavailable on the iOS SDK). Wrapped in `#if !os(iOS)` with iOS stubs that throw — backup/restore is Mac-only by design.
### Hermes version
Targets Hermes **v2026.4.30 (v0.12.0)**. v2026.4.23 (v0.11.0) hosts continue to work — every v0.12 surface is gated on capability detection, so Scarf v2.6 against v0.11 looks identical to Scarf v2.5.2 against v0.11. Update Hermes (`hermes update`) to unlock the new surfaces.
### Compatibility
- macOS 14+ (unchanged)
- iOS 17+ (unchanged)
- Hermes v0.11+ for the v2.5 surface; v0.12+ for the new features above.
- No data migrations.
+78
View File
@@ -0,0 +1,78 @@
## What's in 2.6.5
A patch release that ships **template discoverability**, **cron observability**, and an **end-to-end UI test harness** that locks the new install path against regression. No breaking changes; every Hermes capability target is unchanged from 2.6.0.
### In-app Template Catalog
The catalog is no longer web-only. **Templates → Browse Catalog…** opens a sheet that fetches the live catalog from `awizemann.github.io/scarf/templates/`, renders one row per published template with name + version + tags, and one-click installs through the existing flow. Search filters across name / description / tags; the category picker constrains to whatever categories the loaded catalog actually carries.
- **Install-state badges** — each row shows "Installed v1.2.0" (green) or "Update v1.3.0" (amber) when the catalog version is newer than what's in `~/.hermes/scarf/projects.json`. Update is "uninstall + reinstall" today; in-place upgrade is on the v3 backlog.
- **24h cache** at `~/.hermes/scarf/catalog_cache.json` so opening the sheet repeatedly doesn't re-hit the network. Refresh icon force-fetches.
- **Bundled fallback** — fresh-install / offline users still see the official templates as a hardcoded list. Network failures serve stale cache with a "refresh failed" hint.
- **Catalog-schema decoder fault tolerance** — one malformed entry on the live catalog can't bring down the whole list. The bad row is dropped with a logged warning; the rest survive.
### HackerNews Daily Digest template
First template added under the new dogfooding-templates loop. Configurable `min_score`, `max_items`, `topics`; one daily-at-08:00 cron job (paused on install) that pulls the HN Firebase API, filters, and prepends a markdown digest to the project's `digest.md`. No API keys required. Live at the catalog URL above.
### Cron observability — auth-error banner + running indicator + log tail
Cron rows now surface the same OAuth-refresh-revoked recovery flow as Chat instead of a generic red dot, plus three previously-missing observability cues:
- **OAuth re-auth.** `ACPErrorHint.classify` runs on `job.lastError`; when it returns `oauthRefreshRevoked(provider)` the detail pane shows the human-readable hint + a **Re-authenticate** button that drops the user into Credential Pools — same wiring ChatView's banner uses. Unrecognized errors fall back to the legacy red `lastError` text.
- **Running indicator.** The row dot turns blue + pulses when `state == "running"` (precedence over disabled / error / success); the detail header gains a "running…" badge next to active/paused. No new polling — `HermesFileWatcher.lastChangeDate` already drives `CronViewModel.load()`.
- **Last run output.** Collapsible panel replacing the inline log: a one-line summary (`<timestamp> — ok|error|running…`) always visible, full monospaced terminal-style scroll on expand, auto-scrolls to bottom when new runs land.
Also fixes a pre-existing bug in `HermesFileService.loadCronOutput` that returned the wrong file under Hermes's per-job-id output nesting.
### Layer B install-drive XCUITest harness
The dogfooding-templates initiative ships its first end-to-end UI test that drives the install pipeline:
```
Launch with --scarf-test-mode → Sidebar → Projects → Install sheet
(via --scarf-test-install-url launch arg) → Configure → Open Project
→ Right-click → Uninstall Template → Confirm Remove → Done
```
Runs ~30 s green on the dev Mac, validates 9 assertion points across the user journey. Covers the new accessibility identifiers wired in this release: `templateConfig.commitButton`, `projects.row.<name>`, `sidebar.section.<rawValue>`, `projects.contextMenu.uninstallTemplate`, `templateUninstall.confirmRemove`, `templateInstall.success.openProject`, `templateUninstall.success.done`. The `--scarf-test-install-url` launch arg + `TestModeFlags.isTestMode` gating lets XCUITest skip SwiftUI Menu / NSToolbarItem accessibility-bridging quirks that otherwise block toolbar-menu driving.
Wiki [Test-Harness](https://github.com/awizemann/scarf/wiki/Test-Harness) documents how to extend the harness for the next template.
### Sentinel-marker test isolation (incident-response hardening)
`SCARF_HERMES_HOME` override now requires the path to contain a `.scarf-test-home-marker` file to activate. Without the marker, production code falls through to the user's real `~/.hermes/`. Lands belt-and-braces protection for cases where a test crashes mid-teardown leaving the env var set, an env var inherits from a parent shell, or a misconfigured launchctl plist exports the variable. The override remains the seam every E2E test relies on; the marker file ensures it can't accidentally pivot a non-test process off the user's data.
### Chat fixes
- **OAuth refresh-revoked surface.** Chat-side error banner now classifies the message via `ACPErrorHint.classify` and offers an in-app **Re-authenticate** button that routes through Credential Pools (#65). Same primitive the new cron banner reuses.
- **Placeholder ghosting fix.** TextEditor's placeholder now clips to the editor's bounds and clears on focus instead of bleeding past the cursor area when the user types fast (#67).
### Profile chip + structured logs
- **Active-profile chip in the sidebar header.** Click → routes to Profiles. Local contexts only (remote SSH would mislead).
- **Switch & Relaunch** flow now writes `~/.hermes/active_profile` and relaunches Scarf in a single click instead of asking the user to quit+reopen.
- Profile-resolver logs are now structured (key=value form) so `log show … | grep ProfileResolver` can pull "which profile did Scarf resolve to and why" out of support requests.
### Swift 6 cleanup
- `MessageSpeechService` — drop `@preconcurrency` on the AVSpeechSynthesizerDelegate conformance now that the protocol's Sendable annotations are upstreamed.
- `ChatView``RichChatViewModel.PendingPermission: @retroactive Identifiable`. Quiets the Swift 6 compiler so downstream breakage would be loud if ScarfCore ever adds the conformance upstream.
- `CredentialPoolsView``.help(Text(verbatim:))` so backticks render literally instead of being treated as markdown inline-code.
### iOS
- Composer redesigned with HIG touch targets + clear disabled state.
- Portrait lock retained.
- Chat-start preflight moved off MainActor.
### Known caveats
- **Cron-job-uninstall by name is ambiguous** when two projects share the same template id. The Layer B test surfaced this — manifests as: the test passes, but if you've manually installed the same template before running the test, your real cron job can disappear. Recovery is `hermes cron create`. Fix is queued: store cron-job IDs in `<project>/.scarf/template.lock.json` at install time and resolve by ID at uninstall time.
- **Full-suite parallel test runs intermittently hang** — pre-existing flaky test infrastructure unrelated to this release. Individual suites all pass; the hang only manifests on `xcodebuild test` with everything concurrent. The sentinel-marker hardening prevents user-data damage from any race.
### Compatibility
- **Hermes target unchanged from 2.6.0**: v2026.4.30 (v0.12.0). Pre-v0.12 Hermes hosts continue to work — no new capability gates added in this release.
- **Min macOS unchanged**: 14.6.
- **No schema changes** to anything in `~/.hermes/`. The two new Scarf-owned files (`scarf/catalog_cache.json` and the template-installer's `.scarf-test-home-marker` for tests) are additive.
@@ -47,6 +47,23 @@ public protocol ACPChannel: Sendable {
/// SSH exec channels return the SSH channel id or `nil` when not
/// applicable.
var diagnosticID: String? { get async }
/// Exit status of the underlying transport once it has terminated.
/// `nil` while the channel is still alive, or for transports that
/// don't have a meaningful integer exit code (Citadel SSH-exec).
/// Read by `ACPClient` when populating `processTerminated` so the
/// user-facing error can name the actual exit code (e.g. `exit
/// 255` for SSH connect failures, `exit 127` for missing remote
/// binary).
var lastExitCode: Int32? { get async }
}
public extension ACPChannel {
/// Default: channels that don't track an exit code report `nil`.
/// Concrete `ProcessACPChannel` overrides this.
var lastExitCode: Int32? {
get async { nil }
}
}
/// Errors raised by `ACPChannel` implementations when the underlying
@@ -266,14 +266,47 @@ public actor ACPClient {
// MARK: - Messaging
public func sendPrompt(sessionId: String, text: String) async throws -> ACPPromptResult {
try await sendPrompt(sessionId: sessionId, text: text, images: [])
}
/// v0.12+ overload: forward zero or more image attachments alongside
/// the user's text. Each attachment becomes a separate
/// `ImageContentBlock` in the ACP `prompt` content array matches
/// the shape Hermes' `acp_adapter/server.py` expects (text first,
/// then image blocks). Hermes routes the resulting payload to a
/// vision-capable model automatically; the producer side only has
/// to deliver the bytes.
///
/// Pre-v0.12 Hermes installs accepted only a single `text` block.
/// Callers gate this overload on
/// `HermesCapabilitiesStore.capabilities.hasACPImagePrompts` so we
/// don't send blocks an older agent would silently drop.
public func sendPrompt(
sessionId: String,
text: String,
images: [ChatImageAttachment]
) async throws -> ACPPromptResult {
statusMessage = "Sending prompt..."
let messageId = UUID().uuidString
// Always include the text block, even when empty keeps the
// server-side text-extraction path stable regardless of whether
// the user sent text alongside the image(s).
var promptBlocks: [[String: Any]] = [
["type": "text", "text": text] as [String: Any],
]
for image in images {
promptBlocks.append([
"type": "image",
"data": image.base64Data,
"mimeType": image.mimeType,
] as [String: Any])
}
let params: [String: AnyCodable] = [
"sessionId": AnyCodable(sessionId),
"messageId": AnyCodable(messageId),
"prompt": AnyCodable([
["type": "text", "text": text] as [String: Any],
] as [Any]),
"prompt": AnyCodable(promptBlocks as [Any]),
]
let result = try await sendRequest(method: "session/prompt", params: params)
let dict = result?.dictValue ?? [:]
@@ -329,10 +362,17 @@ public actor ACPClient {
#endif
// session/prompt streams events and can run for minutes no hard
// timeout. Control messages get a 30s watchdog.
// timeout. Control messages get a 60s watchdog. Older versions
// capped at 30s, which the field reported (#61) was tripping
// under realistic gateway+ACP concurrency: the gateway holds
// state.db locks for Discord sync / skill registration / cron
// scheduling, and ACP's `initialize` / `session/new` /
// `session/load` stall waiting for the lock. SQLite contention
// on a healthy host clears in seconds; 60s gives that headroom
// while still surfacing genuinely broken transports promptly.
let timeoutTask: Task<Void, Error>? = if method != "session/prompt" {
Task { [weak self] in
try await Task.sleep(nanoseconds: 30 * 1_000_000_000)
try await Task.sleep(nanoseconds: 60 * 1_000_000_000)
await self?.timeoutRequest(id: requestId, method: method)
}
} else {
@@ -468,35 +508,48 @@ public actor ACPClient {
// MARK: - Disconnect Cleanup
/// Single idempotent cleanup path for all disconnect scenarios.
private func performDisconnectCleanup(reason: String) {
/// Captures the channel's exit code + recent stderr BEFORE we drop
/// the reference, so the `processTerminated` error rides with
/// diagnostics the user banner shows "exit 255 ssh: connect to
/// host : Connection refused" instead of a bare opaque timeout.
private func performDisconnectCleanup(reason: String) async {
guard isConnected else { return }
#if canImport(os)
logger.warning("ACP disconnecting: \(reason)")
#endif
let exitCode = await channel?.lastExitCode
let tail = recentStderr
isConnected = false
statusMessage = "Connection lost"
for (_, continuation) in pendingRequests {
continuation.resume(throwing: ACPClientError.processTerminated)
continuation.resume(throwing: ACPClientError.processTerminated(
exitCode: exitCode,
stderrTail: tail
))
}
pendingRequests.removeAll()
eventContinuation?.finish()
eventContinuation = nil
}
private func handleReadLoopEnded(cleanly: Bool, error: Error? = nil) {
private func handleReadLoopEnded(cleanly: Bool, error: Error? = nil) async {
let reason = cleanly ? "read loop ended (EOF)" : "read loop failed: \(error?.localizedDescription ?? "unknown")"
performDisconnectCleanup(reason: reason)
await performDisconnectCleanup(reason: reason)
}
private func handleWriteFailed() {
performDisconnectCleanup(reason: "write failed (broken pipe)")
private func handleWriteFailed() async {
await performDisconnectCleanup(reason: "write failed (broken pipe)")
}
private func handleWriteFailedForRequest(id: Int) {
private func handleWriteFailedForRequest(id: Int) async {
if let continuation = pendingRequests.removeValue(forKey: id) {
continuation.resume(throwing: ACPClientError.processTerminated)
let exitCode = await channel?.lastExitCode
continuation.resume(throwing: ACPClientError.processTerminated(
exitCode: exitCode,
stderrTail: recentStderr
))
}
performDisconnectCleanup(reason: "write failed (broken pipe)")
await performDisconnectCleanup(reason: "write failed (broken pipe)")
}
}
@@ -507,7 +560,7 @@ public enum ACPClientError: Error, LocalizedError {
case encodingFailed
case invalidResponse(String)
case rpcError(code: Int, message: String)
case processTerminated
case processTerminated(exitCode: Int32?, stderrTail: String)
case requestTimeout(method: String)
public var errorDescription: String? {
@@ -516,25 +569,121 @@ public enum ACPClientError: Error, LocalizedError {
case .encodingFailed: return "Failed to encode JSON-RPC request"
case .invalidResponse(let msg): return "Invalid ACP response: \(msg)"
case .rpcError(let code, let msg): return "ACP error \(code): \(msg)"
case .processTerminated: return "ACP process terminated unexpectedly"
case .processTerminated(let exit, let tail):
let exitPart = exit.map { "exit \($0)" } ?? "no exit code"
let tailPart = Self.firstNonEmptyLine(in: tail).map { "\($0)" } ?? ""
return "ACP process terminated unexpectedly (\(exitPart))\(tailPart)"
case .requestTimeout(let method): return "ACP request '\(method)' timed out"
}
}
/// Pluck the first non-empty stderr line for the user-facing
/// summary. Full tail still rides through on `acpErrorDetails`,
/// but the description itself stays single-line.
private static func firstNonEmptyLine(in s: String) -> String? {
for raw in s.split(separator: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
if !line.isEmpty { return line }
}
return nil
}
}
/// Maps a raw error message (RPC message or captured stderr) to a short
/// human-readable hint for the chat UI. Pattern-matches the most common
/// fresh-install failure modes. Returns nil when no known pattern matches.
public enum ACPErrorHint {
public static func classify(errorMessage: String, stderrTail: String) -> String? {
/// Result of a classifier hit. `hint` is the user-facing copy; when
/// the failure is an OAuth refresh-revocation, `oauthProvider` names
/// the affected provider (lowercase, matching `auth.json` keys) so
/// the UI can offer a one-click re-authenticate affordance. `nil`
/// `oauthProvider` means "we matched a non-OAuth failure mode, or
/// we matched OAuth but couldn't identify which provider."
public struct Classification: Sendable, Equatable {
public let hint: String
public let oauthProvider: String?
public init(hint: String, oauthProvider: String? = nil) {
self.hint = hint
self.oauthProvider = oauthProvider
}
}
/// Known OAuth-authed providers Hermes ships. Listed lowercase to
/// match `auth.json.providers.<key>` and the values
/// `OAuthFlowController.start(provider:)` accepts.
private static let oauthProviders = [
"nous", "claude", "anthropic", "qwen", "gemini", "google", "copilot", "github",
]
public static func classify(errorMessage: String, stderrTail: String) -> Classification? {
let haystack = errorMessage + "\n" + stderrTail
// SSH-level failures come first they apply only to remote
// contexts and the patterns are unambiguous (system ssh prints
// them verbatim to stderr). Without these classifications a
// vanished droplet, a wrong key, or a missing remote `hermes`
// all surface as opaque "ACP process terminated" / "request
// timed out", and the user has no idea where to look.
if haystack.contains("Connection refused") {
return Classification(hint: "Couldn't reach the remote host — the SSH port is closed or the droplet is down. Check the host is running and reachable.")
}
if haystack.localizedCaseInsensitiveContains("Operation timed out")
|| haystack.localizedCaseInsensitiveContains("Connection timed out")
|| haystack.contains("Network is unreachable")
|| haystack.contains("No route to host") {
return Classification(hint: "Couldn't reach the remote host — the network connection timed out. Check the host is running and your network is up.")
}
if haystack.contains("Permission denied (publickey")
|| haystack.contains("Permission denied, please try again") {
return Classification(hint: "SSH rejected the key. Make sure the right identity file is selected and that ssh-agent has the key loaded — open Terminal and run `ssh-add -l`.")
}
if haystack.contains("Host key verification failed")
|| haystack.contains("REMOTE HOST IDENTIFICATION HAS CHANGED") {
return Classification(hint: "The remote host's SSH key changed. If you just rebuilt the droplet, remove the old entry with `ssh-keygen -R <host>`, then try again.")
}
if haystack.contains("Could not resolve hostname")
|| haystack.contains("Name or service not known") {
return Classification(hint: "Couldn't resolve the host name. Check the host in this server's settings.")
}
if haystack.localizedCaseInsensitiveContains("command not found")
|| haystack.contains("hermes: not found")
|| haystack.contains("exit 127") {
return Classification(hint: "The remote shell couldn't find `hermes`. Either install Hermes on the remote (`pipx install hermes-agent`) or set an absolute binary path in this server's settings.")
}
// OAuth refresh-token revocation. Hermes prints
// "Refresh session has been revoked. Run `hermes model` to
// re-authenticate." to stderr/stdout when an OAuth-authed
// provider's refresh token can no longer mint access tokens
// (user revoked, server rotated keys, etc.). We can't drive
// `hermes model` interactively, but `hermes auth add <provider>
// --type oauth` is the same code path Scarf already drives via
// `OAuthFlowController` for first-time setup, so we surface a
// re-authenticate affordance instead. Checked BEFORE the
// generic "no credentials found" path because the message
// contains the word "credentials" via the surrounding context.
if haystack.localizedCaseInsensitiveContains("refresh session has been revoked")
|| haystack.range(of: #"refresh.*revoked"#, options: [.regularExpression, .caseInsensitive]) != nil
|| haystack.localizedCaseInsensitiveContains("re-authenticate")
|| haystack.localizedCaseInsensitiveContains("reauthenticate")
|| (haystack.contains("401") && oauthProvider(in: haystack) != nil)
|| (haystack.localizedCaseInsensitiveContains("unauthorized") && oauthProvider(in: haystack) != nil) {
let provider = oauthProvider(in: haystack)
let suffix = provider.map { " (affected provider: \($0))." } ?? "."
return Classification(
hint: "Your OAuth session has expired or been revoked\(suffix) Click Re-authenticate below to sign in again.",
oauthProvider: provider
)
}
if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#,
options: .regularExpression) != nil
|| haystack.contains("ANTHROPIC_API_KEY")
|| haystack.contains("ANTHROPIC_TOKEN")
|| haystack.contains("claude setup-token")
|| haystack.contains("claude /login") {
return "Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf."
return Classification(hint: "Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf.")
}
if let match = haystack.range(of: #"No such file or directory:\s*'([^']+)'"#,
options: .regularExpression) {
@@ -542,13 +691,31 @@ public enum ACPErrorHint {
if let nameStart = matched.range(of: "'"),
let nameEnd = matched.range(of: "'", range: nameStart.upperBound..<matched.endIndex) {
let name = String(matched[nameStart.upperBound..<nameEnd.lowerBound])
return "Hermes couldn't find `\(name)` on PATH. If you use nvm/asdf/mise, make sure it's exported in `~/.zprofile` (not only `~/.zshrc`), then restart Scarf."
return Classification(hint: "Hermes couldn't find `\(name)` on PATH. If you use nvm/asdf/mise, make sure it's exported in `~/.zprofile` (not only `~/.zshrc`), then restart Scarf.")
}
return "Hermes couldn't find a required binary on PATH. Check that your shell's PATH is exported in `~/.zprofile`, then restart Scarf."
return Classification(hint: "Hermes couldn't find a required binary on PATH. Check that your shell's PATH is exported in `~/.zprofile`, then restart Scarf.")
}
if haystack.localizedCaseInsensitiveContains("rate limit")
|| haystack.localizedCaseInsensitiveContains("429") {
return "Your AI provider returned a rate-limit error. Try again in a moment."
return Classification(hint: "Your AI provider returned a rate-limit error. Try again in a moment.")
}
return nil
}
/// Best-effort extraction of an OAuth provider name from raw error
/// text. Returns the lowercase provider key (`"nous"`, `"claude"`,
/// etc.) when one of the known OAuth providers appears as a whole
/// word. The first match wins Hermes typically logs the active
/// provider name once, near the failure.
private static func oauthProvider(in haystack: String) -> String? {
let lowered = haystack.lowercased()
for provider in oauthProviders {
// Whole-word match so substrings like "anthropicapi" don't
// false-trigger on "anthropic".
let pattern = "\\b" + NSRegularExpression.escapedPattern(for: provider) + "\\b"
if lowered.range(of: pattern, options: .regularExpression) != nil {
return provider
}
}
return nil
}
@@ -36,6 +36,17 @@ public actor ProcessACPChannel: ACPChannel {
private var readerTask: Task<Void, Never>?
private var stderrTask: Task<Void, Never>?
/// Read by `ACPClient` to fill in `processTerminated(exitCode:)`
/// so the error names the actual exit code rather than reporting a
/// bare timeout. Sourced directly from `Process` `Process` is
/// thread-safe for this read and reflects the actual reap state,
/// so we sidestep the race between the OS-side `terminationHandler`
/// callback and the EOF-driven disconnect cleanup that would
/// otherwise need an atomic to coordinate.
public var lastExitCode: Int32? {
process.isRunning ? nil : process.terminationStatus
}
/// The subprocess's PID as a human-readable string.
public var diagnosticID: String? {
"pid=\(process.processIdentifier)"
@@ -58,7 +69,7 @@ public actor ProcessACPChannel: ACPChannel {
proc.executableURL = URL(fileURLWithPath: executable)
proc.arguments = args
proc.environment = env
try await Self.launch(process: proc, self_: nil)
try await Self.launch(process: proc)
try Self.ignoreSIGPIPE_once()
self.process = proc
@@ -75,14 +86,15 @@ public actor ProcessACPChannel: ACPChannel {
self.stderr = errStream
self.stderrContinuation = errContinuation
await startReaders()
startReaders()
installTerminationHandler()
}
/// Secondary entry point for callers that have a pre-configured
/// `Process` (typically from `SSHTransport.makeProcess`). The process
/// must NOT already be running this initializer calls `run()`.
public init(process: Process) async throws {
try await Self.launch(process: process, self_: nil)
try await Self.launch(process: process)
try Self.ignoreSIGPIPE_once()
self.process = process
@@ -99,15 +111,13 @@ public actor ProcessACPChannel: ACPChannel {
self.stderr = errStream
self.stderrContinuation = errContinuation
await startReaders()
startReaders()
installTerminationHandler()
}
/// Wire fresh stdin/stdout/stderr pipes (overwriting any the caller
/// set) and start the subprocess. `self_` is unused today the
/// placeholder keeps the signature ready for a future hook that
/// captures termination in `proc.terminationHandler` and routes it
/// into the channel's actor state.
private static func launch(process: Process, self_: Any?) async throws {
/// set) and start the subprocess.
private static func launch(process: Process) async throws {
process.standardInput = Pipe()
process.standardOutput = Pipe()
process.standardError = Pipe()
@@ -118,6 +128,22 @@ public actor ProcessACPChannel: ACPChannel {
}
}
/// Install a `terminationHandler` that closes the stdout read end
/// the moment the OS reaps the child. Without this, the reader
/// loop's `availableData` keeps blocking until the kernel tears
/// the pipe down on its own schedule visible to the user as a
/// 30s ACP `initialize` timeout where a fast SSH-side failure
/// (Connection refused, exit 127) should surface in under a
/// second. The exit code itself is read on demand from
/// `Process.terminationStatus` (see `lastExitCode`), so this
/// callback doesn't need to touch actor state.
private func installTerminationHandler() {
let stdoutFh = stdoutPipe.fileHandleForReading
process.terminationHandler = { _ in
try? stdoutFh.close()
}
}
/// Ignore SIGPIPE once per process so a broken-pipe write returns
/// `EPIPE` (which we surface as `.writeEndClosed`) instead of
/// delivering SIGPIPE and tearing the app down. Idempotent; the
@@ -0,0 +1,183 @@
import Foundation
/// Top-level manifest for a `.scarfbackup` archive.
///
/// **Archive layout** (`.scarfbackup` is a plain ZIP):
/// ```
/// <name>.scarfbackup
/// manifest.json this struct, JSON-encoded
/// hermes.tar.gz gzipped tar of `~/.hermes/` (minus exclusions)
/// projects/
/// <project-id>.tar.gz one inner tarball per registered project
/// ...
/// ```
///
/// **Why two layers (outer ZIP + inner tarballs).** The inner tarballs are
/// produced by streaming `tar -czf - ` over SSH that's the only way to
/// keep memory bounded for multi-GB hermes homes. The outer ZIP exists so
/// the manifest sits at a fixed, easy-to-inspect location and so users on
/// macOS can double-click in Finder and see the structure. ZIP also has a
/// central directory at the end, which makes "validate without extracting"
/// cheap.
///
/// **What rides along.** Hermes home (state.db + sessions + skills + cron +
/// memories + scarf sidecars + plugins/profiles), each project's full file
/// tree (the user's code), and the manifest itself. **What does NOT ride
/// along by default**: `auth.json` (provider credentials), `mcp-tokens/`
/// (per-host OAuth bearer tokens), `logs/` (size, low restore value),
/// `state.db-wal` / `state.db-shm` (in-flight WAL siblings we checkpoint
/// before the archive). The `options` block records exactly which
/// exclusions were applied so the restore flow can warn the user.
public struct BackupManifest: Codable, Sendable, Equatable {
/// Bumped when the on-disk shape changes incompatibly. v1 is the only
/// shape today; restores refuse anything they don't recognize.
public var schemaVersion: Int
/// Magic string. Lets a future Scarf reject `.zip` files that aren't
/// our backups before unpacking them as if they were.
public var kind: String
/// ISO-8601 UTC timestamp the archive was produced.
public var createdAt: String
/// Identifies the server the backup came from. The display name is for
/// the restore preview sheet; serverID is for de-dupe and lineage.
public var source: Source
/// Hermes home tree metadata. Always present (even an empty Hermes
/// install ships an empty tarball the restore replaces nothing
/// rather than refusing).
public var hermes: HermesTree
/// One entry per registered project at backup time. Empty array
/// when the user never registered any projects.
public var projects: [ProjectEntry]
/// What was included / excluded from the Hermes tree. Flagged so the
/// restore preview honestly reports "auth.json was not in this
/// backup you'll re-authenticate after restore".
public var options: Options
public init(
schemaVersion: Int = BackupManifest.currentSchemaVersion,
kind: String = BackupManifest.kindMagic,
createdAt: String,
source: Source,
hermes: HermesTree,
projects: [ProjectEntry],
options: Options
) {
self.schemaVersion = schemaVersion
self.kind = kind
self.createdAt = createdAt
self.source = source
self.hermes = hermes
self.projects = projects
self.options = options
}
public static let currentSchemaVersion = 1
public static let kindMagic = "scarf-server-backup"
public struct Source: Codable, Sendable, Equatable {
public var serverID: String
public var displayName: String
public var host: String
public var user: String?
/// Output of `hermes --version` on the source host at backup
/// time. Restore warns if the target installs an older version
/// (state.db schema differences could break things silently).
public var hermesVersion: String?
public init(serverID: String, displayName: String, host: String, user: String?, hermesVersion: String?) {
self.serverID = serverID
self.displayName = displayName
self.host = host
self.user = user
self.hermesVersion = hermesVersion
}
}
public struct HermesTree: Codable, Sendable, Equatable {
/// Absolute path of `~/.hermes/` on the source host (e.g.
/// `/root/.hermes` or `/home/alan/.hermes`). Used by restore to
/// detect path drift when targeting a different user account.
public var homePath: String
/// Path inside the outer ZIP (always `hermes.tar.gz`).
public var tarballPath: String
/// Compressed bytes for the preview sheet's size summary.
public var tarballSize: Int64
/// Hex SHA-256 of the inner tarball. Restore verifies before
/// extracting; corruption surfaces as a single bad path
/// rather than a half-extracted home.
public var tarballSHA256: String
public init(homePath: String, tarballPath: String, tarballSize: Int64, tarballSHA256: String) {
self.homePath = homePath
self.tarballPath = tarballPath
self.tarballSize = tarballSize
self.tarballSHA256 = tarballSHA256
}
}
public struct ProjectEntry: Codable, Sendable, Equatable {
/// Stable UUID for the project. Used to namespace the inner
/// tarball so a project with `name = "scratch"` in two
/// different directories doesn't collide.
public var id: String
public var name: String
/// Absolute path on the source host. Restore re-anchors this if
/// the target has a different home (e.g. backup from `/root`,
/// restore to `/home/ubuntu`).
public var path: String
/// Path inside the outer ZIP (e.g. `projects/<id>.tar.gz`).
public var tarballPath: String
public var tarballSize: Int64
public var tarballSHA256: String
public init(id: String, name: String, path: String, tarballPath: String, tarballSize: Int64, tarballSHA256: String) {
self.id = id
self.name = name
self.path = path
self.tarballPath = tarballPath
self.tarballSize = tarballSize
self.tarballSHA256 = tarballSHA256
}
}
public struct Options: Codable, Sendable, Equatable {
public var includeAuth: Bool
public var includeMcpTokens: Bool
public var includeLogs: Bool
/// True if `sqlite3 PRAGMA wal_checkpoint(TRUNCATE)` was run on
/// the remote before tarballing the Hermes home. False means the
/// archive may contain a `state.db` mid-write usually fine
/// (SQLite tolerates restarted reads from a quiesced DB) but
/// flagged for forensics.
public var checkpointedWAL: Bool
public init(includeAuth: Bool, includeMcpTokens: Bool, includeLogs: Bool, checkpointedWAL: Bool) {
self.includeAuth = includeAuth
self.includeMcpTokens = includeMcpTokens
self.includeLogs = includeLogs
self.checkpointedWAL = checkpointedWAL
}
public static let safeDefault = Options(
includeAuth: false,
includeMcpTokens: false,
includeLogs: false,
checkpointedWAL: true
)
}
}
/// Canonical layout strings referenced by both the producer and the
/// consumer so the on-disk paths stay in sync.
public enum BackupArchiveLayout {
public static let manifestPath = "manifest.json"
public static let hermesTarballPath = "hermes.tar.gz"
public static let projectsTarballPrefix = "projects/"
public static let archiveExtension = "scarfbackup"
/// Returns `projects/<id>.tar.gz`. The id is the `ProjectEntry.id`
/// (stable UUID), not the project name names are renamed all the
/// time and would collide.
public static func projectTarballPath(for id: String) -> String {
projectsTarballPrefix + id + ".tar.gz"
}
}
@@ -0,0 +1,52 @@
import Foundation
/// One image attached to an outgoing chat prompt.
///
/// Hermes v0.12 ACP advertises `prompt_capabilities.image = true` and
/// accepts content-block arrays in `session/prompt`. Scarf produces these
/// blocks from drag-dropped / pasted / picker-selected images. We
/// downsample + JPEG-encode at the producer side so the wire payload
/// stays under a few hundred kilobytes per image even when the user
/// drops a 12 MP screenshot.
///
/// Constructed via `ImageEncoder.encode(...)`. The store-the-bytes-once
/// shape means `RichChatViewModel` can keep the array between turns
/// (e.g. while the agent is responding) without holding `NSImage` /
/// `UIImage` references that would pin the originals in memory.
public struct ChatImageAttachment: Sendable, Equatable, Identifiable {
public let id: String
/// IANA MIME type matches the `mimeType` field on ACP `ImageContentBlock`.
/// Currently always `image/jpeg` after re-encoding; PNG-only originals
/// keep their type when small enough to skip the JPEG step.
public let mimeType: String
/// Base64-encoded payload. NOT prefixed with `data:` Hermes wraps it
/// when forwarding to OpenAI multimodal payloads (see
/// `_image_block_to_openai_part` in `acp_adapter/server.py`).
public let base64Data: String
/// Small inline thumbnail for the composer's preview strip. Same MIME
/// type as `base64Data`. Nil when the source was already small enough
/// to use directly.
public let thumbnailBase64: String?
/// Original filename, when known (drag-drop carries it; paste doesn't).
/// Surfaced as a tooltip on the preview chip.
public let filename: String?
/// Approximate decoded byte count, kept for the composer's
/// "X images, Y KB" status pill.
public let approximateByteCount: Int
public init(
id: String = UUID().uuidString,
mimeType: String,
base64Data: String,
thumbnailBase64: String?,
filename: String?,
approximateByteCount: Int
) {
self.id = id
self.mimeType = mimeType
self.base64Data = base64Data
self.thumbnailBase64 = thumbnailBase64
self.filename = filename
self.approximateByteCount = approximateByteCount
}
}
@@ -258,7 +258,16 @@ public struct VoiceSettings: Sendable, Equatable {
)
}
/// Eight sub-models that share the same provider/model/base_url/api_key/timeout shape.
/// Per-task auxiliary model overrides.
///
/// `flush_memories` was removed in Hermes v0.12 but remains alive on
/// pre-v0.12 hosts the field is preserved here so the YAML parser
/// can round-trip it and `AuxiliaryTab` can render the row when
/// `HermesCapabilities.hasFlushMemoriesAux` is set. On v0.12+ the
/// field stays empty and is never surfaced.
/// `curator` was added in v0.12 Curator's review fork uses its own
/// model so users can keep main-model spend separate from background
/// maintenance.
public struct AuxiliarySettings: Sendable, Equatable {
public var vision: AuxiliaryModel
public var webExtract: AuxiliaryModel
@@ -267,7 +276,10 @@ public struct AuxiliarySettings: Sendable, Equatable {
public var skillsHub: AuxiliaryModel
public var approval: AuxiliaryModel
public var mcp: AuxiliaryModel
/// pre-v0.12 only; on v0.12+ this stays `.empty` and the row is hidden.
public var flushMemories: AuxiliaryModel
/// v0.12+; pre-v0.12 Hermes installs ignore this slot.
public var curator: AuxiliaryModel
public init(
@@ -278,7 +290,8 @@ public struct AuxiliarySettings: Sendable, Equatable {
skillsHub: AuxiliaryModel,
approval: AuxiliaryModel,
mcp: AuxiliaryModel,
flushMemories: AuxiliaryModel
flushMemories: AuxiliaryModel,
curator: AuxiliaryModel
) {
self.vision = vision
self.webExtract = webExtract
@@ -288,6 +301,7 @@ public struct AuxiliarySettings: Sendable, Equatable {
self.approval = approval
self.mcp = mcp
self.flushMemories = flushMemories
self.curator = curator
}
public nonisolated static let empty = AuxiliarySettings(
vision: .empty,
@@ -297,7 +311,8 @@ public struct AuxiliarySettings: Sendable, Equatable {
skillsHub: .empty,
approval: .empty,
mcp: .empty,
flushMemories: .empty
flushMemories: .empty,
curator: .empty
)
}
@@ -634,6 +649,24 @@ public struct HermesConfig: Sendable {
/// platform. Scarf reads for display; edits go through Hermes CLI.
public var platformToolsets: [String: [String]]
// -- Hermes v0.12 additions ----------------------------------------
// Defaults match the Hermes v0.12 defaults so that an absent key in
// config.yaml looks identical to a freshly-installed v0.12 host.
/// `prompt_caching.cache_ttl` `"5m"` (default) or `"1h"`. Hermes
/// v0.12 added the 1-hour ceiling for users with prompt-cache-heavy
/// workloads (long agent loops with stable system prompts).
public var cacheTTL: String
/// `redaction.enabled` flipped from `true` to `false` as the
/// upstream default in v0.12 because the substitution corrupted
/// patches and API payloads. Surface a toggle so users with hard
/// redaction requirements can opt back in.
public var redactionEnabled: Bool
/// `agent.runtime_metadata_footer` opt-in compact footer on each
/// final reply (provider/model/cost/turn count). Off by default;
/// useful for cost auditing and screen-recording demos.
public var runtimeMetadataFooter: Bool
// Grouped blocks
public var display: DisplaySettings
public var terminal: TerminalSettings
@@ -711,8 +744,14 @@ public struct HermesConfig: Sendable {
matrix: MatrixSettings,
mattermost: MattermostSettings,
whatsapp: WhatsAppSettings,
homeAssistant: HomeAssistantSettings
homeAssistant: HomeAssistantSettings,
cacheTTL: String = "5m",
redactionEnabled: Bool = false,
runtimeMetadataFooter: Bool = false
) {
self.cacheTTL = cacheTTL
self.redactionEnabled = redactionEnabled
self.runtimeMetadataFooter = runtimeMetadataFooter
self.model = model
self.provider = provider
self.maxTurns = maxTurns
@@ -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 {
@@ -19,6 +19,15 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
public nonisolated let timeoutType: String?
public nonisolated let timeoutSeconds: Int?
public nonisolated let silent: Bool?
/// Hermes v0.12+ the directory the job runs from. Hermes injects
/// AGENTS.md / CLAUDE.md / .cursorrules from this dir and uses it
/// as cwd for terminal/file/code_exec tools. `nil` preserves the
/// pre-v0.12 behaviour (no project context files).
public nonisolated let workdir: String?
/// Hermes v0.12+ chain another cron job's last output into this
/// job's prompt. YAML-only field today (no `--context-from` CLI
/// flag yet) Scarf displays it but doesn't write it.
public nonisolated let contextFrom: [String]?
public enum CodingKeys: String, CodingKey {
case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
@@ -30,6 +39,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
case lastDeliveryError = "last_delivery_error"
case timeoutType = "timeout_type"
case timeoutSeconds = "timeout_seconds"
case workdir
case contextFrom = "context_from"
}
/// Memberwise init. Swift doesn't synthesize one for us because
@@ -53,7 +64,9 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
lastDeliveryError: String? = nil,
timeoutType: String? = nil,
timeoutSeconds: Int? = nil,
silent: Bool? = nil
silent: Bool? = nil,
workdir: String? = nil,
contextFrom: [String]? = nil
) {
self.id = id
self.name = name
@@ -73,6 +86,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
self.timeoutType = timeoutType
self.timeoutSeconds = timeoutSeconds
self.silent = silent
self.workdir = workdir
self.contextFrom = contextFrom
}
public nonisolated init(from decoder: any Decoder) throws {
@@ -95,6 +110,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
self.timeoutType = try c.decodeIfPresent(String.self, forKey: .timeoutType)
self.timeoutSeconds = try c.decodeIfPresent(Int.self, forKey: .timeoutSeconds)
self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent)
self.workdir = try c.decodeIfPresent(String.self, forKey: .workdir)
self.contextFrom = try c.decodeIfPresent([String].self, forKey: .contextFrom)
}
public nonisolated func encode(to encoder: any Encoder) throws {
@@ -117,6 +134,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
try c.encodeIfPresent(timeoutType, forKey: .timeoutType)
try c.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
try c.encodeIfPresent(silent, forKey: .silent)
try c.encodeIfPresent(workdir, forKey: .workdir)
try c.encodeIfPresent(contextFrom, forKey: .contextFrom)
}
public nonisolated var stateIcon: String {
@@ -0,0 +1,361 @@
import Foundation
/// Parsed view of `hermes curator status` text + the on-disk
/// `~/.hermes/skills/.curator_state` JSON.
///
/// Hermes v0.12 doesn't ship a `--json` flag for `curator status` the
/// CLI writes a human-readable report. CuratorViewModel parses the text
/// output for the human-readable bits ("least recently active", "most
/// active") and reads the state file directly for last-run metadata.
public struct HermesCuratorStatus: Sendable, Equatable {
public enum RunState: String, Sendable, Equatable {
case enabled
case paused
case disabled
case unknown
}
public let state: RunState
public let runCount: Int
public let lastRunISO: String? // raw timestamp string, parsed by callers
public let lastSummary: String? // free-text summary line
public let lastReportPath: String? // absolute path to <YYYYMMDD-HHMMSS>/ dir
public let intervalLabel: String // e.g. "every 7d"
public let staleAfterLabel: String // e.g. "30d unused"
public let archiveAfterLabel: String // e.g. "90d unused"
public let totalSkills: Int
public let activeSkills: Int
public let staleSkills: Int
public let archivedSkills: Int
public let pinnedNames: [String]
/// Top-5 lists rendered in the curator output. Each row carries the
/// skill name + the four counters Hermes prints.
public let leastRecentlyActive: [HermesCuratorSkillRow]
public let mostActive: [HermesCuratorSkillRow]
public let leastActive: [HermesCuratorSkillRow]
public init(
state: RunState,
runCount: Int,
lastRunISO: String?,
lastSummary: String?,
lastReportPath: String?,
intervalLabel: String,
staleAfterLabel: String,
archiveAfterLabel: String,
totalSkills: Int,
activeSkills: Int,
staleSkills: Int,
archivedSkills: Int,
pinnedNames: [String],
leastRecentlyActive: [HermesCuratorSkillRow],
mostActive: [HermesCuratorSkillRow],
leastActive: [HermesCuratorSkillRow]
) {
self.state = state
self.runCount = runCount
self.lastRunISO = lastRunISO
self.lastSummary = lastSummary
self.lastReportPath = lastReportPath
self.intervalLabel = intervalLabel
self.staleAfterLabel = staleAfterLabel
self.archiveAfterLabel = archiveAfterLabel
self.totalSkills = totalSkills
self.activeSkills = activeSkills
self.staleSkills = staleSkills
self.archivedSkills = archivedSkills
self.pinnedNames = pinnedNames
self.leastRecentlyActive = leastRecentlyActive
self.mostActive = mostActive
self.leastActive = leastActive
}
public static let empty = HermesCuratorStatus(
state: .unknown,
runCount: 0,
lastRunISO: nil,
lastSummary: nil,
lastReportPath: nil,
intervalLabel: "",
staleAfterLabel: "",
archiveAfterLabel: "",
totalSkills: 0,
activeSkills: 0,
staleSkills: 0,
archivedSkills: 0,
pinnedNames: [],
leastRecentlyActive: [],
mostActive: [],
leastActive: []
)
}
public struct HermesCuratorSkillRow: Sendable, Equatable, Identifiable {
public var id: String { name }
public let name: String
public let activityCount: Int
public let useCount: Int
public let viewCount: Int
public let patchCount: Int
public let lastActivityLabel: String // raw label as printed (e.g. "never", "2d ago")
public init(
name: String,
activityCount: Int,
useCount: Int,
viewCount: Int,
patchCount: Int,
lastActivityLabel: String
) {
self.name = name
self.activityCount = activityCount
self.useCount = useCount
self.viewCount = viewCount
self.patchCount = patchCount
self.lastActivityLabel = lastActivityLabel
}
}
/// Pure parser for `hermes curator status` stdout. Public for tests.
///
/// Format is stable enough to text-parse; we never error on missing
/// sections we just leave the corresponding field empty so
/// CuratorView can render "" without crashing on a future layout
/// tweak. State file overrides text-parsed values when both are present.
public enum HermesCuratorStatusParser {
public static func parse(text: String, stateFileJSON: Data? = nil) -> HermesCuratorStatus {
let lines = text.components(separatedBy: "\n")
var status = HermesCuratorStatus.empty
// Header section: `curator: ENABLED` / `runs:` / `last run:` /
// `last summary:` / `interval:` / `stale after:` / `archive after:`
var state = HermesCuratorStatus.RunState.unknown
var runCount = 0
var lastRunISO: String?
var lastSummary: String?
var lastReportPath: String?
var interval = ""
var stale = ""
var archive = ""
// Skill counts: `agent-created skills: N total` then
// ` active N` / ` stale N` / ` archived N`
var total = 0
var active = 0
var staleCount = 0
var archived = 0
var pinned: [String] = []
// Lists: `least recently active (top 5):` / `most active (top 5):` /
// `least active (top 5):` followed by indented row lines.
enum Section {
case header
case leastRecent
case mostActive
case leastActive
}
var section = Section.header
var leastRecent: [HermesCuratorSkillRow] = []
var mostActiveRows: [HermesCuratorSkillRow] = []
var leastActiveRows: [HermesCuratorSkillRow] = []
for raw in lines {
let line = raw.trimmingCharacters(in: .whitespaces)
// Section markers
if line.hasPrefix("least recently active") {
section = .leastRecent
continue
}
if line.hasPrefix("most active") {
section = .mostActive
continue
}
if line.hasPrefix("least active") {
section = .leastActive
continue
}
// Header section single-line keys
if line.hasPrefix("curator:") {
let val = String(line.dropFirst("curator:".count)).trimmingCharacters(in: .whitespaces).uppercased()
switch val {
case "ENABLED": state = .enabled
case "PAUSED": state = .paused
case "DISABLED": state = .disabled
default: state = .unknown
}
continue
}
if line.hasPrefix("runs:") {
runCount = Int(line.dropFirst("runs:".count).trimmingCharacters(in: .whitespaces)) ?? 0
continue
}
if line.hasPrefix("last run:") {
let val = String(line.dropFirst("last run:".count)).trimmingCharacters(in: .whitespaces)
lastRunISO = val == "never" ? nil : val
continue
}
if line.hasPrefix("last summary:") {
let val = String(line.dropFirst("last summary:".count)).trimmingCharacters(in: .whitespaces)
lastSummary = (val == "(none)" || val.isEmpty) ? nil : val
continue
}
if line.hasPrefix("last report:") {
let val = String(line.dropFirst("last report:".count)).trimmingCharacters(in: .whitespaces)
lastReportPath = val.isEmpty ? nil : val
continue
}
if line.hasPrefix("interval:") {
interval = String(line.dropFirst("interval:".count)).trimmingCharacters(in: .whitespaces)
continue
}
if line.hasPrefix("stale after:") {
stale = String(line.dropFirst("stale after:".count)).trimmingCharacters(in: .whitespaces)
continue
}
if line.hasPrefix("archive after:") {
archive = String(line.dropFirst("archive after:".count)).trimmingCharacters(in: .whitespaces)
continue
}
// `agent-created skills: 18 total`
if line.hasPrefix("agent-created skills:") {
let after = line.dropFirst("agent-created skills:".count).trimmingCharacters(in: .whitespaces)
if let n = Int(after.split(separator: " ").first ?? "") {
total = n
}
section = .header
continue
}
// Counts: "active 18" / "stale 0" / "archived 0"
if let row = parseStateCountRow(line) {
switch row.state {
case "active": active = row.count
case "stale": staleCount = row.count
case "archived": archived = row.count
default: break
}
continue
}
// pinned (3): foo, bar, baz
if line.hasPrefix("pinned (") {
if let colon = line.firstIndex(of: ":") {
let names = line[line.index(after: colon)...]
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
pinned = names
}
continue
}
// Skill rows like:
// <name> activity= N use= N view= N patches= N last_activity=<label>
if section != .header, let parsed = parseSkillRow(line) {
switch section {
case .leastRecent: leastRecent.append(parsed)
case .mostActive: mostActiveRows.append(parsed)
case .leastActive: leastActiveRows.append(parsed)
case .header: break
}
}
}
// Apply state-file overrides if present. The .curator_state JSON
// is authoritative for last_run_at / last_run_summary /
// last_report_path because those carry timestamps the text
// output rounds.
if let json = stateFileJSON,
let obj = try? JSONSerialization.jsonObject(with: json) as? [String: Any] {
if obj["paused"] as? Bool == true { state = .paused }
if let count = obj["run_count"] as? Int { runCount = count }
if let lr = obj["last_run_at"] as? String { lastRunISO = lr }
if let summary = obj["last_run_summary"] as? String, !summary.isEmpty { lastSummary = summary }
if let path = obj["last_report_path"] as? String, !path.isEmpty { lastReportPath = path }
}
status = HermesCuratorStatus(
state: state,
runCount: runCount,
lastRunISO: lastRunISO,
lastSummary: lastSummary,
lastReportPath: lastReportPath,
intervalLabel: interval,
staleAfterLabel: stale,
archiveAfterLabel: archive,
totalSkills: total,
activeSkills: active,
staleSkills: staleCount,
archivedSkills: archived,
pinnedNames: pinned,
leastRecentlyActive: leastRecent,
mostActive: mostActiveRows,
leastActive: leastActiveRows
)
return status
}
/// `active 18` style row inside the skill-count block.
private static func parseStateCountRow(_ line: String) -> (state: String, count: Int)? {
let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
guard parts.count >= 2,
["active", "stale", "archived"].contains(parts[0]),
let count = Int(parts[1])
else { return nil }
return (parts[0], count)
}
/// Skill-list row parser. Tolerates Hermes's whitespace-padded
/// layout `activity= 0` has two spaces between `=` and the
/// number, so we can't split-on-space-then-split-on-`=`. Instead
/// we slide a key-detection cursor across the row and grab the
/// next non-whitespace token after each known key.
private static func parseSkillRow(_ line: String) -> HermesCuratorSkillRow? {
guard let activityRange = line.range(of: "activity=") else { return nil }
let name = String(line[..<activityRange.lowerBound]).trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return nil }
// Map each known key to its value substring. Read positionally
// by slicing between consecutive known keys handles arbitrary
// whitespace padding without depending on column positions.
let knownKeys = ["activity=", "use=", "view=", "patches=", "last_activity="]
var positions: [(key: String, range: Range<String.Index>)] = []
for key in knownKeys {
if let r = line.range(of: key) {
positions.append((key, r))
}
}
positions.sort { $0.range.lowerBound < $1.range.lowerBound }
var activity = 0, use = 0, view = 0, patch = 0
var lastActivity = ""
for (idx, entry) in positions.enumerated() {
let valueStart = entry.range.upperBound
let valueEnd = idx + 1 < positions.count
? positions[idx + 1].range.lowerBound
: line.endIndex
let raw = String(line[valueStart..<valueEnd]).trimmingCharacters(in: .whitespaces)
switch entry.key {
case "activity=": activity = Int(raw) ?? 0
case "use=": use = Int(raw) ?? 0
case "view=": view = Int(raw) ?? 0
case "patches=": patch = Int(raw) ?? 0
case "last_activity=": lastActivity = raw
default: break
}
}
return HermesCuratorSkillRow(
name: name,
activityCount: activity,
useCount: use,
viewCount: view,
patchCount: patch,
lastActivityLabel: lastActivity
)
}
}
@@ -0,0 +1,90 @@
import Foundation
/// One task from `hermes kanban list --json` (v0.12+).
///
/// Hermes ships a SQLite-backed task board under `~/.hermes/kanban.db`
/// multi-profile collaboration was reverted upstream while the
/// design is reworked, so Scarf v2.6 surfaces this as a read-only
/// list. Create / claim / dispatch / dependency-link UI is deferred
/// until upstream stabilizes.
public struct HermesKanbanTask: Sendable, Equatable, Identifiable, Codable {
public let id: String
public let title: String
public let body: String?
public let assignee: String?
public let status: String // archived | blocked | done | ready | running | todo | triage
public let priority: Int?
public let tenant: String?
public let workspaceKind: String? // scratch | worktree | dir
public let workspacePath: String?
public let createdBy: String?
public let createdAt: String? // ISO timestamp
public let startedAt: String?
public let completedAt: String?
public let result: String?
public let skills: [String]
public init(
id: String,
title: String,
body: String? = nil,
assignee: String? = nil,
status: String,
priority: Int? = nil,
tenant: String? = nil,
workspaceKind: String? = nil,
workspacePath: String? = nil,
createdBy: String? = nil,
createdAt: String? = nil,
startedAt: String? = nil,
completedAt: String? = nil,
result: String? = nil,
skills: [String] = []
) {
self.id = id
self.title = title
self.body = body
self.assignee = assignee
self.status = status
self.priority = priority
self.tenant = tenant
self.workspaceKind = workspaceKind
self.workspacePath = workspacePath
self.createdBy = createdBy
self.createdAt = createdAt
self.startedAt = startedAt
self.completedAt = completedAt
self.result = result
self.skills = skills
}
enum CodingKeys: String, CodingKey {
case id, title, body, assignee, status, priority, tenant
case workspaceKind = "workspace_kind"
case workspacePath = "workspace_path"
case createdBy = "created_by"
case createdAt = "created_at"
case startedAt = "started_at"
case completedAt = "completed_at"
case result, skills
}
public init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decode(String.self, forKey: .id)
self.title = try c.decode(String.self, forKey: .title)
self.body = try c.decodeIfPresent(String.self, forKey: .body)
self.assignee = try c.decodeIfPresent(String.self, forKey: .assignee)
self.status = try c.decodeIfPresent(String.self, forKey: .status) ?? "unknown"
self.priority = try c.decodeIfPresent(Int.self, forKey: .priority)
self.tenant = try c.decodeIfPresent(String.self, forKey: .tenant)
self.workspaceKind = try c.decodeIfPresent(String.self, forKey: .workspaceKind)
self.workspacePath = try c.decodeIfPresent(String.self, forKey: .workspacePath)
self.createdBy = try c.decodeIfPresent(String.self, forKey: .createdBy)
self.createdAt = try c.decodeIfPresent(String.self, forKey: .createdAt)
self.startedAt = try c.decodeIfPresent(String.self, forKey: .startedAt)
self.completedAt = try c.decodeIfPresent(String.self, forKey: .completedAt)
self.result = try c.decodeIfPresent(String.self, forKey: .result)
self.skills = try c.decodeIfPresent([String].self, forKey: .skills) ?? []
}
}
@@ -75,12 +75,35 @@ public struct HermesPathSet: Sendable, Hashable {
public nonisolated var errorsLog: String { home + "/logs/errors.log" }
public nonisolated var agentLog: String { home + "/logs/agent.log" }
public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
/// Curator run-reports root (v0.12+). Hermes writes per-cycle dirs
/// under here named `<YYYYMMDD-HHMMSS>/` containing `run.json` and
/// `REPORT.md`. The `last_report_path` field on `curator_state`
/// points at the most recent dir; `CuratorViewModel` resolves the
/// JSON/Markdown files relative to it.
public nonisolated var curatorLogsDir: String { home + "/logs/curator" }
/// JSON-encoded curator state (v0.12+). Filename has no extension
/// despite holding JSON Hermes writes it via
/// `~/.hermes/skills/.curator_state`. Carries last-run metadata,
/// run count, pause flag, and the path to the most recent report.
public nonisolated var curatorStateFile: String { home + "/skills/.curator_state" }
public nonisolated var scarfDir: String { home + "/scarf" }
public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
/// 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" }
/// Cached `templates/catalog.json` from awizemann.github.io. Populated
/// by `CatalogService` on first sheet-open and refreshed on a 24h TTL
/// or on explicit user click. Mirrors `nousModelsCache` exactly:
/// JSON, scarf-owned, survives offline runs so the catalog browser
/// still has something to render. Wiped by a Hermes home reset.
public nonisolated var catalogCache: String { scarfDir + "/catalog_cache.json" }
public nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
// MARK: - Binary resolution
@@ -37,6 +37,16 @@ public struct HermesSkill: Identifiable, Sendable {
/// Python packages). Used by `SkillPrereqService` to know what to
/// probe; nil when the field is absent.
public let dependencies: [String]?
/// `false` when the skill name appears in `skills.disabled` in
/// `~/.hermes/config.yaml`. Hermes v0.12 stores disable state in
/// the config rather than per-skill markers; this is read-only
/// from Scarf's side until the toggle UI lands. Defaults to `true`.
public let enabled: Bool
/// `true` when the skill is pinned via `hermes curator pin <name>`.
/// Pinned skills are protected from auto-archive / consolidation.
/// Read from `CuratorViewModel.status.pinnedNames`; defaults to
/// `false` when curator state is unavailable.
public let pinned: Bool
public init(
id: String,
@@ -47,7 +57,9 @@ public struct HermesSkill: Identifiable, Sendable {
requiredConfig: [String],
allowedTools: [String]? = nil,
relatedSkills: [String]? = nil,
dependencies: [String]? = nil
dependencies: [String]? = nil,
enabled: Bool = true,
pinned: Bool = false
) {
self.id = id
self.name = name
@@ -58,5 +70,7 @@ public struct HermesSkill: Identifiable, Sendable {
self.allowedTools = allowedTools
self.relatedSkills = relatedSkills
self.dependencies = dependencies
self.enabled = enabled
self.pinned = pinned
}
}
@@ -53,6 +53,13 @@ public enum KnownPlatforms {
HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"),
HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"),
HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"),
// -- v0.12 additions ---------------------------------------------
// Yuanbao is a native gateway adapter (18th platform); Microsoft
// Teams ships as a plugin (19th). PlatformDetail surfaces the
// distinction in the setup copy. Names match Hermes's gateway
// platform identifiers.
HermesToolPlatform(name: "yuanbao", displayName: "Yuanbao 元宝", icon: "bubble.left.and.bubble.right.fill"),
HermesToolPlatform(name: "microsoft-teams", displayName: "Microsoft Teams", icon: "person.2.fill"),
]
public static func icon(for platform: String) -> String {
@@ -70,6 +77,8 @@ public enum KnownPlatforms {
case "feishu": return "message.badge.circle"
case "mattermost": return "bubble.left.and.exclamationmark.bubble.right"
case "imessage": return "message.fill"
case "yuanbao": return "bubble.left.and.bubble.right.fill"
case "microsoft-teams": return "person.2.fill"
default: return "bubble.left"
}
}
@@ -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`
@@ -122,7 +122,8 @@ public extension HermesConfig {
skillsHub: aux("skills_hub"),
approval: aux("approval"),
mcp: aux("mcp"),
flushMemories: aux("flush_memories")
flushMemories: aux("flush_memories"),
curator: aux("curator")
)
let security = SecuritySettings(
@@ -280,7 +281,10 @@ public extension HermesConfig {
matrix: matrix,
mattermost: mattermost,
whatsapp: whatsapp,
homeAssistant: homeAssistant
homeAssistant: homeAssistant,
cacheTTL: str("prompt_caching.cache_ttl", default: "5m"),
redactionEnabled: bool("redaction.enabled", default: false),
runtimeMetadataFooter: bool("agent.runtime_metadata_footer", default: false)
)
}
}
@@ -0,0 +1,314 @@
import Foundation
import Observation
#if canImport(os)
import os
#endif
/// What this Hermes installation can do, derived from `hermes --version`.
///
/// Scarf tracks Hermes feature releases by date-version + semver. v0.12 added
/// a dozen surfaces (Curator, Kanban, multimodal ACP, ...) and removed a few
/// (`flush_memories` aux task). UI that branches on these surfaces calls
/// the boolean accessors here so older Hermes installs degrade silently
/// instead of throwing on an unknown CLI subcommand.
///
/// Pure value type no side effects. The async detection lives in
/// `HermesCapabilitiesStore`.
public struct HermesCapabilities: Sendable, Equatable {
/// Raw version line as printed by `hermes --version`. Preserved verbatim
/// so diagnostics views can show the exact string Scarf saw.
public let versionLine: String
/// Parsed `0.X.Y`. `nil` when the output didn't match the expected format
/// (e.g. Hermes returned an error, or a future format change).
public let semver: SemVer?
/// Parsed `YYYY.M.D` from the parenthesized date suffix. `nil` when
/// absent older Hermes builds didn't always emit it.
public let dateVersion: DateVersion?
public init(versionLine: String, semver: SemVer?, dateVersion: DateVersion?) {
self.versionLine = versionLine
self.semver = semver
self.dateVersion = dateVersion
}
/// Sentinel for "not yet detected" / "detection failed". All capability
/// flags resolve to `false` so unguarded UI stays hidden until the real
/// version lands.
public static let empty = HermesCapabilities(
versionLine: "",
semver: nil,
dateVersion: nil
)
public var detected: Bool { semver != nil }
// MARK: - Capability flags
//
// Add a new flag here when Scarf gains UI that conditionally branches on
// a Hermes capability. Keep the comparison conservative: `>= 0.12.0`
// covers users still on the 0.12 line who haven't upgraded to 0.13 yet.
/// `hermes curator` autonomous skill maintenance (v0.12+).
public var hasCurator: Bool { atLeastSemver(0, 12, 0) }
/// `hermes fallback` provider management (v0.12+).
public var hasFallbackCommand: Bool { atLeastSemver(0, 12, 0) }
/// `hermes kanban` task board CLI (v0.12+).
public var hasKanban: Bool { atLeastSemver(0, 12, 0) }
/// `hermes -z <prompt>` non-interactive one-shot mode (v0.12+).
public var hasOneShot: Bool { atLeastSemver(0, 12, 0) }
/// `hermes skills install <https-url>` direct-URL install (v0.12+).
public var hasSkillURLInstall: Bool { atLeastSemver(0, 12, 0) }
/// ACP `session/prompt` accepts image content blocks (v0.12+).
public var hasACPImagePrompts: Bool { atLeastSemver(0, 12, 0) }
/// `hermes update --check` preflight (v0.12+).
public var hasUpdateCheck: Bool { atLeastSemver(0, 12, 0) }
/// Pluggable TTS providers including native Piper (v0.12+).
public var hasPiperTTS: Bool { atLeastSemver(0, 12, 0) }
/// `terminal.backend = vercel` Vercel Sandbox option (v0.12+).
public var hasVercelTerminal: Bool { atLeastSemver(0, 12, 0) }
/// `auxiliary.flush_memories` config row was removed in v0.12.
/// Inverse semantics `true` means the row should still be shown.
public var hasFlushMemoriesAux: Bool {
guard let s = semver else { return false } // unknown hide
return s < SemVer(major: 0, minor: 12, patch: 0) // pre-v0.12 only
}
/// `auxiliary.curator` aux task is configurable (v0.12+).
public var hasCuratorAux: Bool { atLeastSemver(0, 12, 0) }
/// Microsoft Teams (19th platform) and Yuanbao (18th) added in v0.12.
public var hasTeamsPlatform: Bool { atLeastSemver(0, 12, 0) }
public var hasYuanbaoPlatform: Bool { atLeastSemver(0, 12, 0) }
/// Cron jobs accept `--workdir` and `--context-from` flags (v0.12+).
public var hasCronWorkdir: Bool { atLeastSemver(0, 12, 0) }
/// `prompt_caching.cache_ttl` config knob (v0.12+).
public var hasPromptCacheTTL: Bool { atLeastSemver(0, 12, 0) }
/// `redaction.enabled` is now off by default in v0.12 Scarf surfaces
/// the toggle so users can flip it back on.
public var hasRedactionToggle: Bool { atLeastSemver(0, 12, 0) }
private func atLeastSemver(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
guard let s = semver else { return false }
return s >= SemVer(major: major, minor: minor, patch: patch)
}
public struct SemVer: Sendable, Equatable, Comparable, CustomStringConvertible {
public let major: Int
public let minor: Int
public let patch: Int
public init(major: Int, minor: Int, patch: Int) {
self.major = major
self.minor = minor
self.patch = patch
}
public var description: String { "\(major).\(minor).\(patch)" }
public static func < (a: SemVer, b: SemVer) -> Bool {
if a.major != b.major { return a.major < b.major }
if a.minor != b.minor { return a.minor < b.minor }
return a.patch < b.patch
}
}
public struct DateVersion: Sendable, Equatable, Comparable, CustomStringConvertible {
public let year: Int
public let month: Int
public let day: Int
public init(year: Int, month: Int, day: Int) {
self.year = year
self.month = month
self.day = day
}
public var description: String { "\(year).\(month).\(day)" }
public static func < (a: DateVersion, b: DateVersion) -> Bool {
if a.year != b.year { return a.year < b.year }
if a.month != b.month { return a.month < b.month }
return a.day < b.day
}
}
/// Parse a `Hermes Agent v0.12.0 (2026.4.30)` line out of `hermes --version`
/// output. Tolerates leading/trailing whitespace, extra header lines
/// (e.g. `Project:`, `Python:`), and the absence of the parenthesized
/// date suffix.
///
/// Returns `.empty` when no recognizable version line is present so
/// callers don't have to special-case nil.
public static func parse(_ output: String) -> HermesCapabilities {
for raw in output.components(separatedBy: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
guard line.contains("Hermes Agent v") else { continue }
return parseLine(line)
}
return .empty
}
/// `Hermes Agent v0.12.0 (2026.4.30)` semver + date. Returns `.empty`
/// when the line doesn't match. Public for unit tests; production callers
/// should use `parse(_:)`.
public static func parseLine(_ line: String) -> HermesCapabilities {
// Locate the "v" right after "Hermes Agent ". Don't anchor at line
// start older builds prefix with ANSI color codes Scarf would
// need to strip.
guard let vRange = line.range(of: "Hermes Agent v") else { return .empty }
let tail = String(line[vRange.upperBound...])
// Read digits separated by dots until we hit non-version content.
// First three components are semver. A trailing `(Y.M.D)` is the
// date version.
let semverEnd = tail.firstIndex(where: { c in
!(c.isNumber || c == ".")
}) ?? tail.endIndex
let semverStr = String(tail[..<semverEnd])
let semverParts = semverStr.split(separator: ".").compactMap { Int($0) }
guard semverParts.count >= 3 else { return .empty }
let semver = SemVer(
major: semverParts[0],
minor: semverParts[1],
patch: semverParts[2]
)
// Optional date suffix.
var dateVersion: DateVersion?
if let openParen = tail.firstIndex(of: "("),
let closeParen = tail.firstIndex(of: ")"),
openParen < closeParen {
let dateStr = tail[tail.index(after: openParen)..<closeParen]
let dateParts = dateStr.split(separator: ".").compactMap { Int($0) }
if dateParts.count == 3 {
dateVersion = DateVersion(
year: dateParts[0],
month: dateParts[1],
day: dateParts[2]
)
}
}
return HermesCapabilities(
versionLine: line,
semver: semver,
dateVersion: dateVersion
)
}
}
/// Per-server capability cache. One per `ContextBoundRoot` (Mac) / iOS scene
/// root, injected via `.environment(_:)`. Refreshes once on init; callers
/// invoke `refresh()` after a Hermes update or when the server changes.
///
/// Not thread-safe across instances each server gets its own store, and
/// the underlying `runHermesCLI` call is detached so we never block
/// MainActor.
@Observable
@MainActor
public final class HermesCapabilitiesStore {
#if canImport(os)
private let logger = Logger(subsystem: "com.scarf", category: "HermesCapabilities")
#endif
public private(set) var capabilities: HermesCapabilities = .empty
public private(set) var isLoading = true
public let context: ServerContext
private var refreshTask: Task<Void, Never>?
public init(context: ServerContext) {
self.context = context
// Kick off a one-shot detection. Subsequent refreshes are explicit.
// Task captures `[weak self]`, so if the store is freed before
// detection completes the closure simply no-ops.
refreshTask = Task { [weak self] in
await self?.refresh()
}
}
public func refresh() async {
isLoading = true
let context = self.context
let parsed = await Task.detached(priority: .utility) { () -> HermesCapabilities in
return Self.detectSync(context: context)
}.value
self.capabilities = parsed
self.isLoading = false
#if canImport(os)
if parsed.detected {
logger.info("Hermes \(parsed.versionLine, privacy: .public) detected on \(self.context.displayName, privacy: .public)")
} else {
logger.warning("Hermes version not detected on \(self.context.displayName, privacy: .public)")
}
#endif
}
/// Synchronous detection helper. Lives here (not on `HermesCapabilities`)
/// because `ServerContext.makeTransport()` is a side-effecting call that
/// pulls in the platform-appropriate transport (LocalTransport on Mac,
/// CitadelServerTransport on iOS). The pure parser remains side-effect-free.
nonisolated private static func detectSync(context: ServerContext) -> HermesCapabilities {
let transport = context.makeTransport()
let executable = context.paths.hermesBinary
do {
let result = try transport.runProcess(
executable: executable,
args: ["--version"],
stdin: nil,
timeout: 10
)
// `hermes --version` writes to stdout but Scarf's transport
// helpers occasionally split error output across stderr fold
// both so the parser sees whichever stream the line lands on.
let combined = result.stdoutString + result.stderrString
guard result.exitCode == 0 else { return .empty }
return HermesCapabilities.parse(combined)
} catch {
return .empty
}
}
}
// MARK: - SwiftUI environment wiring
#if canImport(SwiftUI)
import SwiftUI
private struct HermesCapabilitiesStoreKey: EnvironmentKey {
static let defaultValue: HermesCapabilitiesStore? = nil
}
extension EnvironmentValues {
/// The active server's capability store. `nil` outside the per-server
/// `ContextBoundRoot`. Callers should treat `nil` and `.empty` capabilities
/// the same defensive code for harness scenarios (Previews, smoke tests).
public var hermesCapabilities: HermesCapabilitiesStore? {
get { self[HermesCapabilitiesStoreKey.self] }
set { self[HermesCapabilitiesStoreKey.self] = newValue }
}
}
extension View {
/// Inject a `HermesCapabilitiesStore` into the environment. Mirrors the
/// usual `.environment(_:)` shape but routes through the typed key
/// above so callers don't need to import the key.
public func hermesCapabilities(_ store: HermesCapabilitiesStore) -> some View {
environment(\.hermesCapabilities, store)
}
}
#endif
@@ -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"
@@ -51,7 +51,19 @@ public enum HermesProfileResolver {
/// 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."
///
/// **Test override.** Setting `SCARF_HERMES_HOME` in the environment
/// pins this resolver to the supplied absolute path and bypasses both
/// the cache and the `active_profile` lookup. Used by the E2E test
/// harness (`TemplateE2ETests`, `TemplateInstallUITests`) to drive
/// Scarf against an isolated tmpdir Hermes home so the user's real
/// `~/.hermes` is never touched. Read on every call (cheap; a single
/// `ProcessInfo` lookup) so tests can flip it across test methods
/// without stale-cache surprises.
public static func resolveLocalHome() -> String {
if let override = scarfHermesHomeOverride() {
return override
}
return refreshIfNeeded().home
}
@@ -60,9 +72,55 @@ public enum HermesProfileResolver {
/// 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 {
if scarfHermesHomeOverride() != nil {
return "test-override"
}
return refreshIfNeeded().name
}
/// Sentinel filename that the override path MUST contain for the
/// override to be honored. Without it, production code refuses to
/// pivot off the user's real `~/.hermes` even if the env var is
/// set. This is the "even if a test leaks the env var, even if
/// some non-test process inherits it, the user's data is safe"
/// belt-and-braces guard. Tests create this marker before
/// `setenv("SCARF_HERMES_HOME", ...)`.
public static let testHomeMarkerFilename = ".scarf-test-home-marker"
/// Read `SCARF_HERMES_HOME` from the environment. Returns `nil` when
/// unset or empty so production callers fall through to the profile
/// resolver. The override must:
/// 1. Be an absolute path relative paths are rejected (they'd
/// land relative to the cwd of whatever process happened to
/// invoke the resolver, which is not what tests want).
/// 2. Contain the sentinel marker file
/// `<path>/<testHomeMarkerFilename>`. Without the marker we
/// treat the env var as untrusted and ignore it. This protects
/// the user's real `~/.hermes/` from any code path that
/// accidentally exports `SCARF_HERMES_HOME` to the wrong value
/// (e.g. a test crashed mid-teardown, an env var inherited
/// from a parent shell, a misconfigured launchctl plist).
/// Both checks are cheap `FileManager.fileExists` against a
/// known path is microseconds. The override is hot but not
/// hot-hot, so an extra stat per call is negligible.
private static func scarfHermesHomeOverride() -> String? {
guard let raw = ProcessInfo.processInfo.environment["SCARF_HERMES_HOME"] else {
return nil
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard trimmed.hasPrefix("/") else {
logger.warning("SCARF_HERMES_HOME=\(trimmed, privacy: .public) is not absolute; ignoring.")
return nil
}
let markerPath = trimmed + "/" + testHomeMarkerFilename
guard FileManager.default.fileExists(atPath: markerPath) else {
logger.warning("SCARF_HERMES_HOME=\(trimmed, privacy: .public) lacks sentinel marker (\(testHomeMarkerFilename, privacy: .public)); ignoring to protect real ~/.hermes.")
return nil
}
return trimmed
}
/// Force a re-read on the next call, regardless of TTL. Test helper.
public static func invalidateCache() {
lock.withLock { $0.resolvedAt = .distantPast }
@@ -95,15 +153,20 @@ public enum HermesProfileResolver {
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.
// Absent file default profile. Common case for users who
// haven't run `hermes profile use ...`. We still log at
// `.info` (key=value, not warning) so support requests can
// pull `log show | grep ProfileResolver` and confirm the
// resolver IS running and IS resolving to the default
// distinguishing "feature didn't fire" from "feature fired
// and chose default" (issue #70).
guard FileManager.default.fileExists(atPath: activeFile) else {
logger.info("Resolved active Hermes profile: name=default, home=\(defaultHome, privacy: .public), source=default-no-file")
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.")
logger.warning("Found active_profile but could not read it; falling back to default. home=\(defaultHome, privacy: .public)")
return ("default", defaultHome)
}
@@ -111,6 +174,7 @@ public enum HermesProfileResolver {
// Empty file or explicit "default" default profile.
if trimmed.isEmpty || trimmed == "default" {
logger.info("Resolved active Hermes profile: name=default, home=\(defaultHome, privacy: .public), source=file-default")
return ("default", defaultHome)
}
@@ -129,7 +193,7 @@ public enum HermesProfileResolver {
return ("default", defaultHome)
}
logger.info("Resolved active Hermes profile to \(trimmed, privacy: .public) at \(profileHome, privacy: .public).")
logger.info("Resolved active Hermes profile: name=\(trimmed, privacy: .public), home=\(profileHome, privacy: .public), source=file")
return (trimmed, profileHome)
}
@@ -0,0 +1,162 @@
import Foundation
#if canImport(AppKit)
import AppKit
#endif
#if canImport(UIKit)
import UIKit
#endif
#if canImport(CoreImage)
import CoreImage
#endif
/// Downsamples + base64-encodes user-supplied images for ACP transport.
///
/// **Why downsample on the producer side.** Hermes happily forwards the
/// bytes to a vision model, but a 12 MP screenshot at 4 MB is wasteful
/// it eats 56× more tokens than a 1024×1024 thumbnail and gives the
/// model no extra signal. Cap the long edge at 1568 px (Anthropic's
/// recommended max for Claude vision) and drop quality to JPEG 0.85,
/// which keeps screenshot text crisp while landing under ~300 KB per
/// image. The 5-image-per-message limit (chosen on the producer side)
/// keeps the total prompt payload below ~2 MB.
///
/// **Why detached.** Image loading + downsampling is CPU-bound. Run only
/// from a `Task.detached` context (the encoder type is `Sendable` and
/// every method is `nonisolated`). The companion `ChatImageAttachment`
/// is a Sendable value type so the result hops back to MainActor cleanly.
public struct ImageEncoder: Sendable {
/// Long-edge pixel cap. 1568 is Anthropic's recommended ceiling for
/// Claude vision input past it, the provider downsamples server-side
/// and we just paid for the extra bytes. Tweak only with vision-model
/// guidance from Hermes side.
public static let maxLongEdge: CGFloat = 1568
/// JPEG quality factor. 0.85 is the inflection point above which
/// file size jumps quickly without obvious visual gain on screenshots
/// or photographs.
public static let jpegQuality: CGFloat = 0.85
/// Long-edge cap for the inline thumbnail rendered in the composer
/// chip. Kept under the system thumbnail size so `Image(data:)`
/// renders without extra resampling.
public static let thumbnailLongEdge: CGFloat = 256
public init() {}
public enum EncoderError: Error, LocalizedError {
case unsupportedFormat
case decodeFailed
case encodeFailed
case empty
public var errorDescription: String? {
switch self {
case .unsupportedFormat: return "Image format not recognized"
case .decodeFailed: return "Couldn't decode image data"
case .encodeFailed: return "Couldn't encode image as JPEG"
case .empty: return "Image data was empty"
}
}
}
/// Encode raw bytes (from a paste/drop/picker) into a wire-ready
/// attachment. Detached-only never call from MainActor. The
/// originating bytes are not retained beyond this call.
public nonisolated func encode(
rawBytes: Data,
sourceFilename: String? = nil
) throws -> ChatImageAttachment {
guard !rawBytes.isEmpty else { throw EncoderError.empty }
#if canImport(AppKit)
guard let nsImage = NSImage(data: rawBytes) else { throw EncoderError.decodeFailed }
let targetSize = Self.fittedSize(for: nsImage.size, maxLongEdge: Self.maxLongEdge)
let mainData = try Self.jpegBytes(from: nsImage, size: targetSize)
let thumbSize = Self.fittedSize(for: nsImage.size, maxLongEdge: Self.thumbnailLongEdge)
let thumbData = try? Self.jpegBytes(from: nsImage, size: thumbSize)
return ChatImageAttachment(
mimeType: "image/jpeg",
base64Data: mainData.base64EncodedString(),
thumbnailBase64: thumbData?.base64EncodedString(),
filename: sourceFilename,
approximateByteCount: mainData.count
)
#elseif canImport(UIKit)
guard let uiImage = UIImage(data: rawBytes) else { throw EncoderError.decodeFailed }
let targetSize = Self.fittedSize(for: uiImage.size, maxLongEdge: Self.maxLongEdge)
let mainData = try Self.jpegBytes(from: uiImage, size: targetSize)
let thumbSize = Self.fittedSize(for: uiImage.size, maxLongEdge: Self.thumbnailLongEdge)
let thumbData = try? Self.jpegBytes(from: uiImage, size: thumbSize)
return ChatImageAttachment(
mimeType: "image/jpeg",
base64Data: mainData.base64EncodedString(),
thumbnailBase64: thumbData?.base64EncodedString(),
filename: sourceFilename,
approximateByteCount: mainData.count
)
#else
// Linux CI / unknown platforms: pass through raw bytes if the
// input already looks like a JPEG, else refuse. Keeps the
// package compiling without a hard AppKit/UIKit dep.
if rawBytes.starts(with: [0xFF, 0xD8]) {
return ChatImageAttachment(
mimeType: "image/jpeg",
base64Data: rawBytes.base64EncodedString(),
thumbnailBase64: nil,
filename: sourceFilename,
approximateByteCount: rawBytes.count
)
}
throw EncoderError.unsupportedFormat
#endif
}
nonisolated private static func fittedSize(for source: CGSize, maxLongEdge: CGFloat) -> CGSize {
let longest = max(source.width, source.height)
if longest <= maxLongEdge { return source }
let scale = maxLongEdge / longest
return CGSize(
width: floor(source.width * scale),
height: floor(source.height * scale)
)
}
#if canImport(AppKit)
nonisolated private static func jpegBytes(from image: NSImage, size: CGSize) throws -> Data {
let resized = NSImage(size: size)
resized.lockFocus()
NSGraphicsContext.current?.imageInterpolation = .high
image.draw(
in: CGRect(origin: .zero, size: size),
from: .zero,
operation: .copy,
fraction: 1.0
)
resized.unlockFocus()
guard let tiff = resized.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff),
let data = rep.representation(
using: .jpeg,
properties: [.compressionFactor: jpegQuality]
)
else {
throw EncoderError.encodeFailed
}
return data
}
#elseif canImport(UIKit)
nonisolated private static func jpegBytes(from image: UIImage, size: CGSize) throws -> Data {
let format = UIGraphicsImageRendererFormat()
format.scale = 1
format.opaque = true
let renderer = UIGraphicsImageRenderer(size: size, format: format)
let resized = renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: size))
}
guard let data = resized.jpegData(compressionQuality: jpegQuality) else {
throw EncoderError.encodeFailed
}
return data
}
#endif
}
@@ -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? {
@@ -401,15 +425,17 @@ public struct ModelCatalogService: Sendable {
// MARK: - Hermes overlay providers
/// The six providers Hermes surfaces via `hermes model` that have no
/// The 11 providers Hermes surfaces via `hermes model` that have no
/// entry in `models_dev_cache.json` (models.dev doesn't mirror them).
/// Mirrors the overlay-only subset of `HERMES_OVERLAYS` in
/// `hermes-agent/hermes_cli/providers.py`. The other ~19 overlay entries
/// `hermes-agent/hermes_cli/providers.py`. The other overlay entries
/// already ship in the cache and only add augmentation (base-URL
/// override, extra env vars) that Scarf doesn't currently display.
///
/// Keep this in sync with the Python side on Hermes version bumps.
static let overlayOnlyProviders: [String: HermesProviderOverlay] = [
/// Keep this in sync with the Python side on Hermes version bumps
/// see `ToolGatewayTests.v012OverlayProvidersCarryCorrectAuthTypes`
/// for the auth-type lock-in.
public static let overlayOnlyProviders: [String: HermesProviderOverlay] = [
"nous": HermesProviderOverlay(
displayName: "Nous Portal",
baseURL: "https://inference-api.nousresearch.com/v1",
@@ -452,6 +478,53 @@ public struct ModelCatalogService: Sendable {
subscriptionGated: false,
docURL: nil
),
// -- v0.12 additions ---------------------------------------------
// Hermes v2026.4.30 added five overlay-only providers that
// models.dev doesn't mirror. Provider IDs match HERMES_OVERLAYS
// verbatim drift here means the picker can't reach them.
"gmi": HermesProviderOverlay(
displayName: "GMI Cloud",
baseURL: "https://api.gmi-serving.com/v1",
authType: .apiKey,
subscriptionGated: false,
docURL: nil
),
"azure-foundry": HermesProviderOverlay(
displayName: "Azure AI Foundry",
// Base URL is per-tenant Hermes resolves it from the
// AZURE_FOUNDRY_BASE_URL env var at runtime. Leave nil so the
// settings UI shows "Tenant URL set via env" instead of a
// misleading default.
baseURL: nil,
authType: .apiKey,
subscriptionGated: false,
docURL: nil
),
"lmstudio": HermesProviderOverlay(
displayName: "LM Studio",
// v0.12 promotes LM Studio from custom-endpoint alias to a
// first-class provider. 1234 is the LM Studio default port;
// users with a non-default port set LM_BASE_URL.
baseURL: "http://127.0.0.1:1234/v1",
authType: .apiKey,
subscriptionGated: false,
docURL: nil
),
"minimax-oauth": HermesProviderOverlay(
displayName: "MiniMax (OAuth)",
baseURL: "https://api.minimax.io/anthropic",
authType: .oauthExternal,
subscriptionGated: false,
docURL: nil
),
"tencent-tokenhub": HermesProviderOverlay(
displayName: "Tencent TokenHub",
// Resolved from TOKENHUB_BASE_URL at runtime.
baseURL: nil,
authType: .apiKey,
subscriptionGated: false,
docURL: nil
),
]
}
@@ -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,155 @@
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 one-liner that consolidates a project shadow into
/// the global Hermes home AND clears the warning on the next
/// refresh. Two ordered steps:
///
/// 1. Copy `auth.json` into the global home (only when present).
/// Hermes credentials live in this single file; preserving them
/// is the load-bearing part of "consolidate" every other
/// project-local file is either replaceable or scoped to the
/// project anyway.
/// 2. Rename the project-local `.hermes/` to
/// `.hermes.scarf-bak.<UTC-stamp>/`. Hermes' CLI stops seeing it
/// as `$HERMES_HOME` (it scans for a dir literally named
/// `.hermes`), so the global home wins from now on. The
/// user's project-local data `state.db`, `sessions/`,
/// `skills/` survives untouched in the renamed folder, so
/// they can inspect/recover/delete it later without us making
/// that decision for them.
///
/// **Why not delete instead of rename.** A project's shadow can
/// hold uncommitted session history the user hasn't audited yet.
/// `rm -rf` would be unrecoverable; the rename keeps everything
/// addressable while still removing the shadow effect. The user
/// can delete the `.bak` once they're confident.
///
/// Returns a single shell line, suitable for the user to paste
/// into a remote terminal. The rename uses `date -u +%Y%m%d-%H%M%S`
/// for a deterministic UTC suffix so two consecutive consolidations
/// don't collide on the same second.
public static func consolidationCommand(for shadow: Shadow, hermesHome: String) -> String? {
var parts: [String] = []
if shadow.hasAuthJSON {
parts.append("mkdir -p \(shellQuote(hermesHome))")
parts.append("cp \(shellQuote(shadow.shadowPath + "/auth.json")) \(shellQuote(hermesHome + "/auth.json"))")
parts.append("chmod 600 \(shellQuote(hermesHome + "/auth.json"))")
}
// The rename is unconditional: even shadows without auth.json
// still bind as $HERMES_HOME and need to move out of the way.
// `$(date -u +%Y%m%d-%H%M%S)` runs on the remote shell when
// the user pastes the command, producing the timestamp at
// exec time rather than at command-construction time.
parts.append("mv \(shellQuote(shadow.shadowPath)) \(shellQuote(shadow.shadowPath))\".scarf-bak.$(date -u +%Y%m%d-%H%M%S)\"")
return parts.joined(separator: " && ")
}
/// Single-quote a path for embedding in a `bash -c ''` string.
/// POSIX-safe single quotes with escape for embedded quotes
/// (`'` `'\\''`). Matches the convention in
/// `RemoteBackupService.shellQuote`.
private static func shellQuote(_ s: String) -> String {
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
}
@@ -0,0 +1,539 @@
import Foundation
import CryptoKit
#if canImport(os)
import os
#endif
/// Streams a Hermes home + project trees off a (local or remote) server
/// into a single `.scarfbackup` archive on disk.
///
/// **Why not just run `hermes backup`.** Hermes's CLI captures `~/.hermes/`
/// only; project file trees (the user's actual code) live outside that
/// home and aren't included. A "rebuild this droplet from scratch" flow
/// needs both. This service does both Hermes home as one inner tarball,
/// each registered project as its own and writes a manifest pinning the
/// source server, hermes version, and per-tarball SHA-256s so restore can
/// detect corruption before it half-extracts.
///
/// **Memory profile.** Tarballs stream over SSH (`tar -czf -`) and into
/// disk-backed temp files chunk-by-chunk via `streamRawBytes`. We never
/// hold a multi-GB buffer in RAM. The final ZIP step shells out to
/// `/usr/bin/zip`, which also streams from disk.
///
/// **Cleanup.** The temp dir lives under
/// `FileManager.default.temporaryDirectory` and is removed on every exit
/// path (success, failure, cancellation) via `defer`.
public final class RemoteBackupService: @unchecked Sendable {
#if canImport(os)
private static let logger = Logger(subsystem: "com.scarf", category: "RemoteBackupService")
#endif
public let context: ServerContext
public init(context: ServerContext) {
self.context = context
}
/// Coarse stages the UI binds to. The service publishes one of these
/// per meaningful state change so a progress sheet can render
/// "Archiving Hermes home 412 MB so far" without polling.
public enum Progress: Sendable, Equatable {
case preflight
case checkpointingDB
case archivingHermes(bytesWritten: Int64)
case archivingProject(name: String, bytesWritten: Int64)
case bundling
case finalizing
}
public enum BackupError: Error, LocalizedError {
case preflightFailed(String)
case remoteCommandFailed(String)
case localIO(String)
case zipFailed(String)
case cancelled
public var errorDescription: String? {
switch self {
case .preflightFailed(let m): return "Backup preflight failed: \(m)"
case .remoteCommandFailed(let m): return "Remote command failed during backup: \(m)"
case .localIO(let m): return "Local file I/O failed during backup: \(m)"
case .zipFailed(let m): return "Couldn't assemble the backup archive: \(m)"
case .cancelled: return "Backup cancelled."
}
}
}
/// What the UI displays before any archiving starts. Populated by
/// `preflight()` so the user can see (and confirm) total size +
/// project count + hermes version before committing 4 minutes of
/// SSH traffic.
public struct PreflightSummary: Sendable, Equatable {
public var hermesVersion: String?
public var hermesHomePath: String
public var hermesHomeBytes: Int64?
public var projects: [ProjectSummary]
public var sqliteAvailable: Bool
public struct ProjectSummary: Sendable, Equatable {
public var id: String
public var name: String
public var path: String
public var sizeBytes: Int64?
public var reachable: Bool
}
public var totalSizeBytes: Int64? {
let parts: [Int64] = [hermesHomeBytes ?? 0] + projects.compactMap { $0.sizeBytes }
let sum = parts.reduce(0, +)
return sum > 0 ? sum : nil
}
}
public struct BackupResult: Sendable {
public var manifest: BackupManifest
public var archiveURL: URL
public var archiveSize: Int64
}
/// Probe the remote (or local) before committing to the full
/// archive. Cheap three short SSH calls and one file read. Safe
/// to call repeatedly; nothing is mutated on the source side.
public func preflight() async throws -> PreflightSummary {
let transport = context.makeTransport()
// 1. Resolve $HOME so the absolute paths in the manifest are
// canonical (e.g. `/home/alan/.hermes`, not the
// `~`-prefixed `HermesPathSet.home`).
let homeResult = try transport.runProcess(
executable: "/bin/bash",
args: ["-lc", "echo \"$HOME\""],
stdin: nil,
timeout: 30
)
guard homeResult.exitCode == 0 else {
throw BackupError.preflightFailed("Couldn't resolve remote $HOME (exit \(homeResult.exitCode)): \(homeResult.stderrString)")
}
let resolvedHome = homeResult.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)
// 2. Hermes version. Optional older builds may not implement
// `--version`. Empty/missing isn't fatal; the manifest just
// won't carry a version stamp.
let versionResult = try? transport.runProcess(
executable: "/bin/bash",
args: ["-lc", "hermes --version 2>/dev/null || true"],
stdin: nil,
timeout: 30
)
let hermesVersion: String? = {
guard let r = versionResult, r.exitCode == 0 else { return nil }
let trimmed = r.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}()
// 3. Hermes home size + canonical path. `context.paths.home`
// can be `~/.hermes` for remotes that didn't pin
// `SSHConfig.remoteHome`; tar doesn't expand `~`, so we
// resolve every path against the just-fetched $HOME
// BEFORE storing it in the summary. `tar -C '~'` would
// fail with "No such file or directory" otherwise (and
// `du -sb '~/.hermes' 2>/dev/null` swallows the same
// error silently that's why preflight looked green).
let hermesHome = Self.expandTilde(context.paths.home, home: resolvedHome)
let hermesSize = Self.estimateBytes(transport: transport, path: hermesHome)
// 4. Enumerate projects via the existing transport-aware
// service. Empty registry empty list, not an error.
// Same tilde expansion as above so project paths stored
// in `~/.hermes/scarf/projects.json` with `~/projects/foo`
// don't blow up later in `tar -C`.
let registry = ProjectDashboardService(context: context).loadRegistry()
var projectSummaries: [PreflightSummary.ProjectSummary] = []
for project in registry.projects where !project.archived {
let expanded = Self.expandTilde(project.path, home: resolvedHome)
let reachable = transport.fileExists(expanded)
let bytes = reachable ? Self.estimateBytes(transport: transport, path: expanded) : nil
projectSummaries.append(PreflightSummary.ProjectSummary(
id: project.path, // path is the registry's stable handle
name: project.name,
path: expanded,
sizeBytes: bytes,
reachable: reachable
))
}
// 5. Is `sqlite3` on PATH? Drives the WAL-checkpoint toggle.
// Missing we still archive, just without quiescing.
let sqliteCheck = try? transport.runProcess(
executable: "/bin/bash",
args: ["-lc", "command -v sqlite3 >/dev/null 2>&1 && echo yes || echo no"],
stdin: nil,
timeout: 30
)
let sqliteAvailable = sqliteCheck?.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) == "yes"
return PreflightSummary(
hermesVersion: hermesVersion,
hermesHomePath: hermesHome,
hermesHomeBytes: hermesSize,
projects: projectSummaries,
sqliteAvailable: sqliteAvailable
)
}
/// Replace a leading `~` or `~/` with the resolved remote home.
/// Tar (and most non-shell tools) don't expand tildes only the
/// shell does, and we deliberately single-quote paths in the
/// command string for whitespace-safety, which then suppresses
/// shell expansion. So we expand here, in Swift, with a
/// known-good `$HOME` value.
static func expandTilde(_ path: String, home: String) -> String {
guard !home.isEmpty else { return path }
if path == "~" { return home }
if path.hasPrefix("~/") { return home + String(path.dropFirst(1)) }
return path
}
/// Run the full backup: stream Hermes home + each project tarball,
/// build the manifest, ZIP everything into `archiveURL`. Caller
/// holds the `Task` and can cancel; cooperative checks fire between
/// stages.
public func run(
preflight: PreflightSummary,
options: BackupManifest.Options,
archiveURL: URL,
progress: @Sendable @escaping (Progress) -> Void
) async throws -> BackupResult {
let transport = context.makeTransport()
let workDir = FileManager.default.temporaryDirectory
.appendingPathComponent("scarf-backup-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: workDir) }
try Task.checkCancellation()
progress(.preflight)
// Stage 1: WAL checkpoint (best effort). Build the state.db
// path from the already-expanded hermesHomePath rather than
// `context.paths.stateDB`, which can still carry a literal
// `~` for remotes that didn't pin `remoteHome` sqlite3
// would fail to open the file and leave the WAL un-flushed.
var checkpointed = false
if options.checkpointedWAL && preflight.sqliteAvailable {
progress(.checkpointingDB)
let stateDB = preflight.hermesHomePath + "/state.db"
let cmd = "sqlite3 \(Self.shellQuote(stateDB)) 'PRAGMA wal_checkpoint(TRUNCATE);' || true"
let result = try? transport.runProcess(
executable: "/bin/bash",
args: ["-lc", cmd],
stdin: nil,
timeout: 60
)
checkpointed = (result?.exitCode == 0)
}
// Stage 2: Hermes home tarball.
try Task.checkCancellation()
let hermesTarball = workDir.appendingPathComponent("hermes.tar.gz")
let hermesExcludes = Self.hermesExcludes(options: options)
let hermesTarCmd = Self.tarCommand(
workDir: preflight.hermesHomePath.deletingLastPathComponent_String(),
target: ".hermes",
excludes: hermesExcludes
)
let hermesHash = try await streamToFile(
transport: transport,
command: hermesTarCmd,
destination: hermesTarball
) { written in
progress(.archivingHermes(bytesWritten: written))
}
let hermesSize = (try? FileManager.default.attributesOfItem(atPath: hermesTarball.path)[.size] as? Int64) ?? 0
// Stage 3: per-project tarballs.
let projectsDir = workDir.appendingPathComponent("projects", isDirectory: true)
try FileManager.default.createDirectory(at: projectsDir, withIntermediateDirectories: true)
var projectEntries: [BackupManifest.ProjectEntry] = []
for summary in preflight.projects where summary.reachable {
try Task.checkCancellation()
let projID = Self.stableID(forPath: summary.path)
let outerName = "\(projID).tar.gz"
let dest = projectsDir.appendingPathComponent(outerName)
let parent = (summary.path as NSString).deletingLastPathComponent
let leaf = (summary.path as NSString).lastPathComponent
let cmd = Self.tarCommand(
workDir: parent,
target: leaf,
excludes: Self.projectExcludes()
)
let hash = try await streamToFile(
transport: transport,
command: cmd,
destination: dest
) { written in
progress(.archivingProject(name: summary.name, bytesWritten: written))
}
let size = (try? FileManager.default.attributesOfItem(atPath: dest.path)[.size] as? Int64) ?? 0
projectEntries.append(BackupManifest.ProjectEntry(
id: projID,
name: summary.name,
path: summary.path,
tarballPath: BackupArchiveLayout.projectTarballPath(for: projID),
tarballSize: size,
tarballSHA256: hash
))
}
// Stage 4: build manifest, write to workDir.
try Task.checkCancellation()
let manifest = BackupManifest(
createdAt: ISO8601DateFormatter().string(from: Date()),
source: BackupManifest.Source(
serverID: context.id.uuidString,
displayName: context.displayName,
host: Self.host(for: context),
user: Self.user(for: context),
hermesVersion: preflight.hermesVersion
),
hermes: BackupManifest.HermesTree(
homePath: preflight.hermesHomePath,
tarballPath: BackupArchiveLayout.hermesTarballPath,
tarballSize: hermesSize,
tarballSHA256: hermesHash
),
projects: projectEntries,
options: BackupManifest.Options(
includeAuth: options.includeAuth,
includeMcpTokens: options.includeMcpTokens,
includeLogs: options.includeLogs,
checkpointedWAL: checkpointed
)
)
let manifestData: Data
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
manifestData = try encoder.encode(manifest)
} catch {
throw BackupError.localIO("Couldn't encode manifest: \(error.localizedDescription)")
}
let manifestURL = workDir.appendingPathComponent(BackupArchiveLayout.manifestPath)
do {
try manifestData.write(to: manifestURL, options: .atomic)
} catch {
throw BackupError.localIO("Couldn't write manifest: \(error.localizedDescription)")
}
// Stage 5: ZIP everything in workDir into the user-chosen
// destination. Atomic via temp file + rename so a half-written
// archive isn't visible.
try Task.checkCancellation()
progress(.bundling)
let tempArchive = archiveURL.deletingLastPathComponent()
.appendingPathComponent(".\(archiveURL.lastPathComponent).inflight-\(UUID().uuidString).zip")
try Self.zipDirectory(workDir: workDir, into: tempArchive)
progress(.finalizing)
do {
if FileManager.default.fileExists(atPath: archiveURL.path) {
try FileManager.default.removeItem(at: archiveURL)
}
try FileManager.default.moveItem(at: tempArchive, to: archiveURL)
} catch {
try? FileManager.default.removeItem(at: tempArchive)
throw BackupError.localIO("Couldn't move archive into place: \(error.localizedDescription)")
}
let archiveSize = (try? FileManager.default.attributesOfItem(atPath: archiveURL.path)[.size] as? Int64) ?? 0
return BackupResult(
manifest: manifest,
archiveURL: archiveURL,
archiveSize: archiveSize
)
}
// MARK: - Streaming
/// Spawn a remote (or local) `bash -lc <cmd>` and pump its stdout
/// into `destination`, computing SHA-256 incrementally as bytes
/// arrive. Returns the hex digest. The process gets a fresh
/// `bash -lc` shell on each invocation same login-shell story
/// as `streamRawBytes` so PATH picks up pipx installs etc.
private func streamToFile(
transport: any ServerTransport,
command: String,
destination: URL,
onProgress: @Sendable @escaping (Int64) -> Void
) async throws -> String {
FileManager.default.createFile(atPath: destination.path, contents: nil)
guard let fh = try? FileHandle(forWritingTo: destination) else {
throw BackupError.localIO("Couldn't open \(destination.lastPathComponent) for writing")
}
defer { try? fh.close() }
var hasher = SHA256()
var written: Int64 = 0
let stream = transport.streamRawBytes(
executable: "/bin/bash",
args: ["-lc", command]
)
do {
for try await chunk in stream {
try Task.checkCancellation()
try fh.write(contentsOf: chunk)
hasher.update(data: chunk)
written += Int64(chunk.count)
onProgress(written)
}
} catch is CancellationError {
throw BackupError.cancelled
} catch let err as TransportError {
throw BackupError.remoteCommandFailed(err.localizedDescription)
} catch {
throw BackupError.remoteCommandFailed(error.localizedDescription)
}
let digest = hasher.finalize()
return digest.map { String(format: "%02x", $0) }.joined()
}
// MARK: - Tar / shell helpers
private static func tarCommand(workDir: String, target: String, excludes: [String]) -> String {
var parts: [String] = ["tar -czf -"]
for ex in excludes {
parts.append("--exclude=\(shellQuote(ex))")
}
parts.append("-C \(shellQuote(workDir))")
parts.append(shellQuote(target))
return parts.joined(separator: " ")
}
/// Always-on Hermes-tree exclusions, regardless of options:
/// SQLite WAL siblings (would carry mid-flight writes) and runtime
/// state files (`gateway_state.json`).
private static func hermesExcludes(options: BackupManifest.Options) -> [String] {
var excludes: [String] = [
".hermes/state.db-wal",
".hermes/state.db-shm",
".hermes/gateway_state.json",
]
if !options.includeAuth { excludes.append(".hermes/auth.json") }
if !options.includeMcpTokens { excludes.append(".hermes/mcp-tokens") }
if !options.includeLogs { excludes.append(".hermes/logs") }
return excludes
}
/// Default project-tree exclusions: things that don't restore well
/// (compiled object stores, virtualenvs that hard-code absolute
/// paths, system-specific build outputs). Users can opt in via
/// the future "include build artefacts" toggle in the Backup
/// sheet for now we always exclude these.
private static func projectExcludes() -> [String] {
[
"*/node_modules",
"*/.venv",
"*/venv",
"*/__pycache__",
"*/.git/objects",
"*/.next",
"*/dist",
"*/.DS_Store",
]
}
/// Single-quote a path / argument for embedding in a `bash -lc`
/// string. Uses POSIX-safe single quotes with escape for embedded
/// quotes (`'` `'\''`).
private static func shellQuote(_ s: String) -> String {
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
/// Convenience: same idea as ServerContext.host, but tolerates the
/// local case (no host) by returning `"localhost"`.
private static func host(for context: ServerContext) -> String {
if case .ssh(let cfg) = context.kind {
return cfg.host
}
return "localhost"
}
private static func user(for context: ServerContext) -> String? {
if case .ssh(let cfg) = context.kind {
return cfg.user
}
return nil
}
/// `du -sb` (GNU) is the most portable way to get raw bytes
/// on macOS `du -sk` returns kilobytes. Returns nil if neither
/// works.
private static func estimateBytes(transport: any ServerTransport, path: String) -> Int64? {
let cmd = "du -sb \(shellQuote(path)) 2>/dev/null | awk '{print $1}'"
guard let r = try? transport.runProcess(
executable: "/bin/bash",
args: ["-lc", cmd],
stdin: nil,
timeout: 60
), r.exitCode == 0 else { return nil }
let s = r.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)
return Int64(s)
}
/// Stable ID for a project. The project registry tracks projects
/// by absolute path, but paths can differ between source and
/// target (different `$HOME`). We hash the path to get a stable
/// 16-hex-char identifier that's safe to use as a tarball
/// filename. Collisions are vanishingly unlikely a Mac's path
/// space is small and SHA-256 truncated to 64 bits has good
/// properties for non-adversarial input.
private static func stableID(forPath path: String) -> String {
let digest = SHA256.hash(data: Data(path.utf8))
let bytes = digest.map { String(format: "%02x", $0) }.joined()
return String(bytes.prefix(16))
}
/// Shell out to `/usr/bin/zip` to assemble the outer archive.
/// macOS ships `zip` at this fixed path so we don't need a PATH
/// search. `-r` recurse, `-q` quiet, `-X` strip extended attrs
/// for reproducibility.
///
/// Mac-only: iOS doesn't ship `/usr/bin/zip` and Foundation's `Process`
/// is unavailable in the iOS SDK. The whole backup flow is a Mac-side
/// operation; the iOS stub throws so any accidental call surfaces a
/// clear message instead of an opaque link error.
private static func zipDirectory(workDir: URL, into archive: URL) throws {
#if os(iOS)
throw BackupError.zipFailed("Backup zip is not supported on iOS — run the backup from the Mac app.")
#else
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
proc.currentDirectoryURL = workDir
proc.arguments = ["-rqX", archive.path, "."]
let errPipe = Pipe()
proc.standardError = errPipe
proc.standardOutput = Pipe()
do {
try proc.run()
} catch {
throw BackupError.zipFailed("Couldn't launch zip: \(error.localizedDescription)")
}
proc.waitUntilExit()
if proc.terminationStatus != 0 {
let tail = (try? errPipe.fileHandleForReading.readToEnd())
.flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? ""
throw BackupError.zipFailed("zip exited \(proc.terminationStatus): \(tail)")
}
#endif
}
}
// MARK: - Path helpers
private extension String {
/// `(somePath as NSString).deletingLastPathComponent` lifted to a
/// String extension. Used during preflight to derive the
/// remote `$HOME` from `$HOME/.hermes`.
func deletingLastPathComponent_String() -> String {
(self as NSString).deletingLastPathComponent
}
}
@@ -0,0 +1,501 @@
import Foundation
import CryptoKit
#if canImport(os)
import os
#endif
/// Reverses a `.scarfbackup` archive into a target server: validates,
/// streams tarballs into place over SSH, and re-anchors path-bearing
/// JSON sidecars so the restored Hermes home references the new layout.
///
/// **Validation gates.** No bytes are written to the target until the
/// manifest's `kind` magic + `schemaVersion` match, and every inner
/// tarball's SHA-256 matches what the manifest claims. A corrupt
/// archive surfaces a single named-path error instead of a half-extracted
/// home.
///
/// **Path re-anchoring.** Project absolute paths in
/// `~/.hermes/scarf/projects.json` reference the source server's home
/// (e.g. `/root/projects/foo`). After extraction the project lives at
/// `<targetProjectsRoot>/foo`, so the restore rewrites `path` for each
/// entry. Same logic for `<project>/.scarf/manifest.json` if it carries
/// self-references.
///
/// **Cron paused on restore.** Every job in `cron/jobs.json` is flipped
/// to `enabled = false` after restore. Restored cron jobs may carry
/// stale credentials (Slack tokens, webhooks) or run on schedules the
/// user no longer wants auto-running them on a fresh droplet is
/// surprising. The user re-enables what they want from the Cron view.
public final class RemoteRestoreService: @unchecked Sendable {
#if canImport(os)
private static let logger = Logger(subsystem: "com.scarf", category: "RemoteRestoreService")
#endif
public let context: ServerContext
public init(context: ServerContext) {
self.context = context
}
public enum Progress: Sendable, Equatable {
case validating
case verifyingHashes
case planning
case restoringHermes(bytesPushed: Int64)
case restoringProject(name: String, bytesPushed: Int64)
case reanchoringPaths
case pausingCron
case finalizing
}
public enum RestoreError: Error, LocalizedError {
case archiveUnreadable(String)
case unsupportedSchema(Int)
case wrongKind(String)
case integrityCheckFailed(path: String, expected: String, actual: String)
case remoteCommandFailed(String)
case localIO(String)
case cancelled
public var errorDescription: String? {
switch self {
case .archiveUnreadable(let m): return "Couldn't read the backup archive: \(m)"
case .unsupportedSchema(let v): return "Backup uses schema v\(v), which this version of Scarf doesn't recognize."
case .wrongKind(let k): return "This file isn't a Scarf server backup (kind: \(k))."
case .integrityCheckFailed(let p, let exp, let act): return "Backup is corrupt — \(p) hash mismatch (expected \(exp.prefix(12))…, got \(act.prefix(12))…)."
case .remoteCommandFailed(let m): return "Remote command failed during restore: \(m)"
case .localIO(let m): return "Local file I/O failed during restore: \(m)"
case .cancelled: return "Restore cancelled."
}
}
}
/// What `inspect()` returns to drive the restore-plan sheet. The
/// caller picks `targetProjectsRoot`, optionally tweaks the cron
/// pause toggle, then calls `run()` with the same archive URL.
public struct InspectionResult: Sendable {
public var manifest: BackupManifest
public var workDir: URL // unzipped temp dir; reused by run()
public var targetHomeResolved: String?
public var targetHermesVersion: String?
}
public struct RestoreOptions: Sendable {
/// Where to drop project tarballs. Each project lands at
/// `<targetProjectsRoot>/<basename>`. Defaults to
/// `<targetHome>/projects` when not specified.
public var targetProjectsRoot: String?
/// Override the resolved target home (rarely needed; the
/// default is whatever `bash -lc 'echo $HOME'` returned).
public var targetHomeOverride: String?
/// Pause every cron job after restore. Strongly recommended
/// (the user re-enables intentionally).
public var pauseCronJobs: Bool
public init(
targetProjectsRoot: String? = nil,
targetHomeOverride: String? = nil,
pauseCronJobs: Bool = true
) {
self.targetProjectsRoot = targetProjectsRoot
self.targetHomeOverride = targetHomeOverride
self.pauseCronJobs = pauseCronJobs
}
}
public struct RestoreResult: Sendable {
public var manifest: BackupManifest
public var hermesHome: String
public var projectsRestored: [RestoredProject]
public var cronJobsPaused: Int
public struct RestoredProject: Sendable {
public var name: String
public var sourcePath: String
public var targetPath: String
}
}
/// Unzip + manifest-validate + hash-verify in a temp dir. Cheap
/// enough to call from a sheet's appearance handler so the user
/// sees a populated preview before committing.
public func inspect(archiveURL: URL) async throws -> InspectionResult {
let workDir = FileManager.default.temporaryDirectory
.appendingPathComponent("scarf-restore-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
// Unzip outer archive.
try Self.unzipArchive(at: archiveURL, into: workDir)
// Decode + validate manifest.
let manifestURL = workDir.appendingPathComponent(BackupArchiveLayout.manifestPath)
guard let data = try? Data(contentsOf: manifestURL) else {
throw RestoreError.archiveUnreadable("missing manifest.json")
}
let manifest: BackupManifest
do {
manifest = try JSONDecoder().decode(BackupManifest.self, from: data)
} catch {
throw RestoreError.archiveUnreadable("manifest.json malformed: \(error.localizedDescription)")
}
guard manifest.kind == BackupManifest.kindMagic else {
throw RestoreError.wrongKind(manifest.kind)
}
guard manifest.schemaVersion == BackupManifest.currentSchemaVersion else {
throw RestoreError.unsupportedSchema(manifest.schemaVersion)
}
// Hash-verify every inner tarball before any remote bytes are
// pushed.
try await Self.verifyHash(file: workDir.appendingPathComponent(manifest.hermes.tarballPath), expected: manifest.hermes.tarballSHA256)
for project in manifest.projects {
try await Self.verifyHash(file: workDir.appendingPathComponent(project.tarballPath), expected: project.tarballSHA256)
}
// Probe the target for $HOME + hermes version. Doesn't fail
// restore if the probe times out the user can still pick
// an override.
let transport = context.makeTransport()
let homeProbe = try? transport.runProcess(
executable: "/bin/bash",
args: ["-lc", "echo \"$HOME\""],
stdin: nil,
timeout: 30
)
let resolvedHome = homeProbe?.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)
let versionProbe = try? transport.runProcess(
executable: "/bin/bash",
args: ["-lc", "hermes --version 2>/dev/null || true"],
stdin: nil,
timeout: 30
)
let resolvedVersion = versionProbe?.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)
return InspectionResult(
manifest: manifest,
workDir: workDir,
targetHomeResolved: (resolvedHome?.isEmpty == false) ? resolvedHome : nil,
targetHermesVersion: (resolvedVersion?.isEmpty == false) ? resolvedVersion : nil
)
}
/// Run the restore. Pushes tarballs, re-anchors paths, optionally
/// pauses cron. Caller owns the `workDir` URL from `inspect()` and
/// is responsible for cleanup if `run` throws on success this
/// method removes the temp dir.
public func run(
inspection: InspectionResult,
options: RestoreOptions,
progress: @Sendable @escaping (Progress) -> Void
) async throws -> RestoreResult {
defer { try? FileManager.default.removeItem(at: inspection.workDir) }
let transport = context.makeTransport()
let manifest = inspection.manifest
try Task.checkCancellation()
progress(.planning)
let targetHome = options.targetHomeOverride
?? inspection.targetHomeResolved
?? (manifest.hermes.homePath as NSString).deletingLastPathComponent
let projectsRoot = options.targetProjectsRoot ?? (targetHome + "/projects")
// Make sure the projects root exists so `tar -xzf` doesn't
// fail on a missing -C target.
let mkdirCmd = "mkdir -p \(Self.shellQuote(projectsRoot))"
let mkdirResult = try? transport.runProcess(
executable: "/bin/bash",
args: ["-lc", mkdirCmd],
stdin: nil,
timeout: 30
)
if let r = mkdirResult, r.exitCode != 0 {
throw RestoreError.remoteCommandFailed("mkdir \(projectsRoot) failed: \(r.stderrString)")
}
// Stage 1: hermes home. Pushes into $HOME so the inner
// `.hermes/...` paths land at `<targetHome>/.hermes/...`.
try Task.checkCancellation()
let hermesTar = inspection.workDir.appendingPathComponent(manifest.hermes.tarballPath)
try await pushTarball(
transport: transport,
tarball: hermesTar,
extractInto: targetHome
) { written in
progress(.restoringHermes(bytesPushed: written))
}
// Stage 2: per-project tarballs.
var restoredProjects: [RestoreResult.RestoredProject] = []
for project in manifest.projects {
try Task.checkCancellation()
let tar = inspection.workDir.appendingPathComponent(project.tarballPath)
try await pushTarball(
transport: transport,
tarball: tar,
extractInto: projectsRoot
) { written in
progress(.restoringProject(name: project.name, bytesPushed: written))
}
let basename = (project.path as NSString).lastPathComponent
restoredProjects.append(RestoreResult.RestoredProject(
name: project.name,
sourcePath: project.path,
targetPath: projectsRoot + "/" + basename
))
}
// Stage 3: re-anchor `~/.hermes/scarf/projects.json` so the
// restored Hermes references the new project paths instead
// of the source droplet's paths.
try Task.checkCancellation()
progress(.reanchoringPaths)
try await reanchorProjectsRegistry(
transport: transport,
targetHome: targetHome,
mapping: Dictionary(
uniqueKeysWithValues: restoredProjects.map { ($0.sourcePath, $0.targetPath) }
)
)
// Stage 4: pause cron jobs.
var paused = 0
if options.pauseCronJobs {
try Task.checkCancellation()
progress(.pausingCron)
paused = try await pauseAllCronJobs(transport: transport, targetHome: targetHome)
}
progress(.finalizing)
return RestoreResult(
manifest: manifest,
hermesHome: targetHome + "/.hermes",
projectsRestored: restoredProjects,
cronJobsPaused: paused
)
}
// MARK: - Push (tarball -> remote stdin)
/// Stream a local `.tar.gz` into `tar -xzf - -C <target>` on the
/// destination. We use `transport.makeProcess` so the command is
/// shell-wrapped the same way the rest of the app talks to remotes
/// (`bash -lc` for SSH, direct invocation for local).
private func pushTarball(
transport: any ServerTransport,
tarball: URL,
extractInto target: String,
onProgress: @Sendable @escaping (Int64) -> Void
) async throws {
#if os(iOS)
throw RestoreError.remoteCommandFailed("Remote restore is not supported on iOS in this build.")
#else
let cmd = "tar -xzf - -C \(Self.shellQuote(target))"
let proc = transport.makeProcess(executable: "/bin/bash", args: ["-lc", cmd])
// standardInput: read end of an OS pipe whose write end we
// pump from the local tarball file. Going through a pipe (vs
// setting standardInput to a FileHandle directly) gives us
// cooperative chunk-by-chunk control + cancellation.
let inPipe = Pipe()
let outPipe = Pipe()
let errPipe = Pipe()
proc.standardInput = inPipe
proc.standardOutput = outPipe
proc.standardError = errPipe
do {
try proc.run()
} catch {
throw RestoreError.remoteCommandFailed("Couldn't start remote tar: \(error.localizedDescription)")
}
let writer = inPipe.fileHandleForWriting
let reader: FileHandle
do {
reader = try FileHandle(forReadingFrom: tarball)
} catch {
try? writer.close()
proc.terminate()
throw RestoreError.localIO("Couldn't open tarball: \(error.localizedDescription)")
}
defer { try? reader.close() }
var written: Int64 = 0
let chunkSize = 64 * 1024
do {
while true {
try Task.checkCancellation()
let chunk = reader.readData(ofLength: chunkSize)
if chunk.isEmpty { break }
try writer.write(contentsOf: chunk)
written += Int64(chunk.count)
onProgress(written)
}
} catch is CancellationError {
try? writer.close()
proc.terminate()
throw RestoreError.cancelled
} catch {
try? writer.close()
proc.terminate()
throw RestoreError.localIO("Couldn't pump tarball into remote: \(error.localizedDescription)")
}
try? writer.close() // signals EOF to the remote tar
proc.waitUntilExit()
if proc.terminationStatus != 0 {
let tail = (try? errPipe.fileHandleForReading.readToEnd())
.flatMap { $0.flatMap { String(data: $0, encoding: .utf8) } } ?? ""
throw RestoreError.remoteCommandFailed("tar -x exited \(proc.terminationStatus): \(tail)")
}
#endif
}
// MARK: - Path re-anchor
/// Rewrite each entry's `path` in `~/.hermes/scarf/projects.json`
/// from source-host paths to target-host paths. We do this on the
/// remote rather than mutating the tarball locally the Hermes
/// home tarball can be GBs and re-packing would double the
/// transfer cost. Python is universally present on droplets and
/// keeps the JSON shape intact (preserves keys we don't know
/// about).
private func reanchorProjectsRegistry(
transport: any ServerTransport,
targetHome: String,
mapping: [String: String]
) async throws {
guard !mapping.isEmpty else { return }
let registryPath = targetHome + "/.hermes/scarf/projects.json"
let mappingJSON: String
do {
let data = try JSONSerialization.data(withJSONObject: mapping)
mappingJSON = String(data: data, encoding: .utf8) ?? "{}"
} catch {
throw RestoreError.localIO("Couldn't encode path mapping: \(error.localizedDescription)")
}
let script = """
import json, os, sys
path = os.path.expanduser(\(Self.pythonQuote(registryPath)))
if not os.path.exists(path):
sys.exit(0)
try:
with open(path) as f: data = json.load(f)
except Exception as e:
print(f"projects.json parse failed: {e}", file=sys.stderr); sys.exit(1)
mapping = json.loads(\(Self.pythonQuote(mappingJSON)))
for entry in data.get('projects', []):
old = entry.get('path')
if old in mapping: entry['path'] = mapping[old]
with open(path, 'w') as f: json.dump(data, f, indent=2)
"""
let cmd = "python3 -c \(Self.shellQuote(script))"
let result = try? transport.runProcess(
executable: "/bin/bash",
args: ["-lc", cmd],
stdin: nil,
timeout: 60
)
if let r = result, r.exitCode != 0 {
throw RestoreError.remoteCommandFailed("Path re-anchor failed: \(r.stderrString)")
}
}
/// Set `enabled: false` on every cron job. Returns the count
/// flipped (0 if jobs.json is absent).
private func pauseAllCronJobs(transport: any ServerTransport, targetHome: String) async throws -> Int {
let path = targetHome + "/.hermes/cron/jobs.json"
let script = """
import json, os, sys
path = os.path.expanduser(\(Self.pythonQuote(path)))
if not os.path.exists(path):
print(0); sys.exit(0)
with open(path) as f: data = json.load(f)
count = 0
for job in data.get('jobs', []):
if job.get('enabled', False):
job['enabled'] = False
count += 1
with open(path, 'w') as f: json.dump(data, f, indent=2)
print(count)
"""
let cmd = "python3 -c \(Self.shellQuote(script))"
let result = try? transport.runProcess(
executable: "/bin/bash",
args: ["-lc", cmd],
stdin: nil,
timeout: 60
)
if let r = result, r.exitCode == 0 {
let count = Int(r.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
return count
}
return 0
}
// MARK: - Helpers
/// Mac-only: iOS doesn't ship `/usr/bin/unzip` and Foundation's
/// `Process` is unavailable in the iOS SDK. Restore is initiated from
/// the Mac app; the iOS stub throws so any accidental call surfaces a
/// clear message instead of a link-time failure.
private static func unzipArchive(at archive: URL, into dest: URL) throws {
#if os(iOS)
throw RestoreError.archiveUnreadable("Restore unzip is not supported on iOS — run the restore from the Mac app.")
#else
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
proc.arguments = ["-q", archive.path, "-d", dest.path]
let errPipe = Pipe()
proc.standardError = errPipe
proc.standardOutput = Pipe()
do {
try proc.run()
} catch {
throw RestoreError.archiveUnreadable("Couldn't launch unzip: \(error.localizedDescription)")
}
proc.waitUntilExit()
if proc.terminationStatus != 0 {
let tail = (try? errPipe.fileHandleForReading.readToEnd())
.flatMap { $0.flatMap { String(data: $0, encoding: .utf8) } } ?? ""
throw RestoreError.archiveUnreadable("unzip exited \(proc.terminationStatus): \(tail)")
}
#endif
}
/// Hash a local file in 1 MB chunks. We avoid loading the whole
/// file into memory because tarballs can be multi-GB.
private static func verifyHash(file: URL, expected: String) async throws {
guard let fh = try? FileHandle(forReadingFrom: file) else {
throw RestoreError.archiveUnreadable("missing inner file: \(file.lastPathComponent)")
}
defer { try? fh.close() }
var hasher = SHA256()
let chunkSize = 1024 * 1024
while true {
let chunk = fh.readData(ofLength: chunkSize)
if chunk.isEmpty { break }
hasher.update(data: chunk)
}
let actual = hasher.finalize().map { String(format: "%02x", $0) }.joined()
if actual != expected {
throw RestoreError.integrityCheckFailed(path: file.lastPathComponent, expected: expected, actual: actual)
}
}
private static func shellQuote(_ s: String) -> String {
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
/// Python source-literal quoting. Triple-quoted with backslash
/// escapes for embedded triple-quotes, backslashes, and the
/// language's own escape sequences. Used to safely embed JSON +
/// path strings into a `python3 -c '...'` invocation.
private static func pythonQuote(_ s: String) -> String {
let escaped = s
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"\"\"", with: "\\\"\\\"\\\"")
return "\"\"\"" + escaped + "\"\"\""
}
}
@@ -13,7 +13,12 @@ import os
public enum SkillsScanner: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "SkillsScanner")
public static func scan(context: ServerContext, transport: any ServerTransport) -> [HermesSkillCategory] {
public static func scan(
context: ServerContext,
transport: any ServerTransport,
disabledNames: Set<String> = [],
pinnedNames: Set<String> = []
) -> [HermesSkillCategory] {
let dir = context.paths.skillsDir
// Fresh install: skills/ may not exist yet return [] without
// logging an error.
@@ -59,7 +64,9 @@ public enum SkillsScanner: Sendable {
requiredConfig: requiredConfig,
allowedTools: v011.allowedTools,
relatedSkills: v011.relatedSkills,
dependencies: v011.dependencies
dependencies: v011.dependencies,
enabled: !disabledNames.contains(skillName),
pinned: pinnedNames.contains(skillName)
)
}
@@ -0,0 +1,34 @@
import Foundation
/// Process-wide toggles for test-mode launches.
///
/// Read `CommandLine.arguments` once at first access and cache the result so
/// any code path can ask `TestModeFlags.shared.isTestMode` without paying for
/// a re-scan. The harness sets `--scarf-test-mode` from XCUITest's
/// `XCUIApplication.launchArguments` and pairs it with `SCARF_HERMES_HOME`
/// (read by `HermesProfileResolver`) to drive Scarf against an isolated
/// Hermes home.
///
/// The flags themselves don't do anything on their own they're hook points
/// for production code paths to gate behavior. v1 lands the wiring; the
/// gating sites (Sparkle update prompt, capability live-probe, first-run
/// walkthrough) are added incrementally as the harness exercises them and
/// surfaces flakes.
public struct TestModeFlags: Sendable {
/// True when the process was launched with `--scarf-test-mode`. Read
/// once from `CommandLine.arguments`; never mutated.
public let isTestMode: Bool
/// Default singleton cached on first access. Production code reads
/// this; tests that need a different shape construct their own value.
public static let shared: TestModeFlags = TestModeFlags(
arguments: CommandLine.arguments
)
/// Constructor exposed for tests so a synthetic argv can be passed
/// without involving the real `CommandLine`. Production callers use
/// `.shared`.
public init(arguments: [String]) {
self.isTestMode = arguments.contains("--scarf-test-mode")
}
}
@@ -176,6 +176,55 @@ public struct LocalTransport: ServerTransport {
}
#endif
public func streamRawBytes(executable: String, args: [String]) -> AsyncThrowingStream<Data, Error> {
#if os(iOS)
return AsyncThrowingStream { $0.finish() }
#else
return AsyncThrowingStream { continuation in
Task.detached {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable)
proc.arguments = args
let outPipe = Pipe()
let errPipe = Pipe()
proc.standardOutput = outPipe
proc.standardError = errPipe
do {
try proc.run()
} catch {
continuation.finish(throwing: error)
return
}
try? outPipe.fileHandleForWriting.close()
try? errPipe.fileHandleForWriting.close()
let handle = outPipe.fileHandleForReading
while true {
let chunk = handle.availableData
if chunk.isEmpty { break }
continuation.yield(chunk)
}
proc.waitUntilExit()
let stderrTail: String
if proc.terminationStatus != 0 {
stderrTail = (try? errPipe.fileHandleForReading.readToEnd())
.flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? ""
} else {
stderrTail = ""
}
try? outPipe.fileHandleForReading.close()
try? errPipe.fileHandleForReading.close()
if proc.terminationStatus != 0 {
continuation.finish(throwing: TransportError.commandFailed(
exitCode: proc.terminationStatus, stderr: stderrTail
))
} else {
continuation.finish()
}
}
}
#endif
}
public func streamLines(executable: String, args: [String]) -> AsyncThrowingStream<String, Error> {
#if os(iOS)
// LocalTransport doesn't run on iOS at runtime the iOS app
@@ -247,6 +296,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)
@@ -425,14 +425,18 @@ public struct SSHTransport: ServerTransport {
public func makeProcess(executable: String, args: [String]) -> Process {
ensureControlDir()
// `-T` disables pty allocation critical for binary-clean stdin/stdout
// (ACP JSON-RPC, log tail bytes). Same sh -c wrapping as runProcess
// so home-relative paths in `executable`/`args` actually expand.
// (ACP JSON-RPC, log tail bytes). `bash -lc` (login shell) sources the
// user's profile so PATH picks up pipx's `~/.local/bin`, Homebrew on
// Linux, asdf shims, and conda envs. Plain `sh -c` is non-login, so
// pipx-installed `hermes` isn't on PATH unless `hermesBinaryHint` was
// set explicitly exactly the failure that surfaces as a
// "command not found" / opaque init timeout against fresh droplets.
let cmd = ([executable] + args).map { Self.remotePathArg($0) }.joined(separator: " ")
var sshArgv = sshArgs()
sshArgv.insert("-T", at: 0)
sshArgv.append(hostSpec)
sshArgv.append("sh")
sshArgv.append("-c")
sshArgv.append("bash")
sshArgv.append("-lc")
sshArgv.append(Self.shellQuote(cmd))
let proc = Process()
proc.executableURL = URL(fileURLWithPath: sshBinary)
@@ -453,12 +457,17 @@ public struct SSHTransport: ServerTransport {
return AsyncThrowingStream { continuation in
Task.detached { [self] in
ensureControlDir()
// `bash -lc` (login shell) so PATH picks up profile-only
// entries like pipx's `~/.local/bin` same rationale as
// `makeProcess` above. Streaming consumers (log tails)
// don't tolerate a missing-binary failure any better than
// ACP does.
let cmd = ([executable] + args).map { Self.remotePathArg($0) }.joined(separator: " ")
var sshArgv = sshArgs()
sshArgv.insert("-T", at: 0)
sshArgv.append(hostSpec)
sshArgv.append("sh")
sshArgv.append("-c")
sshArgv.append("bash")
sshArgv.append("-lc")
sshArgv.append(Self.shellQuote(cmd))
let proc = Process()
proc.executableURL = URL(fileURLWithPath: sshBinary)
@@ -514,6 +523,69 @@ public struct SSHTransport: ServerTransport {
#endif
}
public func streamRawBytes(executable: String, args: [String]) -> AsyncThrowingStream<Data, Error> {
#if os(iOS)
return AsyncThrowingStream { $0.finish() }
#else
return AsyncThrowingStream { continuation in
Task.detached { [self] in
ensureControlDir()
// Same `bash -lc` wrapping as `streamLines` so PATH picks
// up profile-only entries (pipx, asdf, conda). The
// difference here is we yield raw `Data` chunks no
// newline framing, no UTF-8 decoding. Required for
// backup tarballs.
let cmd = ([executable] + args).map { Self.remotePathArg($0) }.joined(separator: " ")
var sshArgv = sshArgs()
sshArgv.insert("-T", at: 0)
sshArgv.append(hostSpec)
sshArgv.append("bash")
sshArgv.append("-lc")
sshArgv.append(Self.shellQuote(cmd))
let proc = Process()
proc.executableURL = URL(fileURLWithPath: sshBinary)
proc.arguments = sshArgv
proc.environment = Self.sshSubprocessEnvironment()
let outPipe = Pipe()
let errPipe = Pipe()
proc.standardOutput = outPipe
proc.standardError = errPipe
do {
try proc.run()
} catch {
continuation.finish(throwing: error)
return
}
try? outPipe.fileHandleForWriting.close()
try? errPipe.fileHandleForWriting.close()
let handle = outPipe.fileHandleForReading
while true {
let chunk = handle.availableData
if chunk.isEmpty { break }
continuation.yield(chunk)
}
proc.waitUntilExit()
let stderrTail: String
if proc.terminationStatus != 0 {
stderrTail = (try? errPipe.fileHandleForReading.readToEnd())
.flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? ""
} else {
stderrTail = ""
}
try? outPipe.fileHandleForReading.close()
try? errPipe.fileHandleForReading.close()
if proc.terminationStatus != 0 {
continuation.finish(throwing: TransportError.classifySSHFailure(
host: config.host, exitCode: proc.terminationStatus, stderr: stderrTail
))
} else {
continuation.finish()
}
}
}
#endif
}
/// Injection point for ssh/scp subprocess environment enrichment.
///
/// On the Mac app, this is wired at startup to
@@ -603,6 +675,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> {
@@ -81,6 +81,21 @@ public protocol ServerTransport: Sendable {
args: [String]
) -> AsyncThrowingStream<String, Error>
/// Binary-safe streaming exec. Same shape as `streamLines` but yields
/// arbitrary `Data` chunks of stdout instead of newline-delimited
/// strings. Required by the backup feature: `tar -czf -` produces
/// gzipped tar bytes that must NOT be decoded as UTF-8 / split on
/// `\n` `streamLines` would silently corrupt the archive.
///
/// Stream finishes on EOF / clean exit; errors with
/// `TransportError.commandFailed` on non-zero exit (carrying the
/// captured stderr tail). Chunk sizes are whatever the underlying
/// pipe returns from `availableData`, typically 464 KB on macOS.
nonisolated func streamRawBytes(
executable: String,
args: [String]
) -> AsyncThrowingStream<Data, Error>
// MARK: - SQLite
/// Return a local filesystem URL pointing at a fresh, consistent copy of
@@ -90,6 +105,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
@@ -97,6 +125,25 @@ public protocol ServerTransport: Sendable {
nonisolated func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent>
}
public extension ServerTransport {
/// Default: backup-class binary streaming isn't implemented for
/// every transport (notably the iOS `CitadelServerTransport`,
/// which doesn't expose a raw stdout pipe). Concrete Mac
/// transports override this. The fallback yields a stream that
/// throws on first iteration so callers fail fast rather than
/// hanging silently.
nonisolated func streamRawBytes(
executable: String,
args: [String]
) -> AsyncThrowingStream<Data, Error> {
AsyncThrowingStream { continuation in
continuation.finish(throwing: TransportError.other(
message: "streamRawBytes is not supported on this transport"
))
}
}
}
/// Stat-style file metadata. `nil` (return value) means the file does not
/// exist or couldn't be queried.
public struct FileStat: Sendable, Hashable {
@@ -16,7 +16,7 @@ public final class ConnectionStatusViewModel {
#endif
public enum Status: Equatable {
/// Healthy: SSH connected AND we can read `~/.hermes/config.yaml`.
/// Healthy: SSH connected AND we can read `~/.hermes/state.db`.
case connected
/// SSH connects but the follow-up read-access probe failed. Data
/// views will be empty until this is resolved.
@@ -38,14 +38,17 @@ public final class ConnectionStatusViewModel {
/// Specific tier-2 failure mode emitted by the probe script. Used to
/// drive both the pill copy and the popover hint (issue #53).
public enum DegradedCause: Equatable {
/// `config.yaml` is missing entirely. Most common cause: Hermes
/// hasn't run `setup` yet on this remote.
/// `state.db` is missing entirely. Most common cause: Hermes
/// is installed but no session has run on this remote yet.
/// Case name kept as `configMissing` for back-compat with
/// callers that pattern-match on it; "config" here is loose
/// for "Scarf's required state file."
case configMissing
/// `~/.hermes` itself doesn't exist. Hermes isn't installed for
/// the SSH user on this host.
case homeMissing
/// File exists but the SSH user can't read it. Permission /
/// ownership mismatch.
/// ownership mismatch. Same back-compat note as above.
case configUnreadable
/// `~/.hermes/active_profile` points at a non-default Hermes
/// profile and the configured Hermes home doesn't carry the
@@ -110,10 +113,18 @@ public final class ConnectionStatusViewModel {
let hermesHome = context.paths.home
// Two-tier probe in one SSH round-trip:
// tier 1: `true` raw connectivity / auth / ControlMaster path
// tier 2: `test -r $HERMESHOME/config.yaml` can we actually
// read the file Dashboard reads on every tick? Green pill
// only if both pass; yellow "degraded" if tier 1 passes
// but tier 2 fails (the exact symptom in issue #19).
// tier 2: `test -r $HERMESHOME/state.db` can we actually read
// the file Dashboard / Sessions / Activity all hit on
// every tick? Green pill only if both pass.
//
// Probe historically targeted `config.yaml`, but Hermes v0.11+
// doesn't materialize that file eagerly it ships with sane
// defaults and only writes config.yaml when the user actually
// changes something. Result: a freshly-installed Hermes that's
// running, persisting sessions, and serving Scarf was being
// marked "degraded config missing" indefinitely. `state.db`
// is created on first agent run and is the actual surface
// Scarf depends on, so we probe that instead.
// Script emits two lines: TIER1:<exitcode> and TIER2:<exitcode>.
let homeArg: String
if hermesHome.hasPrefix("~/") {
@@ -124,22 +135,21 @@ public final class ConnectionStatusViewModel {
homeArg = "\"\(hermesHome.replacingOccurrences(of: "\"", with: "\\\""))\""
}
// Probe emits a granular `TIER2:1:<cause>` code so the pill can
// surface a specific hint (issue #53) instead of the prior
// collapsed-to-binary "can't read config.yaml". Causes:
// surface a specific hint (issue #53). Causes:
// no-home $H itself doesn't exist
// missing config.yaml absent
// missing state.db absent (Hermes hasn't been run yet)
// perm exists but unreadable by SSH user
// profile:<name> config missing AND ~/.hermes/active_profile
// profile:<name> state.db missing AND ~/.hermes/active_profile
// points at a Hermes profile, suggesting Scarf
// is reading the wrong dir
let script = """
echo TIER1:0
H=\(homeArg)
if [ -r "$H/config.yaml" ]; then
if [ -r "$H/state.db" ]; then
echo TIER2:0
elif [ ! -d "$H" ]; then
echo TIER2:1:no-home
elif [ ! -e "$H/config.yaml" ]; then
elif [ ! -e "$H/state.db" ]; then
ACTIVE=""
if [ -r "$HOME/.hermes/active_profile" ]; then
ACTIVE=$(head -n1 "$HOME/.hermes/active_profile" 2>/dev/null | tr -d ' \\t\\r\\n')
@@ -263,23 +273,23 @@ public final class ConnectionStatusViewModel {
)
case .configMissing:
return (
"Hermes hasn't been set up yet",
"`\(hermesHome)/config.yaml` is missing. Run `hermes setup` (or your first `hermes chat`) on the remote to create it. Scarf will go green automatically once it appears."
"Hermes hasn't been run yet",
"`\(hermesHome)/state.db` is missing — Hermes creates it on first agent run. Start any session on the remote (e.g. `hermes chat`) and Scarf will go green automatically."
)
case .configUnreadable:
return (
"Permission denied on config.yaml",
"`\(hermesHome)/config.yaml` exists but the SSH user can't read it. Check ownership: `ls -l \(hermesHome)/config.yaml`. Either run Hermes as the SSH user, `chmod a+r` the file, or SSH as the Hermes user."
"Permission denied on state.db",
"`\(hermesHome)/state.db` exists but the SSH user can't read it. Check ownership: `ls -l \(hermesHome)/state.db`. Either run Hermes as the SSH user, `chmod a+r` the file, or SSH as the Hermes user."
)
case .profileActive(let name):
return (
"Hermes profile \"\(name)\" is active",
"The remote is using Hermes profile `\(name)` — its config lives at `~/.hermes/profiles/\(name)/config.yaml`, not `\(hermesHome)/config.yaml`. Either set this server's Hermes home to `~/.hermes/profiles/\(name)` in Manage Servers → Edit, or run `hermes profile use default` on the remote to revert."
"The remote is using Hermes profile `\(name)` — its state lives at `~/.hermes/profiles/\(name)/state.db`, not `\(hermesHome)/state.db`. Either set this server's Hermes home to `~/.hermes/profiles/\(name)` in Manage Servers → Edit, or run `hermes profile use default` on the remote to revert."
)
case .unknown:
return (
"Can't read Hermes state",
"SSH is fine but Scarf can't reach `\(hermesHome)/config.yaml`. Run diagnostics for a full breakdown."
"SSH is fine but Scarf can't reach `\(hermesHome)/state.db`. Run diagnostics for a full breakdown."
)
}
}
@@ -0,0 +1,125 @@
import Foundation
import Observation
#if canImport(os)
import os
#endif
/// Mac + iOS view model for the v0.12 Curator surface.
///
/// Drives `hermes curator status / run / pause / resume / pin / unpin /
/// restore` plus a parsed view of `~/.hermes/skills/.curator_state`
/// JSON. The CLI doesn't ship a `--json` flag for `status`, so we
/// text-parse stdout (HermesCuratorStatusParser) and use the state
/// file for richer last-run metadata.
///
/// Capability-gated: callers should construct this only when
/// `HermesCapabilities.hasCurator` is true. The view model does not
/// gate itself the gate happens at sidebar/tab routing time.
@Observable
@MainActor
public final class CuratorViewModel {
#if canImport(os)
private let logger = Logger(subsystem: "com.scarf", category: "CuratorViewModel")
#endif
public let context: ServerContext
public private(set) var status: HermesCuratorStatus = .empty
public private(set) var isLoading = false
public private(set) var lastReportMarkdown: String?
public var transientMessage: String?
public init(context: ServerContext) {
self.context = context
}
public func load() async {
isLoading = true
defer { isLoading = false }
let context = self.context
let parsed = await Task.detached(priority: .userInitiated) { () -> (HermesCuratorStatus, String?) in
let textResult = Self.runCuratorStatus(context: context)
let stateData = context.readData(context.paths.curatorStateFile)
let parsed = HermesCuratorStatusParser.parse(text: textResult, stateFileJSON: stateData)
// Best-effort markdown report: the state file points at the
// most recent <YYYYMMDD-HHMMSS>/ dir; load REPORT.md from
// there. Missing on first run, which is fine.
var report: String?
if let reportDir = parsed.lastReportPath {
let reportPath = reportDir.hasSuffix("/")
? "\(reportDir)REPORT.md"
: "\(reportDir)/REPORT.md"
report = context.readText(reportPath)
}
return (parsed, report)
}.value
self.status = parsed.0
self.lastReportMarkdown = parsed.1
}
public func runNow() async {
await runAndReload(args: ["curator", "run"], successMessage: "Curator run started")
}
public func pause() async {
await runAndReload(args: ["curator", "pause"], successMessage: "Curator paused")
}
public func resume() async {
await runAndReload(args: ["curator", "resume"], successMessage: "Curator resumed")
}
public func pin(_ skill: String) async {
await runAndReload(args: ["curator", "pin", skill], successMessage: "Pinned \(skill)")
}
public func unpin(_ skill: String) async {
await runAndReload(args: ["curator", "unpin", skill], successMessage: "Unpinned \(skill)")
}
public func restore(_ skill: String) async {
await runAndReload(args: ["curator", "restore", skill], successMessage: "Restored \(skill)")
}
private func runAndReload(args: [String], successMessage: String) async {
let context = self.context
let exitCode = await Task.detached(priority: .userInitiated) {
Self.runHermes(context: context, args: args).exitCode
}.value
transientMessage = exitCode == 0 ? successMessage : "Command failed"
await load()
// Auto-clear toast after 3s.
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 3_000_000_000)
self?.transientMessage = nil
}
}
/// Wrap the transport-level `runProcess` so the call sites don't
/// have to reach for it directly. Combined stdout+stderr.
nonisolated private static func runHermes(
context: ServerContext,
args: [String]
) -> (exitCode: Int32, output: String) {
let transport = context.makeTransport()
do {
let result = try transport.runProcess(
executable: context.paths.hermesBinary,
args: args,
stdin: nil,
timeout: 30
)
return (result.exitCode, result.stdoutString + result.stderrString)
} catch let error as TransportError {
return (-1, error.diagnosticStderr.isEmpty
? (error.errorDescription ?? "transport error")
: error.diagnosticStderr)
} catch {
return (-1, error.localizedDescription)
}
}
nonisolated private static func runCuratorStatus(context: ServerContext) -> String {
runHermes(context: context, args: ["curator", "status"]).output
}
}
@@ -120,6 +120,12 @@ public final class RichChatViewModel {
/// users can copy-paste the raw output into a bug report.
public var acpErrorDetails: String?
/// Lowercase OAuth provider name (`"nous"`, `"claude"`, ) when the
/// most recent failure was an OAuth refresh-revocation Hermes asked
/// the user to fix via re-authentication. Drives the chat banner's
/// "Re-authenticate" button. Nil for any other failure mode.
public var acpErrorOAuthProvider: String?
/// Optional stderr-tail provider the controller can hook up when it
/// creates the ACPClient. Used by `handlePromptComplete` to enrich
/// the error banner on non-retryable stopReasons. The closure is
@@ -134,6 +140,7 @@ public final class RichChatViewModel {
acpError = nil
acpErrorHint = nil
acpErrorDetails = nil
acpErrorOAuthProvider = nil
}
/// Populate the error triplet from a thrown Error + the ACPClient
@@ -154,10 +161,11 @@ public final class RichChatViewModel {
}
let msg = error.localizedDescription
let stderrTail = await client?.recentStderr ?? ""
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
let cls = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
acpError = msg
acpErrorHint = hint
acpErrorHint = cls?.hint
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
acpErrorOAuthProvider = cls?.oauthProvider
}
/// Populate the error triplet when `handlePromptComplete` sees a
@@ -168,11 +176,11 @@ public final class RichChatViewModel {
public func recordPromptStopFailure(stopReason: String, client: ACPClient?) async {
let msg = "Prompt ended without a response (stopReason: \(stopReason))."
let stderrTail = await client?.recentStderr ?? ""
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
?? Self.fallbackHint(for: stopReason)
let cls = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
acpError = msg
acpErrorHint = hint
acpErrorHint = cls?.hint ?? Self.fallbackHint(for: stopReason)
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
acpErrorOAuthProvider = cls?.oauthProvider
}
/// Same as `recordPromptStopFailure` but pulls stderr from the
@@ -182,11 +190,11 @@ public final class RichChatViewModel {
private func recordPromptStopFailureUsingProvider(stopReason: String) async {
let msg = "Prompt ended without a response (stopReason: \(stopReason))."
let stderrTail = await acpStderrProvider?() ?? ""
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
?? Self.fallbackHint(for: stopReason)
let cls = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
acpError = msg
acpErrorHint = hint
acpErrorHint = cls?.hint ?? Self.fallbackHint(for: stopReason)
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
acpErrorOAuthProvider = cls?.oauthProvider
}
private static func fallbackHint(for stopReason: String) -> String? {
@@ -339,7 +347,34 @@ 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
/// Issue #63: locally-created user messages awaiting state.db
/// persistence, keyed by session id. ACP roundtrips Hermes' DB
/// write asynchronously, so a user who sends a prompt and
/// immediately switches to another session triggers `reset()`
/// before Hermes flushes the row `loadSessionHistory` then reads
/// from a DB that doesn't have the message yet, and the bubble
/// renders blank or vanishes on return. We hold a per-session
/// copy here that survives `reset()` so `loadSessionHistory` can
/// re-inject anything still in flight, and clean entries out as
/// soon as a matching DB row appears.
private var pendingLocalUserMessages: [String: [HermesMessage]] = [:]
private var streamingAssistantText = ""
private var streamingThinkingText = ""
private var streamingToolCalls: [HermesToolCall] = []
@@ -382,6 +417,9 @@ public final class RichChatViewModel {
lastKnownFingerprint = nil
sessionId = nil
originSessionId = nil
oldestLoadedMessageID = nil
hasMoreHistory = false
isLoadingEarlier = false
isAgentWorking = false
userSendPending = false
resetTimestamp = Date()
@@ -451,6 +489,12 @@ public final class RichChatViewModel {
reasoning: nil
)
messages.append(message)
// Track the local message in the pending-user-messages cache
// so a reset/resume cycle on this session before Hermes
// persists the row can still re-inject it on return (#63).
if let sid = sessionId {
pendingLocalUserMessages[sid, default: []].append(message)
}
// Per-turn stopwatch (v2.5): record the start time only when
// we're entering a fresh agent turn. /steer-style mid-run sends
// arrive while isAgentWorking is already true; preserve the
@@ -875,12 +919,15 @@ public final class RichChatViewModel {
let opened = await dataService.open()
guard opened else { return }
var dbMessages = await dataService.fetchMessages(sessionId: sessionId)
// Reconnects don't generate hundreds of unseen messages, so a
// 200-row tail is plenty for the merge and it keeps us from
// re-materializing 1000+ message sessions on every reconnect.
var dbMessages = await dataService.fetchMessages(sessionId: sessionId, limit: HistoryPageSize.reconcile)
// If we have an origin session (CLI session continued via ACP),
// include those messages too
if let origin = originSessionId, origin != sessionId {
let originMessages = await dataService.fetchMessages(sessionId: origin)
let originMessages = await dataService.fetchMessages(sessionId: origin, limit: HistoryPageSize.reconcile)
if !originMessages.isEmpty {
dbMessages = originMessages + dbMessages
dbMessages.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
@@ -925,10 +972,18 @@ public final class RichChatViewModel {
// would have cached a stale copy on resume we need whatever
// Hermes has actually persisted since then, or the resumed session
// will show only history up to the moment the snapshot was taken.
let opened = await dataService.refresh()
// `forceFresh: true` refuses the stale-snapshot fallback the data
// service grew in M11 falling back here would silently hide
// messages the agent streamed during the user's offline window.
let opened = await dataService.refresh(forceFresh: true)
guard opened else { return }
var allMessages = await dataService.fetchMessages(sessionId: sessionId)
let pageSize = HistoryPageSize.initial
var allMessages = await dataService.fetchMessages(sessionId: sessionId, limit: pageSize)
// The DB has more on-disk history when the initial fetch
// saturated the limit. The "Load earlier" affordance reads
// this flag.
var moreHistory = allMessages.count >= pageSize
let session = await dataService.fetchSession(id: sessionId)
// If the ACP session is different from the origin, load its messages too
@@ -936,17 +991,101 @@ 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
}
}
messages = allMessages
// Issue #63 re-inject any locally-created user messages
// we still have on file for this session that haven't yet
// shown up in state.db. Covers two paths:
// 1. The user just sent a prompt then resumed a different
// session before Hermes persisted the row. `reset()` had
// cleared `messages` but the per-session pending cache
// survived; restore the row here so the bubble doesn't
// come back blank.
// 2. The DB-resume path on first load a previously-pending
// message Hermes is still mid-write may not appear in
// this fetch. We merge it in, and drop it from the cache
// as soon as a matching DB row (same content, persisted
// id 0) shows up.
let pendingForSession = pendingLocalUserMessages[sessionId] ?? []
if pendingForSession.isEmpty {
messages = allMessages
} else {
var merged = allMessages
var stillPending: [HermesMessage] = []
for local in pendingForSession {
let persisted = merged.contains { msg in
msg.isUser && msg.id >= 0 && msg.content == local.content
}
if persisted {
continue // DB caught up drop the local copy
}
if !merged.contains(where: { $0.id == local.id }) {
merged.append(local)
}
stillPending.append(local)
}
merged.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
messages = merged
if stillPending.isEmpty {
pendingLocalUserMessages.removeValue(forKey: sessionId)
} else {
pendingLocalUserMessages[sessionId] = stillPending
}
}
currentSession = session
let minId = allMessages.map(\.id).min() ?? 0
let minId = messages.map(\.id).min() ?? 0
nextLocalId = min(minId - 1, -1)
// Track the oldest loaded id from THIS session (not the merged
// origin) so `loadEarlier()` pages back through the live ACP
// session's history. Cross-session backfill (paging into the
// CLI origin) isn't supported in v1 the merged 2× pageSize
// is enough headroom for the dashboard-resume case.
let currentSessionId = self.sessionId ?? sessionId
oldestLoadedMessageID = allMessages
.filter { $0.sessionId == currentSessionId }
.map(\.id)
.min()
hasMoreHistory = moreHistory
buildMessageGroups()
}
// MARK: - Load Earlier (pagination)
/// Page back through the current session's DB-persisted history
/// before `oldestLoadedMessageID` and prepend the page to
/// `messages`. Cheap on the SQLite side (`id` is the primary
/// key); the cost is the data-service `open()` round-trip on
/// remote contexts. `pageSize` defaults to the same 200-row
/// budget as the initial load.
public func loadEarlier(pageSize: Int = HistoryPageSize.initial) async {
guard !isLoadingEarlier, hasMoreHistory else { return }
guard let sessionId, let oldest = oldestLoadedMessageID else { return }
isLoadingEarlier = true
defer { isLoadingEarlier = false }
let opened = await dataService.open()
guard opened else { return }
let older = await dataService.fetchMessages(
sessionId: sessionId,
limit: pageSize,
before: oldest
)
guard !older.isEmpty else {
hasMoreHistory = false
return
}
messages.insert(contentsOf: older, at: 0)
oldestLoadedMessageID = older.first?.id
// If this fetch returned fewer than the page size we've hit
// the bottom of the table no further pages worth fetching.
hasMoreHistory = older.count >= pageSize
buildMessageGroups()
}
@@ -990,7 +1129,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
@@ -70,19 +70,109 @@ public final class SkillsViewModel {
/// Awaitable scan. iOS's `.task { await vm.load() }` and the
/// ScarfCore unit tests use this directly; Mac call sites wrap in
/// `Task { await ... }` from `onAppear`.
///
/// Pinned-name set is auto-fetched from the curator state file on
/// v0.12+ hosts; callers can override by passing an explicit set
/// (the Curator screen does this when it has a fresher snapshot in
/// hand).
@MainActor
public func load() async {
public func load(pinnedNames: Set<String>? = nil) async {
isLoading = true
lastError = nil
let ctx = context
let xport = transport
let pins = pinnedNames
let cats: [HermesSkillCategory] = await Task.detached {
SkillsScanner.scan(context: ctx, transport: xport)
let disabled = Self.readDisabledSkillNames(context: ctx)
let pinned = pins ?? Self.readPinnedSkillNames(context: ctx)
return SkillsScanner.scan(
context: ctx,
transport: xport,
disabledNames: disabled,
pinnedNames: pinned
)
}.value
categories = cats
isLoading = false
}
/// Read the curator's pinned-skills list from
/// `~/.hermes/skills/.curator_state` (JSON despite the lack of an
/// extension). Pre-v0.12 hosts won't have this file yet return
/// an empty set so the pin badge stays hidden.
nonisolated static func readPinnedSkillNames(context: ServerContext) -> Set<String> {
guard let data = context.readData(context.paths.curatorStateFile),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return [] }
// Curator stores pins in either `pinned: [name, ...]` or
// `pinned_skills: [name, ...]` depending on Hermes version
// accept both shapes so we don't break on a future rename.
let raw = (obj["pinned"] as? [String]) ?? (obj["pinned_skills"] as? [String]) ?? []
return Set(raw)
}
/// Read the `skills.disabled:` array from `~/.hermes/config.yaml`.
/// Hermes v0.12 stores skill disable state there (one global list
/// + optional `skills.platform_disabled` overrides). Returns the
/// global list only Scarf doesn't surface platform overrides
/// today. Empty set on missing file / parse failure.
nonisolated static func readDisabledSkillNames(context: ServerContext) -> Set<String> {
guard let yaml = context.readText(context.paths.configYAML) else { return [] }
// Lightweight match: find `skills:` block, then `disabled:` array
// inside it. The full YAML parser is overkill for one nested array.
var inSkillsBlock = false
var disabledIndent: Int?
var collected: [String] = []
for raw in yaml.components(separatedBy: "\n") {
// Top-level `skills:` declaration.
if raw.hasPrefix("skills:") {
inSkillsBlock = true
continue
}
if inSkillsBlock {
// A new top-level block ends the `skills:` scope.
if !raw.hasPrefix(" ") && !raw.hasPrefix("\t") && raw.contains(":") {
break
}
let trimmed = raw.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("disabled:") {
// Inline form `disabled: [a, b, c]`
let after = trimmed.dropFirst("disabled:".count).trimmingCharacters(in: .whitespaces)
if after.hasPrefix("[") && after.hasSuffix("]") {
let body = after.dropFirst().dropLast()
let parts = body.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
for p in parts where !p.isEmpty {
collected.append(p.trimmingCharacters(in: CharacterSet(charactersIn: "\"' ")))
}
return Set(collected)
}
// Block form: `disabled:` followed by ` - name`
disabledIndent = raw.prefix { $0 == " " || $0 == "\t" }.count
continue
}
if let baseIndent = disabledIndent {
let leading = raw.prefix { $0 == " " || $0 == "\t" }.count
if !trimmed.isEmpty {
// PyYAML's default `yaml.dump` emits list items at the
// same indent as the parent key, so `- foo` lines for
// `disabled:` arrive at `leading == baseIndent`. Only
// a strictly shallower indent or a same-indent line
// that isn't a list item (sibling key) ends the block.
if leading < baseIndent { break }
if leading == baseIndent && !trimmed.hasPrefix("- ") { break }
}
if trimmed.hasPrefix("- ") {
let name = trimmed.dropFirst(2).trimmingCharacters(in: CharacterSet(charactersIn: "\"' "))
if !name.isEmpty {
collected.append(String(name))
}
}
}
}
}
return Set(collected)
}
public func selectSkill(_ skill: HermesSkill) {
selectedSkill = skill
let mainFile = skill.files.first(where: { $0.hasSuffix(".md") }) ?? skill.files.first
@@ -200,6 +290,68 @@ public final class SkillsViewModel {
}
}
/// v0.12: install a skill from a direct HTTPS URL pointing at a
/// SKILL.md (or a tarball). Hermes pulls + installs without going
/// through the registry indirection. The Mac UI gates this on
/// `HermesCapabilities.hasSkillURLInstall` so a v0.11 host doesn't
/// see a button that errors out with "unrecognized argument".
///
/// `categoryOverride` and `nameOverride` map to `--category` /
/// `--name` flags Hermes ships for direct-URL installs (the URL's
/// SKILL.md may not declare those, especially for one-off scripts).
public func installFromURL(
_ url: String,
categoryOverride: String? = nil,
nameOverride: String? = nil
) {
isHubLoading = true
hubMessage = "Installing from URL…"
let bin = context.paths.hermesBinary
let xport = transport
Task.detached { [weak self] in
var args = ["skills", "install", url, "--yes"]
if let category = categoryOverride, !category.isEmpty {
args += ["--category", category]
}
if let name = nameOverride, !name.isEmpty {
args += ["--name", name]
}
let result = Self.runHermes(
executable: bin,
args: args,
transport: xport,
timeout: 180
)
await self?.finishInstall(identifier: url, exitCode: result.exitCode)
}
}
/// v0.12: trigger a hot reload of `~/.hermes/skills/` so the agent
/// picks up file edits without a session restart. Hermes ships
/// `/reload-skills` as a slash command in chat AND `hermes skills
/// audit` as a CLI form. We use `audit` here so the reload works
/// even when no chat session is active.
public func reloadSkills() async {
isHubLoading = true
let bin = context.paths.hermesBinary
let xport = transport
let result = await Task.detached {
Self.runHermes(
executable: bin,
args: ["skills", "audit"],
transport: xport,
timeout: 30
)
}.value
hubMessage = result.exitCode == 0 ? "Skills reloaded" : "Reload failed"
isHubLoading = false
await load()
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 3_000_000_000)
self?.hubMessage = nil
}
}
public func uninstallHubSkill(_ identifier: String) {
let bin = context.paths.hermesBinary
let xport = transport
@@ -0,0 +1,136 @@
import Testing
import Foundation
@testable import ScarfCore
/// Pure parser tests for `HermesCapabilities`. The detection store
/// (`HermesCapabilitiesStore`) is exercised separately under integration
/// tests since it spawns `hermes --version`.
@Suite struct HermesCapabilitiesTests {
// MARK: - Version line parsing
@Test func parseV012ReleaseLine() {
let caps = HermesCapabilities.parseLine("Hermes Agent v0.12.0 (2026.4.30)")
#expect(caps.semver == HermesCapabilities.SemVer(major: 0, minor: 12, patch: 0))
#expect(caps.dateVersion == HermesCapabilities.DateVersion(year: 2026, month: 4, day: 30))
#expect(caps.detected)
}
@Test func parseV011ReleaseLine() {
let caps = HermesCapabilities.parseLine("Hermes Agent v0.11.0 (2026.4.23)")
#expect(caps.semver == HermesCapabilities.SemVer(major: 0, minor: 11, patch: 0))
#expect(caps.dateVersion == HermesCapabilities.DateVersion(year: 2026, month: 4, day: 23))
}
@Test func parseSemverWithoutDate() {
// Some older Hermes builds emit only the semver suffix.
let caps = HermesCapabilities.parseLine("Hermes Agent v0.10.5")
#expect(caps.semver == HermesCapabilities.SemVer(major: 0, minor: 10, patch: 5))
#expect(caps.dateVersion == nil)
}
@Test func parseFullStdoutBlock() {
// Real `hermes --version` output is multi-line; the version sits on
// the first line and the rest is metadata.
let stdout = """
Hermes Agent v0.12.0 (2026.4.30)
Project: /Users/alan/.hermes/hermes-agent
Python: 3.11.15
OpenAI SDK: 2.31.0
Up to date
"""
let caps = HermesCapabilities.parse(stdout)
#expect(caps.semver?.minor == 12)
#expect(caps.dateVersion?.year == 2026)
}
@Test func parseRejectsUnrelatedOutput() {
let caps = HermesCapabilities.parse("hermes: command not found")
#expect(caps.semver == nil)
#expect(!caps.detected)
}
@Test func parseHandlesEmptyString() {
let caps = HermesCapabilities.parse("")
#expect(caps == .empty)
}
@Test func parseHandlesPartialSemver() {
// "v0.11" without the patch component shouldn't accidentally match.
let caps = HermesCapabilities.parseLine("Hermes Agent v0.11")
#expect(caps.semver == nil)
}
// MARK: - SemVer ordering
@Test func semverOrdering() {
let v0_11_0 = HermesCapabilities.SemVer(major: 0, minor: 11, patch: 0)
let v0_12_0 = HermesCapabilities.SemVer(major: 0, minor: 12, patch: 0)
let v0_12_5 = HermesCapabilities.SemVer(major: 0, minor: 12, patch: 5)
let v1_0_0 = HermesCapabilities.SemVer(major: 1, minor: 0, patch: 0)
#expect(v0_11_0 < v0_12_0)
#expect(v0_12_0 < v0_12_5)
#expect(v0_12_5 < v1_0_0)
}
// MARK: - Capability flags
@Test func v012FlagsAllOn() {
let caps = HermesCapabilities.parseLine("Hermes Agent v0.12.0 (2026.4.30)")
#expect(caps.hasCurator)
#expect(caps.hasFallbackCommand)
#expect(caps.hasKanban)
#expect(caps.hasOneShot)
#expect(caps.hasSkillURLInstall)
#expect(caps.hasACPImagePrompts)
#expect(caps.hasUpdateCheck)
#expect(caps.hasPiperTTS)
#expect(caps.hasVercelTerminal)
#expect(caps.hasCuratorAux)
#expect(caps.hasTeamsPlatform)
#expect(caps.hasYuanbaoPlatform)
#expect(caps.hasCronWorkdir)
#expect(caps.hasPromptCacheTTL)
#expect(caps.hasRedactionToggle)
// flush_memories was REMOVED in v0.12 flag inverts.
#expect(!caps.hasFlushMemoriesAux)
}
@Test func v011FlagsAllOff() {
let caps = HermesCapabilities.parseLine("Hermes Agent v0.11.0 (2026.4.23)")
#expect(!caps.hasCurator)
#expect(!caps.hasFallbackCommand)
#expect(!caps.hasKanban)
#expect(!caps.hasOneShot)
#expect(!caps.hasSkillURLInstall)
#expect(!caps.hasACPImagePrompts)
#expect(!caps.hasUpdateCheck)
#expect(!caps.hasPiperTTS)
#expect(!caps.hasVercelTerminal)
#expect(!caps.hasCuratorAux)
#expect(!caps.hasTeamsPlatform)
#expect(!caps.hasYuanbaoPlatform)
#expect(!caps.hasCronWorkdir)
#expect(!caps.hasPromptCacheTTL)
#expect(!caps.hasRedactionToggle)
// flush_memories aux row was still alive on v0.11.
#expect(caps.hasFlushMemoriesAux)
}
@Test func emptyCapabilitiesAllOff() {
// Undetected installs should hide every gated UI surface.
let caps = HermesCapabilities.empty
#expect(!caps.hasCurator)
#expect(!caps.hasFlushMemoriesAux) // unknown hide either way
#expect(!caps.detected)
}
@Test func futureVersionRetainsCapabilities() {
// A v0.13 (hypothetical) should still see all v0.12 capabilities on.
let caps = HermesCapabilities.parseLine("Hermes Agent v0.13.0 (2026.6.1)")
#expect(caps.hasCurator)
#expect(caps.hasACPImagePrompts)
// And flush_memories stays gone.
#expect(!caps.hasFlushMemoriesAux)
}
}
@@ -0,0 +1,154 @@
import Testing
import Foundation
@testable import ScarfCore
@Suite struct HermesCuratorParserTests {
/// Real `hermes curator status` output captured from a v0.12.0
/// install with no curator runs yet. Locks in the empty-state
/// happy path so a Hermes layout tweak surfaces here before
/// CuratorView starts rendering "" placeholders silently.
private static let realFreshOutput = """
curator: ENABLED
runs: 0
last run: never
last summary: (none)
interval: every 7d
stale after: 30d unused
archive after: 90d unused
agent-created skills: 18 total
active 18
stale 0
archived 0
least recently active (top 5):
Scarf Dashboard Chart Widget Parse Error Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
Scarf Project Registry Format Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
clip activity= 0 use= 0 view= 0 patches= 0 last_activity=never
find-nearby activity= 0 use= 0 view= 0 patches= 0 last_activity=never
gguf-quantization activity= 0 use= 0 view= 0 patches= 0 last_activity=never
least active (top 5):
Scarf Dashboard Chart Widget Parse Error Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
Scarf Project Registry Format Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
clip activity= 0 use= 0 view= 0 patches= 0 last_activity=never
find-nearby activity= 0 use= 0 view= 0 patches= 0 last_activity=never
gguf-quantization activity= 0 use= 0 view= 0 patches= 0 last_activity=never
"""
@Test func parseRealFreshOutput() {
let s = HermesCuratorStatusParser.parse(text: Self.realFreshOutput)
#expect(s.state == .enabled)
#expect(s.runCount == 0)
#expect(s.lastRunISO == nil)
#expect(s.lastSummary == nil)
#expect(s.intervalLabel == "every 7d")
#expect(s.staleAfterLabel == "30d unused")
#expect(s.archiveAfterLabel == "90d unused")
#expect(s.totalSkills == 18)
#expect(s.activeSkills == 18)
#expect(s.staleSkills == 0)
#expect(s.archivedSkills == 0)
#expect(s.pinnedNames.isEmpty)
#expect(s.leastRecentlyActive.count == 5)
#expect(s.leastActive.count == 5)
#expect(s.mostActive.isEmpty)
let firstRow = s.leastRecentlyActive.first
#expect(firstRow?.name == "Scarf Dashboard Chart Widget Parse Error Fix")
#expect(firstRow?.activityCount == 0)
#expect(firstRow?.lastActivityLabel == "never")
}
@Test func parsedPausedState() {
let text = """
curator: PAUSED
runs: 5
last run: 2026-04-29T03:10:00Z
last summary: pruned 2 skills, consolidated 1
interval: every 7d
stale after: 30d unused
archive after: 90d unused
agent-created skills: 12 total
active 8
stale 3
archived 1
pinned (2): kanban-orchestrator, scarf-template-author
"""
let s = HermesCuratorStatusParser.parse(text: text)
#expect(s.state == .paused)
#expect(s.runCount == 5)
#expect(s.lastRunISO == "2026-04-29T03:10:00Z")
#expect(s.lastSummary == "pruned 2 skills, consolidated 1")
#expect(s.totalSkills == 12)
#expect(s.activeSkills == 8)
#expect(s.staleSkills == 3)
#expect(s.archivedSkills == 1)
#expect(s.pinnedNames == ["kanban-orchestrator", "scarf-template-author"])
}
@Test func stateFileOverridesTextSummary() {
// The state file is authoritative for last_run_at /
// last_run_summary / last_report_path because it carries full
// ISO timestamps the text output may have rounded. Verify that
// a state file with richer values overrides parsed text.
let text = """
curator: ENABLED
runs: 1
last run: 2026-04-30T11:00:00Z
last summary: short
interval: every 7d
stale after: 30d unused
archive after: 90d unused
agent-created skills: 3 total
active 3
stale 0
archived 0
"""
let stateJSON: [String: Any] = [
"run_count": 4,
"last_run_at": "2026-04-30T18:42:13.001Z",
"last_run_summary": "richer summary from state file",
"last_report_path": "/Users/u/.hermes/logs/curator/20260430-184213"
]
let data = try! JSONSerialization.data(withJSONObject: stateJSON)
let s = HermesCuratorStatusParser.parse(text: text, stateFileJSON: data)
#expect(s.runCount == 4)
#expect(s.lastRunISO == "2026-04-30T18:42:13.001Z")
#expect(s.lastSummary == "richer summary from state file")
#expect(s.lastReportPath == "/Users/u/.hermes/logs/curator/20260430-184213")
}
@Test func parsedDisabledStatus() {
let s = HermesCuratorStatusParser.parse(text: "curator: DISABLED\n runs: 0\n")
#expect(s.state == .disabled)
}
@Test func parsedEmptyOutputStaysSafe() {
let s = HermesCuratorStatusParser.parse(text: "")
#expect(s.state == .unknown)
#expect(s.totalSkills == 0)
#expect(s.leastRecentlyActive.isEmpty)
}
@Test func skillRowParserHandlesMultiWordNames() {
// Names with spaces are common (Scarf Dashboard Chart Widget)
// The parser slices at the first `activity=` so names can be
// arbitrary length without breaking the counter columns.
let row = " Some Long Skill Name v2 activity= 12 use= 4 view= 6 patches= 2 last_activity=2026-04-25"
let s = HermesCuratorStatusParser.parse(text: """
least recently active (top 5):
\(row)
""")
let parsed = s.leastRecentlyActive.first
#expect(parsed?.name == "Some Long Skill Name v2")
#expect(parsed?.activityCount == 12)
#expect(parsed?.useCount == 4)
#expect(parsed?.viewCount == 6)
#expect(parsed?.patchCount == 2)
#expect(parsed?.lastActivityLabel == "2026-04-25")
}
}
@@ -0,0 +1,119 @@
import Testing
import Foundation
@testable import ScarfCore
/// Exercises the `SCARF_HERMES_HOME` test-mode override on `HermesProfileResolver`.
/// The override is the seam every E2E test relies on without it, tests would
/// touch the user's real `~/.hermes`. Serialized because we mutate process-wide
/// environment.
///
/// **Marker file requirement.** As of v2.8 the override only activates when the
/// path contains the sentinel `HermesProfileResolver.testHomeMarkerFilename`.
/// Tests that want the override active drop the marker before `setenv`. Tests
/// that want to verify the override is rejected (relative path, missing
/// marker, empty value) skip the marker. The hardening prevents a leaked env
/// var from ever pivoting Scarf off the user's real `~/.hermes`.
@Suite(.serialized)
struct HermesProfileResolverOverrideTests {
private static let envKey = "SCARF_HERMES_HOME"
@Test func absoluteOverrideTakesPrecedenceWhenMarkerPresent() throws {
let saved = ProcessInfo.processInfo.environment[Self.envKey]
defer { restore(saved) }
let tmp = NSTemporaryDirectory().appending("scarf-test-home-\(UUID().uuidString)")
try FileManager.default.createDirectory(atPath: tmp, withIntermediateDirectories: true)
try Data().write(to: URL(fileURLWithPath: tmp + "/" + HermesProfileResolver.testHomeMarkerFilename))
defer { try? FileManager.default.removeItem(atPath: tmp) }
setenv(Self.envKey, tmp, 1)
#expect(HermesProfileResolver.resolveLocalHome() == tmp)
#expect(HermesProfileResolver.activeProfileName() == "test-override")
}
@Test func overrideIsIgnoredWhenMarkerMissing() throws {
let saved = ProcessInfo.processInfo.environment[Self.envKey]
defer { restore(saved) }
// Real-looking dir, no marker exactly the shape a leaked env
// var or misconfigured launchctl plist would produce. Must NOT
// override; must fall through to the real resolver.
let tmp = NSTemporaryDirectory().appending("scarf-no-marker-\(UUID().uuidString)")
try FileManager.default.createDirectory(atPath: tmp, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(atPath: tmp) }
setenv(Self.envKey, tmp, 1)
HermesProfileResolver.invalidateCache()
let resolved = HermesProfileResolver.resolveLocalHome()
#expect(resolved != tmp)
#expect(resolved.hasSuffix("/.hermes") || resolved.contains("/.hermes/profiles/"))
}
@Test func emptyOverrideFallsThrough() {
let saved = ProcessInfo.processInfo.environment[Self.envKey]
defer { restore(saved) }
setenv(Self.envKey, "", 1)
HermesProfileResolver.invalidateCache()
let resolved = HermesProfileResolver.resolveLocalHome()
#expect(!resolved.isEmpty)
#expect(resolved.hasSuffix("/.hermes") || resolved.contains("/.hermes/profiles/"))
}
@Test func relativeOverrideIsRejected() {
let saved = ProcessInfo.processInfo.environment[Self.envKey]
defer { restore(saved) }
setenv(Self.envKey, "relative/path", 1)
HermesProfileResolver.invalidateCache()
let resolved = HermesProfileResolver.resolveLocalHome()
#expect(!resolved.hasSuffix("relative/path"))
}
@Test func unsetOverrideUsesProfileResolver() {
let saved = ProcessInfo.processInfo.environment[Self.envKey]
defer { restore(saved) }
unsetenv(Self.envKey)
HermesProfileResolver.invalidateCache()
let resolved = HermesProfileResolver.resolveLocalHome()
#expect(!resolved.isEmpty)
}
@Test func overrideBypassesCacheWhenMarkerPresent() throws {
let saved = ProcessInfo.processInfo.environment[Self.envKey]
defer { restore(saved) }
let first = NSTemporaryDirectory().appending("scarf-cache-bypass-1-\(UUID().uuidString)")
let second = NSTemporaryDirectory().appending("scarf-cache-bypass-2-\(UUID().uuidString)")
try FileManager.default.createDirectory(atPath: first, withIntermediateDirectories: true)
try FileManager.default.createDirectory(atPath: second, withIntermediateDirectories: true)
try Data().write(to: URL(fileURLWithPath: first + "/" + HermesProfileResolver.testHomeMarkerFilename))
try Data().write(to: URL(fileURLWithPath: second + "/" + HermesProfileResolver.testHomeMarkerFilename))
defer {
try? FileManager.default.removeItem(atPath: first)
try? FileManager.default.removeItem(atPath: second)
}
setenv(Self.envKey, first, 1)
#expect(HermesProfileResolver.resolveLocalHome() == first)
// Flip env var without invalidating the cache. Override is read
// fresh on every call, so the new value takes effect immediately.
setenv(Self.envKey, second, 1)
#expect(HermesProfileResolver.resolveLocalHome() == second)
}
private func restore(_ saved: String?) {
if let saved {
setenv(Self.envKey, saved, 1)
} else {
unsetenv(Self.envKey)
}
HermesProfileResolver.invalidateCache()
}
}
@@ -37,8 +37,8 @@ import Foundation
let b: ConnectionStatusViewModel.Status = .connected
#expect(a == b)
let c: ConnectionStatusViewModel.Status = .degraded(reason: "x")
let d: ConnectionStatusViewModel.Status = .degraded(reason: "x")
let c: ConnectionStatusViewModel.Status = .degraded(reason: "x", hint: "y", cause: .unknown)
let d: ConnectionStatusViewModel.Status = .degraded(reason: "x", hint: "y", cause: .unknown)
#expect(c == d)
let e: ConnectionStatusViewModel.Status = .idle
@@ -265,19 +265,20 @@ import Foundation
errorMessage: "No Anthropic credentials found",
stderrTail: ""
)
#expect(noCreds?.contains("ANTHROPIC_API_KEY") == true)
#expect(noCreds?.hint.contains("ANTHROPIC_API_KEY") == true)
#expect(noCreds?.oauthProvider == nil)
let missingBinary = ACPErrorHint.classify(
errorMessage: "",
stderrTail: "No such file or directory: 'npx'"
)
#expect(missingBinary?.contains("npx") == true)
#expect(missingBinary?.hint.contains("npx") == true)
let rateLimit = ACPErrorHint.classify(
errorMessage: "",
stderrTail: "HTTP 429 Too Many Requests: rate limit"
)
#expect(rateLimit?.contains("rate-limit") == true)
#expect(rateLimit?.hint.contains("rate-limit") == true)
let unknown = ACPErrorHint.classify(
errorMessage: "weird thing",
@@ -286,6 +287,53 @@ import Foundation
#expect(unknown == nil)
}
@Test func errorHintsClassifyOAuthRefreshRevoked() {
// Primary trigger Hermes's verbatim message when an OAuth
// refresh token can't mint a new access token. Provider name
// appears alongside; classifier should extract it.
let revoked = ACPErrorHint.classify(
errorMessage: "",
stderrTail: "Refresh session has been revoked. Run `hermes model` to re-authenticate."
)
#expect(revoked?.hint.contains("Re-authenticate") == true)
// With provider context surfaces the affected provider name
// so the chat banner can offer a one-click re-auth that targets
// the right OAuth flow.
let revokedWithProvider = ACPErrorHint.classify(
errorMessage: "",
stderrTail: "Provider claude: Refresh session has been revoked. Run `hermes model` to re-authenticate."
)
#expect(revokedWithProvider?.oauthProvider == "claude")
// 401 + OAuth provider name broader catchall for providers
// that don't print the verbatim "revoked" string.
let unauthorized = ACPErrorHint.classify(
errorMessage: "",
stderrTail: "HTTP 401 Unauthorized from nous portal"
)
#expect(unauthorized?.oauthProvider == "nous")
#expect(unauthorized?.hint.contains("OAuth") == true)
// Unauthorized on a non-OAuth provider (API-key based) should
// NOT classify as OAuth revocation no `oauthProvider` known
// to dispatch the re-auth flow against.
let unauthorizedNonOAuth = ACPErrorHint.classify(
errorMessage: "",
stderrTail: "HTTP 401 Unauthorized for groq"
)
#expect(unauthorizedNonOAuth?.oauthProvider == nil)
// Word-boundary check "anthropicapi" must not false-trigger
// on "anthropic". Without word boundaries this catches the
// wrong cases.
let substringNoMatch = ACPErrorHint.classify(
errorMessage: "",
stderrTail: "401 unauthorized: anthropicapi.example.com"
)
#expect(substringNoMatch?.oauthProvider != "anthropic")
}
// MARK: - Helpers
/// Poll `predicate` every ~20ms up to `timeout` seconds. Fails if
@@ -456,6 +456,7 @@ import Foundation
}
}
func snapshotSQLite(remotePath: String) throws -> URL { URL(fileURLWithPath: remotePath) }
var cachedSnapshotPath: URL? { nil }
func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
AsyncStream { $0.finish() }
}
@@ -165,6 +165,15 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
try runSync { try await self.asyncSnapshotSQLite(remotePath: remotePath) }
}
/// Path where the most recent successful snapshot was written
/// returned even when the SSH connection is currently down. The
/// data service falls back to this when `snapshotSQLite` throws so
/// Dashboard / Sessions / Chat-history stay viewable while the
/// phone is offline.
public var cachedSnapshotPath: URL? {
snapshotBaseDir.appendingPathComponent("state.db")
}
// MARK: - ServerTransport: watching
public func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
@@ -398,8 +407,76 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
let remoteTmp = "/tmp/scarf-snapshot-\(UUID().uuidString).db"
// Double-quote paths; $HOME expansion happens inside double quotes.
let rewritten = Self.rewriteHomeRelative(remotePath)
let backupScript = #"sqlite3 "\#(rewritten)" ".backup '\#(remoteTmp)'" && sqlite3 '\#(remoteTmp)' "PRAGMA journal_mode=DELETE;" > /dev/null"#
_ = try await client.executeCommand(backupScript + " 2>&1")
// Prepend the same PATH prefix `asyncRunProcess` uses so `sqlite3`
// resolves on hosts where it lives in /usr/local/bin or
// /opt/homebrew/bin (issue #56). Citadel's bare exec channel
// inherits a stripped PATH (typically `/usr/bin:/bin` on Linux);
// without this, statically-linked or custom-prefix sqlite3
// installs fail "command not found" at exit 127.
let backupScript =
#"PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" "#
+ #"sqlite3 "\#(rewritten)" ".backup '\#(remoteTmp)'" && sqlite3 '\#(remoteTmp)' "PRAGMA journal_mode=DELETE;" > /dev/null"#
// Drive `executeCommandStream` instead of `executeCommand` so we
// capture stderr regardless of exit code (issue #56). Pre-fix
// a non-zero exit threw `CommandFailed` and discarded the buffer
// surfaced as the unhelpful "Citadel.SSHClient.CommandFailed
// error 1" banner. Now we propagate the real stderr so
// `HermesDataService.humanize` can translate "sqlite3: command
// not found" / "no such file" / "permission denied" into the
// dashboard banner with actionable copy.
let stream: AsyncThrowingStream<ExecCommandOutput, Error>
do {
stream = try await client.executeCommandStream(backupScript)
} catch {
throw NSError(
domain: "CitadelServerTransport",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to start snapshot stream: \(error.localizedDescription)"]
)
}
var stdout = Data()
var stderr = Data()
var exitCode: Int32 = 0
do {
for try await chunk in stream {
switch chunk {
case .stdout(var buf):
if let s = buf.readString(length: buf.readableBytes) {
stdout.append(Data(s.utf8))
}
case .stderr(var buf):
if let s = buf.readString(length: buf.readableBytes) {
stderr.append(Data(s.utf8))
}
}
}
} catch let failed as SSHClient.CommandFailed {
exitCode = Int32(failed.exitCode)
} catch {
stderr.append(Data(error.localizedDescription.utf8))
exitCode = -1
}
if exitCode != 0 {
// Combine stdout + stderr into the error message sqlite3
// sometimes prints "Error: ..." on stdout depending on the
// remote shell. HermesDataService.humanize keys off
// substrings like "sqlite3: command not found",
// "permission denied", "no such file", so as long as one of
// them ends up in the message we get a useful banner.
let messageBytes = stderr.isEmpty ? stdout : stderr
let message = String(data: messageBytes, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
throw NSError(
domain: "CitadelServerTransport",
code: Int(exitCode),
userInfo: [
NSLocalizedDescriptionKey: message.isEmpty
? "Snapshot exited \(exitCode) with no output (likely sqlite3 missing on remote)"
: message
]
)
}
// SFTP-download the remote tmp into our local snapshot cache.
let sftp = try await connectionHolder.sftp()
@@ -0,0 +1,88 @@
import Foundation
import Network
import Observation
#if canImport(os)
import os
#endif
/// Process-wide reachability monitor wrapping `NWPathMonitor`. Used by
/// `ChatController` to decide when to attempt a reconnect (on
/// `.satisfied`) vs. mark the chat offline (on `.unsatisfied`).
///
/// Singleton because `NWPathMonitor` is per-process by design there's
/// no benefit to instantiating multiple monitors and the cost (a small
/// background queue per instance) accumulates if every controller
/// spawns its own.
///
/// ## Usage
///
/// Don't read the published state from a SwiftUI view body the
/// runtime samples through `NWPathMonitor`'s queue, but a `body`
/// re-evaluation that touches `currentPath` directly would block. Read
/// `isSatisfied` / observe `transitionTick` instead. Tests and
/// non-iOS callers can use the no-op default behavior (`isSatisfied`
/// reports `true`).
@Observable
@MainActor
public final class NetworkReachabilityService {
public static let shared = NetworkReachabilityService()
/// `true` when the OS reports a usable network path (any
/// interface). Inverted via `!isSatisfied` for "we're offline."
public private(set) var isSatisfied: Bool = true
/// Mirrors `NWPath.isExpensive`. Useful as a hint to UI for not
/// auto-fetching big payloads on cellular. Not consumed yet
/// reserved so callers don't have to add another property later.
public private(set) var isExpensive: Bool = false
/// Monotonic counter that bumps every time `isSatisfied` changes.
/// Views observe `transitionTick` rather than `isSatisfied` to
/// kick a `.onChange` even if the value is the same as before
/// (rare but possible during rapid network flapping).
public private(set) var transitionTick: Int = 0
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "com.scarf.ios.reachability")
#if canImport(os)
private static let logger = Logger(subsystem: "com.scarf.ios", category: "NetworkReachability")
#endif
private init() {
// Seed from the current path synchronously so first reads on
// launch don't show "satisfied" while the OS reports otherwise.
// `currentPath` is safe here at init (the monitor hasn't been
// started yet, no queue handler is firing).
let initial = monitor.currentPath
self.isSatisfied = (initial.status == .satisfied)
self.isExpensive = initial.isExpensive
monitor.pathUpdateHandler = { [weak self] path in
// Bounce back through MainActor the `Observable`
// protocol's published-property invariants require main-
// thread mutation. The pathUpdateHandler is invoked on
// `queue`, which is a private background queue.
Task { @MainActor in
guard let self else { return }
let satisfied = (path.status == .satisfied)
if self.isSatisfied != satisfied {
self.isSatisfied = satisfied
self.transitionTick &+= 1
#if canImport(os)
Self.logger.info(
"Reachability transition: \(satisfied ? "satisfied" : "unsatisfied", privacy: .public)"
)
#endif
}
self.isExpensive = path.isExpensive
}
}
monitor.start(queue: queue)
}
deinit {
// Singleton is process-lifetime; this only runs on shutdown.
monitor.cancel()
}
}
@@ -31,6 +31,28 @@ final class ScarfGoCoordinator {
/// `AppCoordinator.pendingProjectChat`.
var pendingProjectChat: String?
/// Most-recent scene-phase value observed at the WindowGroup
/// level. Tab-specific view models (e.g. `ChatController`)
/// observe `scenePhaseTick` to react to transitions even when
/// they're on a non-foreground tab `.onChange(of: ScenePhase)`
/// alone wouldn't fire for views that aren't on screen.
private(set) var scenePhase: ScenePhase = .active
private(set) var scenePhaseTick: Int = 0
/// Wallclock when we last observed `.background`. Used by tab
/// view-models to decide whether a quick `.active` transition is
/// worth a full re-verify (long suspensions warrant it; brief
/// notification-center peeks don't). `nil` until the first
/// background transition.
private(set) var lastBackgroundedAt: Date?
func setScenePhase(_ phase: ScenePhase) {
if phase == .background, scenePhase != .background {
lastBackgroundedAt = Date()
}
scenePhase = phase
scenePhaseTick &+= 1
}
enum Tab: Hashable {
case dashboard, projects, chat, skills, system
}
+86
View File
@@ -30,12 +30,49 @@ struct ScarfGoTabRoot: View {
let onSoftDisconnect: @MainActor () async -> Void
let onForget: @MainActor () async -> Void
/// Stable per-tab context UUID used for the System tab's Curator
/// row so its CuratorViewModel reuses the cached SSH connection
/// keyed by this id rather than building a fresh one. Same pattern
/// as `sharedContextID` on ChatView.
static let systemTabContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A2"
)!
/// One coordinator per server-connected session. Cross-tab
/// signalling (Dashboard row Chat tab resume, Project Detail
/// in-project chat handoff, notification deep-link Chat) flows
/// through here.
@State private var coordinator = ScarfGoCoordinator()
/// Hermes version + capability flags for this remote. Drives the
/// iOS version banner (v0.11 hosts get a yellow "update for new
/// features" banner) and capability-gated affordances like ACP
/// image attachments. Constructed once per server connection so
/// the detection runs over the active SSH transport.
@State private var capabilities: HermesCapabilitiesStore
init(
serverID: ServerID,
config: IOSServerConfig,
key: SSHKeyBundle,
onSoftDisconnect: @escaping @MainActor () async -> Void,
onForget: @escaping @MainActor () async -> Void
) {
self.serverID = serverID
self.config = config
self.key = key
self.onSoftDisconnect = onSoftDisconnect
self.onForget = onForget
let ctx = config.toServerContext(id: serverID)
_capabilities = State(initialValue: HermesCapabilitiesStore(context: ctx))
}
/// SwiftUI's `.onChange(of: ScenePhase)` modifier on a non-active
/// tab doesn't fire while the tab is unmounted the coordinator
/// is the single source of truth for scene-phase transitions
/// 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
@@ -112,6 +149,8 @@ struct ScarfGoTabRoot: View {
.tabViewStyle(.sidebarAdaptable)
.environment(\.serverContext, ctx)
.environment(\.scarfGoCoordinator, coordinator)
.environment(capabilities)
.hermesCapabilities(capabilities)
.onAppear {
// Give the notification router a handle to this session's
// coordinator so notification-taps can route across tabs.
@@ -119,6 +158,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)
}
}
}
@@ -135,6 +180,8 @@ private struct SystemTab: View {
let onSoftDisconnect: @MainActor () async -> Void
let onForget: @MainActor () async -> Void
@Environment(\.hermesCapabilities) private var capabilitiesStore
@State private var showForgetConfirmation = false
@State private var isForgetting = false
@State private var isDisconnecting = false
@@ -169,6 +216,15 @@ private struct SystemTab: View {
}
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
if capabilitiesStore?.capabilities.hasCurator ?? false {
NavigationLink {
CuratorView(context: config.toServerContext(id: ScarfGoTabRoot.systemTabContextID))
} label: {
Label("Curator", systemImage: "sparkles")
}
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
}
NavigationLink {
CronListView(config: config)
} label: {
@@ -185,6 +241,36 @@ private struct SystemTab: View {
.listRowBackground(ScarfColor.backgroundSecondary)
}
// v2.6: read-only mobile views over CLI-driven Hermes
// surfaces. Mac owns the create/edit paths; phones get a
// monitoring window into what the remote agent is honoring.
// None of these are capability-gated the underlying
// `hermes plugins/profile/webhook list` verbs exist on
// both v0.11 and v0.12, so the read views work on either.
Section("Inspect") {
NavigationLink {
WebhooksView(config: config)
} label: {
Label("Webhooks", systemImage: "arrow.up.right.square")
}
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
NavigationLink {
PluginsView(config: config)
} label: {
Label("Plugins", systemImage: "app.badge.checkmark")
}
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
NavigationLink {
ProfilesView(config: config)
} label: {
Label("Profiles", systemImage: "person.2.crop.square.stack")
}
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
}
Section {
Toggle(isOn: $iCloudSyncEnabled) {
HStack(spacing: 10) {
+7
View File
@@ -63,6 +63,13 @@ struct ScarfIOSApp: App {
// Hermes gains a push sender.
await MainActor.run { NotificationRouter.shared.setUpOnLaunch() }
}
.task {
// Drop chat drafts older than 7 days so the
// UserDefaults plist doesn't grow unbounded across
// years of use. Cheap; UserDefaults is already in
// memory by the time we read keys.
ChatController.pruneStaleDrafts()
}
// Clamp Dynamic Type at the scene root. ScarfGo is a
// developer tool that needs more density than Apple's
// .xxxLarge default, but we still scale from .xSmall
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Yellow banner that nudges users to upgrade Hermes when the remote
/// is running pre-v0.12. Shown on the Dashboard tab; auto-dismissed
/// for the rest of the session when the user taps the X. Persistent
/// re-show on each app open keeps the prompt visible without nagging
/// inside a single session.
///
/// Hidden entirely on v0.12+ (the new features are reachable) and
/// while capability detection is still in flight.
struct HermesVersionBanner: View {
@Environment(\.hermesCapabilities) private var capabilitiesStore
@State private var dismissedThisSession = false
/// Capability gate only render when:
/// - the store finished its initial detection AND
/// - the host returned an actual version string AND
/// - that version is below v0.12 AND
/// - the user hasn't dismissed this banner during this session.
private var shouldShow: Bool {
guard let store = capabilitiesStore else { return false }
let caps = store.capabilities
guard caps.detected else { return false } // skip while loading / on detection failure
guard !caps.hasCurator else { return false } // already on v0.12+
return !dismissedThisSession
}
var body: some View {
if shouldShow {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
VStack(alignment: .leading, spacing: 2) {
Text("Hermes update available")
.font(.callout.weight(.semibold))
Text("This server runs \(versionLabel). Update to v0.12 to unlock the autonomous curator, multimodal image input, GMI Cloud / Azure / LM Studio / MiniMax / Tencent providers, and more.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
Button {
dismissedThisSession = true
} label: {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Dismiss this version notice for the rest of the session")
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(ScarfColor.warning.opacity(0.12))
.overlay(
Rectangle()
.fill(ScarfColor.warning.opacity(0.4))
.frame(height: 1),
alignment: .bottom
)
.transition(.opacity)
}
}
/// Pretty-print the detected version. Falls back to the raw line
/// if parsing didn't extract semver keeps the banner honest
/// when Hermes ships an unexpected version string.
private var versionLabel: String {
let caps = capabilitiesStore?.capabilities
if let semver = caps?.semver {
return "Hermes v\(semver.description)"
}
return caps?.versionLine ?? "an older Hermes"
}
}
+193
View File
@@ -0,0 +1,193 @@
import SwiftUI
import ScarfCore
import ScarfDesign
#if canImport(SQLite3)
/// iOS Curator surface read-mostly view of `hermes curator status`
/// with Run Now / Pause / Resume actions and inline pin toggles on
/// the leaderboard rows. Mirrors the Mac surface visually but folds
/// into a single SwiftUI List for thumb-friendly scrolling.
///
/// Capability-gated upstream: only routed when
/// `HermesCapabilities.hasCurator` is true.
struct CuratorView: View {
@State private var viewModel: CuratorViewModel
init(context: ServerContext) {
_viewModel = State(initialValue: CuratorViewModel(context: context))
}
var body: some View {
List {
Section {
statusRow
LabeledContent("Last run", value: viewModel.status.lastRunISO ?? "Never")
if let summary = viewModel.status.lastSummary {
LabeledContent("Summary", value: summary)
}
LabeledContent("Interval", value: viewModel.status.intervalLabel)
LabeledContent("Stale after", value: viewModel.status.staleAfterLabel)
LabeledContent("Archive after", value: viewModel.status.archiveAfterLabel)
LabeledContent("Runs", value: "\(viewModel.status.runCount)")
} header: {
Text("Status")
} footer: {
actionFooter
}
Section("Skills") {
LabeledContent("Total", value: "\(viewModel.status.totalSkills)")
LabeledContent("Active", value: "\(viewModel.status.activeSkills)")
LabeledContent("Stale", value: "\(viewModel.status.staleSkills)")
LabeledContent("Archived", value: "\(viewModel.status.archivedSkills)")
}
if !viewModel.status.pinnedNames.isEmpty {
Section("Pinned") {
ForEach(viewModel.status.pinnedNames, id: \.self) { name in
HStack {
Image(systemName: "pin.fill")
.foregroundStyle(ScarfColor.accent)
Text(name)
Spacer()
Button("Unpin") {
Task { await viewModel.unpin(name) }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
}
if !viewModel.status.leastRecentlyActive.isEmpty {
rowsSection(title: "Least recently active", rows: viewModel.status.leastRecentlyActive)
}
if !viewModel.status.mostActive.isEmpty {
rowsSection(title: "Most active", rows: viewModel.status.mostActive)
}
if !viewModel.status.leastActive.isEmpty {
rowsSection(title: "Least active", rows: viewModel.status.leastActive)
}
if let report = viewModel.lastReportMarkdown {
Section("Last report") {
Text(report)
.font(ScarfFont.monoSmall)
.textSelection(.enabled)
}
}
}
.navigationTitle("Curator")
.navigationBarTitleDisplayMode(.large)
.refreshable {
await viewModel.load()
}
.overlay(alignment: .bottom) {
if let toast = viewModel.transientMessage {
toastView(toast)
}
}
.task { await viewModel.load() }
}
private var statusRow: some View {
HStack {
Text("Curator")
Spacer()
statusBadge
}
}
private var statusBadge: some View {
let kind: ScarfBadgeKind
let label: String
switch viewModel.status.state {
case .enabled: kind = .success; label = "Enabled"
case .paused: kind = .warning; label = "Paused"
case .disabled: kind = .neutral; label = "Disabled"
case .unknown: kind = .neutral; label = "Unknown"
}
return ScarfBadge(label, kind: kind)
}
private var actionFooter: some View {
HStack(spacing: 8) {
Button {
Task { await viewModel.runNow() }
} label: {
Label("Run now", systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(viewModel.isLoading)
if viewModel.status.state == .enabled {
Button {
Task { await viewModel.pause() }
} label: {
Label("Pause", systemImage: "pause.fill")
}
.buttonStyle(.bordered)
.controlSize(.small)
} else if viewModel.status.state == .paused {
Button {
Task { await viewModel.resume() }
} label: {
Label("Resume", systemImage: "play.fill")
}
.buttonStyle(.bordered)
.controlSize(.small)
}
Spacer()
}
.padding(.top, 6)
}
private func rowsSection(title: String, rows: [HermesCuratorSkillRow]) -> some View {
Section(title) {
ForEach(rows) { row in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(row.name)
.font(.body)
.lineLimit(1)
Spacer()
Button {
Task { await viewModel.pin(row.name) }
} label: {
Image(systemName: viewModel.status.pinnedNames.contains(row.name) ? "pin.fill" : "pin")
}
.buttonStyle(.plain)
}
HStack(spacing: 6) {
Text("use \(row.useCount) · view \(row.viewCount) · patch \(row.patchCount)")
.font(ScarfFont.monoSmall)
.foregroundStyle(.secondary)
Spacer()
Text(row.lastActivityLabel)
.font(.caption)
.foregroundStyle(.tertiary)
}
}
}
}
}
private func toastView(_ text: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(ScarfColor.success)
Text(text).font(.caption)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.regularMaterial)
.clipShape(Capsule())
.padding(.bottom, 12)
.transition(.opacity)
}
}
#endif
@@ -42,6 +42,13 @@ struct DashboardView: View {
var body: some View {
VStack(spacing: 0) {
// v2.6 Hermes-version banner. Renders only when the remote
// is pre-v0.12 and the user hasn't dismissed for this
// session. v0.12+ hosts get a tab with no banner above
// the picker; older hosts see the upgrade nudge inline so
// it's visible without burying it inside Settings.
HermesVersionBanner()
Picker("View", selection: $selectedSection) {
Text("Overview").tag(Section.overview)
Text("Sessions").tag(Section.sessions)
+136
View File
@@ -0,0 +1,136 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// iOS read-only Plugins view (v2.6).
///
/// Walks `~/.hermes/plugins/` (each subdirectory is one plugin) and
/// reads the optional `plugin.json` / `plugin.yaml` manifest for each.
/// Mirrors the Mac PluginsViewModel's filesystem-first source-of-truth
/// approach `hermes plugins list`'s box-drawn output is fragile to
/// parse from a phone form-factor.
///
/// Install / update / remove / enable / disable verbs stay on Mac for
/// v2.6 installing a plugin from a phone is an unusual flow.
struct PluginsView: View {
let config: IOSServerConfig
@State private var plugins: [PluginRow] = []
@State private var isLoading = true
@State private var lastError: String?
@Environment(\.serverContext) private var contextFromEnv
private var context: ServerContext {
config.toServerContext(id: contextFromEnv.id)
}
var body: some View {
List {
if let err = lastError {
Section {
Label(err, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
}
}
if plugins.isEmpty && !isLoading {
Section {
ContentUnavailableView(
"No plugins installed",
systemImage: "app.badge.checkmark",
description: Text("Hermes plugins live under `~/.hermes/plugins/<name>/`. Install one with `hermes plugins install <repo>` from the Mac app.")
)
}
} else {
ForEach(plugins) { plugin in
Section(plugin.name) {
HStack {
statusBadge(plugin.enabled)
if !plugin.version.isEmpty {
Text("v\(plugin.version)")
.font(ScarfFont.monoSmall)
.foregroundStyle(.secondary)
}
Spacer()
}
if !plugin.source.isEmpty {
LabeledContent("Source", value: plugin.source)
.font(.caption.monospaced())
}
Text(plugin.path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
}
.navigationTitle("Plugins")
.navigationBarTitleDisplayMode(.large)
.refreshable { await load() }
.task { await load() }
}
private func statusBadge(_ enabled: Bool) -> some View {
ScarfBadge(enabled ? "Enabled" : "Disabled", kind: enabled ? .success : .neutral)
}
private func load() async {
isLoading = true
defer { isLoading = false }
let ctx = context
let entries = await Task.detached {
Self.scan(context: ctx)
}.value
self.plugins = entries
}
nonisolated private static func scan(context: ServerContext) -> [PluginRow] {
let transport = context.makeTransport()
let dir = context.paths.pluginsDir
guard let entries = try? transport.listDirectory(dir) else { return [] }
var results: [PluginRow] = []
for entry in entries.sorted() where !entry.hasPrefix(".") {
let path = dir + "/" + entry
guard transport.stat(path)?.isDirectory == true else { continue }
let manifest = readManifest(path: path, context: context)
let disabled = transport.fileExists(path + "/.disabled")
results.append(PluginRow(
name: entry,
version: manifest.version,
source: manifest.source,
path: path,
enabled: !disabled
))
}
return results
}
/// Read `plugin.json` first; fall back to `plugin.yaml` for plugins
/// that author manifest in YAML. Same shape as the Mac VM so
/// parsing stays consistent across targets.
nonisolated private static func readManifest(path: String, context: ServerContext) -> (source: String, version: String) {
if let data = context.readData(path + "/plugin.json"),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
let source = (obj["source"] as? String) ?? (obj["repository"] as? String) ?? (obj["url"] as? String) ?? ""
let version = (obj["version"] as? String) ?? ""
return (source, version)
}
if let yaml = context.readText(path + "/plugin.yaml") {
let parsed = HermesYAML.parseNestedYAML(yaml)
let source = HermesYAML.stripYAMLQuotes(parsed.values["source"] ?? parsed.values["repository"] ?? parsed.values["url"] ?? "")
let version = HermesYAML.stripYAMLQuotes(parsed.values["version"] ?? "")
return (source, version)
}
return ("", "")
}
private struct PluginRow: Identifiable {
var id: String { name }
let name: String
let version: String
let source: String
let path: String
let enabled: Bool
}
}
+153
View File
@@ -0,0 +1,153 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// iOS read-only Profiles view (v2.6).
///
/// Lists `hermes profile list` output and highlights the active profile.
/// Profile switching, creation, deletion, and import/export remain on
/// the Mac app those involve writing data we don't want to risk
/// fat-fingering on a phone (e.g., wiping the active profile by accident).
struct ProfilesView: View {
let config: IOSServerConfig
@State private var profiles: [ProfileRow] = []
@State private var activeProfile: String?
@State private var isLoading = true
@State private var lastError: String?
@Environment(\.serverContext) private var contextFromEnv
private var context: ServerContext {
config.toServerContext(id: contextFromEnv.id)
}
var body: some View {
List {
if let err = lastError {
Section {
Label(err, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
}
}
if profiles.isEmpty && !isLoading {
Section {
ContentUnavailableView(
"No profiles",
systemImage: "person.2.crop.square.stack",
description: Text("Hermes profiles let you keep multiple HERMES_HOME directories side-by-side. Create one with `hermes profile create <name>` from the Mac app.")
)
}
} else {
Section {
ForEach(profiles) { p in
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(p.name)
.font(.body)
if let aliases = p.aliasesLabel {
Text(aliases)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if p.name == activeProfile {
ScarfBadge("Active", kind: .success)
}
}
}
} header: {
if let active = activeProfile {
Text("Active profile: \(active)")
} else {
Text("All profiles")
}
} footer: {
Text("Switching profiles, creating new ones, and import/export live in the Mac app — they touch enough state that we keep them off the phone.")
.font(.caption)
}
}
}
.navigationTitle("Profiles")
.navigationBarTitleDisplayMode(.large)
.refreshable { await load() }
.task { await load() }
}
private func load() async {
isLoading = true
defer { isLoading = false }
let ctx = context
let result = await Task.detached { () -> (output: String, active: String?) in
let listOut = Self.runHermes(context: ctx, args: ["profile", "list"])
// Active profile lives at ~/.hermes/active_profile (text file
// with one line). Reading directly is faster than another
// CLI round-trip.
let activeRaw = ctx.readText(ctx.paths.home + "/active_profile")
let active = activeRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
return (listOut, active)
}.value
self.profiles = Self.parse(result.output)
self.activeProfile = result.active.flatMap { $0.isEmpty ? nil : $0 }
}
nonisolated private static func runHermes(context: ServerContext, args: [String]) -> String {
let transport = context.makeTransport()
do {
let r = try transport.runProcess(
executable: context.paths.hermesBinary,
args: args,
stdin: nil,
timeout: 30
)
return r.stdoutString + r.stderrString
} catch {
return ""
}
}
/// Tolerant parser for `hermes profile list`. The CLI prints a
/// table-like format with the profile name on the leading column
/// and optional alias / path columns afterwards. We surface the
/// name (always present); aliases collapse into a comma-separated
/// label in the row when present.
nonisolated private static func parse(_ output: String) -> [ProfileRow] {
var results: [ProfileRow] = []
for raw in output.components(separatedBy: "\n") {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { continue }
// Skip table-rule and header lines.
if trimmed.hasPrefix("") || trimmed.hasPrefix("") || trimmed.hasPrefix("")
|| trimmed.hasPrefix("") || trimmed.hasPrefix("") || trimmed.hasPrefix("") {
// Strip box-drawing chars and try to extract the leading column.
let body = trimmed
.replacingOccurrences(of: "", with: "|")
.replacingOccurrences(of: "", with: "|")
if !body.contains("|") { continue }
let cols = body.split(separator: "|", omittingEmptySubsequences: true)
.map { $0.trimmingCharacters(in: .whitespaces) }
guard let name = cols.first, !name.isEmpty,
name.range(of: "^[A-Za-z0-9_.-]+$", options: .regularExpression) != nil
else { continue }
let aliases = cols.dropFirst().filter { !$0.isEmpty }.joined(separator: ", ")
results.append(ProfileRow(name: name, aliasesLabel: aliases.isEmpty ? nil : aliases))
continue
}
// Plain-text fallback: first whitespace-delimited token is the name.
if let name = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).first,
name.range(of: "^[A-Za-z0-9_.-]+$", options: .regularExpression) != nil {
results.append(ProfileRow(name: String(name), aliasesLabel: nil))
}
}
// Dedupe (the table-row + plain-text passes can overlap).
var seen = Set<String>()
return results.filter { seen.insert($0.name).inserted }
}
private struct ProfileRow: Identifiable {
var id: String { name }
let name: String
let aliasesLabel: String?
}
}
@@ -36,12 +36,33 @@ struct InstalledSkillsListView: View {
NavigationLink {
SkillDetailView(skill: skill, vm: vm)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(skill.name)
.font(.body)
Text("\(skill.files.count) file\(skill.files.count == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(skill.name)
.font(.body)
.foregroundStyle(skill.enabled ? .primary : .secondary)
.strikethrough(!skill.enabled, color: .secondary)
Text("\(skill.files.count) file\(skill.files.count == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
Spacer(minLength: 0)
if skill.pinned {
Image(systemName: "pin.fill")
.font(.caption2)
.foregroundStyle(ScarfColor.accent)
.accessibilityLabel("Pinned by curator")
}
if !skill.enabled {
Text("OFF")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(ScarfColor.backgroundTertiary)
.clipShape(RoundedRectangle(cornerRadius: 3))
.foregroundStyle(ScarfColor.foregroundMuted)
.accessibilityLabel("Disabled — Hermes won't load this skill")
}
}
}
.scarfGoCompactListRow()
@@ -31,6 +31,34 @@ struct SkillDetailView: View {
.font(.caption.monospaced())
.foregroundStyle(ScarfColor.foregroundMuted)
.textSelection(.enabled)
if !skill.enabled {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Disabled").font(.callout.weight(.medium))
Text("This skill is in `skills.disabled` in `~/.hermes/config.yaml`. Hermes won't load it. Re-enable from the Mac app's Skills config UI or with `hermes skills config`.")
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.fixedSize(horizontal: false, vertical: true)
}
} icon: {
Image(systemName: "circle.slash")
.foregroundStyle(.secondary)
}
}
if skill.pinned {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Pinned by curator").font(.callout.weight(.medium))
Text("The autonomous curator won't auto-archive or rewrite this skill. Unpin from the Curator screen.")
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.fixedSize(horizontal: false, vertical: true)
}
} icon: {
Image(systemName: "pin.fill")
.foregroundStyle(ScarfColor.accent)
}
}
}
.listRowBackground(ScarfColor.backgroundSecondary)
+185
View File
@@ -0,0 +1,185 @@
import SwiftUI
import ScarfCore
import ScarfDesign
import os
/// iOS read-only Webhooks view (v2.6).
///
/// Lists `hermes webhook list` output so mobile users can see what
/// dynamic webhook subscriptions the remote agent is honoring. Create /
/// remove / test actions stay on Mac for v2.6 most webhook setup
/// involves pasting URLs / secrets that are inconvenient on a phone.
///
/// Reuses the same tolerant text parser the Mac WebhooksViewModel uses.
struct WebhooksView: View {
let config: IOSServerConfig
@State private var webhooks: [WebhookRow] = []
@State private var notEnabled = false
@State private var isLoading = true
@State private var lastError: String?
@Environment(\.serverContext) private var contextFromEnv
private var context: ServerContext {
// The view receives `IOSServerConfig` directly (matches the
// sibling Skills/Settings tabs); use that to construct a
// context bound to the active server. Falls back to env when
// the navigation host hasn't injected a config-derived ctx.
config.toServerContext(id: contextFromEnv.id)
}
var body: some View {
List {
if let err = lastError {
Section {
Label(err, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
}
}
if notEnabled {
Section("Setup required") {
Text("The webhook gateway platform isn't enabled on this server. Run `hermes setup` from the Mac app or a shell to enable it.")
.font(.caption)
.foregroundStyle(.secondary)
}
} else if webhooks.isEmpty && !isLoading {
Section {
ContentUnavailableView(
"No webhooks subscribed",
systemImage: "arrow.up.right.square",
description: Text("Run `hermes webhook subscribe …` from the Mac app to register one.")
)
}
} else {
ForEach(webhooks) { hook in
Section(hook.name) {
if !hook.description.isEmpty {
LabeledContent("Description", value: hook.description)
}
if !hook.deliver.isEmpty {
LabeledContent("Deliver", value: hook.deliver)
}
if !hook.events.isEmpty {
LabeledContent("Events", value: hook.events.joined(separator: ", "))
}
LabeledContent("Route", value: hook.routeSuffix)
.font(.caption.monospaced())
}
}
}
}
.navigationTitle("Webhooks")
.navigationBarTitleDisplayMode(.large)
.refreshable { await load() }
.task { await load() }
}
private func load() async {
isLoading = true
defer { isLoading = false }
let ctx = context
let result = await Task.detached {
return Self.runHermesList(context: ctx)
}.value
if Self.detectNotEnabled(result) {
self.notEnabled = true
self.webhooks = []
self.lastError = nil
return
}
self.notEnabled = false
let parsed = Self.parse(result)
self.webhooks = parsed
// When the CLI returned text but the parser produced nothing, the
// user otherwise sees a silent empty list. Surface a parse-failure
// message so they know to dig deeper.
self.lastError = (parsed.isEmpty && !result.isEmpty)
? "Couldn't parse webhook list output"
: nil
}
nonisolated private static func runHermesList(context: ServerContext) -> String {
let transport = context.makeTransport()
do {
let r = try transport.runProcess(
executable: context.paths.hermesBinary,
args: ["webhook", "list"],
stdin: nil,
timeout: 30
)
return r.stdoutString + r.stderrString
} catch {
return ""
}
}
nonisolated private static func detectNotEnabled(_ output: String) -> Bool {
let lower = output.lowercased()
return lower.contains("webhook platform is not enabled")
|| lower.contains("run the gateway setup wizard")
|| lower.contains("webhook_enabled=true")
}
/// Tolerant block-parser. Each subscription begins on a non-indented
/// line; description / deliver / events / url details follow as
/// indented `key: value` lines. Mirrors the Mac parser shape so
/// future drift only has to be fixed in one canonical place if/when
/// we promote this VM into ScarfCore.
nonisolated private static func parse(_ output: String) -> [WebhookRow] {
var results: [WebhookRow] = []
var name = ""
var desc = ""
var deliver = ""
var events: [String] = []
var route = ""
func flush() {
if !name.isEmpty {
results.append(WebhookRow(
name: name,
description: desc,
deliver: deliver,
events: events,
routeSuffix: route.isEmpty ? "/webhooks/\(name)" : route
))
}
name = ""; desc = ""; deliver = ""; events = []; route = ""
}
for raw in output.components(separatedBy: "\n") {
let line = raw
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty { continue }
if !line.hasPrefix(" ") && !line.hasPrefix("\t") {
flush()
let candidate = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: ":"))
if candidate.range(of: "^[A-Za-z0-9_-]+$", options: .regularExpression) != nil {
name = candidate
}
continue
}
if trimmed.lowercased().hasPrefix("description:") {
desc = String(trimmed.dropFirst("description:".count)).trimmingCharacters(in: .whitespaces)
} else if trimmed.lowercased().hasPrefix("deliver:") {
deliver = String(trimmed.dropFirst("deliver:".count)).trimmingCharacters(in: .whitespaces)
} else if trimmed.lowercased().hasPrefix("events:") {
let list = String(trimmed.dropFirst("events:".count)).trimmingCharacters(in: .whitespaces)
events = list.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
} else if trimmed.lowercased().hasPrefix("url:") || trimmed.lowercased().hasPrefix("route:") {
route = trimmed.components(separatedBy: ":").dropFirst().joined(separator: ":").trimmingCharacters(in: .whitespaces)
}
}
flush()
return results
}
private struct WebhookRow: Identifiable {
var id: String { name }
let name: String
let description: String
let deliver: String
let events: [String]
let routeSuffix: String
}
}
+22 -22
View File
@@ -529,7 +529,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -540,13 +540,13 @@
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.5.1;
MARKETING_VERSION = 2.6.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -571,7 +571,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -582,13 +582,13 @@
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.5.1;
MARKETING_VERSION = 2.6.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -612,7 +612,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
@@ -635,7 +635,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
@@ -658,7 +658,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
@@ -680,7 +680,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
@@ -834,7 +834,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 31;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
@@ -848,7 +848,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.5.1;
MARKETING_VERSION = 2.6.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -870,7 +870,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 31;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
@@ -884,7 +884,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.5.1;
MARKETING_VERSION = 2.6.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -902,12 +902,12 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 31;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.5.1;
MARKETING_VERSION = 2.6.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -924,12 +924,12 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 31;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.5.1;
MARKETING_VERSION = 2.6.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -945,11 +945,11 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 31;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 2.5.1;
MARKETING_VERSION = 2.6.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -965,11 +965,11 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27;
CURRENT_PROJECT_VERSION = 31;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 2.5.1;
MARKETING_VERSION = 2.6.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
+2
View File
@@ -61,6 +61,7 @@ struct ContentView: View {
case .projects: ProjectsView(context: serverContext)
case .chat: ChatView()
case .memory: MemoryView(context: serverContext)
case .curator: CuratorView(context: serverContext)
case .skills: SkillsView(context: serverContext)
case .platforms: PlatformsView(context: serverContext)
case .personalities: PersonalitiesView(context: serverContext)
@@ -73,6 +74,7 @@ struct ContentView: View {
case .mcpServers: MCPServersView(context: serverContext)
case .gateway: GatewayView(context: serverContext)
case .cron: CronView(context: serverContext)
case .kanban: KanbanView(context: serverContext)
case .health: HealthView(context: serverContext)
case .logs: LogsView(context: serverContext)
case .settings: SettingsView(context: serverContext)
+159
View File
@@ -0,0 +1,159 @@
import Foundation
import ScarfCore
import os
/// One template entry as exposed by `awizemann.github.io/scarf/templates/catalog.json`.
/// Mirrors the per-template shape `tools/build-catalog.py` emits the
/// validator is the source of truth on the schema, this struct is the
/// Swift consumer. **Do not add fields here that aren't in `catalog.json`
/// today.** Keeping the surface 1:1 means we can't accidentally render
/// something the catalog doesn't actually carry.
///
/// Most fields are required-from-the-validator's-perspective but
/// expressed as optionals here so a single-template typo on the
/// website doesn't bring down the whole list we drop the malformed
/// entry and keep going (handled by the decoder in `CatalogService`).
struct CatalogEntry: Codable, Sendable, Identifiable, Hashable {
// Hashable + Equatable conformance is identity-based on `id`
// `TemplateConfigSchema` only conforms to Equatable, so we can't
// synthesize Hashable, and a content-based equality wouldn't be
// useful anyway (the same template re-fetched from cache vs. fresh
// is "the same entry" even if a description was edited upstream).
static func == (lhs: CatalogEntry, rhs: CatalogEntry) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
/// Stable identifier `<author>/<template-name>`, e.g.
/// `awizemann/hackernews-digest`. Matches the value in
/// `template.json`'s `id` field.
let id: String
/// Human-readable name shown in the catalog list.
let name: String
/// Semver. Compared against the installed version from
/// `InstalledTemplatesIndex` to detect "Update available".
let version: String
let description: String?
let category: String?
let tags: [String]
let author: Author
let minScarfVersion: String?
let minHermesVersion: String?
/// HTTPS URL the install flow consumes.
/// `TemplateInstallerViewModel.openRemoteURL(_:)` accepts this
/// directly. The catalog itself only ships HTTPS URLs (validator
/// enforced).
let installUrl: String
/// Bundle metadata for size warnings and integrity checks. Optional
/// because pre-v2 catalogs didn't carry these.
let bundleSize: Int?
let bundleSha256: String?
/// Slug used by the static-site generator for detail-page URLs.
/// Reused as a stable accessibility-ID suffix so XCUITest can find
/// rows even if the human-readable id contains slashes.
let detailSlug: String?
/// What's inside the bundle, mirrored from `template.json`'s
/// `contents` claim. Drives the "what will be installed" preview
/// on the detail page.
let contents: Contents?
/// Config schema + model recommendation if the template declares
/// one. Using the existing `TemplateConfigSchema` decoder keeps
/// parsing aligned with the install sheet's config form.
let config: TemplateConfigSchema?
struct Author: Codable, Sendable, Equatable {
let name: String
let url: String?
}
/// `template.json`'s `contents` object. All counts are optional
/// `nil` means "not declared," which the catalog renders as zero.
struct Contents: Codable, Sendable, Equatable {
let dashboard: Bool?
let agentsMd: Bool?
let cron: Int?
let config: Int?
let memory: Bool?
let skills: [String]?
}
}
/// Top-level shape of `catalog.json`. Only carries what the Swift
/// catalog browser actually uses `templates` is the list itself,
/// `schemaVersion` lets us reject incompatible future formats.
///
/// **The validator's `generated` field is intentionally NOT decoded.**
/// It ships as a boolean (`true`) per `tools/build-catalog.py`'s
/// "human reminder; a timestamp would churn the diff every run"
/// comment. The catalog UI uses the cache file's `fetchedAt` for the
/// "last refreshed" string, not anything from `catalog.json`.
///
/// **Per-element fault tolerance.** `templates` is decoded entry by
/// entry through an unkeyed container a single malformed entry
/// (missing `tags`, `author`, etc.) is dropped with a logged warning
/// rather than failing the whole catalog decode. Honors the contract
/// the per-entry doc-comment promises.
struct Catalog: Codable, Sendable {
let schemaVersion: Int?
let templates: [CatalogEntry]
init(schemaVersion: Int?, templates: [CatalogEntry]) {
self.schemaVersion = schemaVersion
self.templates = templates
}
/// Custom decoder that drops every key other than `schemaVersion`
/// and `templates`. Without this, `generated: true` would surface
/// as a typeMismatch on `String?`.
enum CodingKeys: String, CodingKey {
case schemaVersion
case templates
}
private static let decodeLogger = Logger(subsystem: "com.scarf", category: "CatalogDecoder")
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion)
var entries: [CatalogEntry] = []
if container.contains(.templates) {
var unkeyed = try container.nestedUnkeyedContainer(forKey: .templates)
entries.reserveCapacity(unkeyed.count ?? 0)
while !unkeyed.isAtEnd {
do {
entries.append(try unkeyed.decode(CatalogEntry.self))
} catch {
Self.decodeLogger.warning("dropping malformed catalog entry at index \(unkeyed.currentIndex - 1): \(error.localizedDescription, privacy: .public)")
// Advance past the bad element so the loop terminates.
// Decoding into a permissive `JSONValue` placeholder
// would also work, but Foundation's Decoder API has
// no built-in skip `_Skip` consumes one element.
_ = try? unkeyed.decode(_Skip.self)
}
}
}
self.templates = entries
}
/// Placeholder type used to consume a malformed array element after
/// the real decode threw. Decodes anything by ignoring it.
private struct _Skip: Decodable {
init(from decoder: Decoder) throws {
_ = try decoder.singleValueContainer()
}
}
}
@@ -165,6 +165,119 @@ final class ServerRegistry {
SSHTransport.sweepStaleControlSockets()
}
// MARK: - Export / Import
/// Result summary returned from `importEntries(from:)`. The UI renders
/// it as a one-line confirmation so the user knows whether anything
/// changed (e.g. picking a stale export file imports zero entries
/// because every ID is already present).
struct ImportSummary: Equatable {
var imported: Int
var skippedDuplicates: Int
}
/// Errors raised by `importEntries(from:)` for the user-facing alert.
/// Validation is conservative we'd rather refuse a malformed file
/// than half-import garbage and leave the registry in a weird state.
enum ImportError: Error, LocalizedError {
case unreadable(String)
case malformed(String)
case unsupportedSchema(Int)
var errorDescription: String? {
switch self {
case .unreadable(let m): return "Couldn't read the file: \(m)"
case .malformed(let m): return "The file isn't a valid Scarf servers export: \(m)"
case .unsupportedSchema(let v): return "This export uses schema v\(v), which this version of Scarf doesn't recognize."
}
}
}
/// Encode the current registry as a portable export. `displayName`,
/// `host`, `user`, `port`, `identityFile` (path string only),
/// `remoteHome`, `projectsRoot`, `hermesBinaryHint`, `openOnLaunch`,
/// and the entry's stable UUID travel. **No secrets** ride along
/// SSH private keys live at the path referenced by `identityFile`,
/// not in `servers.json`. Importing on a different Mac requires the
/// user to copy their `~/.ssh/` keys separately (or re-point each
/// entry's identityFile in Edit Server).
func exportFile() throws -> Data {
let payload = ExportFile(
schemaVersion: Self.currentSchemaVersion,
exportedAt: ISO8601DateFormatter().string(from: Date()),
entries: entries
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(payload)
}
/// Merge entries from a `.scarfservers` file. Dedupe is by UUID
/// entries whose ID already exists are skipped (the existing
/// entry wins, since it may carry edits the user made post-export).
/// `openOnLaunch` is normalized after import: at most one entry
/// can be the default, and conflicts resolve in favor of the
/// pre-existing default.
@discardableResult
func importEntries(from data: Data) throws -> ImportSummary {
let payload: ExportFile
do {
payload = try JSONDecoder().decode(ExportFile.self, from: data)
} catch {
throw ImportError.malformed(error.localizedDescription)
}
guard payload.schemaVersion == Self.currentSchemaVersion else {
throw ImportError.unsupportedSchema(payload.schemaVersion)
}
let existingIDs = Set(entries.map(\.id))
var imported = 0
var skipped = 0
for incoming in payload.entries {
if existingIDs.contains(incoming.id) {
skipped += 1
continue
}
var copy = incoming
// Don't let an imported entry seize the default slot if the
// user already has one assigned. Normalization below also
// drops `openOnLaunch` if more than one survives.
if entries.contains(where: { $0.openOnLaunch }) {
copy.openOnLaunch = false
}
entries.append(copy)
imported += 1
}
// Belt-and-suspenders: if multiple entries somehow ended up
// flagged as default (e.g. user imported an export that itself
// had the flag on a different entry than the local default),
// keep only the first one.
var sawDefault = false
for idx in entries.indices {
if entries[idx].openOnLaunch {
if sawDefault { entries[idx].openOnLaunch = false }
else { sawDefault = true }
}
}
save()
if imported > 0 { onEntriesChanged?() }
return ImportSummary(imported: imported, skippedDuplicates: skipped)
}
/// Disk envelope distinct from `RegistryFile`. Adds the export
/// timestamp; structurally compatible so a hand-edited export
/// could in theory be dropped at `~/Library/Application
/// Support/scarf/servers.json` and load we don't rely on that,
/// but keeping the shape close means one less migration surface
/// when we eventually add fields here.
private struct ExportFile: Codable {
var schemaVersion: Int
var exportedAt: String
var entries: [ServerEntry]
}
// MARK: - Persistence
private func load() {
@@ -0,0 +1,99 @@
import AppKit
import Foundation
import os
/// Quits the running app and brings up a fresh instance of the same
/// bundle. Used by the Profile-switching flow (issue #70) so the new
/// active profile lands in a process that has never observed the old
/// one sidesteps any in-process cache or service-state bug that
/// might still be reading from the previous profile's home directory.
///
/// The pairing is intentional:
/// 1. Caller invokes `try AppRelauncher.relaunch()`. That spawns a
/// fresh `open -n <bundleURL>`, captures stderr/exitCode, returns
/// success once the launcher has acknowledged the dispatch.
/// 2. Caller schedules `NSApp.terminate(nil)` 250ms later. The
/// 250ms gives macOS time to begin launching the second PID so
/// the dock-icon hand-off looks smooth (no flash of missing
/// icon). Without the gap, macOS can briefly show zero Scarf
/// icons in the dock.
///
/// Refuses to relaunch when the running bundle is under
/// `DerivedData/` or `Build/Products/Debug` that's an Xcode
/// debug session, and `terminate(nil)` would kill the run mid-debug
/// without giving the new instance any way to attach. The caller
/// surfaces a "restart manually" toast in that case.
@MainActor
enum AppRelauncher {
static let logger = Logger(subsystem: "com.scarf.app", category: "AppRelauncher")
enum RelaunchError: Error, LocalizedError {
case debugBuild
case openFailed(exitCode: Int32, stderr: String)
var errorDescription: String? {
switch self {
case .debugBuild:
return "Refusing to relaunch from an Xcode debug build."
case .openFailed(let code, let stderr):
return "open(1) exited \(code): \(stderr)"
}
}
}
/// Spawns a fresh instance of the running app via `/usr/bin/open -n
/// <bundleURL>` and returns once the launcher process has dispatched
/// the new instance. The caller is responsible for the subsequent
/// `NSApp.terminate(nil)` (deferred ~250ms for a smooth dock hand-off).
/// Throws `.debugBuild` when launched from Xcode/DerivedData;
/// `.openFailed` when `open` itself errored.
static func relaunch() throws {
let bundleURL = Bundle.main.bundleURL
let path = bundleURL.path
if path.contains("/DerivedData/")
|| path.contains("/Build/Products/Debug")
|| path.contains("/Build/Products/Debug-")
{
logger.warning("Refusing relaunch — running from Xcode build (\(path, privacy: .public))")
throw RelaunchError.debugBuild
}
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/open")
// -n: force a NEW instance (without it, `open` activates the
// running app and we'd never get a fresh process).
// Pass the bundle URL directly (not -a <bundleId>) so signed
// dev clones in `~/Applications` still resolve correctly.
// No -W: we want `open` to return immediately after dispatch,
// not block until the spawned app exits.
proc.arguments = ["-n", path]
let stderrPipe = Pipe()
let stdoutPipe = Pipe()
proc.standardError = stderrPipe
proc.standardOutput = stdoutPipe
do {
try proc.run()
} catch {
throw RelaunchError.openFailed(exitCode: -1, stderr: error.localizedDescription)
}
proc.waitUntilExit()
// Drain both streams BEFORE inspecting exit code so we don't leak fds.
let errData = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
_ = try? stdoutPipe.fileHandleForReading.readToEnd()
try? stderrPipe.fileHandleForReading.close()
try? stdoutPipe.fileHandleForReading.close()
guard proc.terminationStatus == 0 else {
let stderr = String(data: errData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
logger.warning("open(1) failed (\(proc.terminationStatus)): \(stderr, privacy: .public)")
throw RelaunchError.openFailed(exitCode: proc.terminationStatus, stderr: stderr)
}
logger.info("Relaunch dispatched for \(path, privacy: .public)")
}
}
@@ -0,0 +1,228 @@
import Foundation
import ScarfCore
import os
/// 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 next to the
/// projects registry so a Hermes wipe takes it with the rest of the
/// Scarf-owned state.
struct CatalogCache: Codable, Sendable {
static let currentVersion = 1
let version: Int
let fetchedAt: Date
let catalog: Catalog
init(version: Int = CatalogCache.currentVersion, fetchedAt: Date, catalog: Catalog) {
self.version = version
self.fetchedAt = fetchedAt
self.catalog = catalog
}
}
/// Result of a `loadCatalog` call. Distinguishes "fetched fresh" from
/// "cache served, network failed" so the catalog UI can surface a
/// "could not refresh" hint next to a stale-but-useful list.
enum CatalogLoadResult: Sendable {
case fresh(catalog: Catalog, fetchedAt: Date)
case cache(catalog: Catalog, fetchedAt: Date, refreshError: String?)
case fallback(catalog: Catalog, reason: String)
}
enum CatalogServiceError: LocalizedError, Sendable {
case transport(String)
case http(status: Int)
case decode(String)
var errorDescription: String? {
switch self {
case .transport(let m): return "Catalog transport: \(m)"
case .http(let status): return "Catalog HTTP \(status)"
case .decode(let m): return "Catalog decode: \(m)"
}
}
}
/// Fetches + caches the public template catalog from
/// awizemann.github.io. Mirrors `NousModelCatalogService` 1:1 in
/// shape: cache-first, 24h TTL, fallback when both cache and fetch
/// fail. The catalog is unauthenticated (a public static file on
/// GitHub Pages), so no bearer-token plumbing.
struct CatalogService: Sendable {
/// Where the catalog lives in production. The static-site builder
/// publishes here on `./scripts/catalog.sh publish`. **Versioned
/// constant**: if we ever move this URL, every old Scarf install
/// pegs at its bundled fallback until the user updates Scarf so
/// keep it stable. Settings-configurable in v2.9 only if anyone
/// asks.
static let baseURL = URL(string: "https://awizemann.github.io/scarf/templates/catalog.json")!
static let cacheTTL: TimeInterval = 24 * 60 * 60 // 24h
static let requestTimeout: TimeInterval = 10 // seconds
/// Hard-coded fallback for offline-with-no-cache. Keeps the picker
/// non-empty on a fresh install so the user sees *something* even
/// before the first network call. **Update on every release that
/// adds a template** the validator's `tools/check-catalog-fallback-sync.py`
/// (TODO) catches drift between this list and `templates/`.
static let fallbackCatalog: Catalog = Catalog(
schemaVersion: 1,
templates: [
CatalogEntry(
id: "awizemann/site-status-checker",
name: "Site Status Checker",
version: "1.1.0",
description: "Daily uptime check for a list of URLs you configure on install.",
category: "monitoring",
tags: ["monitoring", "uptime", "cron", "starter"],
author: .init(name: "Alan Wizemann", url: "https://github.com/awizemann"),
minScarfVersion: "2.3.0",
minHermesVersion: "0.9.0",
installUrl: "https://raw.githubusercontent.com/awizemann/scarf/main/templates/awizemann/site-status-checker/site-status-checker.scarftemplate",
bundleSize: nil,
bundleSha256: nil,
detailSlug: "awizemann-site-status-checker",
contents: .init(dashboard: true, agentsMd: true, cron: 1, config: 2, memory: nil, skills: nil),
config: nil
),
CatalogEntry(
id: "awizemann/hackernews-digest",
name: "HackerNews Daily Digest",
version: "1.0.0",
description: "A daily digest of HackerNews top stories. No API keys required.",
category: "news",
tags: ["news", "digest", "hackernews", "cron", "starter"],
author: .init(name: "Alan Wizemann", url: "https://github.com/awizemann"),
minScarfVersion: "2.3.0",
minHermesVersion: "0.9.0",
installUrl: "https://raw.githubusercontent.com/awizemann/scarf/main/templates/awizemann/hackernews-digest/hackernews-digest.scarftemplate",
bundleSize: nil,
bundleSha256: nil,
detailSlug: "awizemann-hackernews-digest",
contents: .init(dashboard: true, agentsMd: true, cron: 1, config: 3, memory: nil, skills: nil),
config: nil
)
]
)
private static let logger = Logger(subsystem: "com.scarf", category: "CatalogService")
let context: ServerContext
private let session: URLSession
private let cachePath: String
init(context: ServerContext = .local, session: URLSession = .shared) {
self.context = context
self.session = session
self.cachePath = context.paths.catalogCache
}
// 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.
func readCache() -> CatalogCache? {
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(CatalogCache.self, from: data)
guard cache.version == CatalogCache.currentVersion else {
Self.logger.info("catalog cache schema mismatch (got v\(cache.version), expected v\(CatalogCache.currentVersion)); ignoring")
return nil
}
return cache
} catch {
Self.logger.warning("couldn't decode catalog cache: \(error.localizedDescription, privacy: .public)")
return nil
}
}
private func writeCache(_ cache: CatalogCache) {
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 catalog cache: \(error.localizedDescription, privacy: .public)")
}
}
func isCacheStale(_ cache: CatalogCache) -> Bool {
Date().timeIntervalSince(cache.fetchedAt) > Self.cacheTTL
}
// MARK: - Network fetch
/// Make the catalog GET. Times out after `requestTimeout` so a
/// hung network doesn't block the picker indefinitely. Returns the
/// parsed catalog on success, throws on any HTTP / decode error.
func fetchCatalog() async throws -> Catalog {
var request = URLRequest(url: Self.baseURL)
request.httpMethod = "GET"
request.timeoutInterval = Self.requestTimeout
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.cachePolicy = .reloadIgnoringLocalCacheData
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw CatalogServiceError.transport("non-HTTP response")
}
guard (200..<300).contains(http.statusCode) else {
throw CatalogServiceError.http(status: http.statusCode)
}
do {
return try JSONDecoder().decode(Catalog.self, from: data)
} catch {
throw CatalogServiceError.decode(error.localizedDescription)
}
}
// MARK: - Public entry
/// Top-level "give me the catalog" 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.
func loadCatalog(forceRefresh: Bool = false) async -> CatalogLoadResult {
let cached = readCache()
if let cached, !forceRefresh, !isCacheStale(cached) {
return .cache(catalog: cached.catalog, fetchedAt: cached.fetchedAt, refreshError: nil)
}
do {
let catalog = try await fetchCatalog()
let now = Date()
writeCache(CatalogCache(fetchedAt: now, catalog: catalog))
return .fresh(catalog: catalog, fetchedAt: now)
} catch let error as CatalogServiceError {
if let cached {
Self.logger.warning("catalog refresh failed (\(error.localizedDescription, privacy: .public)); serving stale cache")
return .cache(catalog: cached.catalog, fetchedAt: cached.fetchedAt, refreshError: error.localizedDescription)
}
Self.logger.warning("catalog refresh failed and no cache; serving fallback (\(error.localizedDescription, privacy: .public))")
return .fallback(catalog: Self.fallbackCatalog, reason: error.localizedDescription)
} catch {
if let cached {
return .cache(catalog: cached.catalog, fetchedAt: cached.fetchedAt, refreshError: error.localizedDescription)
}
return .fallback(catalog: Self.fallbackCatalog, reason: error.localizedDescription)
}
}
}
@@ -0,0 +1,105 @@
import Foundation
import UserNotifications
import os
#if canImport(AppKit)
import AppKit
#endif
/// Posts a "Hermes finished responding" local notification when an
/// agent prompt completes while Scarf is not in the foreground
/// (issue #64). Users can switch to other work and learn when their
/// prompt has landed without polling the chat pane.
///
/// Authorization is requested lazily on first use. The user's global
/// toggle (`scarf.chat.notifyOnComplete`, default on) gates posting,
/// and notifications are suppressed when `NSApp.isActive` so users
/// who happen to be looking at the chat aren't pinged for nothing.
@MainActor
final class ChatNotificationService {
static let shared = ChatNotificationService()
private let logger = Logger(subsystem: "com.scarf", category: "ChatNotifications")
private let center = UNUserNotificationCenter.current()
private var hasRequestedAuthorization = false
private var isAuthorized = false
/// AppStorage-shared key for the "notify on completion" toggle.
/// Default true; the toggle lives under Settings Display.
static let toggleKey = "scarf.chat.notifyOnComplete"
private init() {}
/// Post a local notification announcing prompt completion. Quietly
/// no-ops when:
/// - The user has disabled the toggle.
/// - Scarf is the foreground app (the in-chat status indicator
/// is sufficient).
/// - The system has not yet granted (or has denied) notification
/// authorization.
/// `preview` is the first line of the assistant's reply, truncated
/// to a sensible length for the lock-screen / notification center.
func postPromptCompleted(sessionTitle: String?, preview: String) {
let enabled = UserDefaults.standard.object(forKey: Self.toggleKey) as? Bool ?? true
guard enabled else { return }
#if canImport(AppKit)
if NSApp?.isActive == true { return }
#endif
Task { [weak self] in
guard let self else { return }
let granted = await self.ensureAuthorized()
guard granted else { return }
let content = UNMutableNotificationContent()
content.title = sessionTitle?.isEmpty == false
? "Hermes finished — \(sessionTitle ?? "")"
: "Hermes finished responding"
content.body = Self.trimmedPreview(preview)
content.sound = .default
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: nil
)
do {
try await self.center.add(request)
} catch {
self.logger.warning("Notification post failed: \(error.localizedDescription, privacy: .public)")
}
}
}
private func ensureAuthorized() async -> Bool {
if isAuthorized { return true }
if hasRequestedAuthorization {
// Already asked once this run; respect the current settings.
let settings = await center.notificationSettings()
isAuthorized = settings.authorizationStatus == .authorized
return isAuthorized
}
hasRequestedAuthorization = true
do {
let granted = try await center.requestAuthorization(options: [.alert, .sound])
isAuthorized = granted
return granted
} catch {
logger.warning("Notification authorization failed: \(error.localizedDescription, privacy: .public)")
return false
}
}
/// First non-empty line, capped at ~140 chars so the notification
/// surface stays readable on every macOS notification style.
static func trimmedPreview(_ raw: String) -> String {
let firstLine = raw
.split(whereSeparator: \.isNewline)
.first
.map(String.init) ?? raw
let trimmed = firstLine.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.count <= 140 { return trimmed }
let prefix = trimmed.prefix(140).trimmingCharacters(in: .whitespacesAndNewlines)
return prefix + ""
}
}
+139 -16
View File
@@ -129,7 +129,8 @@ struct HermesFileService: Sendable {
skillsHub: aux("skills_hub"),
approval: aux("approval"),
mcp: aux("mcp"),
flushMemories: aux("flush_memories")
flushMemories: aux("flush_memories"),
curator: aux("curator")
)
let security = SecuritySettings(
@@ -287,7 +288,10 @@ struct HermesFileService: Sendable {
matrix: matrix,
mattermost: mattermost,
whatsapp: whatsapp,
homeAssistant: homeAssistant
homeAssistant: homeAssistant,
cacheTTL: str("prompt_caching.cache_ttl", default: "5m"),
redactionEnabled: bool("redaction.enabled", default: false),
runtimeMetadataFooter: bool("agent.runtime_metadata_footer", default: false)
)
}
@@ -486,12 +490,35 @@ struct HermesFileService: Sendable {
}
}
/// Read the most-recent run output for a cron job. Hermes writes
/// `~/.hermes/cron/output/<jobId>/<YYYY-MM-DD_HH-MM-SS>.md` per run
/// (one file per execution); we resolve the per-job subdir, take
/// the lexicographically-last filename (which is the newest given
/// the timestamp prefix), and return its contents. Returns nil
/// when the subdir is missing, empty, or the read fails the cron
/// detail surface treats nil as "no output yet."
///
/// A legacy flat-file layout (`<dir>/<filename containing jobId>`)
/// is checked as a fallback so older Hermes installs that used a
/// non-nested layout still surface their last run.
nonisolated func loadCronOutput(jobId: String) -> String? {
let dir = context.paths.cronOutputDir
guard let files = try? transport.listDirectory(dir) else { return nil }
let matching = files.filter { $0.contains(jobId) }.sorted().last
guard let filename = matching else { return nil }
return readFile(dir + "/" + filename)
let perJobDir = dir + "/" + jobId
if let runs = try? transport.listDirectory(perJobDir),
let latest = runs.sorted().last {
if let content = readFile(perJobDir + "/" + latest) {
return content
}
}
// Legacy fallback: pre-subdir layouts had files like
// `<jobId>-<timestamp>.log` directly under cronOutputDir. Keep
// matching them so users on older Hermes versions still see
// their tail.
if let files = try? transport.listDirectory(dir),
let matching = files.filter({ $0.contains(jobId) }).sorted().last {
return readFile(dir + "/" + matching)
}
return nil
}
// MARK: - Skills
@@ -1442,17 +1469,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 +1527,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
@@ -1510,6 +1600,39 @@ struct HermesFileService: Sendable {
}
}
/// Split-stream variant of `runHermesCLI`. Use this when you need to
/// parse stdout (e.g. JSON output) without stderr contamination, and
/// surface stderr separately as a user-facing error message. Transport
/// failures land in `stderr` with an empty `stdout`.
@discardableResult
nonisolated func runHermesCLISplit(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, stdout: String, stderr: String) {
let binary: String
if context.isRemote {
binary = context.paths.hermesBinary
} else {
guard let local = hermesBinaryPath() else { return (-1, "", "hermes binary not found") }
binary = local
}
let stdinData = stdinInput?.data(using: .utf8)
do {
let result = try transport.runProcess(
executable: binary,
args: args,
stdin: stdinData,
timeout: timeout
)
return (result.exitCode, result.stdoutString, result.stderrString)
} catch let error as TransportError {
let message = error.diagnosticStderr.isEmpty
? (error.errorDescription ?? "transport error")
: error.diagnosticStderr
return (-1, "", message)
} catch {
return (-1, "", error.localizedDescription)
}
}
// MARK: - File I/O
/// Read a UTF-8 text file through the transport. Missing files and any
@@ -0,0 +1,158 @@
import Foundation
import ScarfCore
import os
/// Maps `templateId installedVersion` for every project the user has
/// installed via a template. Used by the catalog browser to render
/// each row's "Installed" / "Update available" / "Not installed" badge.
///
/// **Read-only.** This service walks the projects registry + each
/// project's `.scarf/template.lock.json`. It never writes anything.
///
/// **Per-call rebuild.** The index is cheap to compute (a registry
/// read + N lock-file reads, each a few hundred bytes) and changes
/// infrequently from the user's perspective. We rebuild on every
/// catalog-sheet open instead of caching with invalidation rules
/// the cost of a stale "Installed" badge would surprise users far more
/// than the cost of one extra `[String:Data]` walk on each refresh.
struct InstalledTemplatesIndex: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "InstalledTemplatesIndex")
let context: ServerContext
init(context: ServerContext = .local) {
self.context = context
}
/// Build the index. Returns `[templateId: version]`. Projects
/// without a lock file (ad-hoc projects added via "Add Project")
/// are skipped silently they aren't template-installed and don't
/// belong in the index.
func build() -> [String: String] {
let transport = context.makeTransport()
let registryPath = context.paths.projectsRegistry
guard transport.fileExists(registryPath) else { return [:] }
let data: Data
do {
data = try transport.readFile(registryPath)
} catch {
Self.logger.warning("couldn't read projects registry at \(registryPath, privacy: .public): \(error.localizedDescription, privacy: .public)")
return [:]
}
let registry: ProjectRegistry
do {
registry = try JSONDecoder().decode(ProjectRegistry.self, from: data)
} catch {
Self.logger.warning("couldn't decode projects registry: \(error.localizedDescription, privacy: .public)")
return [:]
}
var index: [String: String] = [:]
for project in registry.projects {
guard let lock = readLock(for: project) else { continue }
// Last-write-wins on duplicates. Two installs of the same
// template id at different versions is rare but possible
// (user installed it in two project dirs); the catalog
// doesn't need to render which version, just that
// *something* is installed.
index[lock.templateId] = lock.templateVersion
}
return index
}
/// Update-availability classification for a single catalog entry.
/// `installedVersion == nil` not installed. Equal versions
/// `.installed`. Catalog version newer than installed `.updateAvailable`.
/// Catalog version older or equal-but-different format `.installed`
/// (we trust the catalog; semver-noise comparisons aren't worth a
/// full parse here).
static func classify(catalogVersion: String, installedVersion: String?) -> InstallState {
guard let installedVersion else { return .notInstalled }
if catalogVersion == installedVersion {
return .installed(version: installedVersion)
}
if isVersionNewer(catalogVersion, than: installedVersion) {
return .updateAvailable(installedVersion: installedVersion, catalogVersion: catalogVersion)
}
return .installed(version: installedVersion)
}
enum InstallState: Sendable, Equatable {
case notInstalled
case installed(version: String)
case updateAvailable(installedVersion: String, catalogVersion: String)
}
// MARK: - Internals
/// Read `<project>/.scarf/template.lock.json`. Returns nil for
/// ad-hoc (non-templated) projects, malformed JSON, or any I/O
/// failure the catalog shouldn't crash because one project's
/// lock file got corrupted.
private func readLock(for project: ProjectEntry) -> TemplateLock? {
let path = project.path + "/.scarf/template.lock.json"
let transport = context.makeTransport()
guard transport.fileExists(path) else { return nil }
let data: Data
do {
data = try transport.readFile(path)
} catch {
Self.logger.warning("couldn't read template lock at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
return nil
}
do {
return try JSONDecoder().decode(TemplateLock.self, from: data)
} catch {
Self.logger.warning("couldn't decode template lock at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
return nil
}
}
/// Plain semver-ish comparison: split on `.`, compare numerically
/// from major down. Pre-release suffixes (anything after `-` in a
/// segment) make that release *older* than the same numeric prefix
/// without a suffix matches semver §11 ("a pre-release version has
/// lower precedence than the associated normal version"), so
/// `1.0.0-beta` is *not* newer than `1.0.0`. Two pre-releases on the
/// same numeric prefix fall back to lexicographic compare on the
/// suffix. Good enough for "is the catalog ahead?" this isn't a
/// package manager.
static func isVersionNewer(_ candidate: String, than other: String) -> Bool {
let (aCore, aPre) = splitPrerelease(candidate)
let (bCore, bPre) = splitPrerelease(other)
let a = aCore.split(separator: ".").map(String.init)
let b = bCore.split(separator: ".").map(String.init)
for i in 0..<max(a.count, b.count) {
let ai = i < a.count ? a[i] : "0"
let bi = i < b.count ? b[i] : "0"
if let an = Int(ai), let bn = Int(bi) {
if an != bn { return an > bn }
} else if ai != bi {
return ai > bi
}
}
// Numeric cores match. Pre-release tiebreak: an absent pre-release
// outranks any present pre-release.
switch (aPre, bPre) {
case (nil, nil): return false
case (nil, _): return true // candidate has no pre-release; older has one newer
case (_, nil): return false // candidate has pre-release; other is the release older
case (let ap?, let bp?): return ap > bp
}
}
/// Split a version string into its numeric core and pre-release
/// suffix on the first `-`. `"1.0.0-beta.2"` `("1.0.0", "beta.2")`.
/// `"1.0.0"` `("1.0.0", nil)`.
private static func splitPrerelease(_ version: String) -> (core: String, pre: String?) {
if let dash = version.firstIndex(of: "-") {
return (String(version[..<dash]), String(version[version.index(after: dash)...]))
}
return (version, nil)
}
}
@@ -0,0 +1,110 @@
import Foundation
import AVFoundation
import os
import Observation
/// Per-message text-to-speech for assistant chat replies (issue #66).
/// Uses `AVSpeechSynthesizer` with the system voice no Hermes
/// dependency, works offline, picks up the user's macOS Spoken Content
/// voice selection automatically.
///
/// One synthesizer is shared across the app so starting a second
/// message's playback automatically interrupts the first. The
/// per-message speaker button reads `playingMessageId` to render
/// play vs. stop state.
///
/// The full Hermes-provider TTS pipeline (Edge / ElevenLabs / OpenAI
/// / NeuTTS / Piper from Settings Voice) is deferred to a follow-up
/// wiring per-provider audio fetching, caching, and interruption
/// is a much bigger surface than what's needed to give users a
/// listen-while-doing-other-work affordance today.
@MainActor
@Observable
final class MessageSpeechService: NSObject {
static let shared = MessageSpeechService()
/// The message id currently being spoken, or `nil` when idle.
/// Bubbles read this to flip their speaker icon to a stop glyph.
private(set) var playingMessageId: Int?
private let synthesizer = AVSpeechSynthesizer()
private let logger = Logger(subsystem: "com.scarf", category: "MessageSpeech")
private override init() {
super.init()
synthesizer.delegate = self
}
/// Speak `content`. If a different message is currently playing,
/// interrupt it. If the same message is currently playing, this
/// stops playback (toggle behavior).
func toggle(messageId: Int, content: String) {
if playingMessageId == messageId {
stop()
return
}
if synthesizer.isSpeaking {
synthesizer.stopSpeaking(at: .immediate)
}
let cleaned = Self.strippedForSpeech(content)
guard !cleaned.isEmpty else { return }
let utterance = AVSpeechUtterance(string: cleaned)
// AVSpeechUtterance honors the user's Spoken Content default
// voice when `voice` is `nil`, which is the right behavior:
// users who configured a specific macOS voice get it
// automatically.
utterance.rate = AVSpeechUtteranceDefaultSpeechRate
playingMessageId = messageId
synthesizer.speak(utterance)
}
/// Stop any in-progress speech and clear `playingMessageId`.
func stop() {
guard playingMessageId != nil else { return }
synthesizer.stopSpeaking(at: .immediate)
playingMessageId = nil
}
/// Strip markdown control characters before speech so the user
/// doesn't hear "asterisk asterisk bold". Code fences and inline
/// code are spoken verbatim minus the backticks. Keeps URLs
/// readable but drops square-bracket link wrappers.
static func strippedForSpeech(_ raw: String) -> String {
var out = raw
// Fenced code blocks keep contents
out = out.replacingOccurrences(of: "```", with: "")
// Inline code drop backticks
out = out.replacingOccurrences(of: "`", with: "")
// Bold/italic markers
out = out.replacingOccurrences(of: "**", with: "")
out = out.replacingOccurrences(of: "__", with: "")
// Link syntax: [text](url) text
if let regex = try? NSRegularExpression(
pattern: #"\[([^\]]+)\]\([^)]+\)"#,
options: []
) {
let range = NSRange(out.startIndex..., in: out)
out = regex.stringByReplacingMatches(
in: out,
options: [],
range: range,
withTemplate: "$1"
)
}
return out.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
extension MessageSpeechService: AVSpeechSynthesizerDelegate {
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
Task { @MainActor in
self.playingMessageId = nil
}
}
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
Task { @MainActor in
self.playingMessageId = nil
}
}
}
@@ -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 {
@@ -1,4 +1,5 @@
import Foundation
import ScarfCore
import Sparkle
/// Thin wrapper around Sparkle's `SPUStandardUpdaterController`.
@@ -24,9 +25,15 @@ final class UpdaterService: NSObject {
override init() {
// startingUpdater: true Sparkle scans for updates on launch per Info.plist schedule.
// Default delegates are sufficient for a non-sandboxed app.
// Under `--scarf-test-mode` we keep Sparkle inert so XCUITest runs
// never see a "an update is available" sheet pop on top of the
// window the test is trying to drive. The controller still
// initializes `automaticallyChecksForUpdates` reads/writes
// continue to work it just doesn't fire the on-launch check
// or surface UI.
let startUpdater = !TestModeFlags.shared.isTestMode
self.controller = SPUStandardUpdaterController(
startingUpdater: true,
startingUpdater: startUpdater,
updaterDelegate: nil,
userDriverDelegate: nil
)
@@ -3,12 +3,22 @@ import SwiftUI
struct MarkdownContentView: View {
let content: String
/// Chat font scale plumbed from `RichChatView` (issue #68). Defaults
/// to 1.0 when this view is used outside the chat surface so other
/// callers see the un-scaled rendering.
@Environment(\.chatFontScale) private var chatFontScale: Double
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(parseBlocks().enumerated()), id: \.offset) { _, block in
blockView(block)
}
}
// Paragraphs are rendered as plain `Text(AttributedString)` and
// inherit whatever font is set on the enclosing scope. Pin the
// scope to the scaled body font so the chat slider actually
// moves the visible text.
.font(ChatFontScale.body(chatFontScale))
}
@ViewBuilder
@@ -37,15 +47,19 @@ struct MarkdownContentView: View {
// MARK: - Block Views
private func headingView(level: Int, text: String) -> some View {
let font: Font = switch level {
case 1: .title.bold()
case 2: .title2.bold()
case 3: .title3.bold()
case 4: .headline
default: .subheadline.bold()
// Heading sizes scale with `chatFontScale` (issue #68). Bases
// mirror the SwiftUI semantic tokens we used previously
// (`.title` 28, `.title2` 22, `.title3` 20, `.headline`
// 17, `.subheadline` 15) so 100% matches today's UI.
let baseSize: CGFloat = switch level {
case 1: 28
case 2: 22
case 3: 20
case 4: 17
default: 15
}
return Text(MarkdownRenderer.inlineAttributedString(text))
.font(font)
.font(.system(size: baseSize * chatFontScale, weight: .semibold))
.textSelection(.enabled)
.padding(.top, level <= 2 ? 8 : 4)
}
@@ -54,11 +68,11 @@ struct MarkdownContentView: View {
VStack(alignment: .leading, spacing: 4) {
if let lang = language, !lang.isEmpty {
Text(lang)
.font(.caption2.bold())
.font(ChatFontScale.caption2(chatFontScale).bold())
.foregroundStyle(.secondary)
}
Text(code)
.font(.system(.callout, design: .monospaced))
.font(ChatFontScale.codeInline(chatFontScale))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -15,6 +15,13 @@ enum ChatDensityKeys {
static let toolCardStyle = "scarf.chat.toolCardStyle"
static let reasoningStyle = "scarf.chat.reasoningStyle"
static let fontScale = "scarf.chat.fontScale"
/// Whether the left sessions list pane is visible in the Mac
/// 3-pane chat layout. Defaults true (today's behavior). Issue #58.
static let showSessionsList = "scarf.chat.showSessionsList"
/// Whether the right tool inspector pane is visible. Defaults true.
/// When hidden, clicking a tool card auto-flips it back on so the
/// click does what the user expects (`ToolCallCard.onFocus`). Issue #58.
static let showInspector = "scarf.chat.showInspector"
}
/// How `RichMessageBubble` renders the per-call tool widgets.
@@ -99,4 +106,74 @@ enum ChatFontScale {
let pct = Int((scale * 100).rounded())
return "\(pct)%"
}
// MARK: - Scaled font helpers
//
// ScarfFont's tokens are fixed-point (`Font.system(size: 14, )`),
// so `.environment(\.dynamicTypeSize, )` doesn't reach them the
// Mac chat slider had no visible effect on bubbles, reasoning,
// tool chips, or code blocks (issue #68). These helpers mirror the
// ScarfFont base sizes, multiplied by the user's chat scale, and
// are used by `RichMessageBubble`, `MarkdownContentView`, and
// `CodeBlockView` in place of the static tokens. At scale = 1.0
// they're byte-for-byte identical to ScarfFont so the default UI
// is unchanged.
static func body(_ scale: Double) -> Font {
.system(size: 14 * scale, weight: .regular)
}
static func bodyEmph(_ scale: Double) -> Font {
.system(size: 14 * scale, weight: .medium)
}
static func callout(_ scale: Double) -> Font {
.system(size: 15 * scale, weight: .regular)
}
static func caption(_ scale: Double) -> Font {
.system(size: 12 * scale, weight: .regular)
}
static func captionStrong(_ scale: Double) -> Font {
.system(size: 12 * scale, weight: .semibold)
}
static func caption2(_ scale: Double) -> Font {
.system(size: 10 * scale, weight: .medium)
}
static func mono(_ scale: Double) -> Font {
.system(size: 13 * scale, weight: .regular, design: .monospaced)
}
static func monoSmall(_ scale: Double) -> Font {
.system(size: 12 * scale, weight: .regular, design: .monospaced)
}
/// Code-block body matches `CodeBlockView`'s 12pt mono.
static func codeBlock(_ scale: Double) -> Font {
.system(size: 12 * scale, weight: .regular, design: .monospaced)
}
/// Inline code in markdown paragraphs `.callout` (15pt) mono.
static func codeInline(_ scale: Double) -> Font {
.system(size: 15 * scale, weight: .regular, design: .monospaced)
}
}
// MARK: - Environment plumbing
private struct ChatFontScaleKey: EnvironmentKey {
static let defaultValue: Double = ChatFontScale.default
}
extension EnvironmentValues {
/// Multiplier applied to chat content fonts. Set once on
/// `RichChatView`'s root so message bubbles, markdown paragraphs,
/// and code blocks scale together. Default 1.0 = today's UI.
var chatFontScale: Double {
get { self[ChatFontScaleKey.self] }
set { self[ChatFontScaleKey.self] = newValue }
}
}
@@ -139,9 +139,27 @@ final class ChatViewModel {
get { richChatViewModel.acpErrorDetails }
set { richChatViewModel.acpErrorDetails = newValue }
}
var acpErrorOAuthProvider: String? {
get { richChatViewModel.acpErrorOAuthProvider }
set { richChatViewModel.acpErrorOAuthProvider = newValue }
}
/// 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
@@ -240,14 +258,32 @@ final class ChatViewModel {
// MARK: - Send Message
func sendText(_ text: String) {
sendText(text, images: [])
}
/// v0.12+ overload: forward image attachments alongside the text.
/// Empty `images` keeps the legacy v0.11 wire shape; non-empty images
/// only flow when `HermesCapabilities.hasACPImagePrompts` is true
/// (the input bar gates the attachment UI on the same flag, so a
/// non-empty array reaching here means we've already verified the
/// agent supports it).
///
/// Terminal mode silently drops attachments there's no way to
/// pipe binary content through the TTY. Surface a one-shot warning
/// so the user knows.
func sendText(_ text: String, images: [ChatImageAttachment]) {
if displayMode == .richChat {
if let client = acpClient {
sendViaACP(client: client, text: text)
sendViaACP(client: client, text: text, images: images)
} else {
// Auto-start ACP and send the queued message
autoStartACPAndSend(text: text)
autoStartACPAndSend(text: text, images: images)
}
} else if let tv = terminalView {
if !images.isEmpty {
logger.warning("Terminal-mode chat dropped \(images.count) image attachment(s) — image input only works in ACP rich-chat mode")
acpError = "Image attachments require ACP mode (rich chat)."
}
sendToTerminal(tv, text: text + "\r")
}
}
@@ -260,7 +296,7 @@ final class ChatViewModel {
/// user never interacted with; those can be garbage-collected by Hermes
/// between the DB read and ACP `session/load`, producing a silent prompt
/// failure with no UI feedback.
private func autoStartACPAndSend(text: String) {
private func autoStartACPAndSend(text: String, images: [ChatImageAttachment] = []) {
// Show the user message immediately
richChatViewModel.addUserMessage(text: text)
@@ -299,7 +335,7 @@ final class ChatViewModel {
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
// Now send the queued prompt
sendViaACP(client: client, text: text)
sendViaACP(client: client, text: text, images: images)
} catch {
acpStatus = "Failed"
await recordACPFailure(error, client: client, context: "Auto-start ACP failed")
@@ -336,7 +372,7 @@ final class ChatViewModel {
return ProjectSlashCommandService(context: context).expand(cmd, withArgument: argument)
}
private func sendViaACP(client: ACPClient, text: String) {
private func sendViaACP(client: ACPClient, text: String, images: [ChatImageAttachment] = []) {
guard let sessionId = richChatViewModel.sessionId else {
clearACPErrorState()
acpError = "No session ID — cannot send"
@@ -376,13 +412,28 @@ final class ChatViewModel {
}
acpPromptTask = Task { @MainActor in
do {
let result = try await client.sendPrompt(sessionId: sessionId, text: wireText)
let result = try await client.sendPrompt(sessionId: sessionId, text: wireText, images: images)
acpStatus = "Ready"
richChatViewModel.handleACPEvent(
.promptComplete(sessionId: sessionId, response: result)
)
// Re-fetch session from DB to pick up cost/token data Hermes may have written
await richChatViewModel.refreshSessionFromDB()
// Issue #64 notify the user that Hermes has
// finished if Scarf isn't the foreground app. The
// notifier handles the foreground/disabled gating;
// we just hand it the latest assistant text and
// session title for the body line.
if !isSteer {
let preview = richChatViewModel.messages
.last(where: { $0.isAssistant })?
.content ?? ""
let title = richChatViewModel.currentSession?.title
ChatNotificationService.shared.postPromptCompleted(
sessionTitle: title,
preview: preview
)
}
} catch is CancellationError {
acpStatus = "Cancelled"
} catch {
@@ -404,6 +455,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 +784,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)"
}
}
@@ -9,7 +9,7 @@ import ScarfDesign
struct ChatTranscriptPane: View {
@Bindable var richChat: RichChatViewModel
@Bindable var chatViewModel: ChatViewModel
var onSend: (String) -> Void
var onSend: (String, [ChatImageAttachment]) -> Void
var isEnabled: Bool
var body: some View {
@@ -34,19 +34,33 @@ 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()
if let hint = richChat.transientHint {
steeringToast(hint)
}
// Issue #62: bind composer identity to the active session
// ID so SwiftUI rebuilds `RichChatInputBar` (and its
// `@State` `text`/`attachments`) when the user switches
// conversations. Without this the composer is structurally
// identical across sessions and SwiftUI happily reuses the
// instance, leaking the unsent draft into the new session.
// A stable fallback id covers the brief "no session
// selected" window using `UUID()` here would mint a
// fresh value per render and trash the composer on every
// body re-eval.
RichChatInputBar(
onSend: onSend,
isEnabled: isEnabled,
commands: richChat.availableCommands,
showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu
)
.id(richChat.sessionId ?? "scarf.chat.no-session")
}
.background(ScarfColor.backgroundPrimary)
}
+74 -2
View File
@@ -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
@@ -107,6 +116,15 @@ struct ChatView: View {
.lineLimit(showErrorDetails ? nil : 2)
}
Spacer()
if let provider = viewModel.acpErrorOAuthProvider {
Button("Re-authenticate") {
coordinator.pendingOAuthReauth = provider
coordinator.selectedSection = .credentialPools
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.help("Open Credential Pools and re-authenticate \(provider).")
}
if viewModel.acpErrorDetails != nil {
Button(showErrorDetails ? "Hide details" : "Show details") {
showErrorDetails.toggle()
@@ -225,6 +243,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")
@@ -363,7 +405,7 @@ struct ChatView: View {
if viewModel.hermesBinaryExists {
RichChatView(
richChat: viewModel.richChatViewModel,
onSend: { viewModel.sendText($0) },
onSend: { text, images in viewModel.sendText(text, images: images) },
isEnabled: viewModel.hasActiveProcess || viewModel.hermesBinaryExists
)
} else {
@@ -386,6 +428,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,11 +453,24 @@ 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
extension RichChatViewModel.PendingPermission: Identifiable {
// `@retroactive` acknowledges that we're declaring conformance for a
// type (`PendingPermission`) and protocol (`Identifiable`) we don't own
// the Swift 6 compiler flags this otherwise so that downstream
// breakage is loud if `ScarfCore` ever adds the conformance upstream.
extension RichChatViewModel.PendingPermission: @retroactive Identifiable {
public var id: Int { requestId }
}
@@ -7,12 +7,16 @@ struct CodeBlockView: View {
@State private var copied = false
/// Chat font scale plumbed from `RichChatView` (issue #68). Defaults
/// to 1.0 outside the chat surface.
@Environment(\.chatFontScale) private var chatFontScale: Double
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if let language, !language.isEmpty {
HStack {
Text(language)
.font(.caption2.bold())
.font(ChatFontScale.caption2(chatFontScale).bold())
.foregroundStyle(.secondary)
Spacer()
copyButton
@@ -31,7 +35,7 @@ struct CodeBlockView: View {
ScrollView(.horizontal, showsIndicators: false) {
Text(code)
.font(.system(size: 12, design: .monospaced))
.font(ChatFontScale.codeBlock(chatFontScale))
.foregroundStyle(Color(nsColor: NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)))
.textSelection(.enabled)
.padding(.horizontal, 10)
@@ -1,20 +1,51 @@
import SwiftUI
import ScarfCore
import ScarfDesign
import UniformTypeIdentifiers
import os
#if canImport(AppKit)
import AppKit
#endif
struct RichChatInputBar: View {
let onSend: (String) -> Void
/// Send the user's text and any attached images. Empty `images`
/// preserves the v0.11 wire shape; non-empty images are forwarded
/// as ACP image content blocks (Hermes v0.12+; the composer hides
/// the attachment UI on older hosts).
let onSend: (String, [ChatImageAttachment]) -> Void
let isEnabled: Bool
var commands: [HermesSlashCommand] = []
var showCompressButton: Bool = false
@Environment(\.hermesCapabilities) private var capabilitiesStore
@State private var text = ""
@State private var showCompressSheet = false
@State private var compressFocus = ""
@State private var showMenu = false
@State private var selectedIndex = 0
@State private var attachments: [ChatImageAttachment] = []
/// True while ImageEncoder is decoding/encoding pasted/dropped bytes.
/// Renders a small spinner in the preview strip so the user knows
/// their drop landed.
@State private var isEncodingAttachment = false
/// User-visible failure (decode failed, format unsupported). Auto-clears.
@State private var attachmentError: String?
@FocusState private var isFocused: Bool
/// Hard cap matches what Hermes' vision aux model swallows comfortably
/// in one prompt. Going higher costs tokens without a quality gain.
private static let maxAttachments = 5
private static let logger = Logger(subsystem: "com.scarf", category: "ChatComposer")
/// `nil` until detection finishes we hide the attachment UI in
/// that brief window (~50ms locally, longer over SSH) so we never
/// flash an attachment chip a v0.11 host couldn't honor.
private var supportsImagePrompts: Bool {
capabilitiesStore?.capabilities.hasACPImagePrompts ?? false
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if showMenu {
@@ -36,6 +67,10 @@ struct RichChatInputBar: View {
.padding(.top, 8)
}
if !attachments.isEmpty || isEncodingAttachment || attachmentError != nil {
attachmentStrip
}
HStack(alignment: .bottom, spacing: ScarfSpace.s2) {
if showCompressButton {
Button {
@@ -52,6 +87,10 @@ struct RichChatInputBar: View {
.help("Compress conversation (/compress)")
}
if supportsImagePrompts {
attachmentButton
}
TextEditor(text: $text)
.font(ScarfFont.body)
.scrollContentBackground(.hidden)
@@ -69,14 +108,66 @@ struct RichChatInputBar: View {
)
)
.overlay(alignment: .topLeading) {
if text.isEmpty {
Text("Message Hermes… / for commands")
.scarfStyle(.body)
.foregroundStyle(ScarfColor.foregroundFaint)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.allowsHitTesting(false)
}
// Placeholder ghosting (#65): TextEditor's
// NSTextView updates the visible glyphs a frame
// before the SwiftUI binding propagates, so a
// bare `if text.isEmpty` overlay renders the
// translucent placeholder text on top of the
// just-typed character visible as a "behind
// or around" ghost. Three mitigations:
//
// 1. Pin an opaque rectangle behind the
// placeholder text. During any single-
// frame lag the user sees a clean
// placeholder, never layered glyphs.
// 2. Use `.opacity(...)` instead of an `if`.
// Keeps the view tree stable per
// keystroke (removes the per-keystroke
// view-mutation churn the composer was
// already paying for).
// 3. Constrain to a single line with
// `frame(maxWidth: .infinity)` and
// `truncationMode(.tail)` so the long-form
// hint can't escape the rounded
// TextEditor bounds when the sidebar /
// detail-pane geometry compresses the
// composer (was visibly overflowing).
Text(supportsImagePrompts
? "Message Hermes… / for commands · drag images to attach"
: "Message Hermes… / for commands")
.scarfStyle(.body)
.foregroundStyle(ScarfColor.foregroundFaint)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(ScarfColor.backgroundSecondary)
// Hide once the field has any content OR
// the user is actively focused matches
// standard NSTextField / UITextField
// placeholder semantics.
.opacity((text.isEmpty && !isFocused) ? 1 : 0)
.allowsHitTesting(false)
}
// Drag-drop image attachments. Receives both file URLs
// (from Finder) and raw image bitmap data (from
// screenshot tools that drop tiff/png directly).
// Capability-gated so v0.11 hosts don't surface a
// drop target that does nothing.
.onDrop(
of: supportsImagePrompts ? [.image, .fileURL] : [],
isTargeted: nil
) { providers in
guard supportsImagePrompts else { return false }
ingestProviders(providers)
return true
}
// Paste from screenshots / browser context menu.
// Accepting `Data` keeps us off `NSImage` which would
// require AppKit-typed paste. v0.12+ only.
.onPasteCommand(of: pasteAcceptedTypes) { providers in
ingestProviders(providers)
}
.onKeyPress(.upArrow, phases: .down) { _ in
guard showMenu, !filteredCommands.isEmpty else { return .ignored }
@@ -140,7 +231,12 @@ struct RichChatInputBar: View {
.onChange(of: text) { _, _ in
updateMenuState()
}
.onChange(of: commands.map(\.id)) { _, _ in
// Watch `commands.count` rather than `commands.map(\.id)` the
// mapped form allocates a fresh `[String]` on every body
// re-eval (i.e. every keystroke), which is wasted work even
// when the array compares equal. The count proxy fires when
// the agent advertises new commands.
.onChange(of: commands.count) { _, _ in
updateMenuState()
}
.sheet(isPresented: $showCompressSheet) {
@@ -148,6 +244,96 @@ struct RichChatInputBar: View {
}
}
/// Horizontal preview strip for attached images. Each chip shows the
/// thumbnail (or a placeholder icon if we couldn't render one) plus
/// an X to remove the attachment.
@ViewBuilder
private var attachmentStrip: some View {
HStack(alignment: .center, spacing: ScarfSpace.s2) {
if isEncodingAttachment {
ProgressView()
.controlSize(.small)
Text("Encoding…")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
ForEach(attachments) { attachment in
attachmentChip(attachment)
}
if let err = attachmentError {
Text(err)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.danger)
}
Spacer(minLength: 0)
if !attachments.isEmpty {
Text("\(attachments.count)/\(Self.maxAttachments)")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
}
}
.padding(.horizontal, ScarfSpace.s3)
.padding(.top, ScarfSpace.s2)
}
@ViewBuilder
private func attachmentChip(_ attachment: ChatImageAttachment) -> some View {
let thumb = chipThumbnail(for: attachment)
HStack(spacing: 4) {
thumb
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
Button {
attachments.removeAll { $0.id == attachment.id }
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(ScarfColor.foregroundMuted)
}
.buttonStyle(.plain)
.help(attachment.filename ?? "Image attachment")
}
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.md)
.fill(ScarfColor.backgroundTertiary)
)
}
/// Render the inline thumbnail for a chip. Falls back to a generic
/// photo icon when the encoder didn't produce a thumbnail (e.g. the
/// image was already small enough to skip the resize step).
@ViewBuilder
private func chipThumbnail(for attachment: ChatImageAttachment) -> some View {
if let thumb = attachment.thumbnailBase64,
let data = Data(base64Encoded: thumb),
let image = NSImage(data: data) {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Image(systemName: "photo")
.foregroundStyle(ScarfColor.foregroundMuted)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(ScarfColor.backgroundSecondary)
}
}
private var attachmentButton: some View {
Button {
presentImagePicker()
} label: {
Image(systemName: "paperclip")
.font(.system(size: 16))
.foregroundStyle(ScarfColor.foregroundMuted)
.padding(6)
}
.buttonStyle(.plain)
.disabled(!isEnabled || attachments.count >= Self.maxAttachments)
.help("Attach image (\(attachments.count)/\(Self.maxAttachments))")
}
private var compressSheet: some View {
VStack(alignment: .leading, spacing: ScarfSpace.s3) {
Text("Compress Conversation")
@@ -164,7 +350,7 @@ struct RichChatInputBar: View {
Button("Compress") {
let focus = compressFocus.trimmingCharacters(in: .whitespacesAndNewlines)
let command = focus.isEmpty ? "/compress" : "/compress \(focus)"
onSend(command)
onSend(command, [])
showCompressSheet = false
}
.buttonStyle(ScarfPrimaryButton())
@@ -176,7 +362,18 @@ struct RichChatInputBar: View {
}
private var canSend: Bool {
isEnabled && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
guard isEnabled else { return false }
// Allow sending image-only messages once at least one attachment
// exists vision models accept "describe this" with no text.
if !attachments.isEmpty { return true }
return !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// MIME types accepted for paste. Restricting to image-bearing
/// providers stops macOS from offering a paste menu when the user
/// has plain text on the clipboard.
private var pasteAcceptedTypes: [UTType] {
supportsImagePrompts ? [.image, .png, .jpeg, .tiff, .heic] : []
}
/// Show the slash menu only while the user is typing the command token:
@@ -197,17 +394,37 @@ struct RichChatInputBar: View {
private func updateMenuState() {
let shouldShow = shouldShowMenu
// Common case: user is composing normal text and the menu is
// already hidden. Skip the filter computation + state writes
// entirely so onChange stays cheap. Without this guard typing
// recomputes `filteredCommands` on every keystroke even when
// the menu can't possibly appear.
guard shouldShow || showMenu else { return }
// Compute desired selection, then only write what changed.
// SwiftUI emits "onChange action tried to update multiple
// times per frame" when an onChange handler mutates more than
// one piece of state per frame; the warning correlates with
// unusable typing lag because each redundant write triggers
// another body re-eval.
let count = filteredCommands.count
let newSelection: Int
if count == 0 {
newSelection = 0
} else if selectedIndex >= count {
newSelection = count - 1
} else if selectedIndex < 0 {
newSelection = 0
} else {
newSelection = selectedIndex
}
if shouldShow != showMenu {
showMenu = shouldShow
}
// Re-clamp selection whenever the filtered list may have shrunk.
let count = filteredCommands.count
if count == 0 {
selectedIndex = 0
} else if selectedIndex >= count {
selectedIndex = count - 1
} else if selectedIndex < 0 {
selectedIndex = 0
if newSelection != selectedIndex {
selectedIndex = newSelection
}
}
@@ -224,12 +441,118 @@ struct RichChatInputBar: View {
private func send() {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, isEnabled else { return }
onSend(trimmed)
guard canSend else { return }
onSend(trimmed, attachments)
text = ""
attachments.removeAll()
showMenu = false
selectedIndex = 0
}
// MARK: - Attachment ingestion
/// Pull image bytes out of a set of `NSItemProvider`s (drag/drop or
/// paste). Each provider may carry a file URL OR raw image data
/// we try both. Caps at `maxAttachments`; surplus drops are
/// dropped silently with a status message.
private func ingestProviders(_ providers: [NSItemProvider]) {
let remainingSlots = Self.maxAttachments - attachments.count
guard remainingSlots > 0 else {
attachmentError = "Limit of \(Self.maxAttachments) images reached"
scheduleAttachmentErrorClear()
return
}
let toIngest = providers.prefix(remainingSlots)
for provider in toIngest {
ingestProvider(provider)
}
}
private func ingestProvider(_ provider: NSItemProvider) {
// Prefer file URL when available gives us the original filename
// for the attachment chip's tooltip.
if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
isEncodingAttachment = true
provider.loadObject(ofClass: URL.self) { url, _ in
guard let url, let data = try? Data(contentsOf: url) else {
Task { @MainActor in
isEncodingAttachment = false
attachmentError = "Couldn't read dropped file"
scheduleAttachmentErrorClear()
}
return
}
encode(data: data, filename: url.lastPathComponent)
}
return
}
for typeId in [UTType.image.identifier, UTType.png.identifier, UTType.jpeg.identifier, UTType.tiff.identifier, UTType.heic.identifier] {
if provider.hasItemConformingToTypeIdentifier(typeId) {
isEncodingAttachment = true
provider.loadDataRepresentation(forTypeIdentifier: typeId) { data, _ in
guard let data else {
Task { @MainActor in
isEncodingAttachment = false
attachmentError = "Couldn't decode pasted image"
scheduleAttachmentErrorClear()
}
return
}
encode(data: data, filename: nil)
}
return
}
}
}
private func encode(data: Data, filename: String?) {
Task.detached(priority: .userInitiated) {
do {
let attachment = try ImageEncoder().encode(rawBytes: data, sourceFilename: filename)
await MainActor.run {
isEncodingAttachment = false
attachments.append(attachment)
}
} catch {
await MainActor.run {
isEncodingAttachment = false
attachmentError = (error as? LocalizedError)?.errorDescription ?? "Couldn't encode image"
Self.logger.warning("ImageEncoder failed: \(error.localizedDescription, privacy: .public)")
scheduleAttachmentErrorClear()
}
}
}
}
private func scheduleAttachmentErrorClear() {
Task { @MainActor in
try? await Task.sleep(nanoseconds: 4_000_000_000)
attachmentError = nil
}
}
private func presentImagePicker() {
#if canImport(AppKit)
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowedContentTypes = [.image, .png, .jpeg, .tiff, .heic]
panel.message = "Choose images to attach"
panel.prompt = "Attach"
let response = panel.runModal()
guard response == .OK else { return }
let urls = Array(panel.urls.prefix(Self.maxAttachments - attachments.count))
guard !urls.isEmpty else { return }
isEncodingAttachment = true
Task.detached(priority: .userInitiated) {
for url in urls {
guard let data = try? Data(contentsOf: url) else { continue }
encode(data: data, filename: url.lastPathComponent)
}
}
#endif
}
}
private extension Array {
@@ -15,6 +15,13 @@ struct RichChatMessageList: View {
/// bubble's metadata footer can render the v2.5 stopwatch pill.
/// Defaults empty so callers that don't care can omit it.
var turnDurations: [Int: TimeInterval] = [:]
/// Show the "Load earlier messages" button at the top of the
/// transcript when the underlying session has more on-disk
/// history that hasn't been paged in yet. Hidden by default so
/// existing callers who haven't opted in see no UI change.
var hasMoreHistory: Bool = false
var isLoadingEarlier: Bool = false
var onLoadEarlier: (() -> Void)? = nil
/// Scrolling strategy: plain `VStack` (not `LazyVStack`) plus
/// `.defaultScrollAnchor(.bottom)`.
@@ -57,6 +64,30 @@ struct RichChatMessageList: View {
.transition(.opacity)
}
if hasMoreHistory, let onLoadEarlier {
Button {
onLoadEarlier()
} label: {
HStack(spacing: 6) {
if isLoadingEarlier {
ProgressView().scaleEffect(0.7)
} else {
Image(systemName: "arrow.up.circle")
.font(.caption)
}
Text(isLoadingEarlier ? "Loading earlier…" : "Load earlier messages")
.font(.caption)
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.regularMaterial, in: Capsule())
}
.buttonStyle(.plain)
.disabled(isLoadingEarlier)
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
}
ForEach(groups) { group in
MessageGroupView(group: group, turnDurations: turnDurations)
.equatable()
@@ -17,7 +17,7 @@ import ScarfDesign
/// can scroll horizontally inside the panes rather than losing them.
struct RichChatView: View {
@Bindable var richChat: RichChatViewModel
var onSend: (String) -> Void
var onSend: (String, [ChatImageAttachment]) -> Void
var isEnabled: Bool
@Environment(HermesFileWatcher.self) private var fileWatcher
@Environment(ChatViewModel.self) private var chatViewModel
@@ -29,14 +29,25 @@ struct RichChatView: View {
@AppStorage(ChatDensityKeys.fontScale)
private var fontScale: Double = ChatFontScale.default
/// Sessions-list / inspector pane visibility (issue #58). Defaults
/// `true` so existing users see no change until they opt out via
/// the toolbar buttons or Settings Display Chat density.
@AppStorage(ChatDensityKeys.showSessionsList)
private var showSessionsList: Bool = true
@AppStorage(ChatDensityKeys.showInspector)
private var showInspector: Bool = true
/// In ACP mode, events drive updates directly no DB polling needed.
private var isACPMode: Bool { chatViewModel.isACPConnected }
var body: some View {
HStack(spacing: 0) {
ChatSessionListPane(chatViewModel: chatViewModel, richChat: richChat)
.frame(width: 264)
Divider().background(ScarfColor.border)
if showSessionsList {
ChatSessionListPane(chatViewModel: chatViewModel, richChat: richChat)
.frame(width: 264)
.transition(.move(edge: .leading).combined(with: .opacity))
Divider().background(ScarfColor.border)
}
ChatTranscriptPane(
richChat: richChat,
chatViewModel: chatViewModel,
@@ -44,12 +55,35 @@ 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))
// ScarfFont tokens are fixed-point so dynamicTypeSize alone
// doesn't move bubble / markdown / code-block text. Plumb the
// raw scale via `\.chatFontScale` so chat content views can
// read it and scale their explicit sizes too (issue #68).
.environment(\.chatFontScale, 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 {
@@ -14,6 +14,11 @@ struct RichMessageBubble: View, Equatable {
@Environment(ChatViewModel.self) private var chatViewModel
/// Chat-only font scale set on `RichChatView`. Chat content uses
/// these multiplied sizes (issue #68); other surfaces still see
/// the static ScarfFont tokens at scale = 1.0.
@Environment(\.chatFontScale) private var chatFontScale: Double
/// 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
@@ -68,7 +73,7 @@ struct RichMessageBubble: View, Equatable {
HStack {
Spacer(minLength: 80)
Text(message.content)
.scarfStyle(.body)
.font(ChatFontScale.body(chatFontScale))
.foregroundStyle(ScarfColor.onAccent)
.textSelection(.enabled)
.padding(.horizontal, 14)
@@ -91,7 +96,7 @@ struct RichMessageBubble: View, Equatable {
.font(.system(size: 9))
.foregroundStyle(ScarfColor.success)
Text(time, style: .time)
.font(ScarfFont.caption2)
.font(ChatFontScale.caption2(chatFontScale))
.foregroundStyle(ScarfColor.foregroundFaint)
}
.padding(.trailing, 4)
@@ -183,7 +188,7 @@ struct RichMessageBubble: View, Equatable {
private var reasoningDisclosure: some View {
DisclosureGroup {
Text(message.preferredReasoning ?? "")
.font(ScarfFont.monoSmall)
.font(ChatFontScale.monoSmall(chatFontScale))
.foregroundStyle(ScarfColor.foregroundMuted)
.italic()
.textSelection(.enabled)
@@ -194,11 +199,11 @@ struct RichMessageBubble: View, Equatable {
Image(systemName: "brain")
.font(.system(size: 11))
Text("REASONING")
.scarfStyle(.captionStrong)
.font(ChatFontScale.captionStrong(chatFontScale))
.tracking(0.5)
if let tokens = message.tokenCount, tokens > 0 {
Text("· \(tokens) tok")
.font(ScarfFont.monoSmall)
.font(ChatFontScale.monoSmall(chatFontScale))
.foregroundStyle(ScarfColor.foregroundFaint)
}
}
@@ -222,7 +227,7 @@ struct RichMessageBubble: View, Equatable {
.font(.system(size: 9))
.foregroundStyle(ScarfColor.warning)
Text(message.preferredReasoning ?? "")
.font(ScarfFont.caption)
.font(ChatFontScale.caption(chatFontScale))
.italic()
.foregroundStyle(ScarfColor.foregroundFaint)
.textSelection(.enabled)
@@ -281,7 +286,7 @@ struct RichMessageBubble: View, Equatable {
.font(.system(size: 10))
.foregroundStyle(color)
Text(call.functionName)
.font(ScarfFont.monoSmall)
.font(ChatFontScale.monoSmall(chatFontScale))
.fontWeight(.medium)
.foregroundStyle(ScarfColor.foregroundPrimary)
.lineLimit(1)
@@ -341,28 +346,70 @@ struct RichMessageBubble: View, Equatable {
HStack(spacing: 8) {
if let tokens = message.tokenCount, tokens > 0 {
Text("\(tokens) tok")
.font(ScarfFont.monoSmall)
.font(ChatFontScale.monoSmall(chatFontScale))
}
if let reason = message.finishReason, !reason.isEmpty {
Text("·")
Text(reason)
.scarfStyle(.caption)
.font(ChatFontScale.caption(chatFontScale))
}
if let time = message.timestamp {
Text("·")
Text(time, style: .time)
.scarfStyle(.caption)
.font(ChatFontScale.caption(chatFontScale))
}
if let seconds = turnDuration {
Text("·")
Text(RichChatViewModel.formatTurnDuration(seconds))
.font(ScarfFont.monoSmall)
.font(ChatFontScale.monoSmall(chatFontScale))
.help("Wall-clock duration of this turn")
}
// Per-message TTS playback toggle (issue #66). Only on
// settled assistant bubbles streaming bubble (id == 0)
// would speak partial text. Empty content has nothing to
// speak.
if message.id != 0, !message.content.isEmpty {
speakButton
}
}
.font(ChatFontScale.caption(chatFontScale))
.foregroundStyle(ScarfColor.foregroundFaint)
.padding(.leading, 4)
}
/// Speaker glyph that toggles `AVSpeechSynthesizer` playback for
/// the assistant reply. Lives in its own view so the
/// `MessageSpeechService` observation doesn't fight the bubble's
/// `Equatable` short-circuit the parent only needs to pass
/// stable id + content; this view re-renders on its own when
/// playback state flips.
private var speakButton: some View {
SpeakMessageButton(messageId: message.id, content: message.content)
}
}
/// Stand-alone speaker button so the `MessageSpeechService`
/// observation doesn't get short-circuited by `RichMessageBubble`'s
/// `Equatable`. Only the button re-renders when playback flips
/// the bubble itself stays optimised.
private struct SpeakMessageButton: View {
let messageId: Int
let content: String
@State private var speech = MessageSpeechService.shared
var body: some View {
let isPlaying = speech.playingMessageId == messageId
Button {
speech.toggle(messageId: messageId, content: content)
} label: {
Image(systemName: isPlaying ? "stop.circle.fill" : "speaker.wave.2")
.font(.system(size: 11))
.foregroundStyle(isPlaying ? ScarfColor.accent : ScarfColor.foregroundFaint)
}
.buttonStyle(.plain)
.help(isPlaying ? "Stop speaking" : "Read this reply aloud")
}
}
// MARK: - Content Block Parsing
@@ -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] {
@@ -6,6 +6,14 @@ struct CredentialPoolsView: View {
@State private var viewModel: CredentialPoolsViewModel
@State private var showAddSheet = false
@State private var pendingRemove: HermesCredential?
/// When non-nil, `AddCredentialSheet` opens pre-seeded with this
/// provider name + OAuth type driven by the chat banner's
/// "Re-authenticate" button via `AppCoordinator.pendingOAuthReauth`,
/// or by clicking the per-row "Re-authenticate" button in this
/// view. Reset to nil when the sheet dismisses so the next plain
/// "Add Credential" press doesn't accidentally inherit it.
@State private var reauthInitialProvider: String?
@Environment(AppCoordinator.self) private var coordinator
init(context: ServerContext) {
_viewModel = State(initialValue: CredentialPoolsViewModel(context: context))
@@ -20,9 +28,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,11 +48,17 @@ 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) {
AddCredentialSheet(viewModel: viewModel) {
.onAppear {
viewModel.load()
consumePendingReauth()
}
.onChange(of: coordinator.pendingOAuthReauth) { _, _ in
consumePendingReauth()
}
.sheet(isPresented: $showAddSheet, onDismiss: { reauthInitialProvider = nil }) {
AddCredentialSheet(viewModel: viewModel, initialProvider: reauthInitialProvider) {
showAddSheet = false
}
}
@@ -61,6 +78,19 @@ struct CredentialPoolsView: View {
}
}
/// Drain any pending re-auth hand-off from the chat banner: the
/// banner's "Re-authenticate" button writes to
/// `coordinator.pendingOAuthReauth` and switches to this view; we
/// pick the value up here, seed the sheet's initial provider, and
/// clear the slot so navigating back to this view doesn't re-open
/// the sheet.
private func consumePendingReauth() {
guard let pending = coordinator.pendingOAuthReauth else { return }
reauthInitialProvider = pending
showAddSheet = true
coordinator.pendingOAuthReauth = nil
}
private var header: some View {
ScarfPageHeader(
"Credential Pools",
@@ -114,6 +144,108 @@ 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()
Button("Re-authenticate") {
reauthInitialProvider = provider.provider
showAddSheet = true
}
.controlSize(.small)
// `Text(verbatim:)` skips the LocalizedStringKey
// overload that would interpret the backticks as
// markdown inline-code styling `.help(_:)` rejects
// styled Text. Plain string preserves the backticks
// literally.
.help(Text(verbatim: "Run `hermes auth add \(provider.provider) --type oauth` again to refresh this provider's tokens."))
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
HStack {
Text("Click Re-authenticate to refresh tokens. Removing or rotating providers is still done via `hermes auth …` in a terminal.")
.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") {
@@ -243,8 +375,25 @@ struct CredentialPoolsView: View {
/// OAuth flow so the user can paste the authorization code back.
private struct AddCredentialSheet: View {
@Bindable var viewModel: CredentialPoolsViewModel
/// Optional pre-fill from the re-auth path. When non-nil, the sheet
/// opens with this provider name + OAuth selected, mirroring the
/// state the user would otherwise have to type. Plain "Add
/// Credential" presses leave it nil.
let initialProvider: String?
let onDismiss: () -> Void
init(
viewModel: CredentialPoolsViewModel,
initialProvider: String? = nil,
onDismiss: @escaping () -> Void
) {
self.viewModel = viewModel
self.initialProvider = initialProvider
self.onDismiss = onDismiss
_providerID = State(initialValue: initialProvider ?? "")
_authType = State(initialValue: initialProvider == nil ? .apiKey : .oauth)
}
enum AuthType: String, CaseIterable, Identifiable {
case apiKey = "API Key"
case oauth = "OAuth"
@@ -258,11 +407,16 @@ private struct AddCredentialSheet: View {
}
}
@State private var providerID: String = ""
@State private var authType: AuthType = .apiKey
@State private var providerID: String
@State private var authType: AuthType
@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 +445,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
@@ -24,6 +24,16 @@ final class CronViewModel {
var editingJob: HermesCronJob?
var isLoading = false
/// Classified hint for the selected job's `lastError`, computed via
/// `ACPErrorHint.classify` so cron rows surface the same OAuth-revoked
/// affordance that ChatView's banner offers. `nil` when the selected
/// job has no error or the error doesn't match a known pattern the
/// detail pane falls back to rendering `lastError` raw.
var selectedErrorClassification: ACPErrorHint.Classification? {
guard let job = selectedJob, let lastError = job.lastError, !lastError.isEmpty else { return nil }
return ACPErrorHint.classify(errorMessage: lastError, stderrTail: "")
}
func load() {
isLoading = true
let svc = fileService
@@ -131,19 +141,24 @@ final class CronViewModel {
}
}
func createJob(schedule: String, prompt: String, name: String, deliver: String, skills: [String], script: String, repeatCount: String) {
func createJob(schedule: String, prompt: String, name: String, deliver: String, skills: [String], script: String, repeatCount: String, workdir: String = "") {
var args = ["cron", "create"]
if !name.isEmpty { args += ["--name", name] }
if !deliver.isEmpty { args += ["--deliver", deliver] }
if !repeatCount.isEmpty { args += ["--repeat", repeatCount] }
for skill in skills where !skill.isEmpty { args += ["--skill", skill] }
if !script.isEmpty { args += ["--script", script] }
// v0.12+: --workdir injects AGENTS.md/CLAUDE.md context and pins
// cwd for terminal/file/code_exec tools. Hermes pre-v0.12 doesn't
// know the flag argparse rejects unknown args, so the form
// omits the flag when the field is empty.
if !workdir.isEmpty { args += ["--workdir", workdir] }
args.append(schedule)
if !prompt.isEmpty { args.append(prompt) }
runAndReload(args, success: "Job created")
}
func updateJob(id: String, schedule: String?, prompt: String?, name: String?, deliver: String?, repeatCount: String?, newSkills: [String]?, clearSkills: Bool, script: String?) {
func updateJob(id: String, schedule: String?, prompt: String?, name: String?, deliver: String?, repeatCount: String?, newSkills: [String]?, clearSkills: Bool, script: String?, workdir: String? = nil) {
var args = ["cron", "edit", id]
if let schedule, !schedule.isEmpty { args += ["--schedule", schedule] }
if let prompt, !prompt.isEmpty { args += ["--prompt", prompt] }
@@ -156,6 +171,10 @@ final class CronViewModel {
for skill in newSkills where !skill.isEmpty { args += ["--skill", skill] }
}
if let script { args += ["--script", script] }
// `nil` = caller didn't touch the field (omit the flag). Empty string
// = user cleared an existing workdir; Hermes documents `--workdir ""`
// on edit as the explicit clear gesture, mirroring the `--script` shape.
if let workdir { args += ["--workdir", workdir] }
runAndReload(args, success: "Updated")
}
+194 -12
View File
@@ -12,11 +12,19 @@ import ScarfDesign
struct CronView: View {
@State private var viewModel: CronViewModel
@State private var pendingDelete: HermesCronJob?
@State private var showOutputPanel: Bool = false
@Environment(\.hermesCapabilities) private var capabilitiesStore
@Environment(AppCoordinator.self) private var coordinator
@Environment(HermesFileWatcher.self) private var fileWatcher
init(context: ServerContext) {
_viewModel = State(initialValue: CronViewModel(context: context))
}
private var hasCronWorkdir: Bool {
capabilitiesStore?.capabilities.hasCronWorkdir ?? false
}
var body: some View {
VStack(spacing: 0) {
pageHeader
@@ -31,8 +39,15 @@ struct CronView: View {
.navigationTitle("Cron Jobs")
.loadingOverlay(viewModel.isLoading, label: "Loading cron jobs…", isEmpty: viewModel.jobs.isEmpty)
.onAppear { viewModel.load() }
// Reload on Hermes file mutations Hermes flips `state` between
// "scheduled" and "running" inside `~/.hermes/cron/jobs.json`
// when a job starts/finishes, and writes a new run-output file
// under `~/.hermes/cron/output/`. The watcher gives us the
// running indicator + log tail refresh "for free" without a
// polling timer. Same wiring ActivityView uses.
.onChange(of: fileWatcher.lastChangeDate) { viewModel.load() }
.sheet(isPresented: $viewModel.showCreateSheet) {
CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills) { form in
CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills, supportsWorkdir: hasCronWorkdir) { form in
viewModel.createJob(
schedule: form.schedule,
prompt: form.prompt,
@@ -40,7 +55,8 @@ struct CronView: View {
deliver: form.deliver,
skills: form.skills,
script: form.script,
repeatCount: form.repeatCount
repeatCount: form.repeatCount,
workdir: hasCronWorkdir ? form.workdir : ""
)
viewModel.showCreateSheet = false
} onCancel: {
@@ -48,7 +64,7 @@ struct CronView: View {
}
}
.sheet(item: $viewModel.editingJob) { job in
CronJobEditor(mode: .edit(job), availableSkills: viewModel.availableSkills) { form in
CronJobEditor(mode: .edit(job), availableSkills: viewModel.availableSkills, supportsWorkdir: hasCronWorkdir) { form in
viewModel.updateJob(
id: job.id,
schedule: form.schedule,
@@ -58,7 +74,8 @@ struct CronView: View {
repeatCount: form.repeatCount,
newSkills: form.skills,
clearSkills: form.clearSkills,
script: form.script
script: form.script,
workdir: hasCronWorkdir ? form.workdir : nil
)
viewModel.editingJob = nil
} onCancel: {
@@ -165,6 +182,13 @@ struct CronView: View {
Circle()
.fill(statusDotColor(job))
.frame(width: 7, height: 7)
.opacity(job.state == "running" ? 0.55 : 1.0)
.animation(
job.state == "running"
? .easeInOut(duration: 0.9).repeatForever(autoreverses: true)
: .default,
value: job.state
)
}
HStack(spacing: 10) {
Text(job.schedule.expression ?? job.schedule.display ?? "")
@@ -214,7 +238,13 @@ struct CronView: View {
}
private func statusDotColor(_ job: HermesCronJob) -> Color {
// Order matters: a currently-running job overrides a stale
// lastError so the user sees "yes, retrying right now" rather
// than "still showing the old failure." Disabled wins over
// everything else a paused job isn't running, regardless
// of state-field churn.
if !job.enabled { return ScarfColor.foregroundFaint }
if job.state == "running" { return ScarfColor.info }
if job.lastError != nil { return ScarfColor.danger }
return ScarfColor.success
}
@@ -265,6 +295,9 @@ struct CronView: View {
.foregroundStyle(ScarfColor.foregroundPrimary)
ScarfBadge(job.enabled ? "active" : "paused",
kind: job.enabled ? .success : .neutral)
if job.state == "running" {
ScarfBadge("running…", kind: .info)
}
}
Text(CronScheduleFormatter.humanReadable(from: job.schedule))
.scarfStyle(.footnote)
@@ -413,26 +446,165 @@ struct CronView: View {
}
if let error = job.lastError {
errorBanner(job: job, error: error)
}
outputPanel(job: job)
}
/// Last-error surface. When `ACPErrorHint` recognizes the message
/// (OAuth refresh-revoked, missing credentials, SSH failure, etc.),
/// it renders the human hint + raw error + a re-auth button when
/// applicable. Otherwise falls back to the legacy single-line
/// red text same chrome the view used pre-PR for unrecognized
/// errors. Mirrors `ChatView.errorBanner` so the recovery flow is
/// identical between cron and chat.
@ViewBuilder
private func errorBanner(job: HermesCronJob, error: String) -> some View {
if let classification = viewModel.selectedErrorClassification {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
VStack(alignment: .leading, spacing: 4) {
Text(classification.hint)
.scarfStyle(.body)
.foregroundStyle(ScarfColor.foregroundPrimary)
.textSelection(.enabled)
Text(error)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.textSelection(.enabled)
.lineLimit(2)
}
Spacer(minLength: ScarfSpace.s2)
if let provider = classification.oauthProvider {
Button("Re-authenticate") {
coordinator.pendingOAuthReauth = provider
coordinator.selectedSection = .credentialPools
}
.buttonStyle(ScarfPrimaryButton())
.help("Open Credential Pools and re-authenticate \(provider).")
}
}
}
.padding(ScarfSpace.s3)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
.fill(ScarfColor.warning.opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
.strokeBorder(ScarfColor.warning.opacity(0.25), lineWidth: 1)
)
} else {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
Text(error)
.scarfStyle(.caption)
.textSelection(.enabled)
}
.foregroundStyle(ScarfColor.danger)
}
}
if let output = viewModel.jobOutput {
sectionBlock("LAST OUTPUT") {
Text(output)
.font(ScarfFont.monoSmall)
.foregroundStyle(ScarfColor.foregroundPrimary)
.textSelection(.enabled)
.padding(ScarfSpace.s3)
.frame(maxWidth: .infinity, alignment: .leading)
/// Per-job run-output panel. Always visible; collapsed by default
/// with a one-line summary so the detail pane stays scannable when
/// the user has dozens of cron jobs. Expanded body mirrors the
/// dark monospaced tail layout `LogsView` uses, fed by
/// `HermesFileService.loadCronOutput` (Hermes writes per-run files
/// under `~/.hermes/cron/output/<jobId>-*`). Reload happens via the
/// outer `HermesFileWatcher` `.onChange` when a fresh run lands a
/// new output file, the VM re-reads on the next mtime tick.
@ViewBuilder
private func outputPanel(job: HermesCronJob) -> some View {
let summary = outputSummary(job)
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
Button {
showOutputPanel.toggle()
} label: {
HStack(spacing: ScarfSpace.s2) {
Image(systemName: showOutputPanel ? "chevron.down" : "chevron.right")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(ScarfColor.foregroundMuted)
Text("LAST RUN OUTPUT")
.scarfStyle(.captionUppercase)
.foregroundStyle(ScarfColor.foregroundMuted)
Text(summary)
.font(ScarfFont.monoSmall)
.foregroundStyle(ScarfColor.foregroundFaint)
.lineLimit(1)
Spacer()
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if showOutputPanel {
if let output = viewModel.jobOutput, !output.isEmpty {
ScrollViewReader { proxy in
ScrollView {
Text(output)
.font(ScarfFont.monoSmall)
.foregroundStyle(ScarfColor.foregroundPrimary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(ScarfSpace.s3)
.id("cron-output-bottom")
}
.frame(maxHeight: 320)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
.fill(Color(red: 0.07, green: 0.06, blue: 0.05))
)
.overlay(
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
.strokeBorder(ScarfColor.border, lineWidth: 1)
)
// Auto-scroll to the latest line whenever the
// output content changes (a new run lands).
.onChange(of: output) {
withAnimation(.easeOut(duration: 0.18)) {
proxy.scrollTo("cron-output-bottom", anchor: .bottom)
}
}
.onAppear {
proxy.scrollTo("cron-output-bottom", anchor: .bottom)
}
}
} else {
Text("No output yet — this job hasn't run, or its output file is gone.")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(ScarfSpace.s3)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
.fill(ScarfColor.backgroundSecondary)
)
.overlay(
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
.strokeBorder(ScarfColor.border, lineWidth: 1)
)
}
}
}
}
/// One-line summary rendered next to the LAST RUN OUTPUT chevron
/// when the panel is collapsed. Gives a quick "yes there's content"
/// (or "no output yet") read without expanding.
private func outputSummary(_ job: HermesCronJob) -> String {
let timestamp = job.lastRunAt.map { CronScheduleFormatter.formatNextRun(iso: $0) } ?? "never"
let status: String = {
if job.state == "running" { return "running…" }
if job.lastError != nil { return "error" }
if job.lastRunAt != nil { return "ok" }
return "no runs yet"
}()
return "\(timestamp)\(status)"
}
@ViewBuilder
private func sectionBlock<Content: View>(_ title: String, @ViewBuilder _ content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
@@ -468,10 +640,16 @@ struct CronJobEditor: View {
var skills: [String] = []
var clearSkills: Bool = false
var script: String = ""
/// v0.12+ workdir flag fills `--workdir <path>`. Empty string
/// preserves the v0.11 behaviour of running with no cwd hint.
var workdir: String = ""
}
let mode: Mode
let availableSkills: [String]
/// Pass `false` on pre-v0.12 hosts; the `--workdir` field is hidden and
/// the form's value is dropped when the parent calls `createJob`/`updateJob`.
let supportsWorkdir: Bool
let onSave: (FormState) -> Void
let onCancel: () -> Void
@@ -506,6 +684,9 @@ struct CronJobEditor: View {
formField("Deliver", text: $form.deliver, placeholder: "origin | local | discord:CHANNEL | telegram:CHAT", mono: true)
formField("Repeat", text: $form.repeatCount, placeholder: "Optional count")
formField("Script path", text: $form.script, placeholder: "Python script whose stdout is injected", mono: true)
if supportsWorkdir {
formField("Workdir", text: $form.workdir, placeholder: "Absolute path; pulls AGENTS.md/CLAUDE.md context", mono: true)
}
if !availableSkills.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Skills")
@@ -564,6 +745,7 @@ struct CronJobEditor: View {
form.deliver = job.deliver ?? ""
form.skills = job.skills ?? []
form.script = job.preRunScript ?? ""
form.workdir = job.workdir ?? ""
}
}
}
@@ -0,0 +1,67 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Modal that lists archived skills (state active) and exposes a
/// one-click "Restore" action per row. v0.12 archives are recoverable
/// `hermes curator restore <name>` brings the skill back into
/// `~/.hermes/skills/<category>/<name>/` and re-marks it active.
///
/// The Curator's `status` text doesn't enumerate archived skills with
/// names; we surface what's available (counts + pinned list) and rely
/// on the user knowing the names. Hermes ergo does an interactive
/// `--name` arg if missing but Scarf prefers explicit selection so
/// users don't have to remember names. For v2.6 we render a free-form
/// text field; once Hermes ships a `curator list-archived` (tracked
/// upstream), swap to a pickable list.
struct CuratorRestoreSheet: View {
let viewModel: CuratorViewModel
@Environment(\.dismiss) private var dismiss
@State private var skillName: String = ""
@State private var isRestoring = false
var body: some View {
VStack(alignment: .leading, spacing: ScarfSpace.s3) {
Text("Restore Archived Skill")
.scarfStyle(.headline)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text("Hermes archives skills the curator decides are stale or redundant. Restoring brings the original SKILL.md back into place — no data lost.")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
VStack(alignment: .leading, spacing: ScarfSpace.s1) {
Text("Skill name")
.scarfStyle(.captionUppercase)
.foregroundStyle(ScarfColor.foregroundMuted)
ScarfTextField("e.g. legacy-helper", text: $skillName)
}
Text("\(viewModel.status.archivedSkills) archived skill(s) available — list them with `hermes curator status`.")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
HStack {
Spacer()
Button("Cancel") { dismiss() }
.buttonStyle(ScarfGhostButton())
Button("Restore") {
let trimmed = skillName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
isRestoring = true
Task {
await viewModel.restore(trimmed)
isRestoring = false
dismiss()
}
}
.buttonStyle(ScarfPrimaryButton())
.keyboardShortcut(.defaultAction)
.disabled(isRestoring || skillName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(ScarfSpace.s5)
.frame(width: 420)
}
}
@@ -0,0 +1,322 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Mac UI for Hermes v0.12's autonomous skill curator.
///
/// Surfaces the running state (enabled / paused / disabled), last-run
/// metadata, agent-created skill counts, and the most/least-active /
/// least-recently-active leaderboards. Pin-and-restore actions hit
/// `hermes curator pin/unpin/restore` via CuratorViewModel.
///
/// Capability-gated upstream: AppCoordinator only wires the sidebar
/// item when `HermesCapabilities.hasCurator` is true. This view assumes
/// it's reachable on a v0.12+ host.
struct CuratorView: View {
@State private var viewModel: CuratorViewModel
@State private var showRestoreSheet = false
init(context: ServerContext) {
_viewModel = State(initialValue: CuratorViewModel(context: context))
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: ScarfSpace.s4) {
ScarfPageHeader(
"Curator",
subtitle: "Autonomous skill maintenance — Hermes v0.12+"
) {
HStack(spacing: ScarfSpace.s2) {
if viewModel.isLoading {
ProgressView().controlSize(.small)
}
Button("Run Now") {
Task { await viewModel.runNow() }
}
.buttonStyle(ScarfPrimaryButton())
.disabled(viewModel.isLoading)
Menu {
switch viewModel.status.state {
case .paused:
Button("Resume") { Task { await viewModel.resume() } }
case .enabled:
Button("Pause") { Task { await viewModel.pause() } }
default:
EmptyView()
}
Button("Restore Archived…") {
showRestoreSheet = true
}
.disabled(viewModel.status.archivedSkills == 0)
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
if let toast = viewModel.transientMessage {
transientToast(toast)
}
statusSummary
skillCountsSection
pinnedSection
activityTables
if let report = viewModel.lastReportMarkdown {
lastReportSection(markdown: report)
}
}
.padding(ScarfSpace.s4)
}
.background(ScarfColor.backgroundPrimary)
.task { await viewModel.load() }
.sheet(isPresented: $showRestoreSheet) {
CuratorRestoreSheet(viewModel: viewModel)
}
}
private var statusSummary: some View {
ScarfCard {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
HStack {
statusBadge
Spacer()
Text("\(viewModel.status.runCount) runs")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
ScarfDivider()
infoRow(label: "Last run", value: viewModel.status.lastRunISO ?? "Never")
if let summary = viewModel.status.lastSummary {
infoRow(label: "Last summary", value: summary)
}
infoRow(label: "Interval", value: viewModel.status.intervalLabel)
infoRow(label: "Stale after", value: viewModel.status.staleAfterLabel)
infoRow(label: "Archive after", value: viewModel.status.archiveAfterLabel)
}
}
}
private var statusBadge: some View {
let kind: ScarfBadgeKind
let label: String
switch viewModel.status.state {
case .enabled: kind = .success; label = "Enabled"
case .paused: kind = .warning; label = "Paused"
case .disabled: kind = .neutral; label = "Disabled"
case .unknown: kind = .neutral; label = "Unknown"
}
return ScarfBadge(label, kind: kind)
}
private var skillCountsSection: some View {
ScarfCard {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
ScarfSectionHeader("Agent-created skills")
HStack(spacing: ScarfSpace.s4) {
countCell(value: viewModel.status.totalSkills, label: "Total")
countCell(value: viewModel.status.activeSkills, label: "Active")
countCell(value: viewModel.status.staleSkills, label: "Stale")
countCell(value: viewModel.status.archivedSkills, label: "Archived")
Spacer(minLength: 0)
}
}
}
}
@ViewBuilder
private var pinnedSection: some View {
if !viewModel.status.pinnedNames.isEmpty {
ScarfCard {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
ScarfSectionHeader("Pinned")
Text("Pinned skills are never auto-archived or rewritten by the curator.")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
FlowLayout(spacing: ScarfSpace.s2) {
ForEach(viewModel.status.pinnedNames, id: \.self) { name in
HStack(spacing: 4) {
Image(systemName: "pin.fill")
.font(.system(size: 10))
Text(name)
.scarfStyle(.caption)
Button {
Task { await viewModel.unpin(name) }
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 11))
.foregroundStyle(ScarfColor.foregroundMuted)
}
.buttonStyle(.plain)
.help("Unpin")
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.md)
.fill(ScarfColor.accentTint)
)
}
}
}
}
}
}
private var activityTables: some View {
VStack(alignment: .leading, spacing: ScarfSpace.s4) {
if !viewModel.status.leastRecentlyActive.isEmpty {
skillTable(title: "Least recently active", rows: viewModel.status.leastRecentlyActive)
}
if !viewModel.status.mostActive.isEmpty {
skillTable(title: "Most active", rows: viewModel.status.mostActive)
}
if !viewModel.status.leastActive.isEmpty {
skillTable(title: "Least active", rows: viewModel.status.leastActive)
}
}
}
private func skillTable(title: String, rows: [HermesCuratorSkillRow]) -> some View {
ScarfCard {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
ScarfSectionHeader(title)
ForEach(rows) { row in
HStack(alignment: .center, spacing: ScarfSpace.s2) {
Text(row.name)
.scarfStyle(.body)
.foregroundStyle(ScarfColor.foregroundPrimary)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
counterChip(label: "use", value: row.useCount)
counterChip(label: "view", value: row.viewCount)
counterChip(label: "patch", value: row.patchCount)
Text(row.lastActivityLabel)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
.frame(width: 92, alignment: .trailing)
Button {
Task { await viewModel.pin(row.name) }
} label: {
Image(systemName: viewModel.status.pinnedNames.contains(row.name)
? "pin.fill" : "pin")
.font(.system(size: 12))
}
.buttonStyle(.plain)
.help(viewModel.status.pinnedNames.contains(row.name) ? "Pinned" : "Pin skill")
}
.padding(.vertical, 2)
}
}
}
}
private func counterChip(label: String, value: Int) -> some View {
Text("\(label) \(value)")
.font(ScarfFont.monoSmall)
.foregroundStyle(ScarfColor.foregroundMuted)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.sm)
.fill(ScarfColor.backgroundTertiary)
)
}
private func lastReportSection(markdown: String) -> some View {
ScarfCard {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
ScarfSectionHeader("Last report")
Text(markdown)
.scarfStyle(.mono)
.foregroundStyle(ScarfColor.foregroundPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
}
}
private func infoRow(label: String, value: String) -> some View {
HStack(alignment: .top) {
Text(label)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.frame(width: 110, alignment: .leading)
Text(value)
.scarfStyle(.body)
.foregroundStyle(ScarfColor.foregroundPrimary)
Spacer(minLength: 0)
}
}
private func countCell(value: Int, label: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text("\(value)")
.scarfStyle(.title2)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text(label)
.scarfStyle(.captionUppercase)
.foregroundStyle(ScarfColor.foregroundMuted)
}
.frame(minWidth: 64, alignment: .leading)
}
private func transientToast(_ text: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(ScarfColor.success)
Text(text)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundPrimary)
Spacer(minLength: 0)
}
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 6)
.background(ScarfColor.accentTint)
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.md))
}
}
/// Simple `FlowLayout` for the pinned-skill chips. Custom layout
/// keeps the chip wrap behaviour predictable across DynamicType
/// scales without resorting to LazyVGrid (which forces fixed columns).
private struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > maxWidth {
x = 0
y += rowHeight + spacing
rowHeight = 0
}
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
return CGSize(width: maxWidth, height: y + rowHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var x = bounds.minX
var y = bounds.minY
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > bounds.maxX {
x = bounds.minX
y += rowHeight + spacing
rowHeight = 0
}
subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
@@ -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()
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(shadow.hasAuthJSON
? "Copies a one-liner that consolidates this project's auth.json into your global ~/.hermes/ and renames the shadow .hermes/ aside as .hermes.scarf-bak.<timestamp>/ so it stops binding. Run it on the remote, then refresh the Dashboard."
: "Copies a one-liner that renames this project's shadow .hermes/ aside as .hermes.scarf-bak.<timestamp>/ so Hermes' CLI stops binding to it as $HERMES_HOME. 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 {
@@ -180,7 +180,7 @@ final class HealthViewModel {
("skills_hub", config.auxiliary.skillsHub.provider),
("approval", config.auxiliary.approval.provider),
("mcp", config.auxiliary.mcp.provider),
("flush_memories", config.auxiliary.flushMemories.provider),
("curator", config.auxiliary.curator.provider),
].filter { $0.1 == "nous" }.map(\.0)
if !auxOnNous.isEmpty {
checks.append(HealthCheck(
@@ -0,0 +1,116 @@
import Foundation
import Observation
import ScarfCore
import os
/// Read-only view of `hermes kanban list --json`. Multi-profile
/// collaboration was reverted upstream while the design is reworked,
/// so v2.6 ships read-only on Mac and defers create/claim/dispatch UI
/// to v2.7+.
///
/// Polls every 5s while foregrounded so dispatcher progress is visible
/// without manual refresh; the polling task is suspended when the view
/// disappears so background windows don't keep hammering SSH.
@Observable
@MainActor
final class KanbanViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "KanbanViewModel")
let context: ServerContext
private let fileService: HermesFileService
init(context: ServerContext = .local) {
self.context = context
self.fileService = HermesFileService(context: context)
}
var tasks: [HermesKanbanTask] = []
var isLoading = false
var lastError: String?
var statusFilter: StatusFilter = .all
/// Subset Hermes accepts on `--status`. `.all` skips the flag.
enum StatusFilter: String, CaseIterable, Identifiable {
case all
case triage
case todo
case ready
case running
case blocked
case done
case archived
var id: String { rawValue }
var label: String {
switch self {
case .all: return "All"
default: return rawValue.capitalized
}
}
}
private var pollTask: Task<Void, Never>?
func startPolling() {
stopPolling()
pollTask = Task { [weak self] in
while !Task.isCancelled {
await self?.load()
try? await Task.sleep(nanoseconds: 5_000_000_000)
}
}
}
func stopPolling() {
pollTask?.cancel()
pollTask = nil
}
func load() async {
isLoading = true
let svc = fileService
let filter = statusFilter
let result = await Task.detached { () -> (exitCode: Int32, stdout: String, stderr: String) in
var args = ["kanban", "list", "--json"]
if filter != .all {
args.append(contentsOf: ["--status", filter.rawValue])
}
return svc.runHermesCLISplit(args: args, timeout: 15)
}.value
defer { isLoading = false }
guard result.exitCode == 0 else {
lastError = result.stderr.isEmpty
? "kanban list failed (\(result.exitCode))"
: result.stderr
tasks = []
return
}
guard let data = result.stdout.data(using: .utf8) else {
lastError = "kanban list returned non-UTF8 output"
tasks = []
return
}
do {
let decoded = try JSONDecoder().decode([HermesKanbanTask].self, from: data)
tasks = decoded
lastError = nil
} catch {
// Hermes may print a "no matching tasks" line as text instead of
// empty JSON; handle gracefully so the UI shows an empty list
// without raising an error banner.
if result.stdout.contains("no matching tasks") {
tasks = []
lastError = nil
return
}
logger.warning("kanban JSON decode failed: \(error.localizedDescription, privacy: .public)")
lastError = "Couldn't parse kanban list output"
tasks = []
}
}
}
@@ -0,0 +1,167 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Mac UI for `hermes kanban list` (v0.12+). Read-only create / claim
/// / dispatch / dependency-link UI is deferred until upstream
/// stabilizes the multi-profile collaboration design.
///
/// Capability-gated upstream: AppCoordinator only routes to this view
/// when `HermesCapabilities.hasKanban` is true.
struct KanbanView: View {
@State private var viewModel: KanbanViewModel
init(context: ServerContext) {
_viewModel = State(initialValue: KanbanViewModel(context: context))
}
var body: some View {
VStack(spacing: 0) {
ScarfPageHeader(
"Kanban",
subtitle: "Hermes v0.12+ task board (read-only)"
) {
HStack(spacing: ScarfSpace.s2) {
Picker("Status", selection: $viewModel.statusFilter) {
ForEach(KanbanViewModel.StatusFilter.allCases) { f in
Text(f.label).tag(f)
}
}
.pickerStyle(.menu)
.frame(width: 120)
Button {
Task { await viewModel.load() }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(ScarfGhostButton())
}
}
Divider()
if let err = viewModel.lastError {
errorBanner(err)
}
ScrollView {
if viewModel.tasks.isEmpty && !viewModel.isLoading {
emptyState
} else {
taskTable
}
}
}
.background(ScarfColor.backgroundPrimary)
.onChange(of: viewModel.statusFilter) { _, _ in
Task { await viewModel.load() }
}
.onAppear { viewModel.startPolling() }
.onDisappear { viewModel.stopPolling() }
}
private var taskTable: some View {
VStack(spacing: 0) {
ForEach(viewModel.tasks) { task in
taskRow(task)
Divider()
}
}
.padding(ScarfSpace.s3)
}
private func taskRow(_ task: HermesKanbanTask) -> some View {
HStack(alignment: .top, spacing: ScarfSpace.s3) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: ScarfSpace.s2) {
statusBadge(for: task.status)
Text(task.title)
.scarfStyle(.bodyEmph)
.foregroundStyle(ScarfColor.foregroundPrimary)
.lineLimit(1)
}
HStack(spacing: 12) {
metaChip(systemImage: "number", value: task.id.prefix(8) + "")
if let assignee = task.assignee, !assignee.isEmpty {
metaChip(systemImage: "person.fill", value: assignee)
}
if let workspace = task.workspaceKind {
metaChip(systemImage: "folder", value: workspace)
}
if !task.skills.isEmpty {
metaChip(systemImage: "lightbulb", value: task.skills.joined(separator: ", "))
}
Spacer(minLength: 0)
}
}
Spacer(minLength: 0)
VStack(alignment: .trailing, spacing: 2) {
if let createdAt = task.createdAt {
Text(createdAt)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
}
if let priority = task.priority {
Text("p\(priority)")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
}
}
.padding(.vertical, ScarfSpace.s2)
}
private func statusBadge(for status: String) -> some View {
let kind: ScarfBadgeKind
switch status.lowercased() {
case "done": kind = .success
case "running": kind = .info
case "ready": kind = .info
case "blocked": kind = .warning
case "archived": kind = .neutral
default: kind = .neutral
}
return ScarfBadge(status, kind: kind)
}
private func metaChip(systemImage: String, value: String) -> some View {
HStack(spacing: 3) {
Image(systemName: systemImage)
.font(.system(size: 10))
Text(value)
.font(ScarfFont.monoSmall)
}
.foregroundStyle(ScarfColor.foregroundMuted)
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "rectangle.split.3x1")
.font(.system(size: 36))
.foregroundStyle(ScarfColor.foregroundFaint)
Text("No kanban tasks")
.scarfStyle(.headline)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text("Create one with `hermes kanban create \"task title\"`. Tasks dispatched by the gateway show up here automatically.")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.multilineTextAlignment(.center)
.frame(maxWidth: 460)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 60)
}
private func errorBanner(_ message: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
Text(message)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundPrimary)
}
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(ScarfColor.warning.opacity(0.12))
}
}
@@ -147,6 +147,8 @@ struct PlatformsView: View {
case "imessage": IMessageSetupView(context: ctx)
case "homeassistant": HomeAssistantSetupView(context: ctx)
case "webhook": WebhookSetupView(context: ctx)
case "yuanbao": yuanbaoPanel
case "microsoft-teams": microsoftTeamsPanel
default:
SettingsSection(title: LocalizedStringKey(viewModel.selected.displayName), icon: KnownPlatforms.icon(for: viewModel.selected.name)) {
ReadOnlyRow(label: "Setup", value: "No setup form for this platform yet.")
@@ -154,6 +156,30 @@ struct PlatformsView: View {
}
}
/// Hermes v0.12 Yuanbao ships as a native gateway adapter
/// (the 18th platform). Setup is YAML-driven; we surface the
/// shell command and a docs link rather than a per-field form
/// because the auth dance is OAuth-style and lives outside Scarf.
private var yuanbaoPanel: some View {
SettingsSection(title: "Yuanbao 元宝", icon: KnownPlatforms.icon(for: "yuanbao")) {
ReadOnlyRow(label: "Type", value: "Native gateway adapter (v0.12+)")
ReadOnlyRow(label: "Setup", value: "Run `hermes setup` and select Yuanbao to walk the OAuth flow.")
ReadOnlyRow(label: "Multi-image", value: "Supported via the gateway's centralized media routing.")
ReadOnlyRow(label: "Configured", value: viewModel.hasConfigBlock(for: viewModel.selected) ? "Yes" : "No")
}
}
/// Hermes v0.12 Microsoft Teams ships as a plugin (the 19th
/// platform). Surface that explicitly so users know the setup
/// path differs from the native adapters.
private var microsoftTeamsPanel: some View {
SettingsSection(title: "Microsoft Teams", icon: KnownPlatforms.icon(for: "microsoft-teams")) {
ReadOnlyRow(label: "Type", value: "Plugin-shipped gateway platform (v0.12+)")
ReadOnlyRow(label: "Setup", value: "Install the plugin from the Plugins tab, then run `hermes setup` to register the bot.")
ReadOnlyRow(label: "Configured", value: viewModel.hasConfigBlock(for: viewModel.selected) ? "Yes" : "No")
}
}
private var cliPanel: some View {
SettingsSection(title: "CLI", icon: "terminal") {
ReadOnlyRow(label: "Scope", value: "Local terminal sessions")
@@ -1,3 +1,4 @@
import AppKit
import Foundation
import ScarfCore
import os
@@ -50,8 +51,65 @@ final class ProfilesViewModel {
}
}
/// Set the active profile via `hermes profile use <name>` without
/// relaunching Scarf. Most users will reach for `switchAndRelaunch`
/// instead kept here so the context-menu "Use" item stays
/// functional and so callers that genuinely want a no-relaunch
/// switch (tests, scripted setups) have a path. Invalidates the
/// resolver cache on success so the next `context.paths` access
/// picks up the new home directory.
func switchTo(_ profile: HermesProfile) {
runAndReload(["profile", "use", profile.name], success: "Active profile set to \(profile.name)")
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: ["profile", "use", profile.name], timeout: 60)
await MainActor.run {
if result.exitCode == 0 {
HermesProfileResolver.invalidateCache()
self.message = "Active profile set to \(profile.name) — restart Scarf to refresh."
} else {
self.message = "Failed: \(result.output.prefix(120))"
}
self.load()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
}
/// Set the active profile and immediately relaunch Scarf. The
/// canonical user-facing switch path (issue #70): a fresh process
/// guarantees every service constructs from the new
/// `~/.hermes/active_profile` value, sidestepping any in-process
/// state that might still be holding the previous profile's
/// data. Failures fall back to a "restart manually" toast.
@MainActor
func switchAndRelaunch(_ profile: HermesProfile) {
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: ["profile", "use", profile.name], timeout: 30)
await MainActor.run {
guard result.exitCode == 0 else {
self.message = "Failed: \(result.output.prefix(120))"
self.load()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
return
}
HermesProfileResolver.invalidateCache()
do {
try AppRelauncher.relaunch()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
NSApp.terminate(nil)
}
} catch AppRelauncher.RelaunchError.debugBuild {
self.message = "Profile switched to \(profile.name). Restart Scarf manually (Xcode-launched instance)."
self.load()
} catch {
self.message = "Profile switched to \(profile.name). Please quit and reopen Scarf manually."
self.load()
}
}
}
}
func create(name: String, cloneConfig: Bool, cloneAll: Bool) {
@@ -20,6 +20,22 @@ struct ProfilesView: View {
@State private var renameTarget: HermesProfile?
@State private var renameNewName = ""
@State private var pendingDelete: HermesProfile?
/// Profile the user has clicked "Switch & Relaunch" on, awaiting
/// confirmation before we run `hermes profile use` and exit. The
/// confirmation step is load-bearing relaunching closes every
/// open Scarf window in the process, so the user needs an explicit
/// agreement.
@State private var pendingSwitch: 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 +69,48 @@ struct ProfilesView: View {
} message: {
Text("This removes the profile directory and all data within it. This cannot be undone.")
}
.confirmationDialog(
pendingSwitch.map { "Switch to '\($0.name)' and relaunch Scarf?" } ?? "",
isPresented: Binding(get: { pendingSwitch != nil }, set: { if !$0 { pendingSwitch = nil } })
) {
Button("Switch & Relaunch") {
if let profile = pendingSwitch { viewModel.switchAndRelaunch(profile) }
pendingSwitch = nil
}
Button("Cancel", role: .cancel) { pendingSwitch = nil }
} message: {
Text("All Scarf windows will close and reopen. Unsaved chat input may be lost.")
}
.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 +130,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")
@@ -112,18 +178,29 @@ struct ProfilesView: View {
}
.tag(profile.id)
.contextMenu {
Button("Use") { viewModel.switchTo(profile) }
Button("Switch & Relaunch") { pendingSwitch = profile }
.disabled(profile.isActive)
Button("Set Active (no relaunch)") { viewModel.switchTo(profile) }
.disabled(profile.isActive)
Button("Rename") {
renameTarget = profile
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()
@@ -158,16 +235,17 @@ struct ProfilesView: View {
Spacer()
if !profile.isActive {
Button {
viewModel.switchTo(profile)
pendingSwitch = profile
} label: {
Label("Switch to This Profile", systemImage: "arrow.triangle.swap")
Label("Switch & Relaunch", systemImage: "arrow.triangle.2.circlepath")
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.help("Set as active profile and relaunch Scarf so every tab loads from \(profile.name)")
}
}
if !profile.isActive {
profileSwitchWarning
profileSwitchInfo
}
SettingsSection(title: "Details", icon: "info.circle") {
if !profile.path.isEmpty {
@@ -198,16 +276,16 @@ struct ProfilesView: View {
}
}
private var profileSwitchWarning: some View {
private var profileSwitchInfo: some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.orange)
Text("Switching the active profile changes the `~/.hermes` directory hermes uses. Restart Scarf after switching so it re-reads from the new profile's files.")
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
Text("**Switch & Relaunch** sets this as the active profile (writes `~/.hermes/active_profile`) and relaunches Scarf so every tab — Webhooks, Sessions, SOUL.md, Memory — reloads from the new profile's `~/.hermes/profiles/<name>/` directory.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(10)
.background(.orange.opacity(0.1))
.background(ScarfColor.backgroundSecondary)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
@@ -264,3 +342,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
}
}
@@ -161,6 +161,7 @@ struct ProjectsSidebar: View {
.truncationMode(.tail)
}
.tag(project)
.accessibilityIdentifier("projects.row.\(project.name)")
.contextMenu {
projectContextMenu(project)
}
@@ -190,6 +191,7 @@ struct ProjectsSidebar: View {
Button("Uninstall Template (remove installed files)…", systemImage: "trash") {
onUninstallTemplate(project)
}
.accessibilityIdentifier("projects.contextMenu.uninstallTemplate")
Divider()
}
Button("Remove from List (keep files)…", systemImage: "minus.circle") {
@@ -40,6 +40,7 @@ struct ProjectsView: View {
@State private var exportSheetProject: ProjectEntry?
@State private var showingInstallURLPrompt = false
@State private var installURLInput = ""
@State private var showingCatalogSheet = false
@State private var showingUninstallSheet = false
@State private var configEditorProject: ProjectEntry?
/// Project queued for the "remove from list" confirmation dialog.
@@ -132,6 +133,17 @@ struct ProjectsView: View {
.sheet(isPresented: $showingInstallURLPrompt) {
installURLSheet
}
.sheet(isPresented: $showingCatalogSheet) {
CatalogView { url in
// Hand the catalog's HTTPS URL to the existing install
// flow no new entry-point logic, just a different
// way to surface the URL. The install sheet's
// `awaitingParentDirectory` stage takes over from here.
installerViewModel.openRemoteURL(url)
showingCatalogSheet = false
showingInstallSheet = true
}
}
.sheet(isPresented: $showingUninstallSheet) {
TemplateUninstallSheet(viewModel: uninstallerViewModel) { removed in
// Refresh the registry and clear selection if we just
@@ -198,13 +210,20 @@ struct ProjectsView: View {
private var templatesToolbar: some ToolbarContent {
ToolbarItem(placement: .primaryAction) {
Menu {
Button("Browse Catalog…", systemImage: "books.vertical") {
showingCatalogSheet = true
}
.accessibilityIdentifier("templates.browseCatalog")
Divider()
Button("Install from File…", systemImage: "tray.and.arrow.down") {
openInstallFilePicker()
}
.accessibilityIdentifier("templates.installFromFile")
Button("Install from URL…", systemImage: "link") {
installURLInput = ""
showingInstallURLPrompt = true
}
.accessibilityIdentifier("templates.installFromURL")
Divider()
if let selected = viewModel.selectedProject {
Button("Export \"\(selected.name)\" as Template…", systemImage: "tray.and.arrow.up") {
@@ -217,6 +236,16 @@ struct ProjectsView: View {
} label: {
Label("Templates", systemImage: "shippingbox")
}
// `.accessibilityElement(children: .ignore)` collapses
// the inner Label's automatic accessibility tree so our
// explicit identifier sticks. Without it, SwiftUI uses
// the systemImage name (`chevron.down` in macOS toolbar
// contexts) as the menu button's accessibility identifier
// and our `.accessibilityIdentifier` is silently
// overridden verified via XCUITest tree dump.
.accessibilityElement(children: .ignore)
.accessibilityLabel("Templates")
.accessibilityIdentifier("templates.toolbar.menu")
}
}
@@ -229,6 +258,7 @@ struct ProjectsView: View {
.foregroundStyle(.secondary)
TextField("https://example.com/my.scarftemplate", text: $installURLInput)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("templates.installURL.field")
HStack {
Button("Cancel") { showingInstallURLPrompt = false }
.keyboardShortcut(.cancelAction)
@@ -243,6 +273,7 @@ struct ProjectsView: View {
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(URL(string: installURLInput)?.scheme?.lowercased() != "https")
.accessibilityIdentifier("templates.installURL.confirm")
}
}
.padding()
@@ -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
)
}

Some files were not shown because too many files have changed in this diff Show More