Compare commits

...

24 Commits

Author SHA1 Message Date
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
115 changed files with 9545 additions and 140 deletions
+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.
+121
View File
@@ -0,0 +1,121 @@
## 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
- **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.
@@ -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 ?? [:]
@@ -468,35 +501,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 +553,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,10 +562,24 @@ 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
@@ -528,6 +588,40 @@ public enum ACPClientError: Error, LocalizedError {
public enum ACPErrorHint {
public static func classify(errorMessage: String, stderrTail: String) -> String? {
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 "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 "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 "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 "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 "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 "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."
}
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")
@@ -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
@@ -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,6 +75,17 @@ 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" }
@@ -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"
}
}
@@ -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
@@ -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
}
@@ -425,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",
@@ -476,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
),
]
}
@@ -101,14 +101,55 @@ public struct ProjectHermesShadowDetector: Sendable {
return found
}
/// Suggested shell command the user can copy-paste / run on the remote
/// to consolidate a shadow's auth.json into their global Hermes home.
/// Skips state.db / sessions / skills migration intentionally those
/// require Hermes to be quiesced and risk data loss; the user should
/// decide what to keep on a case-by-case basis. We give them the
/// load-bearing one-liner (auth) and let them handle the rest.
/// 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? {
guard shadow.hasAuthJSON else { return nil }
return "cp \(shadow.shadowPath)/auth.json \(hermesHome)/auth.json && chmod 600 \(hermesHome)/auth.json"
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)
)
}
@@ -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
@@ -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
@@ -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
@@ -110,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 {
@@ -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
}
}
@@ -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")
}
}
@@ -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
@@ -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() }
}
+74
View File
@@ -30,12 +30,43 @@ 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
@@ -118,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.
@@ -147,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
@@ -181,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: {
@@ -197,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) {
+217 -7
View File
@@ -3,6 +3,9 @@ import ScarfCore
import ScarfIOS
import ScarfDesign
import os
#if canImport(PhotosUI)
import PhotosUI
#endif
// The Chat feature on iOS is gated on `canImport(SQLite3)` because
// `RichChatViewModel` reads session history from `HermesDataService`
@@ -24,9 +27,23 @@ struct ChatView: View {
@Environment(\.scarfGoCoordinator) private var coordinator
@Environment(\.serverContext) private var envContext
@Environment(\.hermesCapabilities) private var capabilitiesStore
@State private var controller: ChatController
@State private var showProjectPicker = false
@State private var showSlashCommandsSheet = false
/// PhotosPicker selection. Bridge between SwiftUI's selection
/// binding and our `ChatImageAttachment` payload `loadTransferable`
/// produces raw `Data` we then hand to `ImageEncoder`. v0.12+ only.
@State private var pickerSelection: [PhotosPickerItem] = []
@State private var showPhotoPicker = false
@State private var isEncodingAttachment = false
@State private var attachmentError: String?
private static let maxAttachments = 5
private var supportsImagePrompts: Bool {
capabilitiesStore?.capabilities.hasACPImagePrompts ?? false
}
/// Drives the composer's keyboard. Bound to the TextField via
/// `.focused(...)`; cleared by the scroll-to-dismiss gesture on
/// the message list AND by an explicit keyboard-toolbar button.
@@ -431,7 +448,108 @@ struct ChatView: View {
}
private var composer: some View {
VStack(alignment: .leading, spacing: 4) {
if !controller.attachments.isEmpty || isEncodingAttachment || attachmentError != nil {
attachmentStrip
}
composerRow
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.regularMaterial)
#if canImport(PhotosUI)
.photosPicker(
isPresented: $showPhotoPicker,
selection: $pickerSelection,
maxSelectionCount: max(0, Self.maxAttachments - controller.attachments.count),
matching: .images
)
.onChange(of: pickerSelection) { _, items in
ingestPickerItems(items)
}
#endif
}
@ViewBuilder
private var attachmentStrip: some View {
HStack(alignment: .center, spacing: 8) {
if isEncodingAttachment {
ProgressView().controlSize(.small)
Text("Encoding…")
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(controller.attachments) { attachment in
attachmentChip(attachment)
}
if let err = attachmentError {
Text(err)
.font(.caption)
.foregroundStyle(ScarfColor.danger)
}
Spacer(minLength: 0)
if !controller.attachments.isEmpty {
Text("\(controller.attachments.count)/\(Self.maxAttachments)")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
@ViewBuilder
private func attachmentChip(_ attachment: ChatImageAttachment) -> some View {
HStack(spacing: 4) {
attachmentChipThumbnail(attachment)
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
Button {
controller.attachments.removeAll { $0.id == attachment.id }
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Remove attached image")
}
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(ScarfColor.backgroundSecondary)
)
}
@ViewBuilder
private func attachmentChipThumbnail(_ attachment: ChatImageAttachment) -> some View {
if let thumb = attachment.thumbnailBase64,
let data = Data(base64Encoded: thumb),
let image = UIImage(data: data) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Image(systemName: "photo")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(ScarfColor.backgroundSecondary)
}
}
private var composerRow: some View {
HStack(alignment: .bottom, spacing: 8) {
if supportsImagePrompts {
Button {
showPhotoPicker = true
} label: {
Image(systemName: "paperclip")
.font(.system(size: 22))
.foregroundStyle(.secondary)
.padding(.bottom, 4)
}
.buttonStyle(.plain)
.disabled(controller.state != .ready || controller.attachments.count >= Self.maxAttachments)
.accessibilityLabel("Attach image")
}
TextField(
"Message…",
text: $controller.draft,
@@ -480,13 +598,89 @@ struct ChatView: View {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 28))
}
.disabled(controller.state != .ready || controller.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.disabled(!canSendComposer)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.regularMaterial)
}
/// Send is enabled when ready AND we have either text or at least
/// one attachment. Image-only sends are valid for vision models.
private var canSendComposer: Bool {
guard controller.state == .ready else { return false }
if !controller.attachments.isEmpty { return true }
return !controller.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// Pull JPEG/PNG bytes out of each PhotosPickerItem and feed them
/// through ImageEncoder. Detached so the heavyweight resize +
/// JPEG-encode work doesn't block MainActor; the resulting
/// attachment hops back to MainActor for state mutation.
///
/// PhotosPickerItem can deliver `Data` directly via the
/// `Transferable` API. After ingestion the binding is reset so a
/// follow-up pick triggers `onChange` again.
#if canImport(PhotosUI)
private func ingestPickerItems(_ items: [PhotosPickerItem]) {
guard !items.isEmpty else { return }
// Cap up front and snapshot so the slot calculation is honest under
// concurrent ingestion (we'd otherwise have to re-check
// controller.attachments.count after every parallel completion).
let remainingSlots = Self.maxAttachments - controller.attachments.count
let snapshot = Array(items.prefix(max(remainingSlots, 0)))
// Clear the binding immediately so a follow-up pick triggers onChange
// even when the user re-selects the same image set (PhotosPicker
// doesn't re-fire onChange unless the binding flips through nil).
pickerSelection = []
guard !snapshot.isEmpty else { return }
isEncodingAttachment = true
Task { @MainActor in
// Run loadTransferable + encode for each item in parallel.
// iCloud-backed PHAssets are network-bound, so 5 picks finish
// closer to 1 round-trip than 5 sequential ones. Errors carry
// a Sendable String (not the Error itself) since `any Error`
// isn't Sendable under strict concurrency.
let outcomes = await withTaskGroup(
of: (index: Int, attachment: ChatImageAttachment?, errorMessage: String?).self
) { group in
for (index, item) in snapshot.enumerated() {
group.addTask {
do {
guard let data = try await item.loadTransferable(type: Data.self) else {
return (index, nil, nil)
}
let attachment = try await Task.detached(priority: .userInitiated) {
try ImageEncoder().encode(rawBytes: data, sourceFilename: nil)
}.value
return (index, attachment, nil)
} catch {
let message = (error as? LocalizedError)?.errorDescription ?? "Couldn't encode image"
return (index, nil, message)
}
}
}
var rows: [(index: Int, attachment: ChatImageAttachment?, errorMessage: String?)] = []
for await row in group { rows.append(row) }
return rows.sorted { $0.index < $1.index }
}
var firstError: String?
for outcome in outcomes {
if let attachment = outcome.attachment {
controller.attachments.append(attachment)
} else if firstError == nil, let message = outcome.errorMessage {
firstError = message
}
}
if let firstError {
attachmentError = firstError
Task { @MainActor in
try? await Task.sleep(nanoseconds: 4_000_000_000)
attachmentError = nil
}
}
isEncodingAttachment = false
}
}
#endif
@State private var showErrorDetails: Bool = false
/// Inline error banner rendered above the message list when the
@@ -696,6 +890,12 @@ final class ChatController {
var vm: RichChatViewModel
var draft: String = ""
/// v0.12+ image attachments queued to send with the next prompt.
/// Capped at 5 by the composer UI; the cap matches the Mac behavior
/// and keeps total ACP prompt payload under ~2 MB even on a slow
/// cellular link. Cleared after each successful `send()`.
var attachments: [ChatImageAttachment] = []
/// Set when chat-start is blocked because the active server's
/// `config.yaml` has no `model.default` / `model.provider`. ChatView
/// observes this to present an inline "pick a model" sheet the
@@ -1003,12 +1203,22 @@ final class ChatController {
func send() async {
guard state == .ready, let client else { return }
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
// v0.12+ allows image-only sends vision models accept "describe
// this" with no text. Bail only when both fields are empty.
guard !text.isEmpty || !attachments.isEmpty else { return }
let sessionId = vm.sessionId ?? ""
guard !sessionId.isEmpty else { return }
let images = attachments
attachments = []
draft = ""
clearStoredDraft()
vm.addUserMessage(text: text)
if !text.isEmpty {
vm.addUserMessage(text: text)
} else {
// Surface an image-only message so the user sees their bubble
// even when they didn't type any caption.
vm.addUserMessage(text: "[image attached]")
}
// /steer is non-interruptive the agent is still on its
// current turn; the guidance applies after the next tool call.
// Surface a transient toast confirming the guidance was
@@ -1029,7 +1239,7 @@ final class ChatController {
// literally. v2.5.
let wireText = expandIfProjectScoped(text)
do {
_ = try await client.sendPrompt(sessionId: sessionId, text: wireText)
_ = try await client.sendPrompt(sessionId: sessionId, text: wireText, images: images)
} catch {
// The event task may already have surfaced a
// .connectionLost; show the send-time error only if the
@@ -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
}
}
+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)
@@ -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() {
@@ -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)
)
}
@@ -1573,6 +1577,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
@@ -254,14 +254,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")
}
}
@@ -274,7 +292,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)
@@ -313,7 +331,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")
@@ -350,7 +368,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"
@@ -390,7 +408,7 @@ 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)
@@ -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 {
@@ -396,7 +396,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 {
@@ -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)
@@ -70,7 +109,9 @@ struct RichChatInputBar: View {
)
.overlay(alignment: .topLeading) {
if text.isEmpty {
Text("Message Hermes… / for commands")
Text(supportsImagePrompts
? "Message Hermes… / for commands · drag images to attach"
: "Message Hermes… / for commands")
.scarfStyle(.body)
.foregroundStyle(ScarfColor.foregroundFaint)
.padding(.horizontal, 14)
@@ -78,6 +119,25 @@ struct RichChatInputBar: View {
.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 }
let n = filteredCommands.count
@@ -148,6 +208,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 +314,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 +326,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:
@@ -224,12 +385,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 {
@@ -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
@@ -131,19 +131,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 +161,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")
}
+21 -4
View File
@@ -12,11 +12,16 @@ import ScarfDesign
struct CronView: View {
@State private var viewModel: CronViewModel
@State private var pendingDelete: HermesCronJob?
@Environment(\.hermesCapabilities) private var capabilitiesStore
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
@@ -32,7 +37,7 @@ struct CronView: View {
.loadingOverlay(viewModel.isLoading, label: "Loading cron jobs…", isEmpty: viewModel.jobs.isEmpty)
.onAppear { 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 +45,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 +54,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 +64,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: {
@@ -468,10 +475,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 +519,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 +580,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)
}
}
}
@@ -196,24 +196,24 @@ struct DashboardView: View {
}
}
Spacer()
if shadow.hasAuthJSON {
Button("Copy fix command") {
Task { @MainActor in
let home = await viewModel.context.resolvedUserHome() + "/.hermes"
if let cmd = ProjectHermesShadowDetector.consolidationCommand(
for: shadow,
hermesHome: home
) {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(cmd, forType: .string)
}
Button("Copy fix command") {
Task { @MainActor in
let home = await viewModel.context.resolvedUserHome() + "/.hermes"
if let cmd = ProjectHermesShadowDetector.consolidationCommand(
for: shadow,
hermesHome: home
) {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(cmd, forType: .string)
}
}
.buttonStyle(ScarfSecondaryButton())
.controlSize(.small)
.help("Copies a one-liner that consolidates this project's auth.json into your global ~/.hermes/. Run it on the remote, then refresh the Dashboard.")
}
.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(
@@ -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")
@@ -0,0 +1,146 @@
import Foundation
import Observation
import ScarfCore
import os
/// Drives `BackupServerSheet`. Splits the user-facing flow into three
/// phases (preflight run done | failed) so the sheet renders one
/// coherent screen per phase. The actual backup work runs as a `Task`
/// that this VM owns; cancellation tears the SSH stream down via
/// `Task.checkCancellation()` checks inside `RemoteBackupService.run`.
@Observable
@MainActor
final class BackupServerViewModel {
enum Phase: Equatable {
case loading
case ready(RemoteBackupService.PreflightSummary)
case running(RemoteBackupService.Progress)
case done(RemoteBackupService.BackupResult)
case failed(String)
static func == (lhs: Phase, rhs: Phase) -> Bool {
switch (lhs, rhs) {
case (.loading, .loading): return true
case (.ready(let a), .ready(let b)): return a == b
case (.running(let a), .running(let b)): return a == b
case (.done, .done): return true
case (.failed(let a), .failed(let b)): return a == b
default: return false
}
}
}
private static let logger = Logger(subsystem: "com.scarf", category: "BackupServerViewModel")
let context: ServerContext
var phase: Phase = .loading
var includeAuth = false
var includeMcpTokens = false
var includeLogs = false
var bytesPushedHermes: Int64 = 0
var bytesPushedCurrentProject: Int64 = 0
var currentProjectName: String?
private var workTask: Task<Void, Never>?
init(context: ServerContext) {
self.context = context
}
func start() async {
let service = RemoteBackupService(context: context)
do {
let summary = try await service.preflight()
phase = .ready(summary)
} catch {
phase = .failed(error.localizedDescription)
Self.logger.error("Backup preflight failed: \(error.localizedDescription, privacy: .public)")
}
}
func runBackup(to destination: URL, summary: RemoteBackupService.PreflightSummary) {
let options = BackupManifest.Options(
includeAuth: includeAuth,
includeMcpTokens: includeMcpTokens,
includeLogs: includeLogs,
checkpointedWAL: summary.sqliteAvailable
)
phase = .running(.preflight)
// Two-step capture: the outer task gets [weak self] so a sheet
// dismiss-mid-run doesn't pin the VM; once the task starts we
// promote to a strong reference so the @Sendable progress
// callback (called off-actor by the service) can hop back via
// an unowned hop without the Swift 6 capture warning.
let weakSelf = WeakBox(self)
workTask = Task { @MainActor in
guard let viewModel = weakSelf.value else { return }
let service = RemoteBackupService(context: viewModel.context)
do {
let result = try await service.run(
preflight: summary,
options: options,
archiveURL: destination,
progress: { step in
Task { @MainActor in
weakSelf.value?.applyProgress(step)
}
}
)
viewModel.phase = .done(result)
} catch is CancellationError {
viewModel.phase = .failed("Cancelled.")
} catch {
viewModel.phase = .failed(error.localizedDescription)
Self.logger.error("Backup run failed: \(error.localizedDescription, privacy: .public)")
}
}
}
/// Tiny weak-reference box that's `Sendable` even when its
/// referent isn't (the value is fetched on the actor). Lets us
/// pass a "weak self" handle through `@Sendable` closures
/// without the Swift 6 var-self warning.
private final class WeakBox: @unchecked Sendable {
weak var value: BackupServerViewModel?
init(_ v: BackupServerViewModel) { self.value = v }
}
func cancel() {
workTask?.cancel()
workTask = nil
}
private func applyProgress(_ step: RemoteBackupService.Progress) {
switch step {
case .archivingHermes(let n):
bytesPushedHermes = n
case .archivingProject(let name, let n):
currentProjectName = name
bytesPushedCurrentProject = n
default:
break
}
phase = .running(step)
}
/// Default filename for the save panel `<displayName>-<date>.scarfbackup`.
/// Slug-cased so it survives Finder display.
var defaultArchiveName: String {
let stamp = Self.timestamp()
let slug = context.displayName
.lowercased()
.replacingOccurrences(of: " ", with: "-")
.filter { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }
let leaf = slug.isEmpty ? "scarf" : slug
return "\(leaf)-\(stamp).scarfbackup"
}
private static func timestamp() -> String {
let f = DateFormatter()
f.calendar = Calendar(identifier: .iso8601)
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone.current
f.dateFormat = "yyyy-MM-dd-HHmmss"
return f.string(from: Date())
}
}
@@ -0,0 +1,105 @@
import Foundation
import Observation
import ScarfCore
import os
/// Drives `RestoreServerSheet`. Mirrors `BackupServerViewModel`: the
/// flow is pickArchive inspect confirm run done | failed.
@Observable
@MainActor
final class RestoreServerViewModel {
enum Phase: Equatable {
case awaitingFile
case inspecting
case ready(RemoteRestoreService.InspectionResult)
case running(RemoteRestoreService.Progress)
case done(RemoteRestoreService.RestoreResult)
case failed(String)
static func == (lhs: Phase, rhs: Phase) -> Bool {
switch (lhs, rhs) {
case (.awaitingFile, .awaitingFile): return true
case (.inspecting, .inspecting): return true
case (.ready, .ready): return true
case (.running(let a), .running(let b)): return a == b
case (.done, .done): return true
case (.failed(let a), .failed(let b)): return a == b
default: return false
}
}
}
private static let logger = Logger(subsystem: "com.scarf", category: "RestoreServerViewModel")
let context: ServerContext
var phase: Phase = .awaitingFile
var pauseCronJobs = true
var targetProjectsRoot: String = ""
private var workTask: Task<Void, Never>?
init(context: ServerContext) {
self.context = context
}
func inspect(archiveURL: URL) async {
phase = .inspecting
let service = RemoteRestoreService(context: context)
do {
let result = try await service.inspect(archiveURL: archiveURL)
// Default the projects root to `<targetHome>/projects`.
if targetProjectsRoot.isEmpty {
let home = result.targetHomeResolved ?? (result.manifest.hermes.homePath as NSString).deletingLastPathComponent
targetProjectsRoot = home + "/projects"
}
phase = .ready(result)
} catch {
phase = .failed(error.localizedDescription)
Self.logger.error("Restore inspect failed: \(error.localizedDescription, privacy: .public)")
}
}
func runRestore(inspection: RemoteRestoreService.InspectionResult) {
let opts = RemoteRestoreService.RestoreOptions(
targetProjectsRoot: targetProjectsRoot.isEmpty ? nil : targetProjectsRoot,
pauseCronJobs: pauseCronJobs
)
phase = .running(.planning)
// Same two-step capture pattern as BackupServerViewModel:
// weak handle in the outer Task, strong promotion inside, so
// the @Sendable progress callback hops back via the box
// without the Swift 6 var-self warning.
let weakSelf = WeakBox(self)
workTask = Task { @MainActor in
guard let viewModel = weakSelf.value else { return }
let service = RemoteRestoreService(context: viewModel.context)
do {
let result = try await service.run(
inspection: inspection,
options: opts,
progress: { step in
Task { @MainActor in
weakSelf.value?.phase = .running(step)
}
}
)
viewModel.phase = .done(result)
} catch is CancellationError {
viewModel.phase = .failed("Cancelled.")
} catch {
viewModel.phase = .failed(error.localizedDescription)
Self.logger.error("Restore run failed: \(error.localizedDescription, privacy: .public)")
}
}
}
private final class WeakBox: @unchecked Sendable {
weak var value: RestoreServerViewModel?
init(_ v: RestoreServerViewModel) { self.value = v }
}
func cancel() {
workTask?.cancel()
workTask = nil
}
}
@@ -0,0 +1,275 @@
import SwiftUI
import AppKit
import UniformTypeIdentifiers
import ScarfCore
import ScarfDesign
/// Sheet for running a full backup of a remote (or local) server. Walks
/// the user through preflight confirm scope run done.
struct BackupServerSheet: View {
let context: ServerContext
@State private var viewModel: BackupServerViewModel
@Environment(\.dismiss) private var dismiss
init(context: ServerContext) {
self.context = context
_viewModel = State(initialValue: BackupServerViewModel(context: context))
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
ScrollView {
content
.padding(20)
}
Divider()
footer
}
.frame(width: 560, height: 540)
.task {
if case .loading = viewModel.phase {
await viewModel.start()
}
}
}
private var header: some View {
HStack(spacing: 10) {
Image(systemName: "arrow.down.doc")
.font(.title2)
VStack(alignment: .leading, spacing: 2) {
Text("Back up server").scarfStyle(.headline)
Text(verbatim: context.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
@ViewBuilder
private var content: some View {
switch viewModel.phase {
case .loading:
VStack(spacing: 12) {
ProgressView()
Text("Probing the server…").foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 60)
case .ready(let summary):
readyView(summary: summary)
case .running(let step):
runningView(step: step)
case .done(let result):
doneView(result: result)
case .failed(let message):
failedView(message: message)
}
}
private func readyView(summary: RemoteBackupService.PreflightSummary) -> some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Scope").font(.subheadline).bold().foregroundStyle(.secondary)
Text("Backs up the Hermes home (`~/.hermes/`) and every registered project so this server can be reconstructed from scratch.")
.font(.callout)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 6) {
row(label: "Hermes version", value: summary.hermesVersion ?? "(unknown)")
row(label: "Hermes home", value: summary.hermesHomePath, mono: true)
row(label: "Hermes home size", value: Self.formatBytes(summary.hermesHomeBytes))
row(label: "Projects", value: "\(summary.projects.count) registered")
if !summary.projects.isEmpty {
let total: Int64 = summary.projects.compactMap { $0.sizeBytes }.reduce(0, +)
row(label: "Projects size", value: Self.formatBytes(total))
}
if !summary.sqliteAvailable {
row(label: "WAL checkpoint", value: "skipped (sqlite3 not on remote PATH)")
}
}
if !summary.projects.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text("Projects to include").font(.subheadline).bold().foregroundStyle(.secondary)
ForEach(summary.projects, id: \.path) { p in
HStack(spacing: 6) {
Image(systemName: p.reachable ? "folder.fill" : "exclamationmark.triangle.fill")
.foregroundStyle(p.reachable ? AnyShapeStyle(.secondary) : AnyShapeStyle(Color.orange))
.font(.caption)
Text(verbatim: p.name).font(.callout)
Spacer()
Text(Self.formatBytes(p.sizeBytes))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
VStack(alignment: .leading, spacing: 6) {
Text("Optional inclusions").font(.subheadline).bold().foregroundStyle(.secondary)
Toggle(isOn: $viewModel.includeAuth) {
VStack(alignment: .leading, spacing: 2) {
Text("Include `auth.json`").font(.callout)
Text("Provider credentials (Anthropic/OpenAI/Nous keys). **Off by default** — they're sensitive and you'll likely re-auth on the new droplet anyway.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Toggle(isOn: $viewModel.includeLogs) {
VStack(alignment: .leading, spacing: 2) {
Text("Include logs").font(.callout)
Text("`agent.log`, `errors.log`, `gateway.log`. Useful for forensics; usually skipped to keep archive size down.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
private func runningView(step: RemoteBackupService.Progress) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 10) {
ProgressView()
Text(stepLabel(step)).font(.subheadline)
}
switch step {
case .archivingHermes(let n):
Text("Hermes home: \(Self.formatBytes(n)) so far")
.font(.caption)
.foregroundStyle(.secondary)
case .archivingProject(let name, let n):
Text(verbatim: "\(name): \(Self.formatBytes(n)) so far")
.font(.caption)
.foregroundStyle(.secondary)
default:
EmptyView()
}
}
.padding(.vertical, 30)
}
private func doneView(result: RemoteBackupService.BackupResult) -> some View {
VStack(alignment: .leading, spacing: 14) {
Label("Backup complete", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.headline)
row(label: "Archive", value: result.archiveURL.lastPathComponent, mono: true)
row(label: "Size", value: Self.formatBytes(result.archiveSize))
row(label: "Hermes version", value: result.manifest.source.hermesVersion ?? "(unknown)")
row(label: "Projects", value: "\(result.manifest.projects.count)")
HStack {
Button("Show in Finder") {
NSWorkspace.shared.activateFileViewerSelecting([result.archiveURL])
}
.buttonStyle(.bordered)
Spacer()
}
}
}
private func failedView(message: String) -> some View {
VStack(alignment: .leading, spacing: 12) {
Label("Backup failed", systemImage: "xmark.octagon.fill")
.foregroundStyle(.red)
.font(.headline)
ScrollView {
Text(verbatim: message)
.font(.callout)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 180)
}
}
private var footer: some View {
HStack {
switch viewModel.phase {
case .running:
Button("Cancel", role: .destructive) {
viewModel.cancel()
}
default:
Button("Close") { dismiss() }
}
Spacer()
switch viewModel.phase {
case .ready(let summary):
Button("Back up…") { presentSavePanel(summary: summary) }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
case .failed:
Button("Try again") { Task { await viewModel.start() } }
.keyboardShortcut(.defaultAction)
default:
EmptyView()
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
private func presentSavePanel(summary: RemoteBackupService.PreflightSummary) {
let panel = NSSavePanel()
panel.title = "Save Backup"
panel.prompt = "Back Up"
panel.nameFieldStringValue = viewModel.defaultArchiveName
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
let backupDir = documentsURL.appendingPathComponent("Scarf Backups", isDirectory: true)
try? FileManager.default.createDirectory(at: backupDir, withIntermediateDirectories: true)
panel.directoryURL = backupDir
}
panel.allowedContentTypes = [Self.scarfBackupType]
panel.canCreateDirectories = true
guard panel.runModal() == .OK, let url = panel.url else { return }
viewModel.runBackup(to: url, summary: summary)
}
/// `.scarfbackup` declared inline (project doesn't have a shared
/// UTType bundle yet). `archive` parent type so Finder treats it
/// like any other archive bundle.
private static let scarfBackupType: UTType = {
if let t = UTType(filenameExtension: BackupArchiveLayout.archiveExtension) { return t }
return UTType.archive
}()
private static func formatBytes(_ bytes: Int64?) -> String {
guard let bytes else { return "" }
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: bytes)
}
private func stepLabel(_ step: RemoteBackupService.Progress) -> String {
switch step {
case .preflight: return "Preparing…"
case .checkpointingDB: return "Checkpointing state.db…"
case .archivingHermes: return "Archiving Hermes home…"
case .archivingProject(let name, _): return "Archiving project: \(name)"
case .bundling: return "Bundling archive…"
case .finalizing: return "Finalizing…"
}
}
@ViewBuilder
private func row(label: String, value: String, mono: Bool = false) -> some View {
HStack(alignment: .firstTextBaseline) {
Text(label).font(.caption).foregroundStyle(.secondary).frame(width: 120, alignment: .leading)
Text(verbatim: value)
.font(mono ? .system(.caption, design: .monospaced) : .callout)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
@@ -1,6 +1,8 @@
import SwiftUI
import ScarfCore
import ScarfDesign
import UniformTypeIdentifiers
import AppKit
/// List of registered remote servers with add/remove actions. Rendered as a
/// popover from the toolbar switcher.
@@ -9,6 +11,18 @@ struct ManageServersView: View {
@State private var showAddSheet = false
@State private var pendingRemoveID: ServerID?
@State private var diagnosticsContext: ServerContext?
@State private var importAlert: ImportAlertState?
@State private var backupContext: ServerContext?
@State private var restoreContext: ServerContext?
/// Lightweight wrapper around the after-import message so we can
/// present a single SwiftUI `.alert` for both success summaries
/// ("Imported 3 servers") and refusals ("Schema v2 not recognized").
private struct ImportAlertState: Identifiable {
var id = UUID()
var title: String
var message: String
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -32,6 +46,18 @@ struct ManageServersView: View {
)) { wrapper in
RemoteDiagnosticsView(context: wrapper.context)
}
.sheet(item: Binding(
get: { backupContext.map { IdentifiableContext(context: $0) } },
set: { backupContext = $0?.context }
)) { wrapper in
BackupServerSheet(context: wrapper.context)
}
.sheet(item: Binding(
get: { restoreContext.map { IdentifiableContext(context: $0) } },
set: { restoreContext = $0?.context }
)) { wrapper in
RestoreServerSheet(context: wrapper.context)
}
.confirmationDialog(
"Remove this server?",
isPresented: Binding(
@@ -49,6 +75,9 @@ struct ManageServersView: View {
Text("The server's SSH configuration is removed from Scarf. Your remote files are untouched.")
}
)
.alert(item: $importAlert) { state in
Alert(title: Text(state.title), message: Text(state.message), dismissButton: .default(Text("OK")))
}
}
/// Wrapper because `ServerContext` isn't `Identifiable` against the sheet
@@ -62,6 +91,17 @@ struct ManageServersView: View {
HStack {
Text("Servers").scarfStyle(.headline)
Spacer()
Menu {
Button("Export Servers…") { exportServers() }
.disabled(registry.entries.isEmpty)
Button("Import Servers…") { importServers() }
} label: {
Image(systemName: "ellipsis.circle")
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
.help("Export or import the list of remote servers. SSH keys aren't included — you copy those separately.")
Button {
showAddSheet = true
} label: {
@@ -72,6 +112,83 @@ struct ManageServersView: View {
.padding(12)
}
/// `.scarfservers` is a plain JSON file (`ServerRegistry.exportFile()`).
/// Declared inline so callers don't need a shared UTType module just to
/// open one save panel. The conformance is dual: also `.json` so users
/// renaming the file don't break the import handler.
private static let scarfServersType: UTType = {
if let t = UTType("com.scarf.servers") { return t }
return UTType.json
}()
private func exportServers() {
let panel = NSSavePanel()
panel.title = "Export Servers"
panel.prompt = "Export"
panel.allowedContentTypes = [Self.scarfServersType, .json]
panel.nameFieldStringValue = "scarf-servers-\(Self.todayStamp()).scarfservers"
panel.canCreateDirectories = true
panel.isExtensionHidden = false
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
let data = try registry.exportFile()
try data.write(to: url, options: .atomic)
} catch {
importAlert = ImportAlertState(
title: "Couldn't export servers",
message: error.localizedDescription
)
}
}
private func importServers() {
let panel = NSOpenPanel()
panel.title = "Import Servers"
panel.prompt = "Import"
panel.allowedContentTypes = [Self.scarfServersType, .json]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
let data = try Data(contentsOf: url)
let summary = try registry.importEntries(from: data)
let count = summary.imported
let skipped = summary.skippedDuplicates
let title = count == 0 && skipped > 0
? "Nothing to import"
: (count == 1 ? "Imported 1 server" : "Imported \(count) servers")
var lines: [String] = []
if count == 0 && skipped > 0 {
lines.append("Every entry was already in your registry. Nothing changed.")
} else if skipped > 0 {
lines.append("\(skipped) duplicate \(skipped == 1 ? "entry was" : "entries were") skipped — your existing copy is preserved.")
}
lines.append("SSH keys aren't included in the export — make sure your `~/.ssh/` keys are in place on this Mac, or edit each server to point at the right identity file.")
importAlert = ImportAlertState(title: title, message: lines.joined(separator: "\n\n"))
} catch let err as ServerRegistry.ImportError {
importAlert = ImportAlertState(
title: "Couldn't import servers",
message: err.localizedDescription
)
} catch {
importAlert = ImportAlertState(
title: "Couldn't import servers",
message: error.localizedDescription
)
}
}
/// `yyyy-MM-dd` so the exported filename sorts naturally in Finder
/// when a user accumulates rotating exports.
private static func todayStamp() -> String {
let f = DateFormatter()
f.calendar = Calendar(identifier: .iso8601)
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "UTC")
f.dateFormat = "yyyy-MM-dd"
return f.string(from: Date())
}
private var empty: some View {
VStack(spacing: 8) {
Image(systemName: "server.rack")
@@ -105,6 +222,7 @@ struct ManageServersView: View {
.foregroundStyle(.secondary)
}
Spacer()
actionsMenu(for: ServerContext.local, removable: false)
}
.padding(.vertical, 4)
@@ -122,21 +240,7 @@ struct ManageServersView: View {
}
}
Spacer()
Button {
diagnosticsContext = entry.context
} label: {
Image(systemName: "stethoscope")
}
.buttonStyle(.borderless)
.help("Run remote diagnostics — check exactly which files are readable on this server.")
Button {
pendingRemoveID = entry.id
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.foregroundStyle(.red)
.help("Remove this server from Scarf.")
actionsMenu(for: entry.context, removable: true)
}
.padding(.vertical, 4)
}
@@ -144,6 +248,50 @@ struct ManageServersView: View {
.listStyle(.inset)
}
/// Per-row actions menu. Consolidates Backup / Restore /
/// Diagnostics / Remove behind a single ellipsis so the row stays
/// readable as the count of available actions grows. Local
/// servers can be backed up + restored just like remotes
/// (running `tar` against `~/.hermes`) but can't be removed
/// the local entry is synthesized, not registry-backed.
@ViewBuilder
private func actionsMenu(for context: ServerContext, removable: Bool) -> some View {
Menu {
Button {
backupContext = context
} label: {
Label("Back Up…", systemImage: "arrow.down.doc")
}
Button {
restoreContext = context
} label: {
Label("Restore from Backup…", systemImage: "arrow.up.doc")
}
if context.isRemote {
Divider()
Button {
diagnosticsContext = context
} label: {
Label("Diagnostics…", systemImage: "stethoscope")
}
}
if removable {
Divider()
Button(role: .destructive) {
pendingRemoveID = context.id
} label: {
Label("Remove Server…", systemImage: "trash")
}
}
} label: {
Image(systemName: "ellipsis.circle")
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
.help("Backup, restore, or remove this server.")
}
/// A star button that marks the open-on-launch default. Filled + yellow
/// on the current default row (disabled, since clicking would be a
/// no-op); outline + secondary elsewhere, clicking promotes that row
@@ -0,0 +1,292 @@
import SwiftUI
import AppKit
import UniformTypeIdentifiers
import ScarfCore
import ScarfDesign
/// Sheet for restoring a `.scarfbackup` onto a server. Walks the user
/// through file pick inspect (manifest preview + hash verify)
/// confirm scope run done.
struct RestoreServerSheet: View {
let context: ServerContext
@State private var viewModel: RestoreServerViewModel
@Environment(\.dismiss) private var dismiss
init(context: ServerContext) {
self.context = context
_viewModel = State(initialValue: RestoreServerViewModel(context: context))
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
ScrollView {
content
.padding(20)
}
Divider()
footer
}
.frame(width: 580, height: 560)
.task {
if case .awaitingFile = viewModel.phase {
presentOpenPanel()
}
}
}
private var header: some View {
HStack(spacing: 10) {
Image(systemName: "arrow.up.doc")
.font(.title2)
VStack(alignment: .leading, spacing: 2) {
Text("Restore from backup").scarfStyle(.headline)
Text(verbatim: "Target: \(context.displayName)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
@ViewBuilder
private var content: some View {
switch viewModel.phase {
case .awaitingFile:
VStack(spacing: 12) {
Image(systemName: "tray.and.arrow.up")
.font(.system(size: 32))
.foregroundStyle(.secondary)
Text("Pick a `.scarfbackup` file to inspect.").foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 60)
case .inspecting:
VStack(spacing: 12) {
ProgressView()
Text("Validating archive + verifying hashes…").foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 60)
case .ready(let inspection):
readyView(inspection: inspection)
case .running(let step):
runningView(step: step)
case .done(let result):
doneView(result: result)
case .failed(let message):
failedView(message: message)
}
}
private func readyView(inspection: RemoteRestoreService.InspectionResult) -> some View {
let m = inspection.manifest
return VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text("Source").font(.subheadline).bold().foregroundStyle(.secondary)
row(label: "Server", value: m.source.displayName)
row(label: "Host", value: m.source.host, mono: true)
row(label: "Hermes version", value: m.source.hermesVersion ?? "(unknown)")
row(label: "Backup time", value: m.createdAt)
row(label: "Hermes size", value: ByteCountFormatter.string(fromByteCount: m.hermes.tarballSize, countStyle: .file))
row(label: "Projects", value: "\(m.projects.count)")
}
VStack(alignment: .leading, spacing: 6) {
Text("Target").font(.subheadline).bold().foregroundStyle(.secondary)
row(label: "Server", value: context.displayName)
if let v = inspection.targetHermesVersion {
row(label: "Hermes version", value: v)
}
if let h = inspection.targetHomeResolved {
row(label: "Home", value: h, mono: true)
}
}
if !m.projects.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text("Projects landing path").font(.subheadline).bold().foregroundStyle(.secondary)
HStack {
TextField("e.g. /home/ubuntu/projects", text: $viewModel.targetProjectsRoot)
.textFieldStyle(.roundedBorder)
.font(.system(.callout, design: .monospaced))
}
Text("Each project lands at `<this path>/<project name>`. Existing files at the same path will be overwritten.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
VStack(alignment: .leading, spacing: 6) {
Toggle(isOn: $viewModel.pauseCronJobs) {
VStack(alignment: .leading, spacing: 2) {
Text("Pause cron jobs after restore").font(.callout)
Text("Restored cron jobs may carry stale credentials or schedules you no longer want. Pausing them lets you re-enable intentionally from the Cron view.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// Warning panel for sensitive contents.
VStack(alignment: .leading, spacing: 6) {
if !m.options.includeAuth {
HStack(spacing: 6) {
Image(systemName: "info.circle").foregroundStyle(.secondary)
Text("`auth.json` was excluded — re-authenticate AI providers after restore.").font(.caption).foregroundStyle(.secondary)
}
}
if !m.options.includeMcpTokens {
HStack(spacing: 6) {
Image(systemName: "info.circle").foregroundStyle(.secondary)
Text("MCP tokens were excluded — re-authenticate any MCP servers (Spotify, Google Workspace, etc.) after restore.").font(.caption).foregroundStyle(.secondary)
}
}
}
}
}
private func runningView(step: RemoteRestoreService.Progress) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 10) {
ProgressView()
Text(stepLabel(step)).font(.subheadline)
}
switch step {
case .restoringHermes(let n):
Text("Hermes home: \(ByteCountFormatter.string(fromByteCount: n, countStyle: .file)) pushed")
.font(.caption)
.foregroundStyle(.secondary)
case .restoringProject(let name, let n):
Text(verbatim: "\(name): \(ByteCountFormatter.string(fromByteCount: n, countStyle: .file)) pushed")
.font(.caption)
.foregroundStyle(.secondary)
default:
EmptyView()
}
}
.padding(.vertical, 30)
}
private func doneView(result: RemoteRestoreService.RestoreResult) -> some View {
VStack(alignment: .leading, spacing: 14) {
Label("Restore complete", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.headline)
row(label: "Hermes home", value: result.hermesHome, mono: true)
row(label: "Projects", value: "\(result.projectsRestored.count) restored")
if result.cronJobsPaused > 0 {
row(label: "Cron jobs paused", value: "\(result.cronJobsPaused)")
}
if !result.projectsRestored.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Restored to").font(.caption).foregroundStyle(.secondary)
ForEach(result.projectsRestored, id: \.targetPath) { p in
Text(verbatim: "\(p.name)\(p.targetPath)")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
}
Text("Re-authenticate AI providers and any MCP servers from Settings if those weren't included in the backup.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func failedView(message: String) -> some View {
VStack(alignment: .leading, spacing: 12) {
Label("Restore failed", systemImage: "xmark.octagon.fill")
.foregroundStyle(.red)
.font(.headline)
ScrollView {
Text(verbatim: message)
.font(.callout)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 180)
}
}
private var footer: some View {
HStack {
switch viewModel.phase {
case .running:
Button("Cancel", role: .destructive) {
viewModel.cancel()
}
default:
Button("Close") { dismiss() }
}
Spacer()
switch viewModel.phase {
case .ready(let inspection):
Button("Restore") { viewModel.runRestore(inspection: inspection) }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(viewModel.targetProjectsRoot.isEmpty)
case .failed:
Button("Pick another file") { presentOpenPanel() }
.keyboardShortcut(.defaultAction)
case .awaitingFile:
Button("Pick a backup…") { presentOpenPanel() }
.keyboardShortcut(.defaultAction)
default:
EmptyView()
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
private func presentOpenPanel() {
let panel = NSOpenPanel()
panel.title = "Choose Backup"
panel.prompt = "Inspect"
panel.allowedContentTypes = [Self.scarfBackupType, .zip]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
guard panel.runModal() == .OK, let url = panel.url else {
// User cancelled keep the awaitingFile phase so the
// sheet's "Pick a backup" button stays available.
return
}
Task { await viewModel.inspect(archiveURL: url) }
}
private static let scarfBackupType: UTType = {
if let t = UTType(filenameExtension: BackupArchiveLayout.archiveExtension) { return t }
return UTType.archive
}()
private func stepLabel(_ step: RemoteRestoreService.Progress) -> String {
switch step {
case .validating: return "Validating archive…"
case .verifyingHashes: return "Verifying hashes…"
case .planning: return "Planning…"
case .restoringHermes: return "Restoring Hermes home…"
case .restoringProject(let name, _): return "Restoring project: \(name)"
case .reanchoringPaths: return "Re-anchoring project paths…"
case .pausingCron: return "Pausing cron jobs…"
case .finalizing: return "Finalizing…"
}
}
@ViewBuilder
private func row(label: String, value: String, mono: Bool = false) -> some View {
HStack(alignment: .firstTextBaseline) {
Text(label).font(.caption).foregroundStyle(.secondary).frame(width: 120, alignment: .leading)
Text(verbatim: value)
.font(mono ? .system(.caption, design: .monospaced) : .callout)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
@@ -21,9 +21,15 @@ final class SettingsViewModel {
var hermesRunning = false
var rawConfigYAML = ""
var personalities: [String] = []
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"]
// v0.12: terminal.backend gained `vercel` (Vercel Sandbox); tts.provider
// gained `piper` (native local TTS via the Piper engine). These show up
// unconditionally Hermes silently ignores unknown values, so a v0.11
// host that picks "vercel" simply falls back to local. We don't gate
// either on `HermesCapabilities` because the cost of seeing an option
// that no-ops on older hosts is low compared to gating overhead.
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh", "vercel"]
var browserBackends = ["browseruse", "firecrawl", "local"]
var ttsProviders = ["edge", "elevenlabs", "openai", "minimax", "mistral", "neutts"]
var ttsProviders = ["edge", "elevenlabs", "openai", "minimax", "mistral", "neutts", "piper"]
var sttProviders = ["local", "groq", "openai", "mistral"]
var memoryProviders = ["", "honcho", "openviking", "mem0", "hindsight", "holographic", "retaindb", "byterover", "supermemory"]
var saveMessage: String?
@@ -5,8 +5,16 @@ import UniformTypeIdentifiers
/// Advanced tab network, compression, checkpoints, logging, delegation, file read cap,
/// cron wrap, config diagnostics, backup/restore, paths, raw config.
///
/// v0.12 added a "Caching & Redaction" section near the top: prompt cache
/// TTL picker (5m / 1h), the redaction toggle (off-by-default in v0.12
/// we surface a toggle so security-sensitive users can flip it back on),
/// and the runtime metadata footer toggle. All three are gated on
/// `HermesCapabilities` so a v0.11 host doesn't see toggles that write
/// keys it ignores.
struct AdvancedTab: View {
@Bindable var viewModel: SettingsViewModel
@Environment(\.hermesCapabilities) private var capabilitiesStore
@State private var showRawConfig = false
@State private var showRestoreConfirm = false
@State private var pendingRestorePath: String?
@@ -15,6 +23,10 @@ struct AdvancedTab: View {
@State private var showDiagnostics = false
var body: some View {
if capabilitiesStore?.capabilities.hasPromptCacheTTL ?? false {
v012CachingSection
}
SettingsSection(title: "Network", icon: "network") {
ToggleRow(label: "Force IPv4", isOn: viewModel.config.forceIPv4) { viewModel.setForceIPv4($0) }
}
@@ -99,6 +111,32 @@ struct AdvancedTab: View {
rawConfigSection
}
/// Caching, redaction, and runtime-metadata footer all v0.12+
/// knobs. The cache_ttl picker is two options today (5m default,
/// 1h opt-in); when Hermes adds more they should be surfaced here
/// without changing the writer (`hermes config set` accepts arbitrary
/// scalars, Hermes validates).
@ViewBuilder
private var v012CachingSection: some View {
SettingsSection(title: "Caching & Redaction", icon: "lock.shield") {
PickerRow(
label: "Prompt Cache TTL",
selection: viewModel.config.cacheTTL,
options: ["5m", "1h"]
) { viewModel.setSetting("prompt_caching.cache_ttl", value: $0) }
ToggleRow(
label: "Redact secrets in patches",
isOn: viewModel.config.redactionEnabled
) { viewModel.setSetting("redaction.enabled", value: $0 ? "true" : "false") }
ToggleRow(
label: "Runtime metadata footer",
isOn: viewModel.config.runtimeMetadataFooter
) { viewModel.setSetting("agent.runtime_metadata_footer", value: $0 ? "true" : "false") }
}
}
private var backupSection: some View {
SettingsSection(title: "Backup & Restore", icon: "externaldrive") {
HStack {
@@ -9,25 +9,46 @@ import ScarfCore
/// (subscription-routed) and `auto` (inherit main provider) Hermes derives
/// the gateway routing from that single field; there is no separate
/// `use_gateway` key to write.
///
/// v0.12 dropped the `flush_memories` aux task on the server side and
/// added `curator` (the autonomous skill-maintenance review fork). The
/// Curator row only appears when `HermesCapabilities.hasCuratorAux` is
/// set; the Flush Memories row only appears when
/// `HermesCapabilities.hasFlushMemoriesAux` is set (inverse semantics
/// `true` only on pre-v0.12 hosts where the task still exists). v0.11
/// users keep their edit surface; v0.12 users never see it.
struct AuxiliaryTab: View {
@Bindable var viewModel: SettingsViewModel
@Environment(\.serverContext) private var serverContext
@Environment(\.hermesCapabilities) private var capabilitiesStore
@State private var subscription: NousSubscriptionState = .absent
@State private var showNousSignIn: Bool = false
// Keyed by the config path name matches `auxiliary.<task>.*` in config.yaml.
private let tasks: [(key: String, title: LocalizedStringKey, icon: String)] = [
// Static base list; the v0.12-only `curator` row is appended at render
// time when the target Hermes supports it.
private let baseTasks: [(key: String, title: LocalizedStringKey, icon: String)] = [
("vision", "Vision", "eye"),
("web_extract", "Web Extract", "doc.richtext"),
("compression", "Compression", "arrow.down.right.and.arrow.up.left.circle"),
("session_search", "Session Search", "magnifyingglass"),
("skills_hub", "Skills Hub", "books.vertical"),
("approval", "Approval", "checkmark.seal"),
("mcp", "MCP", "puzzlepiece"),
("flush_memories", "Flush Memories", "trash.slash")
("mcp", "MCP", "puzzlepiece")
]
private var tasks: [(key: String, title: LocalizedStringKey, icon: String)] {
var t = baseTasks
if capabilitiesStore?.capabilities.hasFlushMemoriesAux ?? false {
t.append(("flush_memories", "Flush Memories", "trash.slash"))
}
if capabilitiesStore?.capabilities.hasCuratorAux ?? false {
t.append(("curator", "Curator", "sparkles"))
}
return t
}
var body: some View {
Text("Auxiliary tasks use separate, typically cheaper models. Leave Provider as `auto` to inherit the main provider.")
.font(.caption)
@@ -95,6 +116,7 @@ struct AuxiliaryTab: View {
case "approval": return viewModel.config.auxiliary.approval
case "mcp": return viewModel.config.auxiliary.mcp
case "flush_memories": return viewModel.config.auxiliary.flushMemories
case "curator": return viewModel.config.auxiliary.curator
default: return .empty
}
}
@@ -0,0 +1,87 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// v0.12+ direct-URL skill install. Hermes accepts an HTTPS URL pointing
/// at a SKILL.md (or a tarball) and installs it under
/// `~/.hermes/skills/<category>/<name>/`. Authors who don't ship via a
/// registry can use this to share a one-off skill with a single URL.
///
/// Capability-gated upstream SkillsView only opens this sheet when
/// `HermesCapabilities.hasSkillURLInstall` is true.
struct InstallFromURLSheet: View {
let viewModel: SkillsViewModel
@Environment(\.dismiss) private var dismiss
@State private var url: String = ""
@State private var category: String = ""
@State private var nameOverride: String = ""
/// Loose validity check accept anything that starts with `https://`
/// (HTTP gets blocked because Hermes refuses non-TLS skill URLs by
/// default to keep MITM-injected SKILL.md off the host).
private var isValid: Bool {
let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.lowercased().hasPrefix("https://") && trimmed.count > 10
}
var body: some View {
VStack(alignment: .leading, spacing: ScarfSpace.s3) {
Text("Install Skill from URL")
.scarfStyle(.headline)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text("Paste an HTTPS URL pointing at a SKILL.md or a tarball. Hermes downloads, scans, and installs it under `~/.hermes/skills/<category>/<name>/`.")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
VStack(alignment: .leading, spacing: 4) {
Text("URL")
.scarfStyle(.captionUppercase)
.foregroundStyle(ScarfColor.foregroundMuted)
ScarfTextField("https://example.com/path/to/SKILL.md", text: $url)
}
DisclosureGroup("Optional overrides") {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
VStack(alignment: .leading, spacing: 4) {
Text("Category")
.scarfStyle(.captionUppercase)
.foregroundStyle(ScarfColor.foregroundMuted)
ScarfTextField("e.g. productivity (defaults to `local`)", text: $category)
}
VStack(alignment: .leading, spacing: 4) {
Text("Skill name")
.scarfStyle(.captionUppercase)
.foregroundStyle(ScarfColor.foregroundMuted)
ScarfTextField("Override if SKILL.md has no `name:`", text: $nameOverride)
}
}
.padding(.top, ScarfSpace.s2)
}
.scarfStyle(.body)
HStack {
Spacer()
Button("Cancel") { dismiss() }
.buttonStyle(ScarfGhostButton())
Button("Install") {
let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
let cat = category.trimmingCharacters(in: .whitespacesAndNewlines)
let name = nameOverride.trimmingCharacters(in: .whitespacesAndNewlines)
viewModel.installFromURL(
trimmedURL,
categoryOverride: cat.isEmpty ? nil : cat,
nameOverride: name.isEmpty ? nil : name
)
dismiss()
}
.buttonStyle(ScarfPrimaryButton())
.keyboardShortcut(.defaultAction)
.disabled(!isValid)
}
}
.padding(ScarfSpace.s5)
.frame(width: 460)
}
}
@@ -14,7 +14,11 @@ struct SkillsView: View {
/// for the active server. Drives the v2.5 "What's New" pill at
/// the top of the Skills list. Nil before first compute.
@State private var snapshotDiff: SkillSnapshotDiff?
/// Sheet for v0.12 direct-URL skill install. Capability-gated so
/// the trigger button only appears on hosts that support it.
@State private var showInstallFromURLSheet = false
@Environment(\.serverContext) private var serverContext
@Environment(\.hermesCapabilities) private var capabilitiesStore
@State private var currentTab: Tab = .installed
init(context: ServerContext) {
@@ -42,7 +46,26 @@ struct SkillsView: View {
ScarfPageHeader(
"Skills",
subtitle: "Pre-packaged prompt collections the agent can call into. \(viewModel.totalSkillCount) installed."
)
) {
HStack(spacing: 6) {
Button {
Task { await viewModel.reloadSkills() }
} label: {
Label("Reload", systemImage: "arrow.clockwise")
}
.buttonStyle(ScarfGhostButton())
.help("Re-scan ~/.hermes/skills/ and pick up edits without restarting Hermes")
if capabilitiesStore?.capabilities.hasSkillURLInstall ?? false {
Button {
showInstallFromURLSheet = true
} label: {
Label("Install from URL…", systemImage: "link.badge.plus")
}
.buttonStyle(ScarfPrimaryButton())
}
}
}
modePicker
// v2.5 "What's New" pill only renders when the diff has
// changes against a non-empty prior snapshot (first launch
@@ -92,6 +115,9 @@ struct SkillsView: View {
.task {
recomputeSnapshotDiff()
}
.sheet(isPresented: $showInstallFromURLSheet) {
InstallFromURLSheet(viewModel: viewModel)
}
}
/// Compute the snapshot diff against the active server's last-seen
@@ -186,7 +212,7 @@ struct SkillsView: View {
ForEach(viewModel.filteredCategories) { category in
Section(category.name) {
ForEach(category.skills) { skill in
Label(skill.name, systemImage: "lightbulb")
skillRow(skill)
.tag(skill.id)
}
}
@@ -195,6 +221,38 @@ struct SkillsView: View {
.listStyle(.sidebar)
}
/// Sidebar row with enabled/disabled visual state + pin badge.
/// Disabled skills render at .secondary opacity so the user can see
/// they exist but Hermes won't load them.
@ViewBuilder
private func skillRow(_ skill: HermesSkill) -> some View {
HStack(spacing: 4) {
Image(systemName: "lightbulb")
.frame(width: 14)
.foregroundStyle(skill.enabled ? .primary : .secondary)
Text(skill.name)
.foregroundStyle(skill.enabled ? .primary : .secondary)
.strikethrough(!skill.enabled, color: .secondary)
Spacer(minLength: 0)
if skill.pinned {
Image(systemName: "pin.fill")
.font(.system(size: 9))
.foregroundStyle(ScarfColor.accent)
.help("Pinned by curator")
}
if !skill.enabled {
Text("OFF")
.scarfStyle(.captionUppercase)
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(ScarfColor.backgroundTertiary)
.clipShape(RoundedRectangle(cornerRadius: 3))
.foregroundStyle(ScarfColor.foregroundMuted)
.help("Disabled in skills.disabled — Hermes won't load this one")
}
}
}
@ViewBuilder
private var skillDetail: some View {
if let skill = viewModel.selectedSkill {
+323 -1
View File
@@ -111,6 +111,18 @@
}
}
},
"%@ %lld" : {
"comment" : "A small, rounded chip displaying a label and value.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@ %2$lld"
}
}
}
},
"%@ → %@" : {
"localizations" : {
"en" : {
@@ -391,6 +403,10 @@
"comment" : "A label that shows the number of API calls made by a session.",
"isCommentAutoGenerated" : true
},
"%lld archived skill(s) available — list them with `hermes curator status`." : {
"comment" : "A message that shows the number of archived skills available. The argument is the number of archived skills.",
"isCommentAutoGenerated" : true
},
"%lld changes" : {
"comment" : "A label showing the number of changes that will be made when installing a template. The argument is the number of changes.",
"isCommentAutoGenerated" : true
@@ -825,6 +841,10 @@
}
}
},
"%lld runs" : {
"comment" : "A label showing the number of times the curator has run.",
"isCommentAutoGenerated" : true
},
"%lld sessions" : {
"localizations" : {
"de" : {
@@ -964,6 +984,18 @@
},
"%lld." : {
},
"%lld/%lld" : {
"comment" : "A label showing the number of attachments and the maximum allowed.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$lld/%2$lld"
}
}
}
},
"•" : {
@@ -979,6 +1011,14 @@
"comment" : "A description of the sign-in flow for a given provider.",
"isCommentAutoGenerated" : true
},
"`agent.log`, `errors.log`, `gateway.log`. Useful for forensics; usually skipped to keep archive size down." : {
"comment" : "A description of the logs included in a backup.",
"isCommentAutoGenerated" : true
},
"`auth.json` was excluded — re-authenticate AI providers after restore." : {
"comment" : "A warning that will be shown in a restore sheet if",
"isCommentAutoGenerated" : true
},
"`npx` not found on the Hermes host." : {
},
@@ -2801,6 +2841,18 @@
"comment" : "A description of the dashboard.",
"isCommentAutoGenerated" : true
},
"Attach image (%lld/%lld)" : {
"comment" : "A button that opens a file picker to select an image to attach.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Attach image (%1$lld/%2$lld)"
}
}
}
},
"Auth" : {
"localizations" : {
"de" : {
@@ -3188,6 +3240,18 @@
}
}
},
"Back up server" : {
"comment" : "A title for a backup server sheet.",
"isCommentAutoGenerated" : true
},
"Back up…" : {
"comment" : "A button that triggers a backup of a remote (or local) server.",
"isCommentAutoGenerated" : true
},
"Back Up…" : {
"comment" : "A label for backing up a server.",
"isCommentAutoGenerated" : true
},
"Backend" : {
"localizations" : {
"de" : {
@@ -3228,6 +3292,10 @@
}
}
},
"Backs up the Hermes home (`~/.hermes/`) and every registered project so this server can be reconstructed from scratch." : {
"comment" : "A description of the scope of a full backup.",
"isCommentAutoGenerated" : true
},
"Backup & Restore" : {
"localizations" : {
"de" : {
@@ -3268,6 +3336,14 @@
}
}
},
"Backup complete" : {
"comment" : "A label that indicates that a backup has completed.",
"isCommentAutoGenerated" : true
},
"Backup failed" : {
"comment" : "A label that indicates that a backup failed.",
"isCommentAutoGenerated" : true
},
"Backup Now" : {
"localizations" : {
"de" : {
@@ -3308,6 +3384,10 @@
}
}
},
"Backup, restore, or remove this server." : {
"comment" : "A tooltip for the \"Backup, restore, or remove this server.\" button.",
"isCommentAutoGenerated" : true
},
"Becomes the key under mcp_servers: in config.yaml." : {
"localizations" : {
"de" : {
@@ -3721,6 +3801,10 @@
}
}
},
"Caching & Redaction" : {
"comment" : "Section title for the advanced tab's \"Caching & Redaction\" section.",
"isCommentAutoGenerated" : true
},
"Call timeout" : {
"localizations" : {
"de" : {
@@ -5654,7 +5738,11 @@
}
}
},
"Copies a one-liner that consolidates this project's auth.json into your global ~/.hermes/. Run it on the remote, then refresh the Dashboard." : {
"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." : {
"comment" : "A tooltip for the \"Copy fix command\" button.",
"isCommentAutoGenerated" : true
},
"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." : {
"comment" : "A tooltip for the \"Copy fix command\" button.",
"isCommentAutoGenerated" : true
},
@@ -6201,6 +6289,10 @@
}
}
},
"Create one with `hermes kanban create \"task title\"`. Tasks dispatched by the gateway show up here automatically." : {
"comment" : "A description of the Kanban view.",
"isCommentAutoGenerated" : true
},
"Create Profile" : {
"localizations" : {
"de" : {
@@ -6485,6 +6577,10 @@
}
}
},
"Curator" : {
"comment" : "Name of the curator task.",
"isCommentAutoGenerated" : true
},
"Current: %@" : {
"localizations" : {
"de" : {
@@ -7272,6 +7368,9 @@
}
}
}
},
"Diagnostics…" : {
},
"Disable" : {
"localizations" : {
@@ -7353,6 +7452,10 @@
}
}
},
"Disabled in skills.disabled — Hermes won't load this one" : {
"comment" : "A tooltip for a disabled skill.",
"isCommentAutoGenerated" : true
},
"Discard" : {
"comment" : "A button that discards changes made to the memory.",
"isCommentAutoGenerated" : true
@@ -7496,6 +7599,10 @@
},
"Duplicate" : {
},
"e.g. /home/ubuntu/projects" : {
"comment" : "A placeholder for a path to the root of a user's projects.",
"isCommentAutoGenerated" : true
},
"e.g. ~/.hermes-backups/hermes-2026-04-28.zip" : {
"comment" : "A placeholder for a remote backup path.",
@@ -7785,6 +7892,10 @@
}
}
},
"Each project lands at `<this path>/<project name>`. Existing files at the same path will be overwritten." : {
"comment" : "A warning that projects will be overwritten during a restore.",
"isCommentAutoGenerated" : true
},
"Edit" : {
"localizations" : {
"de" : {
@@ -8240,6 +8351,9 @@
}
}
}
},
"Encoding…" : {
},
"End-to-End Encryption (experimental)" : {
"localizations" : {
@@ -8764,6 +8878,14 @@
"comment" : "A title for the error screen when exporting a template fails.",
"isCommentAutoGenerated" : true
},
"Export or import the list of remote servers. SSH keys aren't included — you copy those separately." : {
"comment" : "A help message for the export/import button.",
"isCommentAutoGenerated" : true
},
"Export Servers…" : {
"comment" : "A button that exports a list of servers.",
"isCommentAutoGenerated" : true
},
"Export..." : {
"localizations" : {
"de" : {
@@ -9778,6 +9900,10 @@
},
"Hermes" : {
},
"Hermes archives skills the curator decides are stale or redundant. Restoring brings the original SKILL.md back into place — no data lost." : {
"comment" : "A description of the curator's `curator restore` action.",
"isCommentAutoGenerated" : true
},
"hermes at %@" : {
"localizations" : {
@@ -9859,6 +9985,14 @@
}
}
},
"Hermes home: %@ pushed" : {
"comment" : "A label that shows the size of the Hermes home directory that has been pushed to the server. The argument is the size of the Hermes home directory in bytes.",
"isCommentAutoGenerated" : true
},
"Hermes home: %@ so far" : {
"comment" : "A label that shows the amount of data that has been backed up so far. The argument is the amount of data that has been backed up.",
"isCommentAutoGenerated" : true
},
"Hermes needs a global webhook secret and port before subscriptions can receive traffic. Run the gateway setup wizard or edit ~/.hermes/config.yaml manually." : {
"localizations" : {
"de" : {
@@ -10161,6 +10295,14 @@
}
}
},
"Hide sessions list" : {
"comment" : "A label for hiding the sessions list.",
"isCommentAutoGenerated" : true
},
"Hide tool inspector" : {
"comment" : "A label for hiding the tool inspector.",
"isCommentAutoGenerated" : true
},
"Home Assistant Docs" : {
},
@@ -10538,6 +10680,10 @@
}
}
},
"Import Servers…" : {
"comment" : "A button that imports a list of servers from a file.",
"isCommentAutoGenerated" : true
},
"Inactive" : {
"localizations" : {
"de" : {
@@ -10618,8 +10764,16 @@
}
}
},
"Include `auth.json`" : {
"comment" : "A checkbox label for including the `auth.json` file.",
"isCommentAutoGenerated" : true
},
"Include Cron Jobs" : {
},
"Include logs" : {
"comment" : "A checkbox label for including logs in a backup.",
"isCommentAutoGenerated" : true
},
"Include Skills" : {
"comment" : "A heading for a section of a template export sheet that lets the user select which skills to include in the generated template.",
@@ -10878,6 +11032,9 @@
}
}
}
},
"Install Skill from URL" : {
},
"Install Template" : {
"comment" : "Button prompt to install a template from a file.",
@@ -11018,6 +11175,10 @@
}
}
},
"Kanban" : {
"comment" : "\"Kanban\" is a French term for a project management tool.",
"isCommentAutoGenerated" : true
},
"keep (not installed by template)" : {
"comment" : "A description of a file that is not part of the template's installation.",
"isCommentAutoGenerated" : true
@@ -11622,6 +11783,9 @@
"Loading earlier…" : {
"comment" : "A label displayed while loading older messages.",
"isCommentAutoGenerated" : true
},
"Loading providers…" : {
},
"Loading session…" : {
"localizations" : {
@@ -12174,6 +12338,10 @@
}
}
},
"MCP tokens were excluded — re-authenticate any MCP servers (Spotify, Google Workspace, etc.) after restore." : {
"comment" : "A warning message displayed in a restore sheet.",
"isCommentAutoGenerated" : true
},
"Memory" : {
"localizations" : {
"de" : {
@@ -12309,6 +12477,9 @@
"Message Hermes… / for commands" : {
"comment" : "A placeholder text displayed in the text editor of the Rich Chat input bar.",
"isCommentAutoGenerated" : true
},
"Message Hermes… / for commands · drag images to attach" : {
},
"Messages will appear here as the conversation progresses." : {
"localizations" : {
@@ -12366,6 +12537,10 @@
"comment" : "A heading for the metadata section of the template export sheet.",
"isCommentAutoGenerated" : true
},
"Microsoft Teams" : {
"comment" : "Name of the Microsoft Teams platform.",
"isCommentAutoGenerated" : true
},
"Migrate" : {
"localizations" : {
"de" : {
@@ -13568,6 +13743,10 @@
"comment" : "A description of a tool's permission status.",
"isCommentAutoGenerated" : true
},
"No kanban tasks" : {
"comment" : "A message displayed when there are no kanban tasks.",
"isCommentAutoGenerated" : true
},
"No matches for \"%@\"." : {
"comment" : "A message that appears when a search yields no results. The argument is the search term.",
"isCommentAutoGenerated" : true
@@ -14404,6 +14583,10 @@
"comment" : "Title of a section in the credential pools view that lists OAuth-authed providers.",
"isCommentAutoGenerated" : true
},
"OFF" : {
"comment" : "A label for a disabled skill.",
"isCommentAutoGenerated" : true
},
"OK" : {
"localizations" : {
"de" : {
@@ -14984,6 +15167,14 @@
}
}
},
"Optional inclusions" : {
"comment" : "A heading for optional inclusions in a backup.",
"isCommentAutoGenerated" : true
},
"Optional overrides" : {
"comment" : "A section that lets you override the category or name of the skill.",
"isCommentAutoGenerated" : true
},
"Optional. Sets the LLM model for this turn." : {
"comment" : "A label for the LLM model override field in the slash command editor.",
"isCommentAutoGenerated" : true
@@ -15162,6 +15353,10 @@
"comment" : "A label for the template's owner and name.",
"isCommentAutoGenerated" : true
},
"p%lld" : {
"comment" : "A priority indicator. The argument is the priority level.",
"isCommentAutoGenerated" : true
},
"Paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription — no separate API keys needed." : {
"comment" : "A description of the benefits of using a Nous",
"isCommentAutoGenerated" : true
@@ -15253,6 +15448,10 @@
"comment" : "A description of the URL field in the template installation prompt.",
"isCommentAutoGenerated" : true
},
"Paste an HTTPS URL pointing at a SKILL.md or a tarball. Hermes downloads, scans, and installs it under `~/.hermes/skills/<category>/<name>/`." : {
"comment" : "A description of how to install a skill from a URL.",
"isCommentAutoGenerated" : true
},
"Paste code here…" : {
"localizations" : {
"de" : {
@@ -15385,6 +15584,10 @@
}
}
},
"Pause cron jobs after restore" : {
"comment" : "A checkbox to pause cron jobs after restore.",
"isCommentAutoGenerated" : true
},
"Pending Approvals" : {
"localizations" : {
"de" : {
@@ -15585,6 +15788,14 @@
}
}
},
"Pick a `.scarfbackup` file to inspect." : {
"comment" : "A description of the action to pick a `.scarfbackup` file.",
"isCommentAutoGenerated" : true
},
"Pick a backup…" : {
"comment" : "A button that opens a file picker to select a `.scarfbackup` file.",
"isCommentAutoGenerated" : true
},
"Pick a model to start chatting" : {
"comment" : "A heading for the chat model picker sheet.",
"isCommentAutoGenerated" : true
@@ -15629,6 +15840,10 @@
}
}
},
"Pick another file" : {
"comment" : "A button that lets the user pick a new backup file.",
"isCommentAutoGenerated" : true
},
"Pick from catalog" : {
"comment" : "A button to select a model from the catalog.",
"isCommentAutoGenerated" : true
@@ -15753,6 +15968,22 @@
}
}
},
"Pin skill" : {
"comment" : "A tooltip for pinning a skill.",
"isCommentAutoGenerated" : true
},
"Pinned" : {
"comment" : "A button that pins a skill to the user's list of pinned skills.",
"isCommentAutoGenerated" : true
},
"Pinned by curator" : {
"comment" : "A tooltip for a pinned skill.",
"isCommentAutoGenerated" : true
},
"Pinned skills are never auto-archived or rewritten by the curator." : {
"comment" : "A description of pinned skills.",
"isCommentAutoGenerated" : true
},
"Placeholder shown after `/<name> ` in the menu — e.g. `<focus area>`." : {
"comment" : "A description of the placeholder shown after the slash command name in the menu.",
"isCommentAutoGenerated" : true
@@ -16004,6 +16235,9 @@
}
}
}
},
"Probing the server…" : {
},
"Profile" : {
"localizations" : {
@@ -16228,11 +16462,18 @@
}
}
}
},
"Projects landing path" : {
},
"Projects registry" : {
"comment" : "Section title for the section that lists the projects registry.",
"isCommentAutoGenerated" : true
},
"Projects to include" : {
"comment" : "A heading for the list of projects to be backed up.",
"isCommentAutoGenerated" : true
},
"Prompt" : {
"localizations" : {
"de" : {
@@ -16357,6 +16598,10 @@
}
}
},
"Provider credentials (Anthropic/OpenAI/Nous keys). **Off by default** — they're sensitive and you'll likely re-auth on the new droplet anyway." : {
"comment" : "A description of the credentials that will be backed up.",
"isCommentAutoGenerated" : true
},
"Push to Talk" : {
"localizations" : {
"de" : {
@@ -16682,6 +16927,10 @@
}
}
},
"Re-authenticate AI providers and any MCP servers from Settings if those weren't included in the backup." : {
"comment" : "A message that instructs the user to re-authenticate AI providers and MCP servers if they weren't included in the backup.",
"isCommentAutoGenerated" : true
},
"Re-run" : {
"localizations" : {
"de" : {
@@ -16726,6 +16975,10 @@
"comment" : "A tooltip for the \"Re-run\" button.",
"isCommentAutoGenerated" : true
},
"Re-scan ~/.hermes/skills/ and pick up edits without restarting Hermes" : {
"comment" : "A help message for the reload button in the skills view.",
"isCommentAutoGenerated" : true
},
"Read" : {
"extractionState" : "stale",
"localizations" : {
@@ -17303,11 +17556,16 @@
"comment" : "Title of a dialog that asks the user to confirm removing a project from Scarf's project list.",
"isCommentAutoGenerated" : true
},
"Remove Server…" : {
"comment" : "A label for a button that removes a server.",
"isCommentAutoGenerated" : true
},
"Remove the entire namespace dir recursively" : {
"comment" : "A description of a template uninstall action.",
"isCommentAutoGenerated" : true
},
"Remove this server from Scarf." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -18024,6 +18282,26 @@
}
}
},
"Restore Archived Skill" : {
"comment" : "A title for the curator's restore sheet.",
"isCommentAutoGenerated" : true
},
"Restore Archived…" : {
"comment" : "A button that restores archived skills.",
"isCommentAutoGenerated" : true
},
"Restore complete" : {
"comment" : "A label that indicates that a restore has completed.",
"isCommentAutoGenerated" : true
},
"Restore failed" : {
"comment" : "A label displayed when a restore fails.",
"isCommentAutoGenerated" : true
},
"Restore from backup" : {
"comment" : "A title for a screen that lets the user restore a",
"isCommentAutoGenerated" : true
},
"Restore from backup?" : {
"localizations" : {
"de" : {
@@ -18064,6 +18342,10 @@
}
}
},
"Restore from Backup…" : {
"comment" : "A label for a button that restores a server from a backup.",
"isCommentAutoGenerated" : true
},
"Restore from remote backup" : {
"comment" : "A heading for a sheet that lets the user restore from a remote backup.",
"isCommentAutoGenerated" : true
@@ -18108,6 +18390,14 @@
}
}
},
"Restored cron jobs may carry stale credentials or schedules you no longer want. Pausing them lets you re-enable intentionally from the Cron view." : {
"comment" : "A warning message that appears in the Restore Server Sheet if the user has chosen to pause cron jobs after restoring a backup.",
"isCommentAutoGenerated" : true
},
"Restored to" : {
"comment" : "A label displayed under a list of restored projects.",
"isCommentAutoGenerated" : true
},
"Result" : {
"extractionState" : "stale",
"localizations" : {
@@ -18612,6 +18902,7 @@
}
},
"Run remote diagnostics — check exactly which files are readable on this server." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -19009,6 +19300,10 @@
"comment" : "A description of the purpose of the cron jobs.",
"isCommentAutoGenerated" : true
},
"Scope" : {
"comment" : "A label displayed above the scope of the backup.",
"isCommentAutoGenerated" : true
},
"Search" : {
"localizations" : {
"de" : {
@@ -20530,6 +20825,14 @@
}
}
},
"Show sessions list" : {
"comment" : "A label for a button that shows the sessions list.",
"isCommentAutoGenerated" : true
},
"Show tool inspector" : {
"comment" : "A button that shows the tool inspector.",
"isCommentAutoGenerated" : true
},
"Show values" : {
"localizations" : {
"de" : {
@@ -20799,6 +21102,10 @@
},
"sk-…" : {
},
"Skill name" : {
"comment" : "A label for the name of a skill.",
"isCommentAutoGenerated" : true
},
"Skills" : {
"localizations" : {
@@ -21970,6 +22277,10 @@
"comment" : "A label for a field that allows the user to enter a list of tags, separated by commas.",
"isCommentAutoGenerated" : true
},
"Target" : {
"comment" : "A heading for the target server of a restore.",
"isCommentAutoGenerated" : true
},
"Telegram Setup Docs" : {
},
@@ -23601,6 +23912,10 @@
}
}
},
"Unpin" : {
"comment" : "A button that unpins a pinned skill.",
"isCommentAutoGenerated" : true
},
"Update" : {
"localizations" : {
"de" : {
@@ -24103,6 +24418,9 @@
"v%@" : {
"comment" : "A version number.",
"isCommentAutoGenerated" : true
},
"Validating archive + verifying hashes…" : {
},
"value" : {
@@ -24952,6 +25270,10 @@
"Your tools will now route through your subscription." : {
"comment" : "A description of the success state of the",
"isCommentAutoGenerated" : true
},
"Yuanbao 元宝" : {
"comment" : "Name of the Yuanbao platform.",
"isCommentAutoGenerated" : true
}
},
"version" : "1.1"
@@ -11,6 +11,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
// Interact
case chat = "Chat"
case memory = "Memory"
case curator = "Curator"
case skills = "Skills"
// Configure (Phase 2/3 additions)
case platforms = "Platforms"
@@ -25,6 +26,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case mcpServers = "MCP Servers"
case gateway = "Gateway"
case cron = "Cron"
case kanban = "Kanban"
case health = "Health"
case logs = "Logs"
case settings = "Settings"
@@ -40,6 +42,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case .projects: return "Projects"
case .chat: return "Chat"
case .memory: return "Memory"
case .curator: return "Curator"
case .skills: return "Skills"
case .platforms: return "Platforms"
case .personalities: return "Personalities"
@@ -52,6 +55,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case .mcpServers: return "MCP Servers"
case .gateway: return "Messaging Gateway"
case .cron: return "Cron"
case .kanban: return "Kanban"
case .health: return "Health"
case .logs: return "Logs"
case .settings: return "Settings"
@@ -67,6 +71,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case .projects: return "square.grid.2x2"
case .chat: return "text.bubble"
case .memory: return "brain"
case .curator: return "sparkles"
case .skills: return "lightbulb"
case .platforms: return "dot.radiowaves.left.and.right"
case .personalities: return "theatermasks"
@@ -79,6 +84,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case .mcpServers: return "puzzlepiece.extension"
case .gateway: return "antenna.radiowaves.left.and.right"
case .cron: return "clock.arrow.2.circlepath"
case .kanban: return "rectangle.split.3x1"
case .health: return "stethoscope"
case .logs: return "doc.text"
case .settings: return "gearshape"
+29 -8
View File
@@ -14,21 +14,42 @@ struct SidebarView: View {
@Environment(AppCoordinator.self) private var coordinator
@Environment(ServerLiveStatusRegistry.self) private var liveRegistry
@Environment(\.serverContext) private var serverContext
@Environment(\.hermesCapabilities) private var capabilitiesStore
private static let sections: [Section] = [
Section(title: "Monitor", items: [.dashboard, .insights, .sessions, .activity]),
Section(title: "Projects", items: [.projects]),
Section(title: "Interact", items: [.chat, .memory, .skills]),
Section(title: "Configure", items: [.platforms, .personalities, .quickCommands, .credentialPools, .plugins, .webhooks, .profiles]),
Section(title: "Manage", items: [.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]),
]
/// Capability-gated sections. Curator is v0.12+ only; older Hermes
/// hosts get the same Interact section minus the Curator row.
/// Building the list lazily off the env keeps the sidebar honest
/// when the user reconnects to a different-version host.
private var sections: [Section] {
let caps = capabilitiesStore?.capabilities
var interact: [SidebarSection] = [.chat, .memory]
if caps?.hasCurator ?? false {
interact.append(.curator)
}
interact.append(.skills)
var manage: [SidebarSection] = [.tools, .mcpServers, .gateway, .cron]
if caps?.hasKanban ?? false {
manage.append(.kanban)
}
manage.append(contentsOf: [.health, .logs, .settings])
return [
Section(title: "Monitor", items: [.dashboard, .insights, .sessions, .activity]),
Section(title: "Projects", items: [.projects]),
Section(title: "Interact", items: interact),
Section(title: "Configure", items: [.platforms, .personalities, .quickCommands, .credentialPools, .plugins, .webhooks, .profiles]),
Section(title: "Manage", items: manage),
]
}
var body: some View {
VStack(spacing: 0) {
header
ScrollView {
VStack(alignment: .leading, spacing: 14) {
ForEach(Self.sections) { section in
ForEach(sections) { section in
sectionView(section)
}
}
+9
View File
@@ -196,12 +196,19 @@ private struct ContextBoundRoot: View {
@State private var coordinator: AppCoordinator
@State private var fileWatcher: HermesFileWatcher
@State private var chatViewModel: ChatViewModel
/// Per-window snapshot of the target Hermes installation's capability
/// flags. Drives sidebar visibility (Curator, Kanban only on v0.12+),
/// settings rows (curator aux added on v0.12), and version banners.
/// Refreshes once on init; explicit `refresh()` call rerun after a
/// `hermes update`.
@State private var capabilities: HermesCapabilitiesStore
init(context: ServerContext) {
self.context = context
_coordinator = State(initialValue: AppCoordinator())
_fileWatcher = State(initialValue: HermesFileWatcher(context: context))
_chatViewModel = State(initialValue: ChatViewModel(context: context))
_capabilities = State(initialValue: HermesCapabilitiesStore(context: context))
}
var body: some View {
@@ -209,6 +216,8 @@ private struct ContextBoundRoot: View {
.environment(coordinator)
.environment(fileWatcher)
.environment(chatViewModel)
.environment(capabilities)
.hermesCapabilities(capabilities)
// Per-window title shows which server this window is bound to.
// Local: "Scarf Local". Remote: "Scarf Mardon Mac Mini".
// The colored dot lives inside the toolbar switcher; the window
@@ -1,5 +1,6 @@
import Testing
import Foundation
import ScarfCore
@testable import scarf
/// Tests that ``CredentialPoolsOAuthGate`` steers each known provider to
+26
View File
@@ -55,11 +55,37 @@ import ScarfCore
#expect(ids.contains("nous"), "Nous Portal must appear after overlay merge")
#expect(ids.contains("openai-codex"), "OpenAI Codex overlay must appear")
#expect(ids.contains("qwen-oauth"), "Qwen OAuth overlay must appear")
// v0.12 additions IDs must match HERMES_OVERLAYS in
// hermes-agent/hermes_cli/providers.py exactly. Drift here
// means the picker can't reach the new providers.
#expect(ids.contains("gmi"), "GMI Cloud overlay must appear (v0.12)")
#expect(ids.contains("azure-foundry"), "Azure AI Foundry overlay must appear (v0.12)")
#expect(ids.contains("lmstudio"), "LM Studio overlay must appear (v0.12)")
#expect(ids.contains("minimax-oauth"), "MiniMax OAuth overlay must appear (v0.12)")
#expect(ids.contains("tencent-tokenhub"), "Tencent TokenHub overlay must appear (v0.12)")
// Cached providers still present.
#expect(ids.contains("anthropic"))
#expect(ids.contains("openai"))
}
@Test func v012OverlayProvidersCarryCorrectAuthTypes() throws {
// The auth-type drives whether Settings shows an API-key field,
// an OAuth flow, or external-process wiring. Locking the v0.12
// additions here so a typo doesn't quietly land users in the
// wrong setup flow.
let overlays = ModelCatalogService.overlayOnlyProviders
#expect(overlays["gmi"]?.authType == .apiKey)
#expect(overlays["azure-foundry"]?.authType == .apiKey)
#expect(overlays["lmstudio"]?.authType == .apiKey)
#expect(overlays["minimax-oauth"]?.authType == .oauthExternal)
#expect(overlays["tencent-tokenhub"]?.authType == .apiKey)
// None of the v0.12 additions are subscription-gated (only Nous
// Portal is).
for id in ["gmi", "azure-foundry", "lmstudio", "minimax-oauth", "tencent-tokenhub"] {
#expect(overlays[id]?.subscriptionGated == false, "\(id) shouldn't be subscription-gated")
}
}
@Test func nousPortalSortsFirst() throws {
let path = try writeCacheFixture()
let service = ModelCatalogService(path: path)
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env bash
#
# Scarf landing-site helper — builds the marketing landing page from
# site/landing/ and (on `publish`) commits + pushes to gh-pages.
#
# Usage:
# ./scripts/site.sh check # validate that all required files exist
# ./scripts/site.sh build # render to .gh-pages-worktree/ root (with token substitution)
# ./scripts/site.sh preview [PORT] # build + serve on localhost:PORT (default 8000) + open browser
# ./scripts/site.sh serve [PORT] # serve .gh-pages-worktree/ without rebuilding (default 8000)
# ./scripts/site.sh publish # check + build + secret-scan + commit + push gh-pages (root files only)
# ./scripts/site.sh --help # this help
#
# Path discipline. This script ONLY touches root-level landing files plus the
# top-level assets/ directory on gh-pages. It NEVER touches:
# - appcast.xml (owned by scripts/release.sh)
# - templates/ (owned by scripts/catalog.sh)
# All three publishers stay on disjoint paths.
#
# Bootstrap (one-time): a .gh-pages-worktree/ clone of the gh-pages branch.
# scripts/release.sh creates it on first use. If missing:
# git worktree add .gh-pages-worktree gh-pages
#
# Token substitution. index.html and sitemap.xml.tmpl are run through a
# minimal {{TOKEN}} replacement at build time:
# {{VERSION}} — current Scarf version (read from appcast.xml on
# gh-pages, or "unreleased" if not found)
# {{LASTMOD}} — today's date in YYYY-MM-DD
# {{TEMPLATE_URLS}} — <url> entries for every template in
# templates/catalog.json (only used in sitemap.xml.tmpl)
set -euo pipefail
# ---------- config ----------
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GHPAGES_DIR="$REPO_ROOT/.gh-pages-worktree"
SRC_DIR="$REPO_ROOT/site/landing"
PY="${PYTHON:-python3}"
# Files we OWN on gh-pages root. Anything else stays untouched.
OWNED_ROOT_FILES=(
index.html
styles.css
app.js
llms.txt
robots.txt
sitemap.xml
manifest.webmanifest
favicon.ico
apple-touch-icon.png
)
# ---------- helpers (same shape as scripts/catalog.sh / wiki.sh) ----------
log() { printf '\033[1;34m==> %s\033[0m\n' "$*"; }
warn() { printf '\033[1;33m[WARN] %s\033[0m\n' "$*" >&2; }
die() { printf '\033[1;31m[ERR] %s\033[0m\n' "$*" >&2; exit 1; }
need_src() {
[[ -d "$SRC_DIR" ]] || die "missing $SRC_DIR"
for f in index.html styles.css app.js llms.txt robots.txt sitemap.xml.tmpl manifest.webmanifest favicon.ico apple-touch-icon.png; do
[[ -e "$SRC_DIR/$f" ]] || die "missing required source file: $SRC_DIR/$f"
done
[[ -d "$SRC_DIR/assets" ]] || die "missing $SRC_DIR/assets/"
}
need_ghpages() {
[[ -e "$GHPAGES_DIR/.git" ]] || die "no gh-pages worktree at $GHPAGES_DIR
Run: git worktree add .gh-pages-worktree gh-pages"
}
# ---------- token resolvers ----------
# Pull current version from appcast.xml on gh-pages (preferred — reflects
# what's actually shipped). Fall back to "unreleased".
resolve_version() {
if [[ -f "$GHPAGES_DIR/appcast.xml" ]]; then
APPCAST="$GHPAGES_DIR/appcast.xml" "$PY" -c '
import os, re
src = open(os.environ["APPCAST"], "r", encoding="utf-8").read()
# Sparkle uses <sparkle:shortVersionString>X.Y.Z</sparkle:shortVersionString>.
# Take the first match (newest entry — appcast is reverse-chronological).
m = re.search(r"<sparkle:shortVersionString>([^<]+)</sparkle:shortVersionString>", src)
print(m.group(1) if m else "unreleased")
'
else
echo "unreleased"
fi
}
# Render <url> entries for each template in catalog.json. The catalog lives
# at templates/catalog.json on gh-pages (built by scripts/catalog.sh).
resolve_template_urls() {
local catalog="$GHPAGES_DIR/templates/catalog.json"
if [[ ! -f "$catalog" ]]; then
return 0
fi
"$PY" - <<'PY' "$catalog"
import json, sys, datetime
catalog = json.load(open(sys.argv[1], 'r', encoding='utf-8'))
today = datetime.date.today().isoformat()
out = []
for tpl in catalog.get("templates", []):
slug = tpl.get("slug") or tpl.get("id") or ""
if not slug:
continue
out.append(
f' <url>\n'
f' <loc>https://awizemann.github.io/scarf/templates/{slug}/</loc>\n'
f' <lastmod>{today}</lastmod>\n'
f' <changefreq>monthly</changefreq>\n'
f' <priority>0.6</priority>\n'
f' </url>'
)
print("\n".join(out))
PY
}
# Apply {{TOKEN}} substitution: substitute_tokens VERSION LASTMOD TEMPLATE_URLS SRC_FILE DEST_FILE
substitute_tokens() {
local version="$1"
local lastmod="$2"
local template_urls="$3"
local src_file="$4"
local dest_file="$5"
VERSION="$version" LASTMOD="$lastmod" TEMPLATE_URLS="$template_urls" \
SRC="$src_file" DEST="$dest_file" \
"$PY" -c '
import os
src_path = os.environ["SRC"]
dest_path = os.environ["DEST"]
with open(src_path, "r", encoding="utf-8") as fh:
text = fh.read()
text = text.replace("{{VERSION}}", os.environ["VERSION"])
text = text.replace("{{LASTMOD}}", os.environ["LASTMOD"])
text = text.replace("{{TEMPLATE_URLS}}", os.environ["TEMPLATE_URLS"])
with open(dest_path, "w", encoding="utf-8") as fh:
fh.write(text)
'
}
# ---------- secret-scan (mirrors scripts/wiki.sh + catalog.sh) ----------
hard_regex='(sk-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{30,}|ghs_[A-Za-z0-9]{30,}|ghu_[A-Za-z0-9]{30,}|gho_[A-Za-z0-9]{30,}|ghr_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|-----BEGIN [A-Z ]*PRIVATE KEY-----|BEGIN OPENSSH PRIVATE KEY)'
scan_hard_source() {
# Pre-build pass: scan source files (text only — image content is on the
# author to review visually). Catches accidentally-pasted credentials.
local hits
hits="$(grep -rInE --exclude-dir=.git --include='*.html' --include='*.css' --include='*.js' --include='*.txt' --include='*.xml' --include='*.json' --include='*.tmpl' --include='*.webmanifest' "$hard_regex" "$SRC_DIR" 2>/dev/null || true)"
if [[ -n "$hits" ]]; then
printf '%s\n' "$hits" >&2
die "hard-pattern secret match in source — refusing to build."
fi
}
scan_hard_rendered() {
# Post-build pass: scan the gh-pages tree we're about to publish, but
# only the files we own (so we don't false-flag on appcast.xml or
# templates/ which other scripts manage).
local hits=""
for f in "${OWNED_ROOT_FILES[@]}"; do
[[ -f "$GHPAGES_DIR/$f" ]] || continue
case "$f" in
*.png|*.ico|*.jpg|*.jpeg|*.webp) continue ;;
esac
local h
h="$(grep -InE "$hard_regex" "$GHPAGES_DIR/$f" 2>/dev/null || true)"
[[ -n "$h" ]] && hits="$hits$h"$'\n'
done
if [[ -d "$GHPAGES_DIR/assets" ]]; then
local h
h="$(grep -rInE --include='*.html' --include='*.css' --include='*.js' --include='*.txt' --include='*.xml' --include='*.json' --include='*.tmpl' "$hard_regex" "$GHPAGES_DIR/assets" 2>/dev/null || true)"
[[ -n "$h" ]] && hits="$hits$h"$'\n'
fi
if [[ -n "$hits" ]]; then
printf '%s\n' "$hits" >&2
die "hard-pattern secret match in rendered site — refusing to publish."
fi
}
# ---------- commands ----------
cmd_check() {
need_src
scan_hard_source
log "Source files OK ($(ls -1 "$SRC_DIR" | wc -l | tr -d ' ') entries; assets/: $(find "$SRC_DIR/assets" -type f | wc -l | tr -d ' ') files)"
}
cmd_build() {
need_src
need_ghpages
scan_hard_source
local version lastmod template_urls
version="$(resolve_version)"
lastmod="$(date -u +%Y-%m-%d)"
template_urls="$(resolve_template_urls)"
log "Building (version=$version, lastmod=$lastmod)"
# Static copies (no substitution needed)
for f in styles.css app.js llms.txt robots.txt manifest.webmanifest favicon.ico apple-touch-icon.png; do
cp "$SRC_DIR/$f" "$GHPAGES_DIR/$f"
done
# Token-substituted: index.html
substitute_tokens "$version" "$lastmod" "$template_urls" \
"$SRC_DIR/index.html" "$GHPAGES_DIR/index.html"
# Token-substituted: sitemap.xml (rendered from .tmpl)
substitute_tokens "$version" "$lastmod" "$template_urls" \
"$SRC_DIR/sitemap.xml.tmpl" "$GHPAGES_DIR/sitemap.xml"
# Sync assets/ — mirror the source tree
rm -rf "$GHPAGES_DIR/assets"
cp -R "$SRC_DIR/assets" "$GHPAGES_DIR/assets"
log "Built into $GHPAGES_DIR/"
}
cmd_preview() {
cmd_build
local port="${1:-8000}"
log "Built. Open http://localhost:$port/ in your browser."
log "Press Ctrl-C to stop the server."
cmd_serve "$port"
}
cmd_serve() {
need_ghpages
local port="${1:-8000}"
log "Serving $GHPAGES_DIR on http://localhost:$port/"
log "Open: http://localhost:$port/"
(cd "$GHPAGES_DIR" && "$PY" -m http.server "$port")
}
cmd_publish() {
need_src
need_ghpages
log "Validating source"
scan_hard_source
log "Building"
cmd_build
log "Secret-scanning rendered site"
scan_hard_rendered
log "Staging + committing gh-pages"
(cd "$GHPAGES_DIR" && git add "${OWNED_ROOT_FILES[@]}" assets/)
if (cd "$GHPAGES_DIR" && git diff --cached --quiet); then
log "No changes to publish."
return 0
fi
local msg
msg="site: rebuild landing page at $(date -u +%Y-%m-%dT%H:%M:%SZ)"
(cd "$GHPAGES_DIR" && git commit -m "$msg")
log "Pushing gh-pages"
(cd "$GHPAGES_DIR" && git push origin gh-pages)
log "Published."
}
cmd_help() {
sed -n '1,32p' "$0" | sed -n '/^# Usage/,/^#$/p'
}
# ---------- dispatch ----------
sub="${1:-help}"
shift || true
case "$sub" in
check) cmd_check "$@" ;;
build) cmd_build "$@" ;;
preview) cmd_preview "$@" ;;
serve) cmd_serve "$@" ;;
publish) cmd_publish "$@" ;;
help|--help|-h) cmd_help ;;
*) die "unknown command: $sub (try --help)" ;;
esac
+106
View File
@@ -0,0 +1,106 @@
// Scarf landing page — minimal client behavior.
// No dependencies. Runs after defer-parse.
(function () {
const root = document.documentElement;
const STORAGE_KEY = 'scarf-theme';
function applyTheme(theme) {
if (theme === 'light' || theme === 'dark') {
root.setAttribute('data-theme', theme);
} else {
root.removeAttribute('data-theme');
}
applyImageTheme();
}
// Resolve the *effective* theme — explicit data-theme wins, otherwise
// fall back to the OS preference.
function resolveTheme() {
const explicit = root.getAttribute('data-theme');
if (explicit === 'light' || explicit === 'dark') return explicit;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// Swap every <img data-dark-src="..."> between its light and dark variants.
// Also rewrites the parent <picture>'s <source srcset> so the picture
// algorithm doesn't override us on resize/layout passes.
function applyImageTheme() {
const theme = resolveTheme();
document.querySelectorAll('img[data-dark-src]').forEach((img) => {
if (!img.dataset.lightSrc) {
img.dataset.lightSrc = img.getAttribute('src');
}
const target = theme === 'dark' ? img.dataset.darkSrc : img.dataset.lightSrc;
if (img.getAttribute('src') !== target) img.setAttribute('src', target);
const picture = img.parentElement;
if (picture && picture.tagName === 'PICTURE') {
picture.querySelectorAll('source').forEach((s) => {
if (s.getAttribute('srcset') !== target) s.setAttribute('srcset', target);
});
}
});
}
// Hydrate stored preference (if any) — runs after DOMContentLoaded since
// the <script> is deferred. There's a brief moment of media-query default
// before hydrate; that's acceptable here (no FOUC because the media query
// already gets the right colors and the first images render at light by
// default — JS swaps within a frame on dark-mode systems).
let stored = null;
try {
stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark') applyTheme(stored);
else applyImageTheme(); // initial pass even if no stored preference
} catch (_) {
applyImageTheme();
}
const toggle = document.querySelector('[data-theme-toggle]');
if (toggle) {
toggle.addEventListener('click', () => {
const current = root.getAttribute('data-theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
let next;
if (current === 'light') next = 'dark';
else if (current === 'dark') next = null;
else next = prefersDark ? 'light' : 'dark';
applyTheme(next);
try {
if (next) localStorage.setItem(STORAGE_KEY, next);
else localStorage.removeItem(STORAGE_KEY);
} catch (_) { /* ignore */ }
});
}
// Re-apply on system preference change so users who haven't set an
// explicit override still get matching screenshots.
if (window.matchMedia) {
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => {
if (!root.hasAttribute('data-theme')) applyImageTheme();
};
if (mql.addEventListener) mql.addEventListener('change', onChange);
else if (mql.addListener) mql.addListener(onChange);
}
// Auto-collapse sticky header on scroll-down, restore on scroll-up.
const header = document.querySelector('.site-header');
if (header) {
let lastY = window.scrollY;
let ticking = false;
window.addEventListener('scroll', () => {
if (ticking) return;
window.requestAnimationFrame(() => {
const y = window.scrollY;
if (y > 80 && y > lastY) header.style.transform = 'translateY(-100%)';
else header.style.transform = '';
lastY = y;
ticking = false;
});
ticking = true;
}, { passive: true });
header.style.transition = 'transform 0.25s ease';
}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 632 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

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