mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a7ac21ebe | |||
| 5be67282d8 | |||
| c661945a1f | |||
| f5f8dc30b6 | |||
| 34d315793b | |||
| acd3692faf | |||
| ab615f0c28 | |||
| 982ed7da92 | |||
| cb164f07f9 | |||
| 1dbdf9d079 | |||
| 101488cd0d | |||
| 03c996ee80 | |||
| 8428cbff10 | |||
| 381adfd925 | |||
| 254af46e93 | |||
| 596c844da5 | |||
| ec47d191a1 | |||
| 31e6c31acf | |||
| fcfe1c89d6 | |||
| df1b9caabf | |||
| a41c81c048 | |||
| 88add62997 | |||
| 80589b3f23 | |||
| 13f89e309b | |||
| c055081ba3 | |||
| bd05e01d1c | |||
| b66ed7e8d7 | |||
| 46cec816ec | |||
| 681fa40c3c | |||
| 15642d37cf | |||
| 33022aeb92 | |||
| 4a2ef74b74 | |||
| 11bb2bd0c3 | |||
| 3d85b91392 | |||
| 799332fbcd | |||
| 7a833b6c5a | |||
| 6954f0276a | |||
| ee3791a1b2 | |||
| 686fb37630 | |||
| 1354568992 | |||
| da721fa276 | |||
| a90a29add8 | |||
| 421e6030df | |||
| 7b864d77d5 | |||
| 11946aad67 | |||
| 4140983866 | |||
| cca99d4e13 | |||
| 2aab9dac07 | |||
| c31dfccb9b | |||
| 61e61f556a | |||
| 424711c3d9 | |||
| 067aeda878 | |||
| 389620059c | |||
| 4ffd353835 | |||
| 511726e2c0 | |||
| 587c6c36c8 |
@@ -61,3 +61,8 @@ releases/v*/appcast-entry.xml
|
||||
|
||||
# Wiki helper: personal patterns (hostnames, IPs) blocked from the wiki push.
|
||||
scripts/wiki-blocklist.txt
|
||||
|
||||
# TestFlight feedback / crash JSONs downloaded for triage. PII (emails,
|
||||
# carriers, locales) and never meant for the public repo — kept local
|
||||
# while a fix round is in progress, deleted afterward.
|
||||
crashes/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -19,11 +19,43 @@
|
||||
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
|
||||
</p>
|
||||
|
||||
## What's New in 2.5
|
||||
## What's New in 2.6
|
||||
|
||||
### ScarfGo — the iPhone companion ships in public TestFlight
|
||||
### Hermes v2026.4.30 (v0.12.0) catch-up
|
||||
|
||||
Same Hermes server you've been running on your Mac — now reachable from your phone over SSH. Multi-server, project-scoped chat, session resume, memory editor, cron list, skills tree, settings (read), all native iOS. Pure-Swift SSH (Citadel under the hood — no `ssh` binary needed on iOS). Per-project chat writes the same Scarf-managed `AGENTS.md` block the Mac app does, so the agent boots with the same project context regardless of which client opened the session.
|
||||
The largest single Hermes update Scarf has had to follow since v0.10's Tool Gateway. Every new surface is **capability-gated** through `HermesCapabilities` (parses `hermes --version` once per server) — on a v0.11 host, Scarf 2.6 looks identical to Scarf 2.5.2 and the new affordances are hidden.
|
||||
|
||||
- **Autonomous Curator (Mac sidebar + iOS panel).** `hermes curator` self-prunes / -consolidates the skill library on a 7-day cycle. Status panel, **Run Now / Pause / Resume** actions, three leaderboards (least-recently-active / most-active / least-active) with activity / use / view / patch counters, inline pin toggles, restore-archived sheet. Last-run REPORT.md renders inline. New "Curator" sidebar item under Interact (between Memory and Skills); ScarfGo gets a Curator nav row under System.
|
||||
- **Multimodal image input in chat.** Drag/drop, paste, or NSOpenPanel multi-pick on Mac; PhotosPicker on iOS (up to 5 images per message). `ImageEncoder` downsamples to 1568px long-edge JPEG q=0.85, **detached only** so encoding never blocks MainActor. Hermes routes the prompt to a vision-capable model automatically. Image-only sends are valid — vision models accept "describe this" with no caption.
|
||||
- **5 new inference providers** in the model picker — GMI Cloud, Azure AI Foundry, LM Studio (now first-class), MiniMax (OAuth), Tencent TokenHub. Provider IDs match `HERMES_OVERLAYS` in `hermes_cli/providers.py` exactly.
|
||||
- **Microsoft Teams + Yuanbao** as the 18th and 19th gateway platforms in the Platforms tab.
|
||||
- **Read-only Kanban view (Mac).** Paginated table over `hermes kanban list --json` filtered by status, with status badges, meta chips (id / assignee / workspace / skills), and 5s polling while foregrounded. Create / claim / dispatch UI is deferred until upstream stabilizes the multi-profile collab layer (which was reverted in v0.12).
|
||||
- **Skills v0.12 surface.** Direct-URL install (`hermes skills install <https-url>`) via a new "Install from URL…" toolbar button on Mac; reload via `hermes skills audit`; `skills.disabled` rendered as strikethrough + an "OFF" pill on Mac and iOS rows; Curator pin badge from `~/.hermes/skills/.curator_state` surfaced as a pin glyph.
|
||||
- **Cron — `--workdir` field (Mac).** Inject `AGENTS.md` / `CLAUDE.md` / `.cursorrules` from a working directory and pin cwd for terminal/file/code_exec tools. Scarf's CronJobEditor adds the field; both create and edit paths forward the flag.
|
||||
- **Settings deltas.** New **Caching & Redaction** section under Advanced — prompt cache TTL picker (5m / 1h), redact-secrets-in-patches toggle (now off by default on v0.12; flip back on here), runtime metadata footer toggle. TTS provider list gains **piper** (native local TTS); terminal backend list gains **vercel** (Vercel Sandbox).
|
||||
- **`auxiliary.curator` aux task.** Curator's review fork can run on a separate model from the main agent. `auxiliary.flush_memories` was removed in v0.12 — Scarf preserves the row on v0.11 hosts (inverse gate) and hides it on v0.12.
|
||||
- **ScarfGo catch-up.** Read-only Webhooks / Plugins / Profiles tabs parity-match the Mac surfaces (no mutating CLI verbs on the phone). Yellow Hermes-version banner nudges pre-v0.12 hosts to upgrade; renders only when the connected target is below v0.12.
|
||||
|
||||
### Chat fixes (post-merge round)
|
||||
|
||||
A focused pass over GitHub issue triage:
|
||||
|
||||
- **Typing lag in the chat composer ([#67](https://github.com/awizemann/scarf/issues/67))** — `RichChatInputBar.updateMenuState()` was firing on every keystroke and writing two state vars per `.onChange`, tripping SwiftUI's "action tried to update multiple times per frame" warning. Composer now coalesces writes, short-circuits when the slash menu can't apply, and watches `commands.count` instead of allocating `commands.map(\.id)` per keystroke.
|
||||
- **Chat font-size slider now actually scales rich chat content ([#68](https://github.com/awizemann/scarf/issues/68))** — `\.dynamicTypeSize` couldn't reach the fixed-point ScarfFont tokens. New `\.chatFontScale` env value plumbed through bubbles, markdown, and code blocks.
|
||||
- **Placeholder ghosting on first keystroke ([#65](https://github.com/awizemann/scarf/issues/65))** — `TextEditor`'s NSTextView surfaces a typed glyph one frame before the SwiftUI binding propagates. Pinned an opaque background behind the placeholder rect; switched the conditional to `.opacity(...)` for view-tree stability.
|
||||
- **Draft text leaked between conversations ([#62](https://github.com/awizemann/scarf/issues/62))** — composer `@State` survived session switches because the surrounding view tree was structurally identical. Bound `RichChatInputBar`'s identity to `richChat.sessionId`.
|
||||
- **Sent message rendered blank after navigating away ([#63](https://github.com/awizemann/scarf/issues/63))** — `loadSessionHistory` atomically replaced messages from a state.db that hadn't yet flushed the user's row. New per-session pending-user-messages cache survives `reset()` and re-injects entries until the DB catches up.
|
||||
- **Background completion notifications ([#64](https://github.com/awizemann/scarf/issues/64))** — new `ChatNotificationService` fires a local UNUserNotificationCenter banner when a prompt completes while Scarf isn't the foreground app. Settings → Display → Feedback → "Notify when Hermes finishes" toggle, default on.
|
||||
- **Per-message TTS playback ([#66](https://github.com/awizemann/scarf/issues/66))** — small speaker glyph on each settled assistant bubble. Tap to read aloud through `AVSpeechSynthesizer` with the user's macOS Spoken Content default voice.
|
||||
- **ACP control-message timeout 30s → 60s ([#61](https://github.com/awizemann/scarf/issues/61))** — gives `initialize` / `session/new` / `session/load` headroom against gateway-induced state.db lock contention.
|
||||
|
||||
See the full [v2.6.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.6.0).
|
||||
|
||||
**Previous releases:** see the [Release Notes Index](https://github.com/awizemann/scarf/wiki/Release-Notes-Index) on the wiki for v2.5, v2.3, v2.2, v2.0, v1.6, and earlier.
|
||||
|
||||
## ScarfGo — the iPhone companion
|
||||
|
||||
Same Hermes server you've been running on your Mac — reachable from your phone over SSH. Multi-server, project-scoped chat, session resume, memory editor, cron list, skills tree, settings (read), all native iOS. Pure-Swift SSH (Citadel under the hood — no `ssh` binary needed on iOS). Per-project chat writes the same Scarf-managed `AGENTS.md` block the Mac app does, so the agent boots with the same project context regardless of which client opened the session.
|
||||
|
||||
**[Join the public TestFlight](https://testflight.apple.com/join/qCrRpcTz)** — the link is live now but only accepts new beta testers once Apple's Beta Review approves the first build. If you hit a "not accepting testers" splash, bookmark it and try again in 24–48h.
|
||||
|
||||
@@ -39,21 +71,6 @@ Same Hermes server you've been running on your Mac — now reachable from your p
|
||||
|
||||
See the [ScarfGo wiki page](https://github.com/awizemann/scarf/wiki/ScarfGo) for the full feature tour, [ScarfGo Onboarding](https://github.com/awizemann/scarf/wiki/ScarfGo-Onboarding) for the SSH-key setup walkthrough, and [Platform Differences](https://github.com/awizemann/scarf/wiki/Platform-Differences) for what is and isn't shared between Mac and iOS.
|
||||
|
||||
### Everything else in 2.5
|
||||
|
||||
- **Portable project-scoped slash commands.** Author reusable prompt templates as Markdown files at `<project>/.scarf/slash-commands/<name>.md` with YAML frontmatter (name, description, argumentHint, optional model override). Invoke as `/<name> [args]` from chat — Scarf substitutes `{{argument}}` (with optional `default:` fallback) in the body and sends the expanded prompt to Hermes. Mac authoring tab + iOS read-only browser. Templates carry them via the new `slash-commands/` block in `.scarftemplate` bundles (schemaVersion 3). See [Slash Commands](https://github.com/awizemann/scarf/wiki/Slash-Commands) for the full schema.
|
||||
- **Hermes v2026.4.23 chat parity.** `/steer` non-interruptive guidance command, per-turn stopwatch on assistant bubbles, numbered keyboard shortcuts (1–9) on the permission sheet, git branch chip in the chat header. The new `messages.reasoning_content` and `sessions.api_call_count` columns surface as a richer reasoning disclosure + an "API" chip on session rows.
|
||||
- **Spotify + design-md skills.** Mac ships an in-app Spotify OAuth sheet (mirrors the v2.3 Nous Portal pattern); design-md gets a host-side `npx` prereq check on both platforms. SKILL.md frontmatter (`allowed_tools`, `related_skills`, `dependencies`) renders as chip rows. A "What's New" pill on the Skills tab tells you when remote skills changed since you last looked.
|
||||
- **Mac global Sessions: project filter + project badges** — parity with ScarfGo's Sessions tab. The list grows a filter Menu (All projects / Unattributed / each registered project) and each row carries a tinted folder chip with the project name when attributed.
|
||||
- **Human-readable cron schedules everywhere.** New `CronScheduleFormatter` in ScarfCore translates the common cron shapes into English phrases and falls back to the raw expression on anything custom. Mac and iOS render the same.
|
||||
- **Mac design-system overhaul.** Rust palette, typed token bundle (`ScarfColor`, `ScarfFont`, `ScarfSpace`, `ScarfRadius`), reusable components (`ScarfPageHeader`, `ScarfCard`, `ScarfBadge`, `ScarfTextField`, four button styles), redesigned 3-pane chat. iOS adopts the same tokens with a hybrid Dynamic Type policy so accessibility scaling on body text is preserved. See [Design System](https://github.com/awizemann/scarf/wiki/Design-System) for the full reference.
|
||||
- **Under the hood** — `SessionAttributionService`, `ProjectContextBlock`, `CronScheduleFormatter`, `GitBranchService`, `SkillPrereqService`, `SkillSnapshotService`, `ProjectSlashCommandService`, and the ACP error triplet (`acpError` / `acpErrorHint` / `acpErrorDetails`) consolidated into ScarfCore so Mac and iOS consume one source of truth. 179 tests across 13 suites, three consecutive green runs. Several `try?` swallows in iOS lifecycle code now surface real failures (Keychain unlock errors no longer drop people into onboarding; partial Forget operations report what failed).
|
||||
- **iOS push notifications skeleton** — `NotificationRouter` ships with foreground presentation + a lock-screen "Approve / Deny" action category gated by `apnsEnabled = false`. Lights up when Hermes ships a server-side push sender + an APNs cert.
|
||||
|
||||
See the full [v2.5.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.5.0).
|
||||
|
||||
**Previous releases:** see the [Release Notes Index](https://github.com/awizemann/scarf/wiki/Release-Notes-Index) on the wiki for v2.3, v2.2, v2.0, v1.6, and earlier.
|
||||
|
||||
## Connect ScarfGo to your Hermes server
|
||||
|
||||
ScarfGo speaks SSH directly — no companion service, no developer-controlled server in between. Onboarding takes about a minute:
|
||||
@@ -145,7 +162,7 @@ Custom, agent-generated dashboards for any project. Define stat boxes, charts, t
|
||||
- macOS 14.6+ (Sonoma) for Scarf
|
||||
- iOS 18.0+ for [ScarfGo](https://github.com/awizemann/scarf/wiki/ScarfGo) (the iPhone companion, public TestFlight from v2.5)
|
||||
- Xcode 16.0+ to build from source
|
||||
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` on each target host (v0.11.0+ recommended for full v2.5 feature support — `/steer`, new state.db columns, design-md/spotify skills, SKILL.md frontmatter chips)
|
||||
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` on each target host (v0.12.0+ recommended for full v2.6 feature support — autonomous Curator, multimodal image input, 5 new providers, Microsoft Teams + Yuanbao gateways, Kanban, Skills v0.12 surface, cron `--workdir`, prompt-cache TTL, Piper TTS, Vercel terminal)
|
||||
- For remote servers: SSH access (key-based), `sqlite3` on the remote (for atomic DB snapshots), and the `hermes` CLI resolvable from the remote user's `PATH` or at a path you specify per server. ScarfGo requires the same on every Hermes host it connects to.
|
||||
|
||||
### Compatibility
|
||||
@@ -159,9 +176,10 @@ Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`,
|
||||
| v0.8.0 (2026-04-08) | Verified |
|
||||
| v0.9.0 (2026-04-13) | Verified |
|
||||
| v0.10.0 (2026-04-16) | Verified (Tool Gateway introduced) |
|
||||
| v0.11.0 (2026-04-23) | **Verified — current target (recommended for full v2.5 feature support)** |
|
||||
| v0.11.0 (2026-04-23) | Verified |
|
||||
| v0.12.0 (2026-04-30) | **Verified — current target (recommended for full v2.6 feature support)** |
|
||||
|
||||
Scarf 2.5 targets Hermes v0.11.0 for `/steer`, the new state.db columns (`messages.reasoning_content`, `sessions.api_call_count`), the new skills (design-md, spotify), the SKILL.md frontmatter chip surfaces, and the `hermes memory reset` toolbar action. Earlier Hermes versions remain supported for monitoring, sessions, file-based features, and ACP chat; v0.11-specific behavior degrades gracefully on older agents (`/steer` is harmless, new columns silently nil out).
|
||||
Scarf 2.6 targets Hermes v0.12.0 for the autonomous Curator, multimodal ACP image content blocks, the 5 new inference providers, Microsoft Teams + Yuanbao gateways, the read-only Kanban view, the Skills v0.12 surface (URL install / reload / disable badges / curator pin), cron `--workdir`, `auxiliary.curator`, `prompt_caching.cache_ttl`, the redaction toggle, the runtime metadata footer, Piper TTS, and the Vercel terminal backend. Every v0.12 surface is **capability-gated** — Scarf detects the host's Hermes version once per server connection (`hermes --version` → semver + `YYYY.M.D` parse) and hides v0.12-only UI on older hosts. v0.11.0 hosts keep the full v2.5 surface (`/steer`, `messages.reasoning_content`, `sessions.api_call_count`, design-md/spotify skills, SKILL.md frontmatter chips, `hermes memory reset`). Earlier Hermes versions remain supported for monitoring, sessions, file-based features, and ACP chat; new behavior degrades gracefully on older agents.
|
||||
|
||||
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
## What's in 2.5.2
|
||||
|
||||
A patch with one substantial new feature (**iOS chat resilience** — reconnect, cached snapshot fallback, history paging) plus a stack of fixes for issues reported against 2.5.1 and earlier. Drop-in replacement for 2.5.1 on Mac; drop-in TestFlight build on iOS. No data migrations.
|
||||
|
||||
### iOS chat resilience
|
||||
|
||||
ScarfGo now survives phone-sleep, network handoffs, and SSH socket drops without losing the agent's work. Hermes was already persisting messages to `state.db` in real-time; iOS just had no resync path.
|
||||
|
||||
- **5-attempt exponential reconnect** (1s → 2s → 4s → 8s → 16s) via `session/resume` with `session/load` fallback. Reconciles with `state.db` on success and surfaces a *"Resynced N new messages"* toast when the agent kept working through the disconnect.
|
||||
- **`NetworkReachabilityService`** (NWPathMonitor singleton): suspends reconnect attempts while offline and kicks a fresh cycle on link-up. Two new banner states above the message list — `.reconnecting` and `.offline` — render as slim ScarfDesign-tinted strips so the user always knows what the chat is doing.
|
||||
- **Scene-phase awareness**: returning to foreground triggers a channel-health check; if dead, the reconnect cycle starts immediately rather than waiting for the next interaction.
|
||||
- **Draft persistence**: per-server, per-session draft survives force-quit (UserDefaults-backed, 7-day janitor at app launch).
|
||||
|
||||
### Cached snapshot fallback (Mac + iOS)
|
||||
|
||||
`ServerTransport.cachedSnapshotPath` lets `HermesDataService` fall back to the previously-pulled `state.db` snapshot when a fresh pull fails. `isUsingStaleSnapshot` + `lastSnapshotMtime` surface to views so they render *"Last updated X ago."* Chat-history reload still passes `forceFresh: true` to refuse stale data; everything else (Dashboard, Sessions list, Activity) gets read-while-disconnected for free.
|
||||
|
||||
### Bounded message-history paging
|
||||
|
||||
`HermesDataService.fetchMessages(sessionId:limit:before:)` paginates by id desc with centralized `HistoryPageSize` constants. `RichChatViewModel.loadEarlier()` walks back through long sessions via `oldestLoadedMessageID` + `hasMoreHistory`. Legacy unbounded overload deprecated.
|
||||
|
||||
### Bug fixes
|
||||
|
||||
#### Mac
|
||||
|
||||
- **[#46](https://github.com/awizemann/scarf/issues/46) — chat O(n)-per-token bog-down (already shipped in 2.5.1 for the trailing-group patch; this release retains the fix and pairs with the new history paging so chats with thousands of messages stay smooth).**
|
||||
- **[#19](https://github.com/awizemann/scarf/issues/19) layer-3 — sqlite3 false-negative in diagnostics.** Already in v2.5.1; kept here.
|
||||
- **[#44](https://github.com/awizemann/scarf/issues/44) — pill / diagnostics agreement** via shared `SSHScriptRunner`. From v2.5.1; the tier-2 probe now also checks `state.db` (not just `config.yaml`) so a healthy fresh install reports green.
|
||||
- **[#59](https://github.com/awizemann/scarf/issues/59) — Settings → Model and Credential Pools no longer freeze.** Both views called `ModelCatalogService.loadProviders()` synchronously from `.onAppear` on the MainActor; on a remote SSH context that's a multi-megabyte SSH file read on the main thread, freezing the UI for 1–2 minutes. New `loadProvidersAsync()` / `loadModelsAsync(for:)` wrappers dispatch off the main thread; both views now use `.task` + `await` with a `ProgressView("Loading providers…")` overlay. Per-provider switching in the picker is also async now, so clicking a different provider doesn't re-freeze the UI.
|
||||
- **Diagnostics tri-state.** Hermes v0.11+ doesn't materialize `config.yaml` until the user changes a setting from defaults — so the diagnostics view was reporting *"12/14 passing"* on healthy fresh installs. The probe now distinguishes `.pass` / `.fail` / `.skipped`; a missing `config.yaml` emits SKIP and is excluded from the summary's denominator. Reads as *"12/12 passing (2 optional skipped)"* instead of the misleading 12/14.
|
||||
- **Credentials: OAuth providers visible.** `hasAnyAICredential()` only probed `credential_pool.<provider>` in `auth.json`; OAuth-authed providers land under `providers.<name>.access_token` (Nous, Spotify, GH Copilot ACP, Qwen, Gemini all use that path). The chat banner kept showing *"No AI provider credentials"* even after a successful Nous sign-in. Now both shapes count. Credential Pools view gains a parallel "OAuth providers" section listing OAuth-authed providers with token tail, expiry badge, and portal URL.
|
||||
- **Project-shadowed Hermes detection.** New `ProjectHermesShadowDetector` (ScarfCore) probes each registered project at chat-start; if a `.hermes/` dir or `hermes.yaml` is found inside the project, the user gets a banner explaining that project-local Hermes config will shadow the server-level one (a quiet failure mode for users who didn't realize Hermes prefers project-local config).
|
||||
- **[#58](https://github.com/awizemann/scarf/issues/58) — Mac chat side panes are hideable.** Two toolbar buttons next to the View picker (`sidebar.left` / `sidebar.right`) toggle the sessions list and tool inspector with a slide animation; both default visible (today's behavior). Clicking a tool card auto-shows the inspector if hidden so the click never silently dies. Settings → Display → Chat density gains parity Toggle rows.
|
||||
|
||||
#### ScarfGo (iOS)
|
||||
|
||||
- **[#56](https://github.com/awizemann/scarf/issues/56) — *"Citadel.SSHClient.CommandFailed error 1"* on dashboard.** `asyncSnapshotSQLite` was missed during the v2.5.0 Citadel hardening — used raw `executeCommand` (which discards stderr on non-zero exit) and didn't prepend the Citadel-friendly `PATH=$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH`. Now uses `executeCommandStream` and the same PATH prefix. `HermesDataService.humanize` already translates `sqlite3: command not found` / `permission denied` / `no such file` into actionable user copy — the bug was that the snapshot path never fed it real stderr.
|
||||
- **[#57](https://github.com/awizemann/scarf/issues/57) — keyboard-dismiss chevron over send button.** The keyboard accessory dismiss button added in v2.5.1 (#51) was placed at the trailing edge of the keyboard toolbar, directly above the trailing-edge send button. Moved to the leading edge — matches the iOS convention (Notes, Mail, Reminders).
|
||||
|
||||
### New features (Mac)
|
||||
|
||||
- **Chat-start model preflight ([commit](https://github.com/awizemann/scarf/commit/2aab9da)).** Catches a missing `model.default` / `model.provider` in `config.yaml` *before* the ACP session starts. Pre-fix the user typed a prompt, hit send, and got an opaque *"Model parameter is required"* HTTP 400 from the upstream provider. Now `ChatModelPreflightSheet` wraps the existing model picker so the same selection / validation / Nous-catalog branch is single-sourced; the chat the user originally opened lands without re-clicking the project row.
|
||||
- **Nous Portal live model catalog.** `NousModelCatalogService` fetches `GET /v1/models` from `inference-api.nousresearch.com` using the bearer token in `auth.json`. Cached at `~/.hermes/scarf/nous_models_cache.json` with a 24h TTL. The picker's nous-overlay detail view switches from a free-form TextField to a real model list, with a *"Custom…"* escape hatch for IDs not yet in the API response.
|
||||
- **Remote-aware admin sheets.** Three sheets gained the same context-aware Verify pattern that Add Project got in v2.5.1 (#54):
|
||||
- **Profiles → Import / Export.** Buttons that drive `hermes profile import <zip>` / `hermes profile export <name> <zip>` over SSH. Local context picks via `NSOpenPanel`; remote context shows a path-input + Verify button.
|
||||
- **Settings → Advanced → Restore.** Pick a local backup zip OR enter+verify a remote path.
|
||||
- **Templates → Install destination.** The parent-directory step in the install sheet branches on context — local Browse, or remote text-input + Verify.
|
||||
|
||||
### Translations
|
||||
|
||||
`Localizable.xcstrings` adds strings for all the new copy across the seven supported locales (English, Simplified Chinese, German, French, Spanish, Japanese, Brazilian Portuguese).
|
||||
|
||||
### Notes for users running 2.5.1
|
||||
|
||||
No data migrations needed. `~/.hermes/scarf/nous_models_cache.json` is created lazily on first use of the Nous picker; everything else is forward-compatible with existing config / Keychain / project registries.
|
||||
@@ -0,0 +1,134 @@
|
||||
## What's in 2.6.0
|
||||
|
||||
A major release tracking **Hermes v2026.4.30 (v0.12.0)** — the largest single Hermes update Scarf has had to follow since v0.10's Tool Gateway. Headline additions: the autonomous **Curator**, **multimodal image input** in chat, **5 new inference providers**, **Microsoft Teams + Yuanbao** gateway platforms, a **read-only Kanban** view, and ScarfGo gains read-only Webhooks/Plugins/Profiles plus a Hermes-version banner.
|
||||
|
||||
Pre-v0.12 Hermes hosts are fully supported. Every new surface is gated on a runtime capability detector (`hermes --version` → semver), so users on older Hermes installs see the v2.5 surface unchanged. UI doesn't appear until the underlying CLI subcommand exists.
|
||||
|
||||
### Curator (Mac + iOS)
|
||||
|
||||
Hermes v0.12's autonomous skill curator prunes / consolidates / archives agent-created skills on a 7-day schedule. Scarf adds a dedicated **Curator** sidebar item under Interact (Mac) and a Curator nav row under the System tab (iOS).
|
||||
|
||||
- **Status panel** — enabled/paused/disabled badge, last-run timestamp, last summary, run count, scheduling cadence (interval / stale-after / archive-after).
|
||||
- **Run Now** button triggers `hermes curator run`; pause/resume from the kebab menu.
|
||||
- **Three leaderboards** — least-recently-active, most-active, least-active. Each row carries activity / use / view / patch counters and an inline pin toggle.
|
||||
- **Pin / unpin** — pinned skills are protected from auto-archive and rewrites. State pulled from `~/.hermes/skills/.curator_state` and surfaced as a pin glyph everywhere skills appear (Curator screen, Skills sidebar/list, SkillDetailView).
|
||||
- **Restore archived** sheet calls `hermes curator restore <name>` to bring a previously-archived skill back.
|
||||
- **Last report Markdown** — when present, the previous run's REPORT.md renders inline in mono.
|
||||
|
||||
Capability-gated; sidebar item disappears on pre-v0.12 hosts.
|
||||
|
||||
### Multimodal image input in chat (Mac + iOS)
|
||||
|
||||
Hermes v0.12 advertises `prompt_capabilities.image = true` on ACP and accepts image content blocks in `session/prompt`. Scarf wires the producer side on both targets:
|
||||
|
||||
- **Mac**: paperclip toolbar button on the chat composer opens NSOpenPanel multi-pick. Drag-and-drop and paste also work — drop an image (or a Finder file URL) onto the composer and it attaches. Capability-gated; the entire attachment surface is hidden on pre-v0.12 hosts.
|
||||
- **iOS**: paperclip button opens PhotosPicker (multi-select up to 5 photos). Same byte-for-byte capability gate.
|
||||
- **ImageEncoder** downsamples to 1568px long-edge (Anthropic's recommended ceiling) at JPEG q=0.85, so a 12 MP screenshot lands under ~300 KB on the wire. Detached only — never blocks MainActor.
|
||||
- **Image-only sends are valid** — once at least one attachment is queued, the send button enables even with empty text. Vision models accept "describe this" with no caption.
|
||||
- **Per-attachment chips** above the input field with thumbnail + filename tooltip + X to remove. 5-image-per-message cap; total payload stays under ~2 MB so cellular sends don't time out.
|
||||
|
||||
Hermes routes the resulting prompt to a vision-capable model automatically — no extra Scarf-side work to pick the right aux model.
|
||||
|
||||
### 5 new inference providers (Mac + iOS)
|
||||
|
||||
Five overlay-only providers added to `ModelCatalogService.overlayOnlyProviders`. The model picker reaches all of them; provider IDs match `HERMES_OVERLAYS` in `hermes_cli/providers.py` exactly so a typo here doesn't strand users with an unreachable provider.
|
||||
|
||||
- **GMI Cloud** (api_key) — `https://api.gmi-serving.com/v1`
|
||||
- **Azure AI Foundry** (api_key) — base URL resolved from `AZURE_FOUNDRY_BASE_URL` per tenant
|
||||
- **LM Studio** (api_key, first-class) — promoted from custom-endpoint alias to a real provider; defaults to `http://127.0.0.1:1234/v1`
|
||||
- **MiniMax (OAuth)** (oauth_external) — `https://api.minimax.io/anthropic`
|
||||
- **Tencent TokenHub** (api_key) — base URL resolved from `TOKENHUB_BASE_URL`
|
||||
|
||||
### `auxiliary.curator` aux task (Mac)
|
||||
|
||||
Hermes removed `auxiliary.flush_memories` entirely in v0.12 (the underlying memory pipeline was rewritten) and added `auxiliary.curator` so the curator's review fork can run on a separate model from the main agent. Settings → Auxiliary now surfaces a Curator row when the active host is v0.12+ (gated on `HermesCapabilities.hasCuratorAux`); the obsolete Flush Memories panel is gone.
|
||||
|
||||
The Tool Gateway health view in HealthView lost the flushMemories-routes-through-Nous row and gained a curator row, matching the new aux task list.
|
||||
|
||||
### Skills v0.12 surface (Mac + iOS)
|
||||
|
||||
Three new capabilities Scarf can now reach:
|
||||
|
||||
- **Direct-URL install** — `hermes skills install <https-url>` lets users pull a one-off skill without going through a registry. Mac SkillsView gains an "Install from URL…" toolbar button (capability-gated) opening a sheet with the URL field plus optional `--category` / `--name` overrides.
|
||||
- **Reload** — `hermes skills audit` rescans the skills directory and refreshes the agent's view without a session restart. Wired to a "Reload" toolbar button next to the install button on Mac.
|
||||
- **Enabled / disabled state** — `skills.disabled` in config.yaml is read at scan time. Disabled skills render strikethrough + an "OFF" pill on Mac and iOS rows; iOS detail view explains the state in plain text.
|
||||
- **Curator pin badge** — pinned-skill names from `~/.hermes/skills/.curator_state` surface as a pin glyph on each row across Mac sidebar and iOS list, plus an explanatory chip on iOS detail view.
|
||||
|
||||
The disable-toggle write path is deferred to v2.7 — Hermes only exposes `hermes skills config` as an interactive verb today, and we'd rather read accurately than risk clobbering the user's list with a half-tested write.
|
||||
|
||||
### Cron — `--workdir` flag (Mac)
|
||||
|
||||
Hermes v0.12 cron jobs accept `--workdir <absolute-path>` to inject AGENTS.md / CLAUDE.md / .cursorrules from that directory and pin cwd for terminal/file/code_exec tools. Scarf's CronJobEditor now has a Workdir field; both create and edit paths forward the flag. Existing v0.11 jobs keep the no-cwd behaviour by leaving the field blank.
|
||||
|
||||
The `context_from` chaining field is read-only from Scarf this round (Hermes hasn't exposed a `--context-from` CLI flag yet, only YAML).
|
||||
|
||||
### Microsoft Teams + Yuanbao (Mac)
|
||||
|
||||
Two new gateway platforms. Microsoft Teams (the 19th platform) ships as a plugin; Yuanbao 元宝 (the 18th) is a native gateway adapter. Both surface in the Platforms tab with read-only setup panels — the OAuth dance for Yuanbao and the plugin install for Teams happen outside Scarf.
|
||||
|
||||
### Read-only Kanban (Mac)
|
||||
|
||||
Hermes v0.12 ships a SQLite-backed multi-tenant task board with a full CLI (`hermes kanban create / list / claim / dispatch / …`). The multi-profile *collaboration* layer was reverted upstream while the design is reworked, so v2.6 ships a **read-only** Kanban view: paginated table of `hermes kanban list --json` filtered by status, with status badges, meta chips (id / assignee / workspace / skills), and per-row metadata. 5-second polling while the view is foregrounded; suspended on disappear.
|
||||
|
||||
Create / claim / dispatch UI is deferred until upstream stabilizes — building the editor now would risk rework on a quarter-out timeline.
|
||||
|
||||
### Settings deltas (Mac)
|
||||
|
||||
A new **Caching & Redaction** section under Settings → Advanced with three v0.12 knobs (gated on capability):
|
||||
|
||||
- **Prompt cache TTL** picker — 5m default / 1h opt-in. Reduces cache writes on long agent loops with stable system prompts.
|
||||
- **Redact secrets in patches** toggle — Hermes flipped this off by default in v0.12 because the substitution corrupted patches; security-sensitive users can flip it back on here.
|
||||
- **Runtime metadata footer** toggle — opt-in compact footer on each final reply (provider/model/cost/turn count).
|
||||
|
||||
TTS provider list gains **piper** (native local TTS engine new in v0.12). Terminal backend list gains **vercel** (Vercel Sandbox backend for execute_code/terminal). Both ride along unconditionally — Hermes silently falls back when an older host doesn't recognize the value.
|
||||
|
||||
### iOS catch-up — Webhooks / Plugins / Profiles (read-only)
|
||||
|
||||
Three new System-tab nav rows in ScarfGo, all read-only:
|
||||
|
||||
- **Webhooks** — list of `hermes webhook list` output with description / deliver / events / route per row. "Platform not enabled" detection so a freshly-installed Hermes shows setup guidance instead of error noise.
|
||||
- **Plugins** — filesystem-first scan over `~/.hermes/plugins/` with manifest reads (plugin.json or plugin.yaml). Enabled/disabled badge, version, source, path.
|
||||
- **Profiles** — `hermes profile list` with active-profile highlighting from `~/.hermes/active_profile`. Tolerant of both Rich box-drawn and plain-text outputs.
|
||||
|
||||
None of the three are capability-gated — the underlying list verbs work on both v0.11 and v0.12. Create / edit / delete remain Mac-only since they touch enough state we keep them off the phone.
|
||||
|
||||
### Hermes-version banner (iOS)
|
||||
|
||||
Yellow banner at the top of the Dashboard tab when the active server is pre-v0.12. Lists the v0.12 capabilities the user is missing out on (curator, multimodal image input, new providers); one-tap session-dismiss; reappears on next app open. Hidden entirely on v0.12+ hosts.
|
||||
|
||||
### Internal — version-aware capability detection
|
||||
|
||||
The foundation of every gated surface above:
|
||||
|
||||
- `HermesCapabilities` value type parses `Hermes Agent v0.12.0 (2026.4.30)` from `hermes --version` output. Exposes booleans for each release-gated UI surface (`hasCurator`, `hasACPImagePrompts`, `hasKanban`, `hasOneShot`, `hasSkillURLInstall`, `hasFallbackCommand`, `hasUpdateCheck`, `hasPiperTTS`, `hasVercelTerminal`, `hasCuratorAux`, `hasTeamsPlatform`, `hasYuanbaoPlatform`, `hasCronWorkdir`, `hasPromptCacheTTL`, `hasRedactionToggle`, `hasFlushMemoriesAux`).
|
||||
- `HermesCapabilitiesStore` (`@Observable @MainActor`) caches per-server capabilities. Injected on `ContextBoundRoot` (Mac) and `ScarfGoTabRoot` (iOS) via `.environment(_:)` and `.hermesCapabilities(_:)`.
|
||||
- 12 parser tests + 6 curator-output parser tests lock the v0.12 / v0.11 / fallback flag matrices.
|
||||
|
||||
### Bug fixes
|
||||
|
||||
#### Chat composer + transcript (post-merge round)
|
||||
|
||||
- **Typing lag in the chat composer (#67)** — `RichChatInputBar.updateMenuState()` ran on every keystroke and unconditionally wrote both `showMenu` and `selectedIndex`, tripping SwiftUI's "action tried to update multiple times per frame" warning and stalling input. Composer now coalesces writes to deltas, short-circuits when not in slash mode (the common case), and watches `commands.count` instead of re-allocating `commands.map(\.id)` per keystroke.
|
||||
- **Chat font-size slider had no visible effect (#68)** — `RichChatView` only set `\.dynamicTypeSize`, but `ScarfFont` tokens are fixed-point (`Font.system(size: 14, …)`) so dynamic type didn't reach bubble text, reasoning, tool chips, code blocks, or markdown headings. New `\.chatFontScale` env value plumbed through `RichMessageBubble`, `MarkdownContentView`, and `CodeBlockView`; `ChatFontScale.{body, caption, captionStrong, caption2, mono, monoSmall, codeBlock, codeInline}(_:)` helpers mirror the ScarfFont base sizes so 100% is byte-for-byte identical to today's UI.
|
||||
- **Placeholder ghosting on first keystroke (#65)** — `TextEditor`'s NSTextView surfaces a typed glyph one frame before the SwiftUI binding propagates, so the bare `if text.isEmpty` overlay rendered the translucent placeholder text on top of the just-typed character. Pinned an opaque background behind the placeholder rect and switched the conditional to `.opacity(...)` so the view tree stays stable per keystroke.
|
||||
- **Draft text leaked between conversations (#62)** — composer `@State` survived session switches because the surrounding view tree was structurally identical. Bound `RichChatInputBar`'s identity to `richChat.sessionId` so SwiftUI rebuilds the view (and its `@State`) on session change. Stable fallback string for the "no session selected" window — `UUID()` would have minted a new id per body re-eval and trashed the composer mid-typing.
|
||||
- **Sent message rendered blank after navigating away (#63)** — when a user sent a prompt and immediately resumed a different session before Hermes flushed the row to state.db, `resumeSession`'s `reset()` cleared `messages` and `loadSessionHistory` then read an as-yet-empty DB. New per-session pending-user-messages cache survives `reset()` and re-injects still-pending entries on load; entries clear themselves as soon as a matching DB row catches up.
|
||||
- **No completion notification (#64)** — sending a long prompt and switching to other work required polling the chat to know when the response landed. New `ChatNotificationService` fires a local `UNUserNotificationCenter` banner on prompt completion when Scarf isn't the foreground app. Settings → Display → Feedback → "Notify when Hermes finishes" toggle, default on.
|
||||
- **Per-message TTS playback (#66)** — small speaker glyph in each settled assistant bubble's metadata footer; uses `AVSpeechSynthesizer` with the user's macOS Spoken Content default voice, picks up offline. Markdown control characters stripped before speech. The deeper Settings → Voice provider integration (Edge / ElevenLabs / OpenAI / NeuTTS / Piper) is queued as a v2.7 follow-up.
|
||||
- **ACP control-message timeout under gateway concurrency (#61)** — bumped 30s → 60s. State.db lock contention on a healthy host clears in seconds, but the previous 30s watchdog tripped under realistic gateway+ACP concurrency (Discord sync / skill registration / cron scheduling holding write locks during ACP `initialize` / `session/new` / `session/load`). 60s gives lock resolution headroom while still surfacing genuinely broken transports.
|
||||
|
||||
#### Pre-merge
|
||||
|
||||
- **Test target compile** — `M5FeatureVMTests.ScriptedTransport` had drifted off the `ServerTransport` protocol after `cachedSnapshotPath` landed in v2.5.2; added the missing stub. `M0dViewModelsTests` got the `ConnectionStatusViewModel.Status.degraded` argument-name update. `CredentialPoolsGatingTests` got the missing `import ScarfCore`. The full `swift test` suite now runs (and passes — 215 tests across 17 suites).
|
||||
- **iOS package compile** — `RemoteBackupService.zipDirectory` and `RemoteRestoreService.unzipArchive` used `Foundation.Process` unconditionally, breaking the iOS build entirely (Process is unavailable on the iOS SDK). Wrapped in `#if !os(iOS)` with iOS stubs that throw — backup/restore is Mac-only by design.
|
||||
|
||||
### Hermes version
|
||||
|
||||
Targets Hermes **v2026.4.30 (v0.12.0)**. v2026.4.23 (v0.11.0) hosts continue to work — every v0.12 surface is gated on capability detection, so Scarf v2.6 against v0.11 looks identical to Scarf v2.5.2 against v0.11. Update Hermes (`hermes update`) to unlock the new surfaces.
|
||||
|
||||
### Compatibility
|
||||
|
||||
- macOS 14+ (unchanged)
|
||||
- iOS 17+ (unchanged)
|
||||
- Hermes v0.11+ for the v2.5 surface; v0.12+ for the new features above.
|
||||
- No data migrations.
|
||||
@@ -0,0 +1,78 @@
|
||||
## What's in 2.6.5
|
||||
|
||||
A patch release that ships **template discoverability**, **cron observability**, and an **end-to-end UI test harness** that locks the new install path against regression. No breaking changes; every Hermes capability target is unchanged from 2.6.0.
|
||||
|
||||
### In-app Template Catalog
|
||||
|
||||
The catalog is no longer web-only. **Templates → Browse Catalog…** opens a sheet that fetches the live catalog from `awizemann.github.io/scarf/templates/`, renders one row per published template with name + version + tags, and one-click installs through the existing flow. Search filters across name / description / tags; the category picker constrains to whatever categories the loaded catalog actually carries.
|
||||
|
||||
- **Install-state badges** — each row shows "Installed v1.2.0" (green) or "Update v1.3.0" (amber) when the catalog version is newer than what's in `~/.hermes/scarf/projects.json`. Update is "uninstall + reinstall" today; in-place upgrade is on the v3 backlog.
|
||||
- **24h cache** at `~/.hermes/scarf/catalog_cache.json` so opening the sheet repeatedly doesn't re-hit the network. Refresh icon force-fetches.
|
||||
- **Bundled fallback** — fresh-install / offline users still see the official templates as a hardcoded list. Network failures serve stale cache with a "refresh failed" hint.
|
||||
- **Catalog-schema decoder fault tolerance** — one malformed entry on the live catalog can't bring down the whole list. The bad row is dropped with a logged warning; the rest survive.
|
||||
|
||||
### HackerNews Daily Digest template
|
||||
|
||||
First template added under the new dogfooding-templates loop. Configurable `min_score`, `max_items`, `topics`; one daily-at-08:00 cron job (paused on install) that pulls the HN Firebase API, filters, and prepends a markdown digest to the project's `digest.md`. No API keys required. Live at the catalog URL above.
|
||||
|
||||
### Cron observability — auth-error banner + running indicator + log tail
|
||||
|
||||
Cron rows now surface the same OAuth-refresh-revoked recovery flow as Chat instead of a generic red dot, plus three previously-missing observability cues:
|
||||
|
||||
- **OAuth re-auth.** `ACPErrorHint.classify` runs on `job.lastError`; when it returns `oauthRefreshRevoked(provider)` the detail pane shows the human-readable hint + a **Re-authenticate** button that drops the user into Credential Pools — same wiring ChatView's banner uses. Unrecognized errors fall back to the legacy red `lastError` text.
|
||||
- **Running indicator.** The row dot turns blue + pulses when `state == "running"` (precedence over disabled / error / success); the detail header gains a "running…" badge next to active/paused. No new polling — `HermesFileWatcher.lastChangeDate` already drives `CronViewModel.load()`.
|
||||
- **Last run output.** Collapsible panel replacing the inline log: a one-line summary (`<timestamp> — ok|error|running…`) always visible, full monospaced terminal-style scroll on expand, auto-scrolls to bottom when new runs land.
|
||||
|
||||
Also fixes a pre-existing bug in `HermesFileService.loadCronOutput` that returned the wrong file under Hermes's per-job-id output nesting.
|
||||
|
||||
### Layer B install-drive XCUITest harness
|
||||
|
||||
The dogfooding-templates initiative ships its first end-to-end UI test that drives the install pipeline:
|
||||
|
||||
```
|
||||
Launch with --scarf-test-mode → Sidebar → Projects → Install sheet
|
||||
(via --scarf-test-install-url launch arg) → Configure → Open Project
|
||||
→ Right-click → Uninstall Template → Confirm Remove → Done
|
||||
```
|
||||
|
||||
Runs ~30 s green on the dev Mac, validates 9 assertion points across the user journey. Covers the new accessibility identifiers wired in this release: `templateConfig.commitButton`, `projects.row.<name>`, `sidebar.section.<rawValue>`, `projects.contextMenu.uninstallTemplate`, `templateUninstall.confirmRemove`, `templateInstall.success.openProject`, `templateUninstall.success.done`. The `--scarf-test-install-url` launch arg + `TestModeFlags.isTestMode` gating lets XCUITest skip SwiftUI Menu / NSToolbarItem accessibility-bridging quirks that otherwise block toolbar-menu driving.
|
||||
|
||||
Wiki [Test-Harness](https://github.com/awizemann/scarf/wiki/Test-Harness) documents how to extend the harness for the next template.
|
||||
|
||||
### Sentinel-marker test isolation (incident-response hardening)
|
||||
|
||||
`SCARF_HERMES_HOME` override now requires the path to contain a `.scarf-test-home-marker` file to activate. Without the marker, production code falls through to the user's real `~/.hermes/`. Lands belt-and-braces protection for cases where a test crashes mid-teardown leaving the env var set, an env var inherits from a parent shell, or a misconfigured launchctl plist exports the variable. The override remains the seam every E2E test relies on; the marker file ensures it can't accidentally pivot a non-test process off the user's data.
|
||||
|
||||
### Chat fixes
|
||||
|
||||
- **OAuth refresh-revoked surface.** Chat-side error banner now classifies the message via `ACPErrorHint.classify` and offers an in-app **Re-authenticate** button that routes through Credential Pools (#65). Same primitive the new cron banner reuses.
|
||||
- **Placeholder ghosting fix.** TextEditor's placeholder now clips to the editor's bounds and clears on focus instead of bleeding past the cursor area when the user types fast (#67).
|
||||
|
||||
### Profile chip + structured logs
|
||||
|
||||
- **Active-profile chip in the sidebar header.** Click → routes to Profiles. Local contexts only (remote SSH would mislead).
|
||||
- **Switch & Relaunch** flow now writes `~/.hermes/active_profile` and relaunches Scarf in a single click instead of asking the user to quit+reopen.
|
||||
- Profile-resolver logs are now structured (key=value form) so `log show … | grep ProfileResolver` can pull "which profile did Scarf resolve to and why" out of support requests.
|
||||
|
||||
### Swift 6 cleanup
|
||||
|
||||
- `MessageSpeechService` — drop `@preconcurrency` on the AVSpeechSynthesizerDelegate conformance now that the protocol's Sendable annotations are upstreamed.
|
||||
- `ChatView` — `RichChatViewModel.PendingPermission: @retroactive Identifiable`. Quiets the Swift 6 compiler so downstream breakage would be loud if ScarfCore ever adds the conformance upstream.
|
||||
- `CredentialPoolsView` — `.help(Text(verbatim:))` so backticks render literally instead of being treated as markdown inline-code.
|
||||
|
||||
### iOS
|
||||
|
||||
- Composer redesigned with HIG touch targets + clear disabled state.
|
||||
- Portrait lock retained.
|
||||
- Chat-start preflight moved off MainActor.
|
||||
|
||||
### Known caveats
|
||||
|
||||
- **Cron-job-uninstall by name is ambiguous** when two projects share the same template id. The Layer B test surfaced this — manifests as: the test passes, but if you've manually installed the same template before running the test, your real cron job can disappear. Recovery is `hermes cron create`. Fix is queued: store cron-job IDs in `<project>/.scarf/template.lock.json` at install time and resolve by ID at uninstall time.
|
||||
- **Full-suite parallel test runs intermittently hang** — pre-existing flaky test infrastructure unrelated to this release. Individual suites all pass; the hang only manifests on `xcodebuild test` with everything concurrent. The sentinel-marker hardening prevents user-data damage from any race.
|
||||
|
||||
### Compatibility
|
||||
|
||||
- **Hermes target unchanged from 2.6.0**: v2026.4.30 (v0.12.0). Pre-v0.12 Hermes hosts continue to work — no new capability gates added in this release.
|
||||
- **Min macOS unchanged**: 14.6.
|
||||
- **No schema changes** to anything in `~/.hermes/`. The two new Scarf-owned files (`scarf/catalog_cache.json` and the template-installer's `.scarf-test-home-marker` for tests) are additive.
|
||||
@@ -47,6 +47,23 @@ public protocol ACPChannel: Sendable {
|
||||
/// SSH exec channels return the SSH channel id or `nil` when not
|
||||
/// applicable.
|
||||
var diagnosticID: String? { get async }
|
||||
|
||||
/// Exit status of the underlying transport once it has terminated.
|
||||
/// `nil` while the channel is still alive, or for transports that
|
||||
/// don't have a meaningful integer exit code (Citadel SSH-exec).
|
||||
/// Read by `ACPClient` when populating `processTerminated` so the
|
||||
/// user-facing error can name the actual exit code (e.g. `exit
|
||||
/// 255` for SSH connect failures, `exit 127` for missing remote
|
||||
/// binary).
|
||||
var lastExitCode: Int32? { get async }
|
||||
}
|
||||
|
||||
public extension ACPChannel {
|
||||
/// Default: channels that don't track an exit code report `nil`.
|
||||
/// Concrete `ProcessACPChannel` overrides this.
|
||||
var lastExitCode: Int32? {
|
||||
get async { nil }
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors raised by `ACPChannel` implementations when the underlying
|
||||
|
||||
@@ -266,14 +266,47 @@ public actor ACPClient {
|
||||
// MARK: - Messaging
|
||||
|
||||
public func sendPrompt(sessionId: String, text: String) async throws -> ACPPromptResult {
|
||||
try await sendPrompt(sessionId: sessionId, text: text, images: [])
|
||||
}
|
||||
|
||||
/// v0.12+ overload: forward zero or more image attachments alongside
|
||||
/// the user's text. Each attachment becomes a separate
|
||||
/// `ImageContentBlock` in the ACP `prompt` content array — matches
|
||||
/// the shape Hermes' `acp_adapter/server.py` expects (text first,
|
||||
/// then image blocks). Hermes routes the resulting payload to a
|
||||
/// vision-capable model automatically; the producer side only has
|
||||
/// to deliver the bytes.
|
||||
///
|
||||
/// Pre-v0.12 Hermes installs accepted only a single `text` block.
|
||||
/// Callers gate this overload on
|
||||
/// `HermesCapabilitiesStore.capabilities.hasACPImagePrompts` so we
|
||||
/// don't send blocks an older agent would silently drop.
|
||||
public func sendPrompt(
|
||||
sessionId: String,
|
||||
text: String,
|
||||
images: [ChatImageAttachment]
|
||||
) async throws -> ACPPromptResult {
|
||||
statusMessage = "Sending prompt..."
|
||||
let messageId = UUID().uuidString
|
||||
|
||||
// Always include the text block, even when empty — keeps the
|
||||
// server-side text-extraction path stable regardless of whether
|
||||
// the user sent text alongside the image(s).
|
||||
var promptBlocks: [[String: Any]] = [
|
||||
["type": "text", "text": text] as [String: Any],
|
||||
]
|
||||
for image in images {
|
||||
promptBlocks.append([
|
||||
"type": "image",
|
||||
"data": image.base64Data,
|
||||
"mimeType": image.mimeType,
|
||||
] as [String: Any])
|
||||
}
|
||||
|
||||
let params: [String: AnyCodable] = [
|
||||
"sessionId": AnyCodable(sessionId),
|
||||
"messageId": AnyCodable(messageId),
|
||||
"prompt": AnyCodable([
|
||||
["type": "text", "text": text] as [String: Any],
|
||||
] as [Any]),
|
||||
"prompt": AnyCodable(promptBlocks as [Any]),
|
||||
]
|
||||
let result = try await sendRequest(method: "session/prompt", params: params)
|
||||
let dict = result?.dictValue ?? [:]
|
||||
@@ -329,10 +362,17 @@ public actor ACPClient {
|
||||
#endif
|
||||
|
||||
// session/prompt streams events and can run for minutes — no hard
|
||||
// timeout. Control messages get a 30s watchdog.
|
||||
// timeout. Control messages get a 60s watchdog. Older versions
|
||||
// capped at 30s, which the field reported (#61) was tripping
|
||||
// under realistic gateway+ACP concurrency: the gateway holds
|
||||
// state.db locks for Discord sync / skill registration / cron
|
||||
// scheduling, and ACP's `initialize` / `session/new` /
|
||||
// `session/load` stall waiting for the lock. SQLite contention
|
||||
// on a healthy host clears in seconds; 60s gives that headroom
|
||||
// while still surfacing genuinely broken transports promptly.
|
||||
let timeoutTask: Task<Void, Error>? = if method != "session/prompt" {
|
||||
Task { [weak self] in
|
||||
try await Task.sleep(nanoseconds: 30 * 1_000_000_000)
|
||||
try await Task.sleep(nanoseconds: 60 * 1_000_000_000)
|
||||
await self?.timeoutRequest(id: requestId, method: method)
|
||||
}
|
||||
} else {
|
||||
@@ -468,35 +508,48 @@ public actor ACPClient {
|
||||
// MARK: - Disconnect Cleanup
|
||||
|
||||
/// Single idempotent cleanup path for all disconnect scenarios.
|
||||
private func performDisconnectCleanup(reason: String) {
|
||||
/// Captures the channel's exit code + recent stderr BEFORE we drop
|
||||
/// the reference, so the `processTerminated` error rides with
|
||||
/// diagnostics — the user banner shows "exit 255 — ssh: connect to
|
||||
/// host …: Connection refused" instead of a bare opaque timeout.
|
||||
private func performDisconnectCleanup(reason: String) async {
|
||||
guard isConnected else { return }
|
||||
#if canImport(os)
|
||||
logger.warning("ACP disconnecting: \(reason)")
|
||||
#endif
|
||||
let exitCode = await channel?.lastExitCode
|
||||
let tail = recentStderr
|
||||
isConnected = false
|
||||
statusMessage = "Connection lost"
|
||||
for (_, continuation) in pendingRequests {
|
||||
continuation.resume(throwing: ACPClientError.processTerminated)
|
||||
continuation.resume(throwing: ACPClientError.processTerminated(
|
||||
exitCode: exitCode,
|
||||
stderrTail: tail
|
||||
))
|
||||
}
|
||||
pendingRequests.removeAll()
|
||||
eventContinuation?.finish()
|
||||
eventContinuation = nil
|
||||
}
|
||||
|
||||
private func handleReadLoopEnded(cleanly: Bool, error: Error? = nil) {
|
||||
private func handleReadLoopEnded(cleanly: Bool, error: Error? = nil) async {
|
||||
let reason = cleanly ? "read loop ended (EOF)" : "read loop failed: \(error?.localizedDescription ?? "unknown")"
|
||||
performDisconnectCleanup(reason: reason)
|
||||
await performDisconnectCleanup(reason: reason)
|
||||
}
|
||||
|
||||
private func handleWriteFailed() {
|
||||
performDisconnectCleanup(reason: "write failed (broken pipe)")
|
||||
private func handleWriteFailed() async {
|
||||
await performDisconnectCleanup(reason: "write failed (broken pipe)")
|
||||
}
|
||||
|
||||
private func handleWriteFailedForRequest(id: Int) {
|
||||
private func handleWriteFailedForRequest(id: Int) async {
|
||||
if let continuation = pendingRequests.removeValue(forKey: id) {
|
||||
continuation.resume(throwing: ACPClientError.processTerminated)
|
||||
let exitCode = await channel?.lastExitCode
|
||||
continuation.resume(throwing: ACPClientError.processTerminated(
|
||||
exitCode: exitCode,
|
||||
stderrTail: recentStderr
|
||||
))
|
||||
}
|
||||
performDisconnectCleanup(reason: "write failed (broken pipe)")
|
||||
await performDisconnectCleanup(reason: "write failed (broken pipe)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,7 +560,7 @@ public enum ACPClientError: Error, LocalizedError {
|
||||
case encodingFailed
|
||||
case invalidResponse(String)
|
||||
case rpcError(code: Int, message: String)
|
||||
case processTerminated
|
||||
case processTerminated(exitCode: Int32?, stderrTail: String)
|
||||
case requestTimeout(method: String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
@@ -516,25 +569,121 @@ public enum ACPClientError: Error, LocalizedError {
|
||||
case .encodingFailed: return "Failed to encode JSON-RPC request"
|
||||
case .invalidResponse(let msg): return "Invalid ACP response: \(msg)"
|
||||
case .rpcError(let code, let msg): return "ACP error \(code): \(msg)"
|
||||
case .processTerminated: return "ACP process terminated unexpectedly"
|
||||
case .processTerminated(let exit, let tail):
|
||||
let exitPart = exit.map { "exit \($0)" } ?? "no exit code"
|
||||
let tailPart = Self.firstNonEmptyLine(in: tail).map { " — \($0)" } ?? ""
|
||||
return "ACP process terminated unexpectedly (\(exitPart))\(tailPart)"
|
||||
case .requestTimeout(let method): return "ACP request '\(method)' timed out"
|
||||
}
|
||||
}
|
||||
|
||||
/// Pluck the first non-empty stderr line for the user-facing
|
||||
/// summary. Full tail still rides through on `acpErrorDetails`,
|
||||
/// but the description itself stays single-line.
|
||||
private static func firstNonEmptyLine(in s: String) -> String? {
|
||||
for raw in s.split(separator: "\n") {
|
||||
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||
if !line.isEmpty { return line }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a raw error message (RPC message or captured stderr) to a short
|
||||
/// human-readable hint for the chat UI. Pattern-matches the most common
|
||||
/// fresh-install failure modes. Returns nil when no known pattern matches.
|
||||
public enum ACPErrorHint {
|
||||
public static func classify(errorMessage: String, stderrTail: String) -> String? {
|
||||
/// Result of a classifier hit. `hint` is the user-facing copy; when
|
||||
/// the failure is an OAuth refresh-revocation, `oauthProvider` names
|
||||
/// the affected provider (lowercase, matching `auth.json` keys) so
|
||||
/// the UI can offer a one-click re-authenticate affordance. `nil`
|
||||
/// `oauthProvider` means "we matched a non-OAuth failure mode, or
|
||||
/// we matched OAuth but couldn't identify which provider."
|
||||
public struct Classification: Sendable, Equatable {
|
||||
public let hint: String
|
||||
public let oauthProvider: String?
|
||||
|
||||
public init(hint: String, oauthProvider: String? = nil) {
|
||||
self.hint = hint
|
||||
self.oauthProvider = oauthProvider
|
||||
}
|
||||
}
|
||||
|
||||
/// Known OAuth-authed providers Hermes ships. Listed lowercase to
|
||||
/// match `auth.json.providers.<key>` and the values
|
||||
/// `OAuthFlowController.start(provider:)` accepts.
|
||||
private static let oauthProviders = [
|
||||
"nous", "claude", "anthropic", "qwen", "gemini", "google", "copilot", "github",
|
||||
]
|
||||
|
||||
public static func classify(errorMessage: String, stderrTail: String) -> Classification? {
|
||||
let haystack = errorMessage + "\n" + stderrTail
|
||||
|
||||
// SSH-level failures come first — they apply only to remote
|
||||
// contexts and the patterns are unambiguous (system ssh prints
|
||||
// them verbatim to stderr). Without these classifications a
|
||||
// vanished droplet, a wrong key, or a missing remote `hermes`
|
||||
// all surface as opaque "ACP process terminated" / "request
|
||||
// timed out", and the user has no idea where to look.
|
||||
if haystack.contains("Connection refused") {
|
||||
return Classification(hint: "Couldn't reach the remote host — the SSH port is closed or the droplet is down. Check the host is running and reachable.")
|
||||
}
|
||||
if haystack.localizedCaseInsensitiveContains("Operation timed out")
|
||||
|| haystack.localizedCaseInsensitiveContains("Connection timed out")
|
||||
|| haystack.contains("Network is unreachable")
|
||||
|| haystack.contains("No route to host") {
|
||||
return Classification(hint: "Couldn't reach the remote host — the network connection timed out. Check the host is running and your network is up.")
|
||||
}
|
||||
if haystack.contains("Permission denied (publickey")
|
||||
|| haystack.contains("Permission denied, please try again") {
|
||||
return Classification(hint: "SSH rejected the key. Make sure the right identity file is selected and that ssh-agent has the key loaded — open Terminal and run `ssh-add -l`.")
|
||||
}
|
||||
if haystack.contains("Host key verification failed")
|
||||
|| haystack.contains("REMOTE HOST IDENTIFICATION HAS CHANGED") {
|
||||
return Classification(hint: "The remote host's SSH key changed. If you just rebuilt the droplet, remove the old entry with `ssh-keygen -R <host>`, then try again.")
|
||||
}
|
||||
if haystack.contains("Could not resolve hostname")
|
||||
|| haystack.contains("Name or service not known") {
|
||||
return Classification(hint: "Couldn't resolve the host name. Check the host in this server's settings.")
|
||||
}
|
||||
if haystack.localizedCaseInsensitiveContains("command not found")
|
||||
|| haystack.contains("hermes: not found")
|
||||
|| haystack.contains("exit 127") {
|
||||
return Classification(hint: "The remote shell couldn't find `hermes`. Either install Hermes on the remote (`pipx install hermes-agent`) or set an absolute binary path in this server's settings.")
|
||||
}
|
||||
|
||||
// OAuth refresh-token revocation. Hermes prints
|
||||
// "Refresh session has been revoked. Run `hermes model` to
|
||||
// re-authenticate." to stderr/stdout when an OAuth-authed
|
||||
// provider's refresh token can no longer mint access tokens
|
||||
// (user revoked, server rotated keys, etc.). We can't drive
|
||||
// `hermes model` interactively, but `hermes auth add <provider>
|
||||
// --type oauth` is the same code path Scarf already drives via
|
||||
// `OAuthFlowController` for first-time setup, so we surface a
|
||||
// re-authenticate affordance instead. Checked BEFORE the
|
||||
// generic "no credentials found" path because the message
|
||||
// contains the word "credentials" via the surrounding context.
|
||||
if haystack.localizedCaseInsensitiveContains("refresh session has been revoked")
|
||||
|| haystack.range(of: #"refresh.*revoked"#, options: [.regularExpression, .caseInsensitive]) != nil
|
||||
|| haystack.localizedCaseInsensitiveContains("re-authenticate")
|
||||
|| haystack.localizedCaseInsensitiveContains("reauthenticate")
|
||||
|| (haystack.contains("401") && oauthProvider(in: haystack) != nil)
|
||||
|| (haystack.localizedCaseInsensitiveContains("unauthorized") && oauthProvider(in: haystack) != nil) {
|
||||
let provider = oauthProvider(in: haystack)
|
||||
let suffix = provider.map { " (affected provider: \($0))." } ?? "."
|
||||
return Classification(
|
||||
hint: "Your OAuth session has expired or been revoked\(suffix) Click Re-authenticate below to sign in again.",
|
||||
oauthProvider: provider
|
||||
)
|
||||
}
|
||||
|
||||
if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#,
|
||||
options: .regularExpression) != nil
|
||||
|| haystack.contains("ANTHROPIC_API_KEY")
|
||||
|| haystack.contains("ANTHROPIC_TOKEN")
|
||||
|| haystack.contains("claude setup-token")
|
||||
|| haystack.contains("claude /login") {
|
||||
return "Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf."
|
||||
return Classification(hint: "Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf.")
|
||||
}
|
||||
if let match = haystack.range(of: #"No such file or directory:\s*'([^']+)'"#,
|
||||
options: .regularExpression) {
|
||||
@@ -542,13 +691,31 @@ public enum ACPErrorHint {
|
||||
if let nameStart = matched.range(of: "'"),
|
||||
let nameEnd = matched.range(of: "'", range: nameStart.upperBound..<matched.endIndex) {
|
||||
let name = String(matched[nameStart.upperBound..<nameEnd.lowerBound])
|
||||
return "Hermes couldn't find `\(name)` on PATH. If you use nvm/asdf/mise, make sure it's exported in `~/.zprofile` (not only `~/.zshrc`), then restart Scarf."
|
||||
return Classification(hint: "Hermes couldn't find `\(name)` on PATH. If you use nvm/asdf/mise, make sure it's exported in `~/.zprofile` (not only `~/.zshrc`), then restart Scarf.")
|
||||
}
|
||||
return "Hermes couldn't find a required binary on PATH. Check that your shell's PATH is exported in `~/.zprofile`, then restart Scarf."
|
||||
return Classification(hint: "Hermes couldn't find a required binary on PATH. Check that your shell's PATH is exported in `~/.zprofile`, then restart Scarf.")
|
||||
}
|
||||
if haystack.localizedCaseInsensitiveContains("rate limit")
|
||||
|| haystack.localizedCaseInsensitiveContains("429") {
|
||||
return "Your AI provider returned a rate-limit error. Try again in a moment."
|
||||
return Classification(hint: "Your AI provider returned a rate-limit error. Try again in a moment.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Best-effort extraction of an OAuth provider name from raw error
|
||||
/// text. Returns the lowercase provider key (`"nous"`, `"claude"`,
|
||||
/// etc.) when one of the known OAuth providers appears as a whole
|
||||
/// word. The first match wins — Hermes typically logs the active
|
||||
/// provider name once, near the failure.
|
||||
private static func oauthProvider(in haystack: String) -> String? {
|
||||
let lowered = haystack.lowercased()
|
||||
for provider in oauthProviders {
|
||||
// Whole-word match so substrings like "anthropicapi" don't
|
||||
// false-trigger on "anthropic".
|
||||
let pattern = "\\b" + NSRegularExpression.escapedPattern(for: provider) + "\\b"
|
||||
if lowered.range(of: pattern, options: .regularExpression) != nil {
|
||||
return provider
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -36,6 +36,17 @@ public actor ProcessACPChannel: ACPChannel {
|
||||
private var readerTask: Task<Void, Never>?
|
||||
private var stderrTask: Task<Void, Never>?
|
||||
|
||||
/// Read by `ACPClient` to fill in `processTerminated(exitCode:…)`
|
||||
/// so the error names the actual exit code rather than reporting a
|
||||
/// bare timeout. Sourced directly from `Process` — `Process` is
|
||||
/// thread-safe for this read and reflects the actual reap state,
|
||||
/// so we sidestep the race between the OS-side `terminationHandler`
|
||||
/// callback and the EOF-driven disconnect cleanup that would
|
||||
/// otherwise need an atomic to coordinate.
|
||||
public var lastExitCode: Int32? {
|
||||
process.isRunning ? nil : process.terminationStatus
|
||||
}
|
||||
|
||||
/// The subprocess's PID as a human-readable string.
|
||||
public var diagnosticID: String? {
|
||||
"pid=\(process.processIdentifier)"
|
||||
@@ -58,7 +69,7 @@ public actor ProcessACPChannel: ACPChannel {
|
||||
proc.executableURL = URL(fileURLWithPath: executable)
|
||||
proc.arguments = args
|
||||
proc.environment = env
|
||||
try await Self.launch(process: proc, self_: nil)
|
||||
try await Self.launch(process: proc)
|
||||
try Self.ignoreSIGPIPE_once()
|
||||
|
||||
self.process = proc
|
||||
@@ -75,14 +86,15 @@ public actor ProcessACPChannel: ACPChannel {
|
||||
self.stderr = errStream
|
||||
self.stderrContinuation = errContinuation
|
||||
|
||||
await startReaders()
|
||||
startReaders()
|
||||
installTerminationHandler()
|
||||
}
|
||||
|
||||
/// Secondary entry point for callers that have a pre-configured
|
||||
/// `Process` (typically from `SSHTransport.makeProcess`). The process
|
||||
/// must NOT already be running — this initializer calls `run()`.
|
||||
public init(process: Process) async throws {
|
||||
try await Self.launch(process: process, self_: nil)
|
||||
try await Self.launch(process: process)
|
||||
try Self.ignoreSIGPIPE_once()
|
||||
|
||||
self.process = process
|
||||
@@ -99,15 +111,13 @@ public actor ProcessACPChannel: ACPChannel {
|
||||
self.stderr = errStream
|
||||
self.stderrContinuation = errContinuation
|
||||
|
||||
await startReaders()
|
||||
startReaders()
|
||||
installTerminationHandler()
|
||||
}
|
||||
|
||||
/// Wire fresh stdin/stdout/stderr pipes (overwriting any the caller
|
||||
/// set) and start the subprocess. `self_` is unused today — the
|
||||
/// placeholder keeps the signature ready for a future hook that
|
||||
/// captures termination in `proc.terminationHandler` and routes it
|
||||
/// into the channel's actor state.
|
||||
private static func launch(process: Process, self_: Any?) async throws {
|
||||
/// set) and start the subprocess.
|
||||
private static func launch(process: Process) async throws {
|
||||
process.standardInput = Pipe()
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
@@ -118,6 +128,22 @@ public actor ProcessACPChannel: ACPChannel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a `terminationHandler` that closes the stdout read end
|
||||
/// the moment the OS reaps the child. Without this, the reader
|
||||
/// loop's `availableData` keeps blocking until the kernel tears
|
||||
/// the pipe down on its own schedule — visible to the user as a
|
||||
/// 30s ACP `initialize` timeout where a fast SSH-side failure
|
||||
/// (Connection refused, exit 127) should surface in under a
|
||||
/// second. The exit code itself is read on demand from
|
||||
/// `Process.terminationStatus` (see `lastExitCode`), so this
|
||||
/// callback doesn't need to touch actor state.
|
||||
private func installTerminationHandler() {
|
||||
let stdoutFh = stdoutPipe.fileHandleForReading
|
||||
process.terminationHandler = { _ in
|
||||
try? stdoutFh.close()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ignore SIGPIPE once per process so a broken-pipe write returns
|
||||
/// `EPIPE` (which we surface as `.writeEndClosed`) instead of
|
||||
/// delivering SIGPIPE and tearing the app down. Idempotent; the
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import Foundation
|
||||
|
||||
/// Top-level manifest for a `.scarfbackup` archive.
|
||||
///
|
||||
/// **Archive layout** (`.scarfbackup` is a plain ZIP):
|
||||
/// ```
|
||||
/// <name>.scarfbackup
|
||||
/// ├── manifest.json — this struct, JSON-encoded
|
||||
/// ├── hermes.tar.gz — gzipped tar of `~/.hermes/` (minus exclusions)
|
||||
/// └── projects/
|
||||
/// ├── <project-id>.tar.gz — one inner tarball per registered project
|
||||
/// └── ...
|
||||
/// ```
|
||||
///
|
||||
/// **Why two layers (outer ZIP + inner tarballs).** The inner tarballs are
|
||||
/// produced by streaming `tar -czf - …` over SSH — that's the only way to
|
||||
/// keep memory bounded for multi-GB hermes homes. The outer ZIP exists so
|
||||
/// the manifest sits at a fixed, easy-to-inspect location and so users on
|
||||
/// macOS can double-click in Finder and see the structure. ZIP also has a
|
||||
/// central directory at the end, which makes "validate without extracting"
|
||||
/// cheap.
|
||||
///
|
||||
/// **What rides along.** Hermes home (state.db + sessions + skills + cron +
|
||||
/// memories + scarf sidecars + plugins/profiles), each project's full file
|
||||
/// tree (the user's code), and the manifest itself. **What does NOT ride
|
||||
/// along by default**: `auth.json` (provider credentials), `mcp-tokens/`
|
||||
/// (per-host OAuth bearer tokens), `logs/` (size, low restore value),
|
||||
/// `state.db-wal` / `state.db-shm` (in-flight WAL siblings — we checkpoint
|
||||
/// before the archive). The `options` block records exactly which
|
||||
/// exclusions were applied so the restore flow can warn the user.
|
||||
public struct BackupManifest: Codable, Sendable, Equatable {
|
||||
/// Bumped when the on-disk shape changes incompatibly. v1 is the only
|
||||
/// shape today; restores refuse anything they don't recognize.
|
||||
public var schemaVersion: Int
|
||||
/// Magic string. Lets a future Scarf reject `.zip` files that aren't
|
||||
/// our backups before unpacking them as if they were.
|
||||
public var kind: String
|
||||
/// ISO-8601 UTC timestamp the archive was produced.
|
||||
public var createdAt: String
|
||||
/// Identifies the server the backup came from. The display name is for
|
||||
/// the restore preview sheet; serverID is for de-dupe and lineage.
|
||||
public var source: Source
|
||||
/// Hermes home tree metadata. Always present (even an empty Hermes
|
||||
/// install ships an empty tarball — the restore replaces nothing
|
||||
/// rather than refusing).
|
||||
public var hermes: HermesTree
|
||||
/// One entry per registered project at backup time. Empty array
|
||||
/// when the user never registered any projects.
|
||||
public var projects: [ProjectEntry]
|
||||
/// What was included / excluded from the Hermes tree. Flagged so the
|
||||
/// restore preview honestly reports "auth.json was not in this
|
||||
/// backup — you'll re-authenticate after restore".
|
||||
public var options: Options
|
||||
|
||||
public init(
|
||||
schemaVersion: Int = BackupManifest.currentSchemaVersion,
|
||||
kind: String = BackupManifest.kindMagic,
|
||||
createdAt: String,
|
||||
source: Source,
|
||||
hermes: HermesTree,
|
||||
projects: [ProjectEntry],
|
||||
options: Options
|
||||
) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.kind = kind
|
||||
self.createdAt = createdAt
|
||||
self.source = source
|
||||
self.hermes = hermes
|
||||
self.projects = projects
|
||||
self.options = options
|
||||
}
|
||||
|
||||
public static let currentSchemaVersion = 1
|
||||
public static let kindMagic = "scarf-server-backup"
|
||||
|
||||
public struct Source: Codable, Sendable, Equatable {
|
||||
public var serverID: String
|
||||
public var displayName: String
|
||||
public var host: String
|
||||
public var user: String?
|
||||
/// Output of `hermes --version` on the source host at backup
|
||||
/// time. Restore warns if the target installs an older version
|
||||
/// (state.db schema differences could break things silently).
|
||||
public var hermesVersion: String?
|
||||
|
||||
public init(serverID: String, displayName: String, host: String, user: String?, hermesVersion: String?) {
|
||||
self.serverID = serverID
|
||||
self.displayName = displayName
|
||||
self.host = host
|
||||
self.user = user
|
||||
self.hermesVersion = hermesVersion
|
||||
}
|
||||
}
|
||||
|
||||
public struct HermesTree: Codable, Sendable, Equatable {
|
||||
/// Absolute path of `~/.hermes/` on the source host (e.g.
|
||||
/// `/root/.hermes` or `/home/alan/.hermes`). Used by restore to
|
||||
/// detect path drift when targeting a different user account.
|
||||
public var homePath: String
|
||||
/// Path inside the outer ZIP (always `hermes.tar.gz`).
|
||||
public var tarballPath: String
|
||||
/// Compressed bytes — for the preview sheet's size summary.
|
||||
public var tarballSize: Int64
|
||||
/// Hex SHA-256 of the inner tarball. Restore verifies before
|
||||
/// extracting; corruption surfaces as a single bad path
|
||||
/// rather than a half-extracted home.
|
||||
public var tarballSHA256: String
|
||||
|
||||
public init(homePath: String, tarballPath: String, tarballSize: Int64, tarballSHA256: String) {
|
||||
self.homePath = homePath
|
||||
self.tarballPath = tarballPath
|
||||
self.tarballSize = tarballSize
|
||||
self.tarballSHA256 = tarballSHA256
|
||||
}
|
||||
}
|
||||
|
||||
public struct ProjectEntry: Codable, Sendable, Equatable {
|
||||
/// Stable UUID for the project. Used to namespace the inner
|
||||
/// tarball so a project with `name = "scratch"` in two
|
||||
/// different directories doesn't collide.
|
||||
public var id: String
|
||||
public var name: String
|
||||
/// Absolute path on the source host. Restore re-anchors this if
|
||||
/// the target has a different home (e.g. backup from `/root`,
|
||||
/// restore to `/home/ubuntu`).
|
||||
public var path: String
|
||||
/// Path inside the outer ZIP (e.g. `projects/<id>.tar.gz`).
|
||||
public var tarballPath: String
|
||||
public var tarballSize: Int64
|
||||
public var tarballSHA256: String
|
||||
|
||||
public init(id: String, name: String, path: String, tarballPath: String, tarballSize: Int64, tarballSHA256: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.tarballPath = tarballPath
|
||||
self.tarballSize = tarballSize
|
||||
self.tarballSHA256 = tarballSHA256
|
||||
}
|
||||
}
|
||||
|
||||
public struct Options: Codable, Sendable, Equatable {
|
||||
public var includeAuth: Bool
|
||||
public var includeMcpTokens: Bool
|
||||
public var includeLogs: Bool
|
||||
/// True if `sqlite3 PRAGMA wal_checkpoint(TRUNCATE)` was run on
|
||||
/// the remote before tarballing the Hermes home. False means the
|
||||
/// archive may contain a `state.db` mid-write — usually fine
|
||||
/// (SQLite tolerates restarted reads from a quiesced DB) but
|
||||
/// flagged for forensics.
|
||||
public var checkpointedWAL: Bool
|
||||
|
||||
public init(includeAuth: Bool, includeMcpTokens: Bool, includeLogs: Bool, checkpointedWAL: Bool) {
|
||||
self.includeAuth = includeAuth
|
||||
self.includeMcpTokens = includeMcpTokens
|
||||
self.includeLogs = includeLogs
|
||||
self.checkpointedWAL = checkpointedWAL
|
||||
}
|
||||
|
||||
public static let safeDefault = Options(
|
||||
includeAuth: false,
|
||||
includeMcpTokens: false,
|
||||
includeLogs: false,
|
||||
checkpointedWAL: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical layout strings — referenced by both the producer and the
|
||||
/// consumer so the on-disk paths stay in sync.
|
||||
public enum BackupArchiveLayout {
|
||||
public static let manifestPath = "manifest.json"
|
||||
public static let hermesTarballPath = "hermes.tar.gz"
|
||||
public static let projectsTarballPrefix = "projects/"
|
||||
public static let archiveExtension = "scarfbackup"
|
||||
|
||||
/// Returns `projects/<id>.tar.gz`. The id is the `ProjectEntry.id`
|
||||
/// (stable UUID), not the project name — names are renamed all the
|
||||
/// time and would collide.
|
||||
public static func projectTarballPath(for id: String) -> String {
|
||||
projectsTarballPrefix + id + ".tar.gz"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
|
||||
/// One image attached to an outgoing chat prompt.
|
||||
///
|
||||
/// Hermes v0.12 ACP advertises `prompt_capabilities.image = true` and
|
||||
/// accepts content-block arrays in `session/prompt`. Scarf produces these
|
||||
/// blocks from drag-dropped / pasted / picker-selected images. We
|
||||
/// downsample + JPEG-encode at the producer side so the wire payload
|
||||
/// stays under a few hundred kilobytes per image even when the user
|
||||
/// drops a 12 MP screenshot.
|
||||
///
|
||||
/// Constructed via `ImageEncoder.encode(...)`. The store-the-bytes-once
|
||||
/// shape means `RichChatViewModel` can keep the array between turns
|
||||
/// (e.g. while the agent is responding) without holding `NSImage` /
|
||||
/// `UIImage` references that would pin the originals in memory.
|
||||
public struct ChatImageAttachment: Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
/// IANA MIME type — matches the `mimeType` field on ACP `ImageContentBlock`.
|
||||
/// Currently always `image/jpeg` after re-encoding; PNG-only originals
|
||||
/// keep their type when small enough to skip the JPEG step.
|
||||
public let mimeType: String
|
||||
/// Base64-encoded payload. NOT prefixed with `data:` — Hermes wraps it
|
||||
/// when forwarding to OpenAI multimodal payloads (see
|
||||
/// `_image_block_to_openai_part` in `acp_adapter/server.py`).
|
||||
public let base64Data: String
|
||||
/// Small inline thumbnail for the composer's preview strip. Same MIME
|
||||
/// type as `base64Data`. Nil when the source was already small enough
|
||||
/// to use directly.
|
||||
public let thumbnailBase64: String?
|
||||
/// Original filename, when known (drag-drop carries it; paste doesn't).
|
||||
/// Surfaced as a tooltip on the preview chip.
|
||||
public let filename: String?
|
||||
/// Approximate decoded byte count, kept for the composer's
|
||||
/// "X images, Y KB" status pill.
|
||||
public let approximateByteCount: Int
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
mimeType: String,
|
||||
base64Data: String,
|
||||
thumbnailBase64: String?,
|
||||
filename: String?,
|
||||
approximateByteCount: Int
|
||||
) {
|
||||
self.id = id
|
||||
self.mimeType = mimeType
|
||||
self.base64Data = base64Data
|
||||
self.thumbnailBase64 = thumbnailBase64
|
||||
self.filename = filename
|
||||
self.approximateByteCount = approximateByteCount
|
||||
}
|
||||
}
|
||||
@@ -258,7 +258,16 @@ public struct VoiceSettings: Sendable, Equatable {
|
||||
)
|
||||
}
|
||||
|
||||
/// Eight sub-models that share the same provider/model/base_url/api_key/timeout shape.
|
||||
/// Per-task auxiliary model overrides.
|
||||
///
|
||||
/// `flush_memories` was removed in Hermes v0.12 but remains alive on
|
||||
/// pre-v0.12 hosts — the field is preserved here so the YAML parser
|
||||
/// can round-trip it and `AuxiliaryTab` can render the row when
|
||||
/// `HermesCapabilities.hasFlushMemoriesAux` is set. On v0.12+ the
|
||||
/// field stays empty and is never surfaced.
|
||||
/// `curator` was added in v0.12 — Curator's review fork uses its own
|
||||
/// model so users can keep main-model spend separate from background
|
||||
/// maintenance.
|
||||
public struct AuxiliarySettings: Sendable, Equatable {
|
||||
public var vision: AuxiliaryModel
|
||||
public var webExtract: AuxiliaryModel
|
||||
@@ -267,7 +276,10 @@ public struct AuxiliarySettings: Sendable, Equatable {
|
||||
public var skillsHub: AuxiliaryModel
|
||||
public var approval: AuxiliaryModel
|
||||
public var mcp: AuxiliaryModel
|
||||
/// pre-v0.12 only; on v0.12+ this stays `.empty` and the row is hidden.
|
||||
public var flushMemories: AuxiliaryModel
|
||||
/// v0.12+; pre-v0.12 Hermes installs ignore this slot.
|
||||
public var curator: AuxiliaryModel
|
||||
|
||||
|
||||
public init(
|
||||
@@ -278,7 +290,8 @@ public struct AuxiliarySettings: Sendable, Equatable {
|
||||
skillsHub: AuxiliaryModel,
|
||||
approval: AuxiliaryModel,
|
||||
mcp: AuxiliaryModel,
|
||||
flushMemories: AuxiliaryModel
|
||||
flushMemories: AuxiliaryModel,
|
||||
curator: AuxiliaryModel
|
||||
) {
|
||||
self.vision = vision
|
||||
self.webExtract = webExtract
|
||||
@@ -288,6 +301,7 @@ public struct AuxiliarySettings: Sendable, Equatable {
|
||||
self.approval = approval
|
||||
self.mcp = mcp
|
||||
self.flushMemories = flushMemories
|
||||
self.curator = curator
|
||||
}
|
||||
public nonisolated static let empty = AuxiliarySettings(
|
||||
vision: .empty,
|
||||
@@ -297,7 +311,8 @@ public struct AuxiliarySettings: Sendable, Equatable {
|
||||
skillsHub: .empty,
|
||||
approval: .empty,
|
||||
mcp: .empty,
|
||||
flushMemories: .empty
|
||||
flushMemories: .empty,
|
||||
curator: .empty
|
||||
)
|
||||
}
|
||||
|
||||
@@ -634,6 +649,24 @@ public struct HermesConfig: Sendable {
|
||||
/// platform. Scarf reads for display; edits go through Hermes CLI.
|
||||
public var platformToolsets: [String: [String]]
|
||||
|
||||
// -- Hermes v0.12 additions ----------------------------------------
|
||||
// Defaults match the Hermes v0.12 defaults so that an absent key in
|
||||
// config.yaml looks identical to a freshly-installed v0.12 host.
|
||||
|
||||
/// `prompt_caching.cache_ttl` — `"5m"` (default) or `"1h"`. Hermes
|
||||
/// v0.12 added the 1-hour ceiling for users with prompt-cache-heavy
|
||||
/// workloads (long agent loops with stable system prompts).
|
||||
public var cacheTTL: String
|
||||
/// `redaction.enabled` — flipped from `true` to `false` as the
|
||||
/// upstream default in v0.12 because the substitution corrupted
|
||||
/// patches and API payloads. Surface a toggle so users with hard
|
||||
/// redaction requirements can opt back in.
|
||||
public var redactionEnabled: Bool
|
||||
/// `agent.runtime_metadata_footer` — opt-in compact footer on each
|
||||
/// final reply (provider/model/cost/turn count). Off by default;
|
||||
/// useful for cost auditing and screen-recording demos.
|
||||
public var runtimeMetadataFooter: Bool
|
||||
|
||||
// Grouped blocks
|
||||
public var display: DisplaySettings
|
||||
public var terminal: TerminalSettings
|
||||
@@ -711,8 +744,14 @@ public struct HermesConfig: Sendable {
|
||||
matrix: MatrixSettings,
|
||||
mattermost: MattermostSettings,
|
||||
whatsapp: WhatsAppSettings,
|
||||
homeAssistant: HomeAssistantSettings
|
||||
homeAssistant: HomeAssistantSettings,
|
||||
cacheTTL: String = "5m",
|
||||
redactionEnabled: Bool = false,
|
||||
runtimeMetadataFooter: Bool = false
|
||||
) {
|
||||
self.cacheTTL = cacheTTL
|
||||
self.redactionEnabled = redactionEnabled
|
||||
self.runtimeMetadataFooter = runtimeMetadataFooter
|
||||
self.model = model
|
||||
self.provider = provider
|
||||
self.maxTurns = maxTurns
|
||||
|
||||
@@ -27,6 +27,28 @@ public enum QueryDefaults: Sendable {
|
||||
public nonisolated static let defaultSilenceThreshold = 200
|
||||
}
|
||||
|
||||
/// Page sizes for `HermesDataService.fetchMessages(sessionId:limit:before:)`.
|
||||
/// Centralized so iOS, Mac, and the polling code paths can pick a
|
||||
/// consistent budget — and so we have one knob to retune if perf
|
||||
/// concerns shift.
|
||||
public enum HistoryPageSize: Sendable {
|
||||
/// Initial chat-history load: covers the vast majority of
|
||||
/// sessions in one fetch while keeping the snapshot read bounded
|
||||
/// for the rare 1000+-message session.
|
||||
public nonisolated static let initial = 200
|
||||
/// Reconnection reconcile against the DB. 200 rows is plenty —
|
||||
/// disconnects don't generate hundreds of unseen messages.
|
||||
public nonisolated static let reconcile = 200
|
||||
/// Mac sessions detail view. Larger to reduce paging UX in the
|
||||
/// desktop browser-style read; the desktop has the screen real
|
||||
/// estate and memory headroom for it.
|
||||
public nonisolated static let macSessionDetail = 500
|
||||
/// Terminal-mode polling refresh. Same 500-row budget as Mac
|
||||
/// detail; covers sessions long enough that the user is actively
|
||||
/// scrolling but bounded to keep each poll tick cheap.
|
||||
public nonisolated static let polling = 500
|
||||
}
|
||||
|
||||
// MARK: - File Size Formatting
|
||||
|
||||
public enum FileSizeUnit: Sendable {
|
||||
|
||||
@@ -19,6 +19,15 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
public nonisolated let timeoutType: String?
|
||||
public nonisolated let timeoutSeconds: Int?
|
||||
public nonisolated let silent: Bool?
|
||||
/// Hermes v0.12+ — the directory the job runs from. Hermes injects
|
||||
/// AGENTS.md / CLAUDE.md / .cursorrules from this dir and uses it
|
||||
/// as cwd for terminal/file/code_exec tools. `nil` preserves the
|
||||
/// pre-v0.12 behaviour (no project context files).
|
||||
public nonisolated let workdir: String?
|
||||
/// Hermes v0.12+ — chain another cron job's last output into this
|
||||
/// job's prompt. YAML-only field today (no `--context-from` CLI
|
||||
/// flag yet) — Scarf displays it but doesn't write it.
|
||||
public nonisolated let contextFrom: [String]?
|
||||
|
||||
public enum CodingKeys: String, CodingKey {
|
||||
case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
|
||||
@@ -30,6 +39,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
case lastDeliveryError = "last_delivery_error"
|
||||
case timeoutType = "timeout_type"
|
||||
case timeoutSeconds = "timeout_seconds"
|
||||
case workdir
|
||||
case contextFrom = "context_from"
|
||||
}
|
||||
|
||||
/// Memberwise init. Swift doesn't synthesize one for us because
|
||||
@@ -53,7 +64,9 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
lastDeliveryError: String? = nil,
|
||||
timeoutType: String? = nil,
|
||||
timeoutSeconds: Int? = nil,
|
||||
silent: Bool? = nil
|
||||
silent: Bool? = nil,
|
||||
workdir: String? = nil,
|
||||
contextFrom: [String]? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
@@ -73,6 +86,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
self.timeoutType = timeoutType
|
||||
self.timeoutSeconds = timeoutSeconds
|
||||
self.silent = silent
|
||||
self.workdir = workdir
|
||||
self.contextFrom = contextFrom
|
||||
}
|
||||
|
||||
public nonisolated init(from decoder: any Decoder) throws {
|
||||
@@ -95,6 +110,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
self.timeoutType = try c.decodeIfPresent(String.self, forKey: .timeoutType)
|
||||
self.timeoutSeconds = try c.decodeIfPresent(Int.self, forKey: .timeoutSeconds)
|
||||
self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent)
|
||||
self.workdir = try c.decodeIfPresent(String.self, forKey: .workdir)
|
||||
self.contextFrom = try c.decodeIfPresent([String].self, forKey: .contextFrom)
|
||||
}
|
||||
|
||||
public nonisolated func encode(to encoder: any Encoder) throws {
|
||||
@@ -117,6 +134,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
try c.encodeIfPresent(timeoutType, forKey: .timeoutType)
|
||||
try c.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
|
||||
try c.encodeIfPresent(silent, forKey: .silent)
|
||||
try c.encodeIfPresent(workdir, forKey: .workdir)
|
||||
try c.encodeIfPresent(contextFrom, forKey: .contextFrom)
|
||||
}
|
||||
|
||||
public nonisolated var stateIcon: String {
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import Foundation
|
||||
|
||||
/// Parsed view of `hermes curator status` text + the on-disk
|
||||
/// `~/.hermes/skills/.curator_state` JSON.
|
||||
///
|
||||
/// Hermes v0.12 doesn't ship a `--json` flag for `curator status` — the
|
||||
/// CLI writes a human-readable report. CuratorViewModel parses the text
|
||||
/// output for the human-readable bits ("least recently active", "most
|
||||
/// active") and reads the state file directly for last-run metadata.
|
||||
public struct HermesCuratorStatus: Sendable, Equatable {
|
||||
public enum RunState: String, Sendable, Equatable {
|
||||
case enabled
|
||||
case paused
|
||||
case disabled
|
||||
case unknown
|
||||
}
|
||||
|
||||
public let state: RunState
|
||||
public let runCount: Int
|
||||
public let lastRunISO: String? // raw timestamp string, parsed by callers
|
||||
public let lastSummary: String? // free-text summary line
|
||||
public let lastReportPath: String? // absolute path to <YYYYMMDD-HHMMSS>/ dir
|
||||
public let intervalLabel: String // e.g. "every 7d"
|
||||
public let staleAfterLabel: String // e.g. "30d unused"
|
||||
public let archiveAfterLabel: String // e.g. "90d unused"
|
||||
|
||||
public let totalSkills: Int
|
||||
public let activeSkills: Int
|
||||
public let staleSkills: Int
|
||||
public let archivedSkills: Int
|
||||
|
||||
public let pinnedNames: [String]
|
||||
|
||||
/// Top-5 lists rendered in the curator output. Each row carries the
|
||||
/// skill name + the four counters Hermes prints.
|
||||
public let leastRecentlyActive: [HermesCuratorSkillRow]
|
||||
public let mostActive: [HermesCuratorSkillRow]
|
||||
public let leastActive: [HermesCuratorSkillRow]
|
||||
|
||||
public init(
|
||||
state: RunState,
|
||||
runCount: Int,
|
||||
lastRunISO: String?,
|
||||
lastSummary: String?,
|
||||
lastReportPath: String?,
|
||||
intervalLabel: String,
|
||||
staleAfterLabel: String,
|
||||
archiveAfterLabel: String,
|
||||
totalSkills: Int,
|
||||
activeSkills: Int,
|
||||
staleSkills: Int,
|
||||
archivedSkills: Int,
|
||||
pinnedNames: [String],
|
||||
leastRecentlyActive: [HermesCuratorSkillRow],
|
||||
mostActive: [HermesCuratorSkillRow],
|
||||
leastActive: [HermesCuratorSkillRow]
|
||||
) {
|
||||
self.state = state
|
||||
self.runCount = runCount
|
||||
self.lastRunISO = lastRunISO
|
||||
self.lastSummary = lastSummary
|
||||
self.lastReportPath = lastReportPath
|
||||
self.intervalLabel = intervalLabel
|
||||
self.staleAfterLabel = staleAfterLabel
|
||||
self.archiveAfterLabel = archiveAfterLabel
|
||||
self.totalSkills = totalSkills
|
||||
self.activeSkills = activeSkills
|
||||
self.staleSkills = staleSkills
|
||||
self.archivedSkills = archivedSkills
|
||||
self.pinnedNames = pinnedNames
|
||||
self.leastRecentlyActive = leastRecentlyActive
|
||||
self.mostActive = mostActive
|
||||
self.leastActive = leastActive
|
||||
}
|
||||
|
||||
public static let empty = HermesCuratorStatus(
|
||||
state: .unknown,
|
||||
runCount: 0,
|
||||
lastRunISO: nil,
|
||||
lastSummary: nil,
|
||||
lastReportPath: nil,
|
||||
intervalLabel: "—",
|
||||
staleAfterLabel: "—",
|
||||
archiveAfterLabel: "—",
|
||||
totalSkills: 0,
|
||||
activeSkills: 0,
|
||||
staleSkills: 0,
|
||||
archivedSkills: 0,
|
||||
pinnedNames: [],
|
||||
leastRecentlyActive: [],
|
||||
mostActive: [],
|
||||
leastActive: []
|
||||
)
|
||||
}
|
||||
|
||||
public struct HermesCuratorSkillRow: Sendable, Equatable, Identifiable {
|
||||
public var id: String { name }
|
||||
public let name: String
|
||||
public let activityCount: Int
|
||||
public let useCount: Int
|
||||
public let viewCount: Int
|
||||
public let patchCount: Int
|
||||
public let lastActivityLabel: String // raw label as printed (e.g. "never", "2d ago")
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
activityCount: Int,
|
||||
useCount: Int,
|
||||
viewCount: Int,
|
||||
patchCount: Int,
|
||||
lastActivityLabel: String
|
||||
) {
|
||||
self.name = name
|
||||
self.activityCount = activityCount
|
||||
self.useCount = useCount
|
||||
self.viewCount = viewCount
|
||||
self.patchCount = patchCount
|
||||
self.lastActivityLabel = lastActivityLabel
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure parser for `hermes curator status` stdout. Public for tests.
|
||||
///
|
||||
/// Format is stable enough to text-parse; we never error on missing
|
||||
/// sections — we just leave the corresponding field empty so
|
||||
/// CuratorView can render "—" without crashing on a future layout
|
||||
/// tweak. State file overrides text-parsed values when both are present.
|
||||
public enum HermesCuratorStatusParser {
|
||||
public static func parse(text: String, stateFileJSON: Data? = nil) -> HermesCuratorStatus {
|
||||
let lines = text.components(separatedBy: "\n")
|
||||
var status = HermesCuratorStatus.empty
|
||||
|
||||
// Header section: `curator: ENABLED` / `runs:` / `last run:` /
|
||||
// `last summary:` / `interval:` / `stale after:` / `archive after:`
|
||||
var state = HermesCuratorStatus.RunState.unknown
|
||||
var runCount = 0
|
||||
var lastRunISO: String?
|
||||
var lastSummary: String?
|
||||
var lastReportPath: String?
|
||||
var interval = "—"
|
||||
var stale = "—"
|
||||
var archive = "—"
|
||||
|
||||
// Skill counts: `agent-created skills: N total` then
|
||||
// ` active N` / ` stale N` / ` archived N`
|
||||
var total = 0
|
||||
var active = 0
|
||||
var staleCount = 0
|
||||
var archived = 0
|
||||
|
||||
var pinned: [String] = []
|
||||
|
||||
// Lists: `least recently active (top 5):` / `most active (top 5):` /
|
||||
// `least active (top 5):` followed by indented row lines.
|
||||
enum Section {
|
||||
case header
|
||||
case leastRecent
|
||||
case mostActive
|
||||
case leastActive
|
||||
}
|
||||
var section = Section.header
|
||||
var leastRecent: [HermesCuratorSkillRow] = []
|
||||
var mostActiveRows: [HermesCuratorSkillRow] = []
|
||||
var leastActiveRows: [HermesCuratorSkillRow] = []
|
||||
|
||||
for raw in lines {
|
||||
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||
// Section markers
|
||||
if line.hasPrefix("least recently active") {
|
||||
section = .leastRecent
|
||||
continue
|
||||
}
|
||||
if line.hasPrefix("most active") {
|
||||
section = .mostActive
|
||||
continue
|
||||
}
|
||||
if line.hasPrefix("least active") {
|
||||
section = .leastActive
|
||||
continue
|
||||
}
|
||||
|
||||
// Header section single-line keys
|
||||
if line.hasPrefix("curator:") {
|
||||
let val = String(line.dropFirst("curator:".count)).trimmingCharacters(in: .whitespaces).uppercased()
|
||||
switch val {
|
||||
case "ENABLED": state = .enabled
|
||||
case "PAUSED": state = .paused
|
||||
case "DISABLED": state = .disabled
|
||||
default: state = .unknown
|
||||
}
|
||||
continue
|
||||
}
|
||||
if line.hasPrefix("runs:") {
|
||||
runCount = Int(line.dropFirst("runs:".count).trimmingCharacters(in: .whitespaces)) ?? 0
|
||||
continue
|
||||
}
|
||||
if line.hasPrefix("last run:") {
|
||||
let val = String(line.dropFirst("last run:".count)).trimmingCharacters(in: .whitespaces)
|
||||
lastRunISO = val == "never" ? nil : val
|
||||
continue
|
||||
}
|
||||
if line.hasPrefix("last summary:") {
|
||||
let val = String(line.dropFirst("last summary:".count)).trimmingCharacters(in: .whitespaces)
|
||||
lastSummary = (val == "(none)" || val.isEmpty) ? nil : val
|
||||
continue
|
||||
}
|
||||
if line.hasPrefix("last report:") {
|
||||
let val = String(line.dropFirst("last report:".count)).trimmingCharacters(in: .whitespaces)
|
||||
lastReportPath = val.isEmpty ? nil : val
|
||||
continue
|
||||
}
|
||||
if line.hasPrefix("interval:") {
|
||||
interval = String(line.dropFirst("interval:".count)).trimmingCharacters(in: .whitespaces)
|
||||
continue
|
||||
}
|
||||
if line.hasPrefix("stale after:") {
|
||||
stale = String(line.dropFirst("stale after:".count)).trimmingCharacters(in: .whitespaces)
|
||||
continue
|
||||
}
|
||||
if line.hasPrefix("archive after:") {
|
||||
archive = String(line.dropFirst("archive after:".count)).trimmingCharacters(in: .whitespaces)
|
||||
continue
|
||||
}
|
||||
|
||||
// `agent-created skills: 18 total`
|
||||
if line.hasPrefix("agent-created skills:") {
|
||||
let after = line.dropFirst("agent-created skills:".count).trimmingCharacters(in: .whitespaces)
|
||||
if let n = Int(after.split(separator: " ").first ?? "") {
|
||||
total = n
|
||||
}
|
||||
section = .header
|
||||
continue
|
||||
}
|
||||
// Counts: "active 18" / "stale 0" / "archived 0"
|
||||
if let row = parseStateCountRow(line) {
|
||||
switch row.state {
|
||||
case "active": active = row.count
|
||||
case "stale": staleCount = row.count
|
||||
case "archived": archived = row.count
|
||||
default: break
|
||||
}
|
||||
continue
|
||||
}
|
||||
// pinned (3): foo, bar, baz
|
||||
if line.hasPrefix("pinned (") {
|
||||
if let colon = line.firstIndex(of: ":") {
|
||||
let names = line[line.index(after: colon)...]
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
pinned = names
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Skill rows like:
|
||||
// <name> activity= N use= N view= N patches= N last_activity=<label>
|
||||
if section != .header, let parsed = parseSkillRow(line) {
|
||||
switch section {
|
||||
case .leastRecent: leastRecent.append(parsed)
|
||||
case .mostActive: mostActiveRows.append(parsed)
|
||||
case .leastActive: leastActiveRows.append(parsed)
|
||||
case .header: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply state-file overrides if present. The .curator_state JSON
|
||||
// is authoritative for last_run_at / last_run_summary /
|
||||
// last_report_path because those carry timestamps the text
|
||||
// output rounds.
|
||||
if let json = stateFileJSON,
|
||||
let obj = try? JSONSerialization.jsonObject(with: json) as? [String: Any] {
|
||||
if obj["paused"] as? Bool == true { state = .paused }
|
||||
if let count = obj["run_count"] as? Int { runCount = count }
|
||||
if let lr = obj["last_run_at"] as? String { lastRunISO = lr }
|
||||
if let summary = obj["last_run_summary"] as? String, !summary.isEmpty { lastSummary = summary }
|
||||
if let path = obj["last_report_path"] as? String, !path.isEmpty { lastReportPath = path }
|
||||
}
|
||||
|
||||
status = HermesCuratorStatus(
|
||||
state: state,
|
||||
runCount: runCount,
|
||||
lastRunISO: lastRunISO,
|
||||
lastSummary: lastSummary,
|
||||
lastReportPath: lastReportPath,
|
||||
intervalLabel: interval,
|
||||
staleAfterLabel: stale,
|
||||
archiveAfterLabel: archive,
|
||||
totalSkills: total,
|
||||
activeSkills: active,
|
||||
staleSkills: staleCount,
|
||||
archivedSkills: archived,
|
||||
pinnedNames: pinned,
|
||||
leastRecentlyActive: leastRecent,
|
||||
mostActive: mostActiveRows,
|
||||
leastActive: leastActiveRows
|
||||
)
|
||||
return status
|
||||
}
|
||||
|
||||
/// `active 18` style row inside the skill-count block.
|
||||
private static func parseStateCountRow(_ line: String) -> (state: String, count: Int)? {
|
||||
let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||
guard parts.count >= 2,
|
||||
["active", "stale", "archived"].contains(parts[0]),
|
||||
let count = Int(parts[1])
|
||||
else { return nil }
|
||||
return (parts[0], count)
|
||||
}
|
||||
|
||||
/// Skill-list row parser. Tolerates Hermes's whitespace-padded
|
||||
/// layout — `activity= 0` has two spaces between `=` and the
|
||||
/// number, so we can't split-on-space-then-split-on-`=`. Instead
|
||||
/// we slide a key-detection cursor across the row and grab the
|
||||
/// next non-whitespace token after each known key.
|
||||
private static func parseSkillRow(_ line: String) -> HermesCuratorSkillRow? {
|
||||
guard let activityRange = line.range(of: "activity=") else { return nil }
|
||||
let name = String(line[..<activityRange.lowerBound]).trimmingCharacters(in: .whitespaces)
|
||||
guard !name.isEmpty else { return nil }
|
||||
|
||||
// Map each known key to its value substring. Read positionally
|
||||
// by slicing between consecutive known keys — handles arbitrary
|
||||
// whitespace padding without depending on column positions.
|
||||
let knownKeys = ["activity=", "use=", "view=", "patches=", "last_activity="]
|
||||
var positions: [(key: String, range: Range<String.Index>)] = []
|
||||
for key in knownKeys {
|
||||
if let r = line.range(of: key) {
|
||||
positions.append((key, r))
|
||||
}
|
||||
}
|
||||
positions.sort { $0.range.lowerBound < $1.range.lowerBound }
|
||||
|
||||
var activity = 0, use = 0, view = 0, patch = 0
|
||||
var lastActivity = ""
|
||||
|
||||
for (idx, entry) in positions.enumerated() {
|
||||
let valueStart = entry.range.upperBound
|
||||
let valueEnd = idx + 1 < positions.count
|
||||
? positions[idx + 1].range.lowerBound
|
||||
: line.endIndex
|
||||
let raw = String(line[valueStart..<valueEnd]).trimmingCharacters(in: .whitespaces)
|
||||
switch entry.key {
|
||||
case "activity=": activity = Int(raw) ?? 0
|
||||
case "use=": use = Int(raw) ?? 0
|
||||
case "view=": view = Int(raw) ?? 0
|
||||
case "patches=": patch = Int(raw) ?? 0
|
||||
case "last_activity=": lastActivity = raw
|
||||
default: break
|
||||
}
|
||||
}
|
||||
return HermesCuratorSkillRow(
|
||||
name: name,
|
||||
activityCount: activity,
|
||||
useCount: use,
|
||||
viewCount: view,
|
||||
patchCount: patch,
|
||||
lastActivityLabel: lastActivity
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
|
||||
/// One task from `hermes kanban list --json` (v0.12+).
|
||||
///
|
||||
/// Hermes ships a SQLite-backed task board under `~/.hermes/kanban.db`
|
||||
/// — multi-profile collaboration was reverted upstream while the
|
||||
/// design is reworked, so Scarf v2.6 surfaces this as a read-only
|
||||
/// list. Create / claim / dispatch / dependency-link UI is deferred
|
||||
/// until upstream stabilizes.
|
||||
public struct HermesKanbanTask: Sendable, Equatable, Identifiable, Codable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let body: String?
|
||||
public let assignee: String?
|
||||
public let status: String // archived | blocked | done | ready | running | todo | triage
|
||||
public let priority: Int?
|
||||
public let tenant: String?
|
||||
public let workspaceKind: String? // scratch | worktree | dir
|
||||
public let workspacePath: String?
|
||||
public let createdBy: String?
|
||||
public let createdAt: String? // ISO timestamp
|
||||
public let startedAt: String?
|
||||
public let completedAt: String?
|
||||
public let result: String?
|
||||
public let skills: [String]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
title: String,
|
||||
body: String? = nil,
|
||||
assignee: String? = nil,
|
||||
status: String,
|
||||
priority: Int? = nil,
|
||||
tenant: String? = nil,
|
||||
workspaceKind: String? = nil,
|
||||
workspacePath: String? = nil,
|
||||
createdBy: String? = nil,
|
||||
createdAt: String? = nil,
|
||||
startedAt: String? = nil,
|
||||
completedAt: String? = nil,
|
||||
result: String? = nil,
|
||||
skills: [String] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.body = body
|
||||
self.assignee = assignee
|
||||
self.status = status
|
||||
self.priority = priority
|
||||
self.tenant = tenant
|
||||
self.workspaceKind = workspaceKind
|
||||
self.workspacePath = workspacePath
|
||||
self.createdBy = createdBy
|
||||
self.createdAt = createdAt
|
||||
self.startedAt = startedAt
|
||||
self.completedAt = completedAt
|
||||
self.result = result
|
||||
self.skills = skills
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, title, body, assignee, status, priority, tenant
|
||||
case workspaceKind = "workspace_kind"
|
||||
case workspacePath = "workspace_path"
|
||||
case createdBy = "created_by"
|
||||
case createdAt = "created_at"
|
||||
case startedAt = "started_at"
|
||||
case completedAt = "completed_at"
|
||||
case result, skills
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.id = try c.decode(String.self, forKey: .id)
|
||||
self.title = try c.decode(String.self, forKey: .title)
|
||||
self.body = try c.decodeIfPresent(String.self, forKey: .body)
|
||||
self.assignee = try c.decodeIfPresent(String.self, forKey: .assignee)
|
||||
self.status = try c.decodeIfPresent(String.self, forKey: .status) ?? "unknown"
|
||||
self.priority = try c.decodeIfPresent(Int.self, forKey: .priority)
|
||||
self.tenant = try c.decodeIfPresent(String.self, forKey: .tenant)
|
||||
self.workspaceKind = try c.decodeIfPresent(String.self, forKey: .workspaceKind)
|
||||
self.workspacePath = try c.decodeIfPresent(String.self, forKey: .workspacePath)
|
||||
self.createdBy = try c.decodeIfPresent(String.self, forKey: .createdBy)
|
||||
self.createdAt = try c.decodeIfPresent(String.self, forKey: .createdAt)
|
||||
self.startedAt = try c.decodeIfPresent(String.self, forKey: .startedAt)
|
||||
self.completedAt = try c.decodeIfPresent(String.self, forKey: .completedAt)
|
||||
self.result = try c.decodeIfPresent(String.self, forKey: .result)
|
||||
self.skills = try c.decodeIfPresent([String].self, forKey: .skills) ?? []
|
||||
}
|
||||
}
|
||||
@@ -75,12 +75,35 @@ public struct HermesPathSet: Sendable, Hashable {
|
||||
public nonisolated var errorsLog: String { home + "/logs/errors.log" }
|
||||
public nonisolated var agentLog: String { home + "/logs/agent.log" }
|
||||
public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
|
||||
/// Curator run-reports root (v0.12+). Hermes writes per-cycle dirs
|
||||
/// under here named `<YYYYMMDD-HHMMSS>/` containing `run.json` and
|
||||
/// `REPORT.md`. The `last_report_path` field on `curator_state`
|
||||
/// points at the most recent dir; `CuratorViewModel` resolves the
|
||||
/// JSON/Markdown files relative to it.
|
||||
public nonisolated var curatorLogsDir: String { home + "/logs/curator" }
|
||||
/// JSON-encoded curator state (v0.12+). Filename has no extension
|
||||
/// despite holding JSON — Hermes writes it via
|
||||
/// `~/.hermes/skills/.curator_state`. Carries last-run metadata,
|
||||
/// run count, pause flag, and the path to the most recent report.
|
||||
public nonisolated var curatorStateFile: String { home + "/skills/.curator_state" }
|
||||
public nonisolated var scarfDir: String { home + "/scarf" }
|
||||
public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
|
||||
|
||||
/// Maps Hermes session IDs to the Scarf project path a chat was
|
||||
/// started for. Scarf-owned; Hermes never touches this file.
|
||||
public nonisolated var sessionProjectMap: String { scarfDir + "/session_project_map.json" }
|
||||
/// Cached list of available Nous Portal models. Populated by
|
||||
/// `NousModelCatalogService` from `GET https://inference-api.nousresearch.com/v1/models`
|
||||
/// using the bearer token in `auth.json`. Refreshed on a 24h TTL or
|
||||
/// on user request from the model picker. Survives offline runs so
|
||||
/// the picker still has something to render.
|
||||
public nonisolated var nousModelsCache: String { scarfDir + "/nous_models_cache.json" }
|
||||
/// Cached `templates/catalog.json` from awizemann.github.io. Populated
|
||||
/// by `CatalogService` on first sheet-open and refreshed on a 24h TTL
|
||||
/// or on explicit user click. Mirrors `nousModelsCache` exactly:
|
||||
/// JSON, scarf-owned, survives offline runs so the catalog browser
|
||||
/// still has something to render. Wiped by a Hermes home reset.
|
||||
public nonisolated var catalogCache: String { scarfDir + "/catalog_cache.json" }
|
||||
public nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
|
||||
|
||||
// MARK: - Binary resolution
|
||||
|
||||
@@ -37,6 +37,16 @@ public struct HermesSkill: Identifiable, Sendable {
|
||||
/// Python packages). Used by `SkillPrereqService` to know what to
|
||||
/// probe; nil when the field is absent.
|
||||
public let dependencies: [String]?
|
||||
/// `false` when the skill name appears in `skills.disabled` in
|
||||
/// `~/.hermes/config.yaml`. Hermes v0.12 stores disable state in
|
||||
/// the config rather than per-skill markers; this is read-only
|
||||
/// from Scarf's side until the toggle UI lands. Defaults to `true`.
|
||||
public let enabled: Bool
|
||||
/// `true` when the skill is pinned via `hermes curator pin <name>`.
|
||||
/// Pinned skills are protected from auto-archive / consolidation.
|
||||
/// Read from `CuratorViewModel.status.pinnedNames`; defaults to
|
||||
/// `false` when curator state is unavailable.
|
||||
public let pinned: Bool
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
@@ -47,7 +57,9 @@ public struct HermesSkill: Identifiable, Sendable {
|
||||
requiredConfig: [String],
|
||||
allowedTools: [String]? = nil,
|
||||
relatedSkills: [String]? = nil,
|
||||
dependencies: [String]? = nil
|
||||
dependencies: [String]? = nil,
|
||||
enabled: Bool = true,
|
||||
pinned: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
@@ -58,5 +70,7 @@ public struct HermesSkill: Identifiable, Sendable {
|
||||
self.allowedTools = allowedTools
|
||||
self.relatedSkills = relatedSkills
|
||||
self.dependencies = dependencies
|
||||
self.enabled = enabled
|
||||
self.pinned = pinned
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,13 @@ public enum KnownPlatforms {
|
||||
HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"),
|
||||
HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"),
|
||||
HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"),
|
||||
// -- v0.12 additions ---------------------------------------------
|
||||
// Yuanbao is a native gateway adapter (18th platform); Microsoft
|
||||
// Teams ships as a plugin (19th). PlatformDetail surfaces the
|
||||
// distinction in the setup copy. Names match Hermes's gateway
|
||||
// platform identifiers.
|
||||
HermesToolPlatform(name: "yuanbao", displayName: "Yuanbao 元宝", icon: "bubble.left.and.bubble.right.fill"),
|
||||
HermesToolPlatform(name: "microsoft-teams", displayName: "Microsoft Teams", icon: "person.2.fill"),
|
||||
]
|
||||
|
||||
public static func icon(for platform: String) -> String {
|
||||
@@ -70,6 +77,8 @@ public enum KnownPlatforms {
|
||||
case "feishu": return "message.badge.circle"
|
||||
case "mattermost": return "bubble.left.and.exclamationmark.bubble.right"
|
||||
case "imessage": return "message.fill"
|
||||
case "yuanbao": return "bubble.left.and.bubble.right.fill"
|
||||
case "microsoft-teams": return "person.2.fill"
|
||||
default: return "bubble.left"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ public struct SSHConfig: Sendable, Hashable, Codable {
|
||||
/// `HermesPathSet.defaultRemoteHome` (`~/.hermes`, shell-expanded on the
|
||||
/// remote side).
|
||||
public var remoteHome: String?
|
||||
/// Override for where Scarf installs new project templates on this host.
|
||||
/// `nil` uses `~/projects` (unexpanded — remote shell resolves it).
|
||||
/// Created on first install if missing.
|
||||
public var projectsRoot: String?
|
||||
/// Resolved remote path to the `hermes` binary. Populated by
|
||||
/// `SSHTransport` after the first `command -v hermes` probe; cached here
|
||||
/// so subsequent calls skip the round trip.
|
||||
@@ -36,6 +40,7 @@ public struct SSHConfig: Sendable, Hashable, Codable {
|
||||
port: Int? = nil,
|
||||
identityFile: String? = nil,
|
||||
remoteHome: String? = nil,
|
||||
projectsRoot: String? = nil,
|
||||
hermesBinaryHint: String? = nil
|
||||
) {
|
||||
self.host = host
|
||||
@@ -43,6 +48,7 @@ public struct SSHConfig: Sendable, Hashable, Codable {
|
||||
self.port = port
|
||||
self.identityFile = identityFile
|
||||
self.remoteHome = remoteHome
|
||||
self.projectsRoot = projectsRoot
|
||||
self.hermesBinaryHint = hermesBinaryHint
|
||||
}
|
||||
}
|
||||
@@ -106,6 +112,27 @@ public struct ServerContext: Sendable, Hashable, Identifiable {
|
||||
return false
|
||||
}
|
||||
|
||||
/// Default parent directory under which `ProjectTemplateInstaller` lays
|
||||
/// out new projects. Per-host configurable on `.ssh` via
|
||||
/// `SSHConfig.projectsRoot`; local always resolves to `~/Projects` on the
|
||||
/// user's Mac. The remote default is left as an unexpanded `~/projects`
|
||||
/// — the remote shell resolves the tilde, same convention as
|
||||
/// `HermesPathSet.defaultRemoteHome`. The installer calls
|
||||
/// `transport.createDirectory(_:)` at install time so a missing dir on a
|
||||
/// fresh host is bootstrapped on first use rather than treated as an error.
|
||||
public nonisolated var defaultProjectsRoot: String {
|
||||
switch kind {
|
||||
case .local:
|
||||
return NSHomeDirectory() + "/Projects"
|
||||
case .ssh(let config):
|
||||
if let configured = config.projectsRoot,
|
||||
!configured.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
return configured
|
||||
}
|
||||
return "~/projects"
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct the `ServerTransport` for this context. Local contexts get
|
||||
/// a `LocalTransport`; SSH contexts get an `SSHTransport` configured
|
||||
/// from `SSHConfig` by default, OR whatever `sshTransportFactory`
|
||||
|
||||
@@ -122,7 +122,8 @@ public extension HermesConfig {
|
||||
skillsHub: aux("skills_hub"),
|
||||
approval: aux("approval"),
|
||||
mcp: aux("mcp"),
|
||||
flushMemories: aux("flush_memories")
|
||||
flushMemories: aux("flush_memories"),
|
||||
curator: aux("curator")
|
||||
)
|
||||
|
||||
let security = SecuritySettings(
|
||||
@@ -280,7 +281,10 @@ public extension HermesConfig {
|
||||
matrix: matrix,
|
||||
mattermost: mattermost,
|
||||
whatsapp: whatsapp,
|
||||
homeAssistant: homeAssistant
|
||||
homeAssistant: homeAssistant,
|
||||
cacheTTL: str("prompt_caching.cache_ttl", default: "5m"),
|
||||
redactionEnabled: bool("redaction.enabled", default: false),
|
||||
runtimeMetadataFooter: bool("agent.runtime_metadata_footer", default: false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// What this Hermes installation can do, derived from `hermes --version`.
|
||||
///
|
||||
/// Scarf tracks Hermes feature releases by date-version + semver. v0.12 added
|
||||
/// a dozen surfaces (Curator, Kanban, multimodal ACP, ...) and removed a few
|
||||
/// (`flush_memories` aux task). UI that branches on these surfaces calls
|
||||
/// the boolean accessors here so older Hermes installs degrade silently
|
||||
/// instead of throwing on an unknown CLI subcommand.
|
||||
///
|
||||
/// Pure value type — no side effects. The async detection lives in
|
||||
/// `HermesCapabilitiesStore`.
|
||||
public struct HermesCapabilities: Sendable, Equatable {
|
||||
/// Raw version line as printed by `hermes --version`. Preserved verbatim
|
||||
/// so diagnostics views can show the exact string Scarf saw.
|
||||
public let versionLine: String
|
||||
/// Parsed `0.X.Y`. `nil` when the output didn't match the expected format
|
||||
/// (e.g. Hermes returned an error, or a future format change).
|
||||
public let semver: SemVer?
|
||||
/// Parsed `YYYY.M.D` from the parenthesized date suffix. `nil` when
|
||||
/// absent — older Hermes builds didn't always emit it.
|
||||
public let dateVersion: DateVersion?
|
||||
|
||||
public init(versionLine: String, semver: SemVer?, dateVersion: DateVersion?) {
|
||||
self.versionLine = versionLine
|
||||
self.semver = semver
|
||||
self.dateVersion = dateVersion
|
||||
}
|
||||
|
||||
/// Sentinel for "not yet detected" / "detection failed". All capability
|
||||
/// flags resolve to `false` so unguarded UI stays hidden until the real
|
||||
/// version lands.
|
||||
public static let empty = HermesCapabilities(
|
||||
versionLine: "",
|
||||
semver: nil,
|
||||
dateVersion: nil
|
||||
)
|
||||
|
||||
public var detected: Bool { semver != nil }
|
||||
|
||||
// MARK: - Capability flags
|
||||
//
|
||||
// Add a new flag here when Scarf gains UI that conditionally branches on
|
||||
// a Hermes capability. Keep the comparison conservative: `>= 0.12.0`
|
||||
// covers users still on the 0.12 line who haven't upgraded to 0.13 yet.
|
||||
|
||||
/// `hermes curator` autonomous skill maintenance (v0.12+).
|
||||
public var hasCurator: Bool { atLeastSemver(0, 12, 0) }
|
||||
|
||||
/// `hermes fallback` provider management (v0.12+).
|
||||
public var hasFallbackCommand: Bool { atLeastSemver(0, 12, 0) }
|
||||
|
||||
/// `hermes kanban` task board CLI (v0.12+).
|
||||
public var hasKanban: Bool { atLeastSemver(0, 12, 0) }
|
||||
|
||||
/// `hermes -z <prompt>` non-interactive one-shot mode (v0.12+).
|
||||
public var hasOneShot: Bool { atLeastSemver(0, 12, 0) }
|
||||
|
||||
/// `hermes skills install <https-url>` direct-URL install (v0.12+).
|
||||
public var hasSkillURLInstall: Bool { atLeastSemver(0, 12, 0) }
|
||||
|
||||
/// ACP `session/prompt` accepts image content blocks (v0.12+).
|
||||
public var hasACPImagePrompts: Bool { atLeastSemver(0, 12, 0) }
|
||||
|
||||
/// `hermes update --check` preflight (v0.12+).
|
||||
public var hasUpdateCheck: Bool { atLeastSemver(0, 12, 0) }
|
||||
|
||||
/// Pluggable TTS providers including native Piper (v0.12+).
|
||||
public var hasPiperTTS: Bool { atLeastSemver(0, 12, 0) }
|
||||
|
||||
/// `terminal.backend = vercel` Vercel Sandbox option (v0.12+).
|
||||
public var hasVercelTerminal: Bool { atLeastSemver(0, 12, 0) }
|
||||
|
||||
/// `auxiliary.flush_memories` config row was removed in v0.12.
|
||||
/// Inverse semantics — `true` means the row should still be shown.
|
||||
public var hasFlushMemoriesAux: Bool {
|
||||
guard let s = semver else { return false } // unknown → hide
|
||||
return s < SemVer(major: 0, minor: 12, patch: 0) // pre-v0.12 only
|
||||
}
|
||||
|
||||
/// `auxiliary.curator` aux task is configurable (v0.12+).
|
||||
public var hasCuratorAux: Bool { atLeastSemver(0, 12, 0) }
|
||||
|
||||
/// Microsoft Teams (19th platform) and Yuanbao (18th) added in v0.12.
|
||||
public var hasTeamsPlatform: Bool { atLeastSemver(0, 12, 0) }
|
||||
public var hasYuanbaoPlatform: Bool { atLeastSemver(0, 12, 0) }
|
||||
|
||||
/// Cron jobs accept `--workdir` and `--context-from` flags (v0.12+).
|
||||
public var hasCronWorkdir: Bool { atLeastSemver(0, 12, 0) }
|
||||
|
||||
/// `prompt_caching.cache_ttl` config knob (v0.12+).
|
||||
public var hasPromptCacheTTL: Bool { atLeastSemver(0, 12, 0) }
|
||||
|
||||
/// `redaction.enabled` is now off by default in v0.12 — Scarf surfaces
|
||||
/// the toggle so users can flip it back on.
|
||||
public var hasRedactionToggle: Bool { atLeastSemver(0, 12, 0) }
|
||||
|
||||
private func atLeastSemver(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
|
||||
guard let s = semver else { return false }
|
||||
return s >= SemVer(major: major, minor: minor, patch: patch)
|
||||
}
|
||||
|
||||
public struct SemVer: Sendable, Equatable, Comparable, CustomStringConvertible {
|
||||
public let major: Int
|
||||
public let minor: Int
|
||||
public let patch: Int
|
||||
|
||||
public init(major: Int, minor: Int, patch: Int) {
|
||||
self.major = major
|
||||
self.minor = minor
|
||||
self.patch = patch
|
||||
}
|
||||
|
||||
public var description: String { "\(major).\(minor).\(patch)" }
|
||||
|
||||
public static func < (a: SemVer, b: SemVer) -> Bool {
|
||||
if a.major != b.major { return a.major < b.major }
|
||||
if a.minor != b.minor { return a.minor < b.minor }
|
||||
return a.patch < b.patch
|
||||
}
|
||||
}
|
||||
|
||||
public struct DateVersion: Sendable, Equatable, Comparable, CustomStringConvertible {
|
||||
public let year: Int
|
||||
public let month: Int
|
||||
public let day: Int
|
||||
|
||||
public init(year: Int, month: Int, day: Int) {
|
||||
self.year = year
|
||||
self.month = month
|
||||
self.day = day
|
||||
}
|
||||
|
||||
public var description: String { "\(year).\(month).\(day)" }
|
||||
|
||||
public static func < (a: DateVersion, b: DateVersion) -> Bool {
|
||||
if a.year != b.year { return a.year < b.year }
|
||||
if a.month != b.month { return a.month < b.month }
|
||||
return a.day < b.day
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `Hermes Agent v0.12.0 (2026.4.30)` line out of `hermes --version`
|
||||
/// output. Tolerates leading/trailing whitespace, extra header lines
|
||||
/// (e.g. `Project:`, `Python:`), and the absence of the parenthesized
|
||||
/// date suffix.
|
||||
///
|
||||
/// Returns `.empty` when no recognizable version line is present so
|
||||
/// callers don't have to special-case nil.
|
||||
public static func parse(_ output: String) -> HermesCapabilities {
|
||||
for raw in output.components(separatedBy: "\n") {
|
||||
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||
guard line.contains("Hermes Agent v") else { continue }
|
||||
return parseLine(line)
|
||||
}
|
||||
return .empty
|
||||
}
|
||||
|
||||
/// `Hermes Agent v0.12.0 (2026.4.30)` → semver + date. Returns `.empty`
|
||||
/// when the line doesn't match. Public for unit tests; production callers
|
||||
/// should use `parse(_:)`.
|
||||
public static func parseLine(_ line: String) -> HermesCapabilities {
|
||||
// Locate the "v" right after "Hermes Agent ". Don't anchor at line
|
||||
// start — older builds prefix with ANSI color codes Scarf would
|
||||
// need to strip.
|
||||
guard let vRange = line.range(of: "Hermes Agent v") else { return .empty }
|
||||
let tail = String(line[vRange.upperBound...])
|
||||
|
||||
// Read digits separated by dots until we hit non-version content.
|
||||
// First three components are semver. A trailing `(Y.M.D)` is the
|
||||
// date version.
|
||||
let semverEnd = tail.firstIndex(where: { c in
|
||||
!(c.isNumber || c == ".")
|
||||
}) ?? tail.endIndex
|
||||
let semverStr = String(tail[..<semverEnd])
|
||||
let semverParts = semverStr.split(separator: ".").compactMap { Int($0) }
|
||||
guard semverParts.count >= 3 else { return .empty }
|
||||
let semver = SemVer(
|
||||
major: semverParts[0],
|
||||
minor: semverParts[1],
|
||||
patch: semverParts[2]
|
||||
)
|
||||
|
||||
// Optional date suffix.
|
||||
var dateVersion: DateVersion?
|
||||
if let openParen = tail.firstIndex(of: "("),
|
||||
let closeParen = tail.firstIndex(of: ")"),
|
||||
openParen < closeParen {
|
||||
let dateStr = tail[tail.index(after: openParen)..<closeParen]
|
||||
let dateParts = dateStr.split(separator: ".").compactMap { Int($0) }
|
||||
if dateParts.count == 3 {
|
||||
dateVersion = DateVersion(
|
||||
year: dateParts[0],
|
||||
month: dateParts[1],
|
||||
day: dateParts[2]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return HermesCapabilities(
|
||||
versionLine: line,
|
||||
semver: semver,
|
||||
dateVersion: dateVersion
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-server capability cache. One per `ContextBoundRoot` (Mac) / iOS scene
|
||||
/// root, injected via `.environment(_:)`. Refreshes once on init; callers
|
||||
/// invoke `refresh()` after a Hermes update or when the server changes.
|
||||
///
|
||||
/// Not thread-safe across instances — each server gets its own store, and
|
||||
/// the underlying `runHermesCLI` call is detached so we never block
|
||||
/// MainActor.
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class HermesCapabilitiesStore {
|
||||
#if canImport(os)
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "HermesCapabilities")
|
||||
#endif
|
||||
|
||||
public private(set) var capabilities: HermesCapabilities = .empty
|
||||
public private(set) var isLoading = true
|
||||
|
||||
public let context: ServerContext
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
|
||||
public init(context: ServerContext) {
|
||||
self.context = context
|
||||
// Kick off a one-shot detection. Subsequent refreshes are explicit.
|
||||
// Task captures `[weak self]`, so if the store is freed before
|
||||
// detection completes the closure simply no-ops.
|
||||
refreshTask = Task { [weak self] in
|
||||
await self?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
public func refresh() async {
|
||||
isLoading = true
|
||||
let context = self.context
|
||||
let parsed = await Task.detached(priority: .utility) { () -> HermesCapabilities in
|
||||
return Self.detectSync(context: context)
|
||||
}.value
|
||||
|
||||
self.capabilities = parsed
|
||||
self.isLoading = false
|
||||
|
||||
#if canImport(os)
|
||||
if parsed.detected {
|
||||
logger.info("Hermes \(parsed.versionLine, privacy: .public) detected on \(self.context.displayName, privacy: .public)")
|
||||
} else {
|
||||
logger.warning("Hermes version not detected on \(self.context.displayName, privacy: .public)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Synchronous detection helper. Lives here (not on `HermesCapabilities`)
|
||||
/// because `ServerContext.makeTransport()` is a side-effecting call that
|
||||
/// pulls in the platform-appropriate transport (LocalTransport on Mac,
|
||||
/// CitadelServerTransport on iOS). The pure parser remains side-effect-free.
|
||||
nonisolated private static func detectSync(context: ServerContext) -> HermesCapabilities {
|
||||
let transport = context.makeTransport()
|
||||
let executable = context.paths.hermesBinary
|
||||
do {
|
||||
let result = try transport.runProcess(
|
||||
executable: executable,
|
||||
args: ["--version"],
|
||||
stdin: nil,
|
||||
timeout: 10
|
||||
)
|
||||
// `hermes --version` writes to stdout but Scarf's transport
|
||||
// helpers occasionally split error output across stderr — fold
|
||||
// both so the parser sees whichever stream the line lands on.
|
||||
let combined = result.stdoutString + result.stderrString
|
||||
guard result.exitCode == 0 else { return .empty }
|
||||
return HermesCapabilities.parse(combined)
|
||||
} catch {
|
||||
return .empty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI environment wiring
|
||||
|
||||
#if canImport(SwiftUI)
|
||||
import SwiftUI
|
||||
|
||||
private struct HermesCapabilitiesStoreKey: EnvironmentKey {
|
||||
static let defaultValue: HermesCapabilitiesStore? = nil
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// The active server's capability store. `nil` outside the per-server
|
||||
/// `ContextBoundRoot`. Callers should treat `nil` and `.empty` capabilities
|
||||
/// the same — defensive code for harness scenarios (Previews, smoke tests).
|
||||
public var hermesCapabilities: HermesCapabilitiesStore? {
|
||||
get { self[HermesCapabilitiesStoreKey.self] }
|
||||
set { self[HermesCapabilitiesStoreKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Inject a `HermesCapabilitiesStore` into the environment. Mirrors the
|
||||
/// usual `.environment(_:)` shape but routes through the typed key
|
||||
/// above so callers don't need to import the key.
|
||||
public func hermesCapabilities(_ store: HermesCapabilitiesStore) -> some View {
|
||||
environment(\.hermesCapabilities, store)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -61,6 +61,26 @@ public actor HermesDataService {
|
||||
/// instead of an empty Dashboard with no explanation.
|
||||
public private(set) var lastOpenError: String?
|
||||
|
||||
/// Modification date of the underlying state.db that backs the
|
||||
/// currently-open connection. For local contexts this tracks the
|
||||
/// live DB's mtime; for remote contexts it's the cached snapshot's
|
||||
/// mtime — which equals "when did we last get fresh data."
|
||||
public private(set) var lastSnapshotMtime: Date?
|
||||
|
||||
/// True when a `snapshotSQLite` pull failed and the open succeeded
|
||||
/// against a previously-cached snapshot instead of a fresh one.
|
||||
/// Views render a "Last updated X ago" affordance when this is set
|
||||
/// alongside `lastOpenError`. Always `false` for local contexts.
|
||||
public private(set) var isUsingStaleSnapshot: Bool = false
|
||||
|
||||
/// Convenience: how long ago the cached snapshot was written, when
|
||||
/// we're using a stale snapshot. `nil` when the snapshot is fresh
|
||||
/// or no mtime could be read.
|
||||
public var staleAge: TimeInterval? {
|
||||
guard isUsingStaleSnapshot, let m = lastSnapshotMtime else { return nil }
|
||||
return Date().timeIntervalSince(m)
|
||||
}
|
||||
|
||||
public let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
|
||||
@@ -70,6 +90,18 @@ public actor HermesDataService {
|
||||
}
|
||||
|
||||
public func open() async -> Bool {
|
||||
await openInternal(forceFresh: false)
|
||||
}
|
||||
|
||||
/// Variant that refuses the stale-snapshot fallback. Used by call
|
||||
/// sites that genuinely need post-write consistency — most notably
|
||||
/// the chat session-history reload, where a stale snapshot would
|
||||
/// hide messages the agent just streamed.
|
||||
private func openStrict() async -> Bool {
|
||||
await openInternal(forceFresh: true)
|
||||
}
|
||||
|
||||
private func openInternal(forceFresh: Bool) async -> Bool {
|
||||
if db != nil { return true }
|
||||
let localPath: String
|
||||
if context.isRemote {
|
||||
@@ -86,10 +118,30 @@ public actor HermesDataService {
|
||||
)
|
||||
localPath = url.path
|
||||
lastOpenError = nil
|
||||
isUsingStaleSnapshot = false
|
||||
lastSnapshotMtime = mtime(at: url)
|
||||
} catch {
|
||||
lastOpenError = humanize(error)
|
||||
Self.logger.warning("snapshotSQLite failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
// Fresh pull failed. If the caller demanded fresh data
|
||||
// (`forceFresh: true`) OR there's no usable cache on
|
||||
// disk, surface the error and bail. Otherwise serve
|
||||
// the cached snapshot with `isUsingStaleSnapshot = true`
|
||||
// so views can render a "Last updated X ago" banner.
|
||||
if !forceFresh,
|
||||
let cached = transport.cachedSnapshotPath,
|
||||
FileManager.default.fileExists(atPath: cached.path)
|
||||
{
|
||||
localPath = cached.path
|
||||
isUsingStaleSnapshot = true
|
||||
lastSnapshotMtime = mtime(at: cached)
|
||||
lastOpenError = humanize(error) // user still sees why it's stale
|
||||
Self.logger.warning(
|
||||
"Using stale snapshot after pull failure: \(error.localizedDescription, privacy: .public)"
|
||||
)
|
||||
} else {
|
||||
lastOpenError = humanize(error)
|
||||
Self.logger.warning("snapshotSQLite failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
localPath = context.paths.stateDB
|
||||
@@ -97,6 +149,8 @@ public actor HermesDataService {
|
||||
lastOpenError = "Hermes state database not found at \(localPath)."
|
||||
return false
|
||||
}
|
||||
isUsingStaleSnapshot = false
|
||||
lastSnapshotMtime = mtime(at: URL(fileURLWithPath: localPath))
|
||||
}
|
||||
// Remote snapshots are point-in-time copies that no one writes to;
|
||||
// opening them with `immutable=1` tells SQLite to skip WAL/SHM and
|
||||
@@ -151,17 +205,27 @@ public actor HermesDataService {
|
||||
return desc
|
||||
}
|
||||
|
||||
/// Force a fresh snapshot pull + reopen. Used on session-load and in
|
||||
/// any path that needs the UI to reflect writes Hermes just made.
|
||||
/// Without this, remote snapshots would be frozen at the first `open()`
|
||||
/// for the app's lifetime — new messages added to a resumed session
|
||||
/// would never appear because the snapshot was pulled before they were
|
||||
/// written. Local contexts pay essentially nothing: close+reopen on a
|
||||
/// live DB is a no-op.
|
||||
/// Close the current connection and re-open with a fresh snapshot
|
||||
/// pull (when remote). When `forceFresh` is `false` (default) and
|
||||
/// the snapshot pull fails, falls back to the cached snapshot —
|
||||
/// `isUsingStaleSnapshot` is set so views can render a "Last
|
||||
/// updated X ago" banner. Pass `forceFresh: true` from call sites
|
||||
/// that genuinely need post-write consistency (chat session
|
||||
/// history reload), where stale data would hide messages the
|
||||
/// agent just streamed.
|
||||
@discardableResult
|
||||
public func refresh() async -> Bool {
|
||||
public func refresh(forceFresh: Bool = false) async -> Bool {
|
||||
close()
|
||||
return await open()
|
||||
return await openInternal(forceFresh: forceFresh)
|
||||
}
|
||||
|
||||
/// Read the modification date of a local file. Returns `nil` if
|
||||
/// the file is unreachable or has no mtime metadata. Used to
|
||||
/// stamp `lastSnapshotMtime` so views can show "Last updated
|
||||
/// X ago" without each one duplicating the FileManager dance.
|
||||
private nonisolated func mtime(at url: URL) -> Date? {
|
||||
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
|
||||
return attrs?[.modificationDate] as? Date
|
||||
}
|
||||
|
||||
public func close() {
|
||||
@@ -294,6 +358,50 @@ public actor HermesDataService {
|
||||
return cols
|
||||
}
|
||||
|
||||
/// Bounded message fetch keyed by message id (monotonic per row,
|
||||
/// safer than timestamp-based pagination because streaming chunk
|
||||
/// timestamps can collide). Returns the most recent `limit`
|
||||
/// messages older than `before` (when supplied) in chronological
|
||||
/// (ASC) order ready to display. Pass `before: nil` for the
|
||||
/// initial load — the DB returns the newest `limit` rows.
|
||||
public func fetchMessages(
|
||||
sessionId: String,
|
||||
limit: Int,
|
||||
before: Int? = nil
|
||||
) -> [HermesMessage] {
|
||||
guard let db else { return [] }
|
||||
let sql: String
|
||||
if before != nil {
|
||||
sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? AND id < ? ORDER BY id DESC LIMIT ?"
|
||||
} else {
|
||||
sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT ?"
|
||||
}
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
|
||||
if let before {
|
||||
sqlite3_bind_int(stmt, 2, Int32(before))
|
||||
sqlite3_bind_int(stmt, 3, Int32(limit))
|
||||
} else {
|
||||
sqlite3_bind_int(stmt, 2, Int32(limit))
|
||||
}
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
messages.append(messageFromRow(stmt!))
|
||||
}
|
||||
// Caller wants chronological (oldest-first) order; the SELECT
|
||||
// is DESC for the LIMIT to bite the newest rows, so reverse.
|
||||
return messages.reversed()
|
||||
}
|
||||
|
||||
/// Legacy unbounded fetch retained for one release cycle so any
|
||||
/// out-of-tree consumers don't break. New code should use the
|
||||
/// bounded `fetchMessages(sessionId:limit:before:)` variant —
|
||||
/// snapshot loads on 1000+-message sessions stall the UI when
|
||||
/// they materialize the whole history at once.
|
||||
@available(*, deprecated, message: "Use fetchMessages(sessionId:limit:before:) instead.")
|
||||
public func fetchMessages(sessionId: String) -> [HermesMessage] {
|
||||
guard let db else { return [] }
|
||||
let sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY timestamp ASC"
|
||||
|
||||
@@ -51,7 +51,19 @@ public enum HermesProfileResolver {
|
||||
/// Returns the default `~/.hermes` when no profile is active OR when
|
||||
/// the configured profile is invalid (logged) — so the worst-case
|
||||
/// failure mode is "Scarf shows what it always showed before."
|
||||
///
|
||||
/// **Test override.** Setting `SCARF_HERMES_HOME` in the environment
|
||||
/// pins this resolver to the supplied absolute path and bypasses both
|
||||
/// the cache and the `active_profile` lookup. Used by the E2E test
|
||||
/// harness (`TemplateE2ETests`, `TemplateInstallUITests`) to drive
|
||||
/// Scarf against an isolated tmpdir Hermes home so the user's real
|
||||
/// `~/.hermes` is never touched. Read on every call (cheap; a single
|
||||
/// `ProcessInfo` lookup) so tests can flip it across test methods
|
||||
/// without stale-cache surprises.
|
||||
public static func resolveLocalHome() -> String {
|
||||
if let override = scarfHermesHomeOverride() {
|
||||
return override
|
||||
}
|
||||
return refreshIfNeeded().home
|
||||
}
|
||||
|
||||
@@ -60,9 +72,55 @@ public enum HermesProfileResolver {
|
||||
/// reading from (issue #50 follow-up: prevents the next variant
|
||||
/// of "where's my data — wrong profile" by making it visible).
|
||||
public static func activeProfileName() -> String {
|
||||
if scarfHermesHomeOverride() != nil {
|
||||
return "test-override"
|
||||
}
|
||||
return refreshIfNeeded().name
|
||||
}
|
||||
|
||||
/// Sentinel filename that the override path MUST contain for the
|
||||
/// override to be honored. Without it, production code refuses to
|
||||
/// pivot off the user's real `~/.hermes` even if the env var is
|
||||
/// set. This is the "even if a test leaks the env var, even if
|
||||
/// some non-test process inherits it, the user's data is safe"
|
||||
/// belt-and-braces guard. Tests create this marker before
|
||||
/// `setenv("SCARF_HERMES_HOME", ...)`.
|
||||
public static let testHomeMarkerFilename = ".scarf-test-home-marker"
|
||||
|
||||
/// Read `SCARF_HERMES_HOME` from the environment. Returns `nil` when
|
||||
/// unset or empty so production callers fall through to the profile
|
||||
/// resolver. The override must:
|
||||
/// 1. Be an absolute path — relative paths are rejected (they'd
|
||||
/// land relative to the cwd of whatever process happened to
|
||||
/// invoke the resolver, which is not what tests want).
|
||||
/// 2. Contain the sentinel marker file
|
||||
/// `<path>/<testHomeMarkerFilename>`. Without the marker we
|
||||
/// treat the env var as untrusted and ignore it. This protects
|
||||
/// the user's real `~/.hermes/` from any code path that
|
||||
/// accidentally exports `SCARF_HERMES_HOME` to the wrong value
|
||||
/// (e.g. a test crashed mid-teardown, an env var inherited
|
||||
/// from a parent shell, a misconfigured launchctl plist).
|
||||
/// Both checks are cheap — `FileManager.fileExists` against a
|
||||
/// known path is microseconds. The override is hot but not
|
||||
/// hot-hot, so an extra stat per call is negligible.
|
||||
private static func scarfHermesHomeOverride() -> String? {
|
||||
guard let raw = ProcessInfo.processInfo.environment["SCARF_HERMES_HOME"] else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard trimmed.hasPrefix("/") else {
|
||||
logger.warning("SCARF_HERMES_HOME=\(trimmed, privacy: .public) is not absolute; ignoring.")
|
||||
return nil
|
||||
}
|
||||
let markerPath = trimmed + "/" + testHomeMarkerFilename
|
||||
guard FileManager.default.fileExists(atPath: markerPath) else {
|
||||
logger.warning("SCARF_HERMES_HOME=\(trimmed, privacy: .public) lacks sentinel marker (\(testHomeMarkerFilename, privacy: .public)); ignoring to protect real ~/.hermes.")
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/// Force a re-read on the next call, regardless of TTL. Test helper.
|
||||
public static func invalidateCache() {
|
||||
lock.withLock { $0.resolvedAt = .distantPast }
|
||||
@@ -95,15 +153,20 @@ public enum HermesProfileResolver {
|
||||
let defaultHome = defaultRootHome()
|
||||
let activeFile = defaultHome + "/active_profile"
|
||||
|
||||
// Absent file → default profile. This is the common case for users
|
||||
// who haven't run `hermes profile use ...` and shouldn't generate
|
||||
// any log noise.
|
||||
// Absent file → default profile. Common case for users who
|
||||
// haven't run `hermes profile use ...`. We still log at
|
||||
// `.info` (key=value, not warning) so support requests can
|
||||
// pull `log show … | grep ProfileResolver` and confirm the
|
||||
// resolver IS running and IS resolving to the default —
|
||||
// distinguishing "feature didn't fire" from "feature fired
|
||||
// and chose default" (issue #70).
|
||||
guard FileManager.default.fileExists(atPath: activeFile) else {
|
||||
logger.info("Resolved active Hermes profile: name=default, home=\(defaultHome, privacy: .public), source=default-no-file")
|
||||
return ("default", defaultHome)
|
||||
}
|
||||
|
||||
guard let raw = try? String(contentsOfFile: activeFile, encoding: .utf8) else {
|
||||
logger.warning("Found active_profile but could not read it; falling back to default profile.")
|
||||
logger.warning("Found active_profile but could not read it; falling back to default. home=\(defaultHome, privacy: .public)")
|
||||
return ("default", defaultHome)
|
||||
}
|
||||
|
||||
@@ -111,6 +174,7 @@ public enum HermesProfileResolver {
|
||||
|
||||
// Empty file or explicit "default" → default profile.
|
||||
if trimmed.isEmpty || trimmed == "default" {
|
||||
logger.info("Resolved active Hermes profile: name=default, home=\(defaultHome, privacy: .public), source=file-default")
|
||||
return ("default", defaultHome)
|
||||
}
|
||||
|
||||
@@ -129,7 +193,7 @@ public enum HermesProfileResolver {
|
||||
return ("default", defaultHome)
|
||||
}
|
||||
|
||||
logger.info("Resolved active Hermes profile to \(trimmed, privacy: .public) at \(profileHome, privacy: .public).")
|
||||
logger.info("Resolved active Hermes profile: name=\(trimmed, privacy: .public), home=\(profileHome, privacy: .public), source=file")
|
||||
return (trimmed, profileHome)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import Foundation
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
#if canImport(CoreImage)
|
||||
import CoreImage
|
||||
#endif
|
||||
|
||||
/// Downsamples + base64-encodes user-supplied images for ACP transport.
|
||||
///
|
||||
/// **Why downsample on the producer side.** Hermes happily forwards the
|
||||
/// bytes to a vision model, but a 12 MP screenshot at 4 MB is wasteful
|
||||
/// — it eats 5–6× more tokens than a 1024×1024 thumbnail and gives the
|
||||
/// model no extra signal. Cap the long edge at 1568 px (Anthropic's
|
||||
/// recommended max for Claude vision) and drop quality to JPEG 0.85,
|
||||
/// which keeps screenshot text crisp while landing under ~300 KB per
|
||||
/// image. The 5-image-per-message limit (chosen on the producer side)
|
||||
/// keeps the total prompt payload below ~2 MB.
|
||||
///
|
||||
/// **Why detached.** Image loading + downsampling is CPU-bound. Run only
|
||||
/// from a `Task.detached` context (the encoder type is `Sendable` and
|
||||
/// every method is `nonisolated`). The companion `ChatImageAttachment`
|
||||
/// is a Sendable value type so the result hops back to MainActor cleanly.
|
||||
public struct ImageEncoder: Sendable {
|
||||
/// Long-edge pixel cap. 1568 is Anthropic's recommended ceiling for
|
||||
/// Claude vision input — past it, the provider downsamples server-side
|
||||
/// and we just paid for the extra bytes. Tweak only with vision-model
|
||||
/// guidance from Hermes side.
|
||||
public static let maxLongEdge: CGFloat = 1568
|
||||
/// JPEG quality factor. 0.85 is the inflection point above which
|
||||
/// file size jumps quickly without obvious visual gain on screenshots
|
||||
/// or photographs.
|
||||
public static let jpegQuality: CGFloat = 0.85
|
||||
/// Long-edge cap for the inline thumbnail rendered in the composer
|
||||
/// chip. Kept under the system thumbnail size so `Image(data:)`
|
||||
/// renders without extra resampling.
|
||||
public static let thumbnailLongEdge: CGFloat = 256
|
||||
|
||||
public init() {}
|
||||
|
||||
public enum EncoderError: Error, LocalizedError {
|
||||
case unsupportedFormat
|
||||
case decodeFailed
|
||||
case encodeFailed
|
||||
case empty
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .unsupportedFormat: return "Image format not recognized"
|
||||
case .decodeFailed: return "Couldn't decode image data"
|
||||
case .encodeFailed: return "Couldn't encode image as JPEG"
|
||||
case .empty: return "Image data was empty"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode raw bytes (from a paste/drop/picker) into a wire-ready
|
||||
/// attachment. Detached-only — never call from MainActor. The
|
||||
/// originating bytes are not retained beyond this call.
|
||||
public nonisolated func encode(
|
||||
rawBytes: Data,
|
||||
sourceFilename: String? = nil
|
||||
) throws -> ChatImageAttachment {
|
||||
guard !rawBytes.isEmpty else { throw EncoderError.empty }
|
||||
|
||||
#if canImport(AppKit)
|
||||
guard let nsImage = NSImage(data: rawBytes) else { throw EncoderError.decodeFailed }
|
||||
let targetSize = Self.fittedSize(for: nsImage.size, maxLongEdge: Self.maxLongEdge)
|
||||
let mainData = try Self.jpegBytes(from: nsImage, size: targetSize)
|
||||
let thumbSize = Self.fittedSize(for: nsImage.size, maxLongEdge: Self.thumbnailLongEdge)
|
||||
let thumbData = try? Self.jpegBytes(from: nsImage, size: thumbSize)
|
||||
return ChatImageAttachment(
|
||||
mimeType: "image/jpeg",
|
||||
base64Data: mainData.base64EncodedString(),
|
||||
thumbnailBase64: thumbData?.base64EncodedString(),
|
||||
filename: sourceFilename,
|
||||
approximateByteCount: mainData.count
|
||||
)
|
||||
|
||||
#elseif canImport(UIKit)
|
||||
guard let uiImage = UIImage(data: rawBytes) else { throw EncoderError.decodeFailed }
|
||||
let targetSize = Self.fittedSize(for: uiImage.size, maxLongEdge: Self.maxLongEdge)
|
||||
let mainData = try Self.jpegBytes(from: uiImage, size: targetSize)
|
||||
let thumbSize = Self.fittedSize(for: uiImage.size, maxLongEdge: Self.thumbnailLongEdge)
|
||||
let thumbData = try? Self.jpegBytes(from: uiImage, size: thumbSize)
|
||||
return ChatImageAttachment(
|
||||
mimeType: "image/jpeg",
|
||||
base64Data: mainData.base64EncodedString(),
|
||||
thumbnailBase64: thumbData?.base64EncodedString(),
|
||||
filename: sourceFilename,
|
||||
approximateByteCount: mainData.count
|
||||
)
|
||||
|
||||
#else
|
||||
// Linux CI / unknown platforms: pass through raw bytes if the
|
||||
// input already looks like a JPEG, else refuse. Keeps the
|
||||
// package compiling without a hard AppKit/UIKit dep.
|
||||
if rawBytes.starts(with: [0xFF, 0xD8]) {
|
||||
return ChatImageAttachment(
|
||||
mimeType: "image/jpeg",
|
||||
base64Data: rawBytes.base64EncodedString(),
|
||||
thumbnailBase64: nil,
|
||||
filename: sourceFilename,
|
||||
approximateByteCount: rawBytes.count
|
||||
)
|
||||
}
|
||||
throw EncoderError.unsupportedFormat
|
||||
#endif
|
||||
}
|
||||
|
||||
nonisolated private static func fittedSize(for source: CGSize, maxLongEdge: CGFloat) -> CGSize {
|
||||
let longest = max(source.width, source.height)
|
||||
if longest <= maxLongEdge { return source }
|
||||
let scale = maxLongEdge / longest
|
||||
return CGSize(
|
||||
width: floor(source.width * scale),
|
||||
height: floor(source.height * scale)
|
||||
)
|
||||
}
|
||||
|
||||
#if canImport(AppKit)
|
||||
nonisolated private static func jpegBytes(from image: NSImage, size: CGSize) throws -> Data {
|
||||
let resized = NSImage(size: size)
|
||||
resized.lockFocus()
|
||||
NSGraphicsContext.current?.imageInterpolation = .high
|
||||
image.draw(
|
||||
in: CGRect(origin: .zero, size: size),
|
||||
from: .zero,
|
||||
operation: .copy,
|
||||
fraction: 1.0
|
||||
)
|
||||
resized.unlockFocus()
|
||||
guard let tiff = resized.tiffRepresentation,
|
||||
let rep = NSBitmapImageRep(data: tiff),
|
||||
let data = rep.representation(
|
||||
using: .jpeg,
|
||||
properties: [.compressionFactor: jpegQuality]
|
||||
)
|
||||
else {
|
||||
throw EncoderError.encodeFailed
|
||||
}
|
||||
return data
|
||||
}
|
||||
#elseif canImport(UIKit)
|
||||
nonisolated private static func jpegBytes(from image: UIImage, size: CGSize) throws -> Data {
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 1
|
||||
format.opaque = true
|
||||
let renderer = UIGraphicsImageRenderer(size: size, format: format)
|
||||
let resized = renderer.image { _ in
|
||||
image.draw(in: CGRect(origin: .zero, size: size))
|
||||
}
|
||||
guard let data = resized.jpegData(compressionQuality: jpegQuality) else {
|
||||
throw EncoderError.encodeFailed
|
||||
}
|
||||
return data
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -169,6 +169,19 @@ public struct ModelCatalogService: Sendable {
|
||||
Self.overlayOnlyProviders[providerID]
|
||||
}
|
||||
|
||||
/// Async wrapper around `loadProviders()` for use from MainActor view
|
||||
/// code. The sync method does a transport-backed file read that on a
|
||||
/// remote SSH context can take 1–2 minutes (ControlMaster setup +
|
||||
/// pulling the multi-megabyte models.dev JSON), and on local contexts
|
||||
/// still parses ~1500 models — both unsuitable for the main thread.
|
||||
/// Issue #59. Existing call sites (tests, any non-View consumers)
|
||||
/// can keep using the sync method.
|
||||
public nonisolated func loadProvidersAsync() async -> [HermesProviderInfo] {
|
||||
await Task.detached { [self] in
|
||||
self.loadProviders()
|
||||
}.value
|
||||
}
|
||||
|
||||
/// Models for one provider, sorted by release date (newest first), then name.
|
||||
public func loadModels(for providerID: String) -> [HermesModelInfo] {
|
||||
guard let catalog = loadCatalog(), let provider = catalog[providerID] else { return [] }
|
||||
@@ -198,6 +211,17 @@ public struct ModelCatalogService: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Async wrapper around `loadModels(for:)`. Same rationale as
|
||||
/// `loadProvidersAsync()` — the View call site that fires on every
|
||||
/// provider-switch click in the picker sheet was reading the catalog
|
||||
/// synchronously on the MainActor, freezing the UI on remote contexts.
|
||||
/// Issue #59.
|
||||
public nonisolated func loadModelsAsync(for providerID: String) async -> [HermesModelInfo] {
|
||||
await Task.detached { [self] in
|
||||
self.loadModels(for: providerID)
|
||||
}.value
|
||||
}
|
||||
|
||||
/// Find the provider that ships a given model ID. Useful for auto-syncing
|
||||
/// provider when the user picks a model from a flat list or types one in.
|
||||
public func provider(for modelID: String) -> HermesProviderInfo? {
|
||||
@@ -401,15 +425,17 @@ public struct ModelCatalogService: Sendable {
|
||||
|
||||
// MARK: - Hermes overlay providers
|
||||
|
||||
/// The six providers Hermes surfaces via `hermes model` that have no
|
||||
/// The 11 providers Hermes surfaces via `hermes model` that have no
|
||||
/// entry in `models_dev_cache.json` (models.dev doesn't mirror them).
|
||||
/// Mirrors the overlay-only subset of `HERMES_OVERLAYS` in
|
||||
/// `hermes-agent/hermes_cli/providers.py`. The other ~19 overlay entries
|
||||
/// `hermes-agent/hermes_cli/providers.py`. The other overlay entries
|
||||
/// already ship in the cache and only add augmentation (base-URL
|
||||
/// override, extra env vars) that Scarf doesn't currently display.
|
||||
///
|
||||
/// Keep this in sync with the Python side on Hermes version bumps.
|
||||
static let overlayOnlyProviders: [String: HermesProviderOverlay] = [
|
||||
/// Keep this in sync with the Python side on Hermes version bumps —
|
||||
/// see `ToolGatewayTests.v012OverlayProvidersCarryCorrectAuthTypes`
|
||||
/// for the auth-type lock-in.
|
||||
public static let overlayOnlyProviders: [String: HermesProviderOverlay] = [
|
||||
"nous": HermesProviderOverlay(
|
||||
displayName: "Nous Portal",
|
||||
baseURL: "https://inference-api.nousresearch.com/v1",
|
||||
@@ -452,6 +478,53 @@ public struct ModelCatalogService: Sendable {
|
||||
subscriptionGated: false,
|
||||
docURL: nil
|
||||
),
|
||||
// -- v0.12 additions ---------------------------------------------
|
||||
// Hermes v2026.4.30 added five overlay-only providers that
|
||||
// models.dev doesn't mirror. Provider IDs match HERMES_OVERLAYS
|
||||
// verbatim — drift here means the picker can't reach them.
|
||||
"gmi": HermesProviderOverlay(
|
||||
displayName: "GMI Cloud",
|
||||
baseURL: "https://api.gmi-serving.com/v1",
|
||||
authType: .apiKey,
|
||||
subscriptionGated: false,
|
||||
docURL: nil
|
||||
),
|
||||
"azure-foundry": HermesProviderOverlay(
|
||||
displayName: "Azure AI Foundry",
|
||||
// Base URL is per-tenant — Hermes resolves it from the
|
||||
// AZURE_FOUNDRY_BASE_URL env var at runtime. Leave nil so the
|
||||
// settings UI shows "Tenant URL — set via env" instead of a
|
||||
// misleading default.
|
||||
baseURL: nil,
|
||||
authType: .apiKey,
|
||||
subscriptionGated: false,
|
||||
docURL: nil
|
||||
),
|
||||
"lmstudio": HermesProviderOverlay(
|
||||
displayName: "LM Studio",
|
||||
// v0.12 promotes LM Studio from custom-endpoint alias to a
|
||||
// first-class provider. 1234 is the LM Studio default port;
|
||||
// users with a non-default port set LM_BASE_URL.
|
||||
baseURL: "http://127.0.0.1:1234/v1",
|
||||
authType: .apiKey,
|
||||
subscriptionGated: false,
|
||||
docURL: nil
|
||||
),
|
||||
"minimax-oauth": HermesProviderOverlay(
|
||||
displayName: "MiniMax (OAuth)",
|
||||
baseURL: "https://api.minimax.io/anthropic",
|
||||
authType: .oauthExternal,
|
||||
subscriptionGated: false,
|
||||
docURL: nil
|
||||
),
|
||||
"tencent-tokenhub": HermesProviderOverlay(
|
||||
displayName: "Tencent TokenHub",
|
||||
// Resolved from TOKENHUB_BASE_URL at runtime.
|
||||
baseURL: nil,
|
||||
authType: .apiKey,
|
||||
subscriptionGated: false,
|
||||
docURL: nil
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
/// Pre-flight check used before opening an ACP session. Hermes resolves the
|
||||
/// model+provider from `config.yaml` at session boot; on a fresh install that
|
||||
/// file is missing or has neither key set, and the chat fails with an opaque
|
||||
/// "Model parameter is required" 400 from the upstream provider only after the
|
||||
/// user has typed a prompt and hit send. Catching the missing config here lets
|
||||
/// the UI surface a real "pick a model" sheet before any ACP work starts.
|
||||
///
|
||||
/// `HermesConfig.empty` (returned on read failure) and the YAML parser's
|
||||
/// missing-key fallback both use the literal string `"unknown"`, so the check
|
||||
/// has to treat `""` and `"unknown"` as equivalent. Anything else is
|
||||
/// considered configured — we don't try to validate the model against the
|
||||
/// provider's catalog here; that happens later in `ModelPickerSheet`.
|
||||
public enum ModelPreflight: Sendable {
|
||||
public enum Result: Equatable, Sendable {
|
||||
case configured
|
||||
case missingModel
|
||||
case missingProvider
|
||||
case missingBoth
|
||||
|
||||
public var isConfigured: Bool {
|
||||
self == .configured
|
||||
}
|
||||
|
||||
/// Short user-facing reason. Long enough to be honest, short enough
|
||||
/// for a sheet header — full messaging belongs to the picker UI.
|
||||
public var reason: String {
|
||||
switch self {
|
||||
case .configured: return ""
|
||||
case .missingModel: return "No primary model is set in this server's config."
|
||||
case .missingProvider:return "No primary provider is set in this server's config."
|
||||
case .missingBoth: return "No model is configured on this server yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Treat `""` and the YAML parser's `"unknown"` fallback as missing.
|
||||
/// Trim whitespace so a stray newline in a hand-edited config.yaml
|
||||
/// doesn't read as "configured."
|
||||
public static func check(_ config: HermesConfig) -> Result {
|
||||
let modelMissing = isUnset(config.model)
|
||||
let providerMissing = isUnset(config.provider)
|
||||
switch (modelMissing, providerMissing) {
|
||||
case (true, true): return .missingBoth
|
||||
case (true, false): return .missingModel
|
||||
case (false, true): return .missingProvider
|
||||
case (false, false): return .configured
|
||||
}
|
||||
}
|
||||
|
||||
private static func isUnset(_ value: String) -> Bool {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
return trimmed.isEmpty || trimmed == "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// One Nous Portal model as exposed by `GET /v1/models`. The shape
|
||||
/// mirrors the OpenAI-compatible response schema — Nous's inference
|
||||
/// API uses the same envelope. Optional fields stay optional because
|
||||
/// not every entry includes them; `id` is the only field we strictly
|
||||
/// need (it's what Hermes passes through to the provider).
|
||||
public struct NousModel: Codable, Equatable, Sendable, Identifiable {
|
||||
public let id: String
|
||||
public let owned_by: String?
|
||||
public let created: Int?
|
||||
/// Free-text description if the API ships one. Nous's current
|
||||
/// catalog doesn't include this, but the field is here so future
|
||||
/// shape changes don't drop user-visible context on the floor.
|
||||
public let description: String?
|
||||
|
||||
public init(id: String, owned_by: String? = nil, created: Int? = nil, description: String? = nil) {
|
||||
self.id = id
|
||||
self.owned_by = owned_by
|
||||
self.created = created
|
||||
self.description = description
|
||||
}
|
||||
}
|
||||
|
||||
/// On-disk cache shape. Versioned so a future schema change can lift
|
||||
/// stale caches gracefully — bump `version` and the loader rejects
|
||||
/// anything older without trying to migrate. Stored as JSON next to
|
||||
/// the projects registry so a Hermes wipe takes it with the rest of
|
||||
/// the Scarf-owned state.
|
||||
public struct NousModelsCache: Codable, Sendable {
|
||||
public static let currentVersion = 1
|
||||
public let version: Int
|
||||
public let fetchedAt: Date
|
||||
public let models: [NousModel]
|
||||
|
||||
public init(version: Int = NousModelsCache.currentVersion, fetchedAt: Date, models: [NousModel]) {
|
||||
self.version = version
|
||||
self.fetchedAt = fetchedAt
|
||||
self.models = models
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a `loadModels` call. Distinguishes "fetched fresh from
|
||||
/// the API" from "cache served, network failed" so the picker UI can
|
||||
/// surface a "could not refresh" hint without hiding the cached list.
|
||||
public enum NousModelsLoadResult: Sendable {
|
||||
case fresh(models: [NousModel], fetchedAt: Date)
|
||||
case cache(models: [NousModel], fetchedAt: Date, refreshError: String?)
|
||||
case fallback(models: [NousModel], reason: String)
|
||||
}
|
||||
|
||||
/// Fetches + caches the list of available Nous Portal models. Runs in
|
||||
/// the Scarf process (not on the remote), authenticated with the
|
||||
/// bearer token from `~/.hermes/auth.json` on the active server —
|
||||
/// `NousSubscriptionService` reads that file via the active transport,
|
||||
/// so a remote droplet's token comes back over SSH and the network
|
||||
/// call to Nous still happens from the user's Mac. That's correct:
|
||||
/// we want the model list visible whenever the user has subscription
|
||||
/// credentials, regardless of where Hermes will eventually run the
|
||||
/// chat from.
|
||||
public struct NousModelCatalogService: Sendable {
|
||||
public static let baseURL = URL(string: "https://inference-api.nousresearch.com/v1/models")!
|
||||
public static let cacheTTL: TimeInterval = 24 * 60 * 60 // 24h
|
||||
public static let requestTimeout: TimeInterval = 10 // seconds
|
||||
|
||||
/// Hard-coded fallback for offline-with-no-cache. Short on purpose
|
||||
/// — only the canonical Hermes models (the family the user is most
|
||||
/// likely to want) plus a reminder that fresh data is one
|
||||
/// successful refresh away. Update when Nous releases a new
|
||||
/// flagship; deliberately not exhaustive — the API is the source
|
||||
/// of truth, this just keeps the picker non-empty.
|
||||
public static let fallbackModels: [NousModel] = [
|
||||
NousModel(id: "Hermes-3-Llama-3.1-405B"),
|
||||
NousModel(id: "Hermes-3-Llama-3.1-70B"),
|
||||
NousModel(id: "Hermes-3-Llama-3.1-8B"),
|
||||
NousModel(id: "DeepHermes-3-Llama-3-8B-Preview")
|
||||
]
|
||||
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "NousModelCatalogService")
|
||||
|
||||
public let context: ServerContext
|
||||
private let session: URLSession
|
||||
private let cachePath: String
|
||||
|
||||
public init(context: ServerContext, session: URLSession = .shared) {
|
||||
self.context = context
|
||||
self.session = session
|
||||
self.cachePath = context.paths.nousModelsCache
|
||||
}
|
||||
|
||||
// MARK: - Cache I/O
|
||||
|
||||
/// Read the cache via the active transport (so a remote droplet's
|
||||
/// cache lands on the droplet, not the user's Mac). Missing or
|
||||
/// malformed cache → nil; the loader treats that as "no cache" and
|
||||
/// kicks off a fresh fetch.
|
||||
public func readCache() -> NousModelsCache? {
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(cachePath) else { return nil }
|
||||
do {
|
||||
let data = try transport.readFile(cachePath)
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let cache = try decoder.decode(NousModelsCache.self, from: data)
|
||||
guard cache.version == NousModelsCache.currentVersion else {
|
||||
Self.logger.info("nous models cache schema mismatch (got v\(cache.version), expected v\(NousModelsCache.currentVersion)); ignoring")
|
||||
return nil
|
||||
}
|
||||
return cache
|
||||
} catch {
|
||||
Self.logger.warning("couldn't decode nous models cache: \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func writeCache(_ cache: NousModelsCache) {
|
||||
let transport = context.makeTransport()
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(cache)
|
||||
// Make sure the parent dir exists — fresh remote installs
|
||||
// may not yet have `~/.hermes/scarf/`. mkdir -p is cheap
|
||||
// and idempotent on both transports.
|
||||
let parent = (cachePath as NSString).deletingLastPathComponent
|
||||
if !parent.isEmpty {
|
||||
try? transport.createDirectory(parent)
|
||||
}
|
||||
try transport.writeFile(cachePath, data: data)
|
||||
} catch {
|
||||
Self.logger.warning("couldn't write nous models cache: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
public func isCacheStale(_ cache: NousModelsCache) -> Bool {
|
||||
Date().timeIntervalSince(cache.fetchedAt) > Self.cacheTTL
|
||||
}
|
||||
|
||||
// MARK: - Network fetch
|
||||
|
||||
/// Read the bearer token from `auth.json` on the active server.
|
||||
/// Returns nil when the user isn't signed in to Nous, in which
|
||||
/// case `loadModels` skips the network call and falls through to
|
||||
/// cache or fallback.
|
||||
private func bearerToken() -> String? {
|
||||
// The subscription service already checks for `present`; we
|
||||
// re-read the raw token here because we need the actual string,
|
||||
// not just a Bool. Mirrors the SubscriptionService parse path.
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(context.paths.authJSON) else { return nil }
|
||||
guard let data = try? transport.readFile(context.paths.authJSON) else { return nil }
|
||||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
let providers = root["providers"] as? [String: Any] ?? [:]
|
||||
let nous = providers["nous"] as? [String: Any]
|
||||
let token = nous?["access_token"] as? String
|
||||
guard let token, !token.isEmpty else { return nil }
|
||||
return token
|
||||
}
|
||||
|
||||
/// Make the API call. Times out after `requestTimeout` so a hung
|
||||
/// network doesn't block the picker indefinitely. Returns the raw
|
||||
/// `[NousModel]` on success, throws on any HTTP / decode error so
|
||||
/// the caller can log + fall back.
|
||||
public func fetchModels() async throws -> [NousModel] {
|
||||
guard let token = bearerToken() else {
|
||||
throw NousModelCatalogError.notAuthenticated
|
||||
}
|
||||
var request = URLRequest(url: Self.baseURL)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = Self.requestTimeout
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw NousModelCatalogError.transport("non-HTTP response")
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw NousModelCatalogError.http(status: http.statusCode)
|
||||
}
|
||||
struct Envelope: Decodable { let data: [NousModel] }
|
||||
let envelope = try JSONDecoder().decode(Envelope.self, from: data)
|
||||
return envelope.data
|
||||
}
|
||||
|
||||
// MARK: - Public entry
|
||||
|
||||
/// Top-level "give me models" entry point. Cache-first: serve from
|
||||
/// cache if fresh, fetch + write through if stale or empty, fall
|
||||
/// back to the hard-coded list when both fail. The caller renders
|
||||
/// based on the case so it can show a "could not refresh" hint
|
||||
/// next to a stale-but-still-useful list.
|
||||
public func loadModels(forceRefresh: Bool = false) async -> NousModelsLoadResult {
|
||||
let cached = readCache()
|
||||
|
||||
if let cached, !forceRefresh, !isCacheStale(cached) {
|
||||
return .cache(models: cached.models, fetchedAt: cached.fetchedAt, refreshError: nil)
|
||||
}
|
||||
|
||||
do {
|
||||
let models = try await fetchModels()
|
||||
let now = Date()
|
||||
writeCache(NousModelsCache(fetchedAt: now, models: models))
|
||||
return .fresh(models: models, fetchedAt: now)
|
||||
} catch let error as NousModelCatalogError {
|
||||
// Fetch failed but we may still have *something* useful.
|
||||
if let cached {
|
||||
return .cache(
|
||||
models: cached.models,
|
||||
fetchedAt: cached.fetchedAt,
|
||||
refreshError: error.userMessage
|
||||
)
|
||||
}
|
||||
return .fallback(models: Self.fallbackModels, reason: error.userMessage)
|
||||
} catch {
|
||||
if let cached {
|
||||
return .cache(
|
||||
models: cached.models,
|
||||
fetchedAt: cached.fetchedAt,
|
||||
refreshError: error.localizedDescription
|
||||
)
|
||||
}
|
||||
return .fallback(models: Self.fallbackModels, reason: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum NousModelCatalogError: Error, Sendable {
|
||||
case notAuthenticated
|
||||
case http(status: Int)
|
||||
case transport(String)
|
||||
|
||||
public var userMessage: String {
|
||||
switch self {
|
||||
case .notAuthenticated:
|
||||
return "Sign in to Nous Portal to fetch the latest model list."
|
||||
case .http(let status) where status == 401:
|
||||
return "Nous rejected the saved token (401). Sign in again."
|
||||
case .http(let status):
|
||||
return "Nous returned HTTP \(status)."
|
||||
case .transport(let detail):
|
||||
return "Couldn't reach Nous: \(detail)."
|
||||
}
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import Foundation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// Detects when a registered project directory contains its own `.hermes/`
|
||||
/// subdirectory. Hermes' CLI uses the closest `.hermes/` as `$HERMES_HOME`
|
||||
/// when invoked from inside such a directory, which **shadows** the user's
|
||||
/// global Hermes home — credentials, config, sessions, skills, memories
|
||||
/// all bind to the project-local copy without warning.
|
||||
///
|
||||
/// This causes confusing failure modes: the user runs `hermes auth add nous`
|
||||
/// during setup expecting a global registration, but if their cwd happens to
|
||||
/// be inside a project that already has a `.hermes/` (e.g. seeded by a
|
||||
/// previous workflow, copied from another machine, or checked into git),
|
||||
/// Hermes writes the credentials to the project-local `.hermes/auth.json`.
|
||||
/// Scarf then reads the global path on every dashboard tick and shows
|
||||
/// "missing provider" warnings even though the user did sign in successfully.
|
||||
///
|
||||
/// The detector enumerates the registered projects on a given server and
|
||||
/// reports which ones carry a shadowing `.hermes/`. Views surface a yellow
|
||||
/// banner so the user can consolidate.
|
||||
public struct ProjectHermesShadowDetector: Sendable {
|
||||
public struct Shadow: Sendable, Hashable, Identifiable {
|
||||
public var id: String { projectPath }
|
||||
/// Project name from the registry (`ProjectEntry.name`).
|
||||
public let projectName: String
|
||||
/// Absolute path to the project on the target server.
|
||||
public let projectPath: String
|
||||
/// Absolute path to the shadowing `.hermes/` directory.
|
||||
public let shadowPath: String
|
||||
/// `true` when the shadow `.hermes/auth.json` exists. Strong signal
|
||||
/// that user credentials are landing in the wrong place.
|
||||
public let hasAuthJSON: Bool
|
||||
/// `true` when the shadow `.hermes/state.db` exists. Hermes wrote
|
||||
/// session state to the project-local home — the user's chat
|
||||
/// history is invisible to Scarf's global Dashboard for this slice.
|
||||
public let hasStateDB: Bool
|
||||
|
||||
public init(
|
||||
projectName: String,
|
||||
projectPath: String,
|
||||
shadowPath: String,
|
||||
hasAuthJSON: Bool,
|
||||
hasStateDB: Bool
|
||||
) {
|
||||
self.projectName = projectName
|
||||
self.projectPath = projectPath
|
||||
self.shadowPath = shadowPath
|
||||
self.hasAuthJSON = hasAuthJSON
|
||||
self.hasStateDB = hasStateDB
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(os)
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectHermesShadowDetector")
|
||||
#endif
|
||||
|
||||
private let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
|
||||
public init(context: ServerContext) {
|
||||
self.context = context
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
/// Probe every project in `projects` for a shadowing `.hermes/`. Skips
|
||||
/// archived projects and projects whose absolute path equals the
|
||||
/// resolved Hermes home (rare but possible — a project literally
|
||||
/// rooted at `~/.hermes` shouldn't trigger a self-warning).
|
||||
public func detect(in projects: [ProjectEntry]) async -> [Shadow] {
|
||||
let hermesHome = await context.resolvedUserHome() + "/.hermes"
|
||||
var found: [Shadow] = []
|
||||
for project in projects where !project.archived {
|
||||
// A project nested inside the Hermes home itself is a weird
|
||||
// edge case (someone made `~/.hermes/notes` a Scarf project).
|
||||
// The project is BELOW the Hermes home, so its `.hermes` is
|
||||
// the same dir as `~/.hermes/.hermes` — almost certainly not
|
||||
// present and definitely not a shadow.
|
||||
if project.path.hasPrefix(hermesHome) { continue }
|
||||
let shadowPath = project.path + "/.hermes"
|
||||
guard transport.fileExists(shadowPath) else { continue }
|
||||
// It's only a shadow if the path is a directory; a stray
|
||||
// `.hermes` file would be filtered out here.
|
||||
guard transport.stat(shadowPath)?.isDirectory == true else { continue }
|
||||
let hasAuth = transport.fileExists(shadowPath + "/auth.json")
|
||||
let hasDB = transport.fileExists(shadowPath + "/state.db")
|
||||
#if canImport(os)
|
||||
Self.logger.warning(
|
||||
"Detected shadow Hermes home at \(shadowPath, privacy: .public) (auth: \(hasAuth), state.db: \(hasDB))"
|
||||
)
|
||||
#endif
|
||||
found.append(Shadow(
|
||||
projectName: project.name,
|
||||
projectPath: project.path,
|
||||
shadowPath: shadowPath,
|
||||
hasAuthJSON: hasAuth,
|
||||
hasStateDB: hasDB
|
||||
))
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/// Suggested shell one-liner that consolidates a project shadow into
|
||||
/// the global Hermes home AND clears the warning on the next
|
||||
/// refresh. Two ordered steps:
|
||||
///
|
||||
/// 1. Copy `auth.json` into the global home (only when present).
|
||||
/// Hermes credentials live in this single file; preserving them
|
||||
/// is the load-bearing part of "consolidate" — every other
|
||||
/// project-local file is either replaceable or scoped to the
|
||||
/// project anyway.
|
||||
/// 2. Rename the project-local `.hermes/` to
|
||||
/// `.hermes.scarf-bak.<UTC-stamp>/`. Hermes' CLI stops seeing it
|
||||
/// as `$HERMES_HOME` (it scans for a dir literally named
|
||||
/// `.hermes`), so the global home wins from now on. The
|
||||
/// user's project-local data — `state.db`, `sessions/`,
|
||||
/// `skills/` — survives untouched in the renamed folder, so
|
||||
/// they can inspect/recover/delete it later without us making
|
||||
/// that decision for them.
|
||||
///
|
||||
/// **Why not delete instead of rename.** A project's shadow can
|
||||
/// hold uncommitted session history the user hasn't audited yet.
|
||||
/// `rm -rf` would be unrecoverable; the rename keeps everything
|
||||
/// addressable while still removing the shadow effect. The user
|
||||
/// can delete the `.bak` once they're confident.
|
||||
///
|
||||
/// Returns a single shell line, suitable for the user to paste
|
||||
/// into a remote terminal. The rename uses `date -u +%Y%m%d-%H%M%S`
|
||||
/// for a deterministic UTC suffix so two consecutive consolidations
|
||||
/// don't collide on the same second.
|
||||
public static func consolidationCommand(for shadow: Shadow, hermesHome: String) -> String? {
|
||||
var parts: [String] = []
|
||||
if shadow.hasAuthJSON {
|
||||
parts.append("mkdir -p \(shellQuote(hermesHome))")
|
||||
parts.append("cp \(shellQuote(shadow.shadowPath + "/auth.json")) \(shellQuote(hermesHome + "/auth.json"))")
|
||||
parts.append("chmod 600 \(shellQuote(hermesHome + "/auth.json"))")
|
||||
}
|
||||
// The rename is unconditional: even shadows without auth.json
|
||||
// still bind as $HERMES_HOME and need to move out of the way.
|
||||
// `$(date -u +%Y%m%d-%H%M%S)` runs on the remote shell when
|
||||
// the user pastes the command, producing the timestamp at
|
||||
// exec time rather than at command-construction time.
|
||||
parts.append("mv \(shellQuote(shadow.shadowPath)) \(shellQuote(shadow.shadowPath))\".scarf-bak.$(date -u +%Y%m%d-%H%M%S)\"")
|
||||
return parts.joined(separator: " && ")
|
||||
}
|
||||
|
||||
/// Single-quote a path for embedding in a `bash -c '…'` string.
|
||||
/// POSIX-safe single quotes with escape for embedded quotes
|
||||
/// (`'` → `'\\''`). Matches the convention in
|
||||
/// `RemoteBackupService.shellQuote`.
|
||||
private static func shellQuote(_ s: String) -> String {
|
||||
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// Streams a Hermes home + project trees off a (local or remote) server
|
||||
/// into a single `.scarfbackup` archive on disk.
|
||||
///
|
||||
/// **Why not just run `hermes backup`.** Hermes's CLI captures `~/.hermes/`
|
||||
/// only; project file trees (the user's actual code) live outside that
|
||||
/// home and aren't included. A "rebuild this droplet from scratch" flow
|
||||
/// needs both. This service does both — Hermes home as one inner tarball,
|
||||
/// each registered project as its own — and writes a manifest pinning the
|
||||
/// source server, hermes version, and per-tarball SHA-256s so restore can
|
||||
/// detect corruption before it half-extracts.
|
||||
///
|
||||
/// **Memory profile.** Tarballs stream over SSH (`tar -czf -`) and into
|
||||
/// disk-backed temp files chunk-by-chunk via `streamRawBytes`. We never
|
||||
/// hold a multi-GB buffer in RAM. The final ZIP step shells out to
|
||||
/// `/usr/bin/zip`, which also streams from disk.
|
||||
///
|
||||
/// **Cleanup.** The temp dir lives under
|
||||
/// `FileManager.default.temporaryDirectory` and is removed on every exit
|
||||
/// path (success, failure, cancellation) via `defer`.
|
||||
public final class RemoteBackupService: @unchecked Sendable {
|
||||
#if canImport(os)
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "RemoteBackupService")
|
||||
#endif
|
||||
|
||||
public let context: ServerContext
|
||||
|
||||
public init(context: ServerContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// Coarse stages the UI binds to. The service publishes one of these
|
||||
/// per meaningful state change so a progress sheet can render
|
||||
/// "Archiving Hermes home — 412 MB so far" without polling.
|
||||
public enum Progress: Sendable, Equatable {
|
||||
case preflight
|
||||
case checkpointingDB
|
||||
case archivingHermes(bytesWritten: Int64)
|
||||
case archivingProject(name: String, bytesWritten: Int64)
|
||||
case bundling
|
||||
case finalizing
|
||||
}
|
||||
|
||||
public enum BackupError: Error, LocalizedError {
|
||||
case preflightFailed(String)
|
||||
case remoteCommandFailed(String)
|
||||
case localIO(String)
|
||||
case zipFailed(String)
|
||||
case cancelled
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .preflightFailed(let m): return "Backup preflight failed: \(m)"
|
||||
case .remoteCommandFailed(let m): return "Remote command failed during backup: \(m)"
|
||||
case .localIO(let m): return "Local file I/O failed during backup: \(m)"
|
||||
case .zipFailed(let m): return "Couldn't assemble the backup archive: \(m)"
|
||||
case .cancelled: return "Backup cancelled."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the UI displays before any archiving starts. Populated by
|
||||
/// `preflight()` so the user can see (and confirm) total size +
|
||||
/// project count + hermes version before committing 4 minutes of
|
||||
/// SSH traffic.
|
||||
public struct PreflightSummary: Sendable, Equatable {
|
||||
public var hermesVersion: String?
|
||||
public var hermesHomePath: String
|
||||
public var hermesHomeBytes: Int64?
|
||||
public var projects: [ProjectSummary]
|
||||
public var sqliteAvailable: Bool
|
||||
|
||||
public struct ProjectSummary: Sendable, Equatable {
|
||||
public var id: String
|
||||
public var name: String
|
||||
public var path: String
|
||||
public var sizeBytes: Int64?
|
||||
public var reachable: Bool
|
||||
}
|
||||
|
||||
public var totalSizeBytes: Int64? {
|
||||
let parts: [Int64] = [hermesHomeBytes ?? 0] + projects.compactMap { $0.sizeBytes }
|
||||
let sum = parts.reduce(0, +)
|
||||
return sum > 0 ? sum : nil
|
||||
}
|
||||
}
|
||||
|
||||
public struct BackupResult: Sendable {
|
||||
public var manifest: BackupManifest
|
||||
public var archiveURL: URL
|
||||
public var archiveSize: Int64
|
||||
}
|
||||
|
||||
/// Probe the remote (or local) before committing to the full
|
||||
/// archive. Cheap — three short SSH calls and one file read. Safe
|
||||
/// to call repeatedly; nothing is mutated on the source side.
|
||||
public func preflight() async throws -> PreflightSummary {
|
||||
let transport = context.makeTransport()
|
||||
|
||||
// 1. Resolve $HOME so the absolute paths in the manifest are
|
||||
// canonical (e.g. `/home/alan/.hermes`, not the
|
||||
// `~`-prefixed `HermesPathSet.home`).
|
||||
let homeResult = try transport.runProcess(
|
||||
executable: "/bin/bash",
|
||||
args: ["-lc", "echo \"$HOME\""],
|
||||
stdin: nil,
|
||||
timeout: 30
|
||||
)
|
||||
guard homeResult.exitCode == 0 else {
|
||||
throw BackupError.preflightFailed("Couldn't resolve remote $HOME (exit \(homeResult.exitCode)): \(homeResult.stderrString)")
|
||||
}
|
||||
let resolvedHome = homeResult.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// 2. Hermes version. Optional — older builds may not implement
|
||||
// `--version`. Empty/missing isn't fatal; the manifest just
|
||||
// won't carry a version stamp.
|
||||
let versionResult = try? transport.runProcess(
|
||||
executable: "/bin/bash",
|
||||
args: ["-lc", "hermes --version 2>/dev/null || true"],
|
||||
stdin: nil,
|
||||
timeout: 30
|
||||
)
|
||||
let hermesVersion: String? = {
|
||||
guard let r = versionResult, r.exitCode == 0 else { return nil }
|
||||
let trimmed = r.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}()
|
||||
|
||||
// 3. Hermes home size + canonical path. `context.paths.home`
|
||||
// can be `~/.hermes` for remotes that didn't pin
|
||||
// `SSHConfig.remoteHome`; tar doesn't expand `~`, so we
|
||||
// resolve every path against the just-fetched $HOME
|
||||
// BEFORE storing it in the summary. `tar -C '~'` would
|
||||
// fail with "No such file or directory" otherwise (and
|
||||
// `du -sb '~/.hermes' 2>/dev/null` swallows the same
|
||||
// error silently — that's why preflight looked green).
|
||||
let hermesHome = Self.expandTilde(context.paths.home, home: resolvedHome)
|
||||
let hermesSize = Self.estimateBytes(transport: transport, path: hermesHome)
|
||||
|
||||
// 4. Enumerate projects via the existing transport-aware
|
||||
// service. Empty registry → empty list, not an error.
|
||||
// Same tilde expansion as above so project paths stored
|
||||
// in `~/.hermes/scarf/projects.json` with `~/projects/foo`
|
||||
// don't blow up later in `tar -C`.
|
||||
let registry = ProjectDashboardService(context: context).loadRegistry()
|
||||
var projectSummaries: [PreflightSummary.ProjectSummary] = []
|
||||
for project in registry.projects where !project.archived {
|
||||
let expanded = Self.expandTilde(project.path, home: resolvedHome)
|
||||
let reachable = transport.fileExists(expanded)
|
||||
let bytes = reachable ? Self.estimateBytes(transport: transport, path: expanded) : nil
|
||||
projectSummaries.append(PreflightSummary.ProjectSummary(
|
||||
id: project.path, // path is the registry's stable handle
|
||||
name: project.name,
|
||||
path: expanded,
|
||||
sizeBytes: bytes,
|
||||
reachable: reachable
|
||||
))
|
||||
}
|
||||
|
||||
// 5. Is `sqlite3` on PATH? Drives the WAL-checkpoint toggle.
|
||||
// Missing → we still archive, just without quiescing.
|
||||
let sqliteCheck = try? transport.runProcess(
|
||||
executable: "/bin/bash",
|
||||
args: ["-lc", "command -v sqlite3 >/dev/null 2>&1 && echo yes || echo no"],
|
||||
stdin: nil,
|
||||
timeout: 30
|
||||
)
|
||||
let sqliteAvailable = sqliteCheck?.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) == "yes"
|
||||
|
||||
return PreflightSummary(
|
||||
hermesVersion: hermesVersion,
|
||||
hermesHomePath: hermesHome,
|
||||
hermesHomeBytes: hermesSize,
|
||||
projects: projectSummaries,
|
||||
sqliteAvailable: sqliteAvailable
|
||||
)
|
||||
}
|
||||
|
||||
/// Replace a leading `~` or `~/` with the resolved remote home.
|
||||
/// Tar (and most non-shell tools) don't expand tildes — only the
|
||||
/// shell does, and we deliberately single-quote paths in the
|
||||
/// command string for whitespace-safety, which then suppresses
|
||||
/// shell expansion. So we expand here, in Swift, with a
|
||||
/// known-good `$HOME` value.
|
||||
static func expandTilde(_ path: String, home: String) -> String {
|
||||
guard !home.isEmpty else { return path }
|
||||
if path == "~" { return home }
|
||||
if path.hasPrefix("~/") { return home + String(path.dropFirst(1)) }
|
||||
return path
|
||||
}
|
||||
|
||||
/// Run the full backup: stream Hermes home + each project tarball,
|
||||
/// build the manifest, ZIP everything into `archiveURL`. Caller
|
||||
/// holds the `Task` and can cancel; cooperative checks fire between
|
||||
/// stages.
|
||||
public func run(
|
||||
preflight: PreflightSummary,
|
||||
options: BackupManifest.Options,
|
||||
archiveURL: URL,
|
||||
progress: @Sendable @escaping (Progress) -> Void
|
||||
) async throws -> BackupResult {
|
||||
let transport = context.makeTransport()
|
||||
|
||||
let workDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("scarf-backup-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: workDir) }
|
||||
|
||||
try Task.checkCancellation()
|
||||
progress(.preflight)
|
||||
|
||||
// Stage 1: WAL checkpoint (best effort). Build the state.db
|
||||
// path from the already-expanded hermesHomePath rather than
|
||||
// `context.paths.stateDB`, which can still carry a literal
|
||||
// `~` for remotes that didn't pin `remoteHome` — sqlite3
|
||||
// would fail to open the file and leave the WAL un-flushed.
|
||||
var checkpointed = false
|
||||
if options.checkpointedWAL && preflight.sqliteAvailable {
|
||||
progress(.checkpointingDB)
|
||||
let stateDB = preflight.hermesHomePath + "/state.db"
|
||||
let cmd = "sqlite3 \(Self.shellQuote(stateDB)) 'PRAGMA wal_checkpoint(TRUNCATE);' || true"
|
||||
let result = try? transport.runProcess(
|
||||
executable: "/bin/bash",
|
||||
args: ["-lc", cmd],
|
||||
stdin: nil,
|
||||
timeout: 60
|
||||
)
|
||||
checkpointed = (result?.exitCode == 0)
|
||||
}
|
||||
|
||||
// Stage 2: Hermes home tarball.
|
||||
try Task.checkCancellation()
|
||||
let hermesTarball = workDir.appendingPathComponent("hermes.tar.gz")
|
||||
let hermesExcludes = Self.hermesExcludes(options: options)
|
||||
let hermesTarCmd = Self.tarCommand(
|
||||
workDir: preflight.hermesHomePath.deletingLastPathComponent_String(),
|
||||
target: ".hermes",
|
||||
excludes: hermesExcludes
|
||||
)
|
||||
let hermesHash = try await streamToFile(
|
||||
transport: transport,
|
||||
command: hermesTarCmd,
|
||||
destination: hermesTarball
|
||||
) { written in
|
||||
progress(.archivingHermes(bytesWritten: written))
|
||||
}
|
||||
let hermesSize = (try? FileManager.default.attributesOfItem(atPath: hermesTarball.path)[.size] as? Int64) ?? 0
|
||||
|
||||
// Stage 3: per-project tarballs.
|
||||
let projectsDir = workDir.appendingPathComponent("projects", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: projectsDir, withIntermediateDirectories: true)
|
||||
|
||||
var projectEntries: [BackupManifest.ProjectEntry] = []
|
||||
for summary in preflight.projects where summary.reachable {
|
||||
try Task.checkCancellation()
|
||||
let projID = Self.stableID(forPath: summary.path)
|
||||
let outerName = "\(projID).tar.gz"
|
||||
let dest = projectsDir.appendingPathComponent(outerName)
|
||||
let parent = (summary.path as NSString).deletingLastPathComponent
|
||||
let leaf = (summary.path as NSString).lastPathComponent
|
||||
let cmd = Self.tarCommand(
|
||||
workDir: parent,
|
||||
target: leaf,
|
||||
excludes: Self.projectExcludes()
|
||||
)
|
||||
let hash = try await streamToFile(
|
||||
transport: transport,
|
||||
command: cmd,
|
||||
destination: dest
|
||||
) { written in
|
||||
progress(.archivingProject(name: summary.name, bytesWritten: written))
|
||||
}
|
||||
let size = (try? FileManager.default.attributesOfItem(atPath: dest.path)[.size] as? Int64) ?? 0
|
||||
projectEntries.append(BackupManifest.ProjectEntry(
|
||||
id: projID,
|
||||
name: summary.name,
|
||||
path: summary.path,
|
||||
tarballPath: BackupArchiveLayout.projectTarballPath(for: projID),
|
||||
tarballSize: size,
|
||||
tarballSHA256: hash
|
||||
))
|
||||
}
|
||||
|
||||
// Stage 4: build manifest, write to workDir.
|
||||
try Task.checkCancellation()
|
||||
let manifest = BackupManifest(
|
||||
createdAt: ISO8601DateFormatter().string(from: Date()),
|
||||
source: BackupManifest.Source(
|
||||
serverID: context.id.uuidString,
|
||||
displayName: context.displayName,
|
||||
host: Self.host(for: context),
|
||||
user: Self.user(for: context),
|
||||
hermesVersion: preflight.hermesVersion
|
||||
),
|
||||
hermes: BackupManifest.HermesTree(
|
||||
homePath: preflight.hermesHomePath,
|
||||
tarballPath: BackupArchiveLayout.hermesTarballPath,
|
||||
tarballSize: hermesSize,
|
||||
tarballSHA256: hermesHash
|
||||
),
|
||||
projects: projectEntries,
|
||||
options: BackupManifest.Options(
|
||||
includeAuth: options.includeAuth,
|
||||
includeMcpTokens: options.includeMcpTokens,
|
||||
includeLogs: options.includeLogs,
|
||||
checkpointedWAL: checkpointed
|
||||
)
|
||||
)
|
||||
let manifestData: Data
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
manifestData = try encoder.encode(manifest)
|
||||
} catch {
|
||||
throw BackupError.localIO("Couldn't encode manifest: \(error.localizedDescription)")
|
||||
}
|
||||
let manifestURL = workDir.appendingPathComponent(BackupArchiveLayout.manifestPath)
|
||||
do {
|
||||
try manifestData.write(to: manifestURL, options: .atomic)
|
||||
} catch {
|
||||
throw BackupError.localIO("Couldn't write manifest: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// Stage 5: ZIP everything in workDir into the user-chosen
|
||||
// destination. Atomic via temp file + rename so a half-written
|
||||
// archive isn't visible.
|
||||
try Task.checkCancellation()
|
||||
progress(.bundling)
|
||||
let tempArchive = archiveURL.deletingLastPathComponent()
|
||||
.appendingPathComponent(".\(archiveURL.lastPathComponent).inflight-\(UUID().uuidString).zip")
|
||||
try Self.zipDirectory(workDir: workDir, into: tempArchive)
|
||||
progress(.finalizing)
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: archiveURL.path) {
|
||||
try FileManager.default.removeItem(at: archiveURL)
|
||||
}
|
||||
try FileManager.default.moveItem(at: tempArchive, to: archiveURL)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: tempArchive)
|
||||
throw BackupError.localIO("Couldn't move archive into place: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
let archiveSize = (try? FileManager.default.attributesOfItem(atPath: archiveURL.path)[.size] as? Int64) ?? 0
|
||||
return BackupResult(
|
||||
manifest: manifest,
|
||||
archiveURL: archiveURL,
|
||||
archiveSize: archiveSize
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Streaming
|
||||
|
||||
/// Spawn a remote (or local) `bash -lc <cmd>` and pump its stdout
|
||||
/// into `destination`, computing SHA-256 incrementally as bytes
|
||||
/// arrive. Returns the hex digest. The process gets a fresh
|
||||
/// `bash -lc` shell on each invocation — same login-shell story
|
||||
/// as `streamRawBytes` so PATH picks up pipx installs etc.
|
||||
private func streamToFile(
|
||||
transport: any ServerTransport,
|
||||
command: String,
|
||||
destination: URL,
|
||||
onProgress: @Sendable @escaping (Int64) -> Void
|
||||
) async throws -> String {
|
||||
FileManager.default.createFile(atPath: destination.path, contents: nil)
|
||||
guard let fh = try? FileHandle(forWritingTo: destination) else {
|
||||
throw BackupError.localIO("Couldn't open \(destination.lastPathComponent) for writing")
|
||||
}
|
||||
defer { try? fh.close() }
|
||||
var hasher = SHA256()
|
||||
var written: Int64 = 0
|
||||
let stream = transport.streamRawBytes(
|
||||
executable: "/bin/bash",
|
||||
args: ["-lc", command]
|
||||
)
|
||||
do {
|
||||
for try await chunk in stream {
|
||||
try Task.checkCancellation()
|
||||
try fh.write(contentsOf: chunk)
|
||||
hasher.update(data: chunk)
|
||||
written += Int64(chunk.count)
|
||||
onProgress(written)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
throw BackupError.cancelled
|
||||
} catch let err as TransportError {
|
||||
throw BackupError.remoteCommandFailed(err.localizedDescription)
|
||||
} catch {
|
||||
throw BackupError.remoteCommandFailed(error.localizedDescription)
|
||||
}
|
||||
let digest = hasher.finalize()
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
// MARK: - Tar / shell helpers
|
||||
|
||||
private static func tarCommand(workDir: String, target: String, excludes: [String]) -> String {
|
||||
var parts: [String] = ["tar -czf -"]
|
||||
for ex in excludes {
|
||||
parts.append("--exclude=\(shellQuote(ex))")
|
||||
}
|
||||
parts.append("-C \(shellQuote(workDir))")
|
||||
parts.append(shellQuote(target))
|
||||
return parts.joined(separator: " ")
|
||||
}
|
||||
|
||||
/// Always-on Hermes-tree exclusions, regardless of options:
|
||||
/// SQLite WAL siblings (would carry mid-flight writes) and runtime
|
||||
/// state files (`gateway_state.json`).
|
||||
private static func hermesExcludes(options: BackupManifest.Options) -> [String] {
|
||||
var excludes: [String] = [
|
||||
".hermes/state.db-wal",
|
||||
".hermes/state.db-shm",
|
||||
".hermes/gateway_state.json",
|
||||
]
|
||||
if !options.includeAuth { excludes.append(".hermes/auth.json") }
|
||||
if !options.includeMcpTokens { excludes.append(".hermes/mcp-tokens") }
|
||||
if !options.includeLogs { excludes.append(".hermes/logs") }
|
||||
return excludes
|
||||
}
|
||||
|
||||
/// Default project-tree exclusions: things that don't restore well
|
||||
/// (compiled object stores, virtualenvs that hard-code absolute
|
||||
/// paths, system-specific build outputs). Users can opt in via
|
||||
/// the future "include build artefacts" toggle in the Backup
|
||||
/// sheet — for now we always exclude these.
|
||||
private static func projectExcludes() -> [String] {
|
||||
[
|
||||
"*/node_modules",
|
||||
"*/.venv",
|
||||
"*/venv",
|
||||
"*/__pycache__",
|
||||
"*/.git/objects",
|
||||
"*/.next",
|
||||
"*/dist",
|
||||
"*/.DS_Store",
|
||||
]
|
||||
}
|
||||
|
||||
/// Single-quote a path / argument for embedding in a `bash -lc`
|
||||
/// string. Uses POSIX-safe single quotes with escape for embedded
|
||||
/// quotes (`'` → `'\''`).
|
||||
private static func shellQuote(_ s: String) -> String {
|
||||
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
}
|
||||
|
||||
/// Convenience: same idea as ServerContext.host, but tolerates the
|
||||
/// local case (no host) by returning `"localhost"`.
|
||||
private static func host(for context: ServerContext) -> String {
|
||||
if case .ssh(let cfg) = context.kind {
|
||||
return cfg.host
|
||||
}
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
private static func user(for context: ServerContext) -> String? {
|
||||
if case .ssh(let cfg) = context.kind {
|
||||
return cfg.user
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// `du -sb` (GNU) is the most portable way to get raw bytes —
|
||||
/// on macOS `du -sk` returns kilobytes. Returns nil if neither
|
||||
/// works.
|
||||
private static func estimateBytes(transport: any ServerTransport, path: String) -> Int64? {
|
||||
let cmd = "du -sb \(shellQuote(path)) 2>/dev/null | awk '{print $1}'"
|
||||
guard let r = try? transport.runProcess(
|
||||
executable: "/bin/bash",
|
||||
args: ["-lc", cmd],
|
||||
stdin: nil,
|
||||
timeout: 60
|
||||
), r.exitCode == 0 else { return nil }
|
||||
let s = r.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Int64(s)
|
||||
}
|
||||
|
||||
/// Stable ID for a project. The project registry tracks projects
|
||||
/// by absolute path, but paths can differ between source and
|
||||
/// target (different `$HOME`). We hash the path to get a stable
|
||||
/// 16-hex-char identifier that's safe to use as a tarball
|
||||
/// filename. Collisions are vanishingly unlikely — a Mac's path
|
||||
/// space is small and SHA-256 truncated to 64 bits has good
|
||||
/// properties for non-adversarial input.
|
||||
private static func stableID(forPath path: String) -> String {
|
||||
let digest = SHA256.hash(data: Data(path.utf8))
|
||||
let bytes = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return String(bytes.prefix(16))
|
||||
}
|
||||
|
||||
/// Shell out to `/usr/bin/zip` to assemble the outer archive.
|
||||
/// macOS ships `zip` at this fixed path so we don't need a PATH
|
||||
/// search. `-r` recurse, `-q` quiet, `-X` strip extended attrs
|
||||
/// for reproducibility.
|
||||
///
|
||||
/// Mac-only: iOS doesn't ship `/usr/bin/zip` and Foundation's `Process`
|
||||
/// is unavailable in the iOS SDK. The whole backup flow is a Mac-side
|
||||
/// operation; the iOS stub throws so any accidental call surfaces a
|
||||
/// clear message instead of an opaque link error.
|
||||
private static func zipDirectory(workDir: URL, into archive: URL) throws {
|
||||
#if os(iOS)
|
||||
throw BackupError.zipFailed("Backup zip is not supported on iOS — run the backup from the Mac app.")
|
||||
#else
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
|
||||
proc.currentDirectoryURL = workDir
|
||||
proc.arguments = ["-rqX", archive.path, "."]
|
||||
let errPipe = Pipe()
|
||||
proc.standardError = errPipe
|
||||
proc.standardOutput = Pipe()
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
throw BackupError.zipFailed("Couldn't launch zip: \(error.localizedDescription)")
|
||||
}
|
||||
proc.waitUntilExit()
|
||||
if proc.terminationStatus != 0 {
|
||||
let tail = (try? errPipe.fileHandleForReading.readToEnd())
|
||||
.flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? ""
|
||||
throw BackupError.zipFailed("zip exited \(proc.terminationStatus): \(tail)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Path helpers
|
||||
|
||||
private extension String {
|
||||
/// `(somePath as NSString).deletingLastPathComponent` lifted to a
|
||||
/// String extension. Used during preflight to derive the
|
||||
/// remote `$HOME` from `$HOME/.hermes`.
|
||||
func deletingLastPathComponent_String() -> String {
|
||||
(self as NSString).deletingLastPathComponent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// Reverses a `.scarfbackup` archive into a target server: validates,
|
||||
/// streams tarballs into place over SSH, and re-anchors path-bearing
|
||||
/// JSON sidecars so the restored Hermes home references the new layout.
|
||||
///
|
||||
/// **Validation gates.** No bytes are written to the target until the
|
||||
/// manifest's `kind` magic + `schemaVersion` match, and every inner
|
||||
/// tarball's SHA-256 matches what the manifest claims. A corrupt
|
||||
/// archive surfaces a single named-path error instead of a half-extracted
|
||||
/// home.
|
||||
///
|
||||
/// **Path re-anchoring.** Project absolute paths in
|
||||
/// `~/.hermes/scarf/projects.json` reference the source server's home
|
||||
/// (e.g. `/root/projects/foo`). After extraction the project lives at
|
||||
/// `<targetProjectsRoot>/foo`, so the restore rewrites `path` for each
|
||||
/// entry. Same logic for `<project>/.scarf/manifest.json` if it carries
|
||||
/// self-references.
|
||||
///
|
||||
/// **Cron paused on restore.** Every job in `cron/jobs.json` is flipped
|
||||
/// to `enabled = false` after restore. Restored cron jobs may carry
|
||||
/// stale credentials (Slack tokens, webhooks) or run on schedules the
|
||||
/// user no longer wants — auto-running them on a fresh droplet is
|
||||
/// surprising. The user re-enables what they want from the Cron view.
|
||||
public final class RemoteRestoreService: @unchecked Sendable {
|
||||
#if canImport(os)
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "RemoteRestoreService")
|
||||
#endif
|
||||
|
||||
public let context: ServerContext
|
||||
|
||||
public init(context: ServerContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
public enum Progress: Sendable, Equatable {
|
||||
case validating
|
||||
case verifyingHashes
|
||||
case planning
|
||||
case restoringHermes(bytesPushed: Int64)
|
||||
case restoringProject(name: String, bytesPushed: Int64)
|
||||
case reanchoringPaths
|
||||
case pausingCron
|
||||
case finalizing
|
||||
}
|
||||
|
||||
public enum RestoreError: Error, LocalizedError {
|
||||
case archiveUnreadable(String)
|
||||
case unsupportedSchema(Int)
|
||||
case wrongKind(String)
|
||||
case integrityCheckFailed(path: String, expected: String, actual: String)
|
||||
case remoteCommandFailed(String)
|
||||
case localIO(String)
|
||||
case cancelled
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .archiveUnreadable(let m): return "Couldn't read the backup archive: \(m)"
|
||||
case .unsupportedSchema(let v): return "Backup uses schema v\(v), which this version of Scarf doesn't recognize."
|
||||
case .wrongKind(let k): return "This file isn't a Scarf server backup (kind: \(k))."
|
||||
case .integrityCheckFailed(let p, let exp, let act): return "Backup is corrupt — \(p) hash mismatch (expected \(exp.prefix(12))…, got \(act.prefix(12))…)."
|
||||
case .remoteCommandFailed(let m): return "Remote command failed during restore: \(m)"
|
||||
case .localIO(let m): return "Local file I/O failed during restore: \(m)"
|
||||
case .cancelled: return "Restore cancelled."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `inspect()` returns to drive the restore-plan sheet. The
|
||||
/// caller picks `targetProjectsRoot`, optionally tweaks the cron
|
||||
/// pause toggle, then calls `run()` with the same archive URL.
|
||||
public struct InspectionResult: Sendable {
|
||||
public var manifest: BackupManifest
|
||||
public var workDir: URL // unzipped temp dir; reused by run()
|
||||
public var targetHomeResolved: String?
|
||||
public var targetHermesVersion: String?
|
||||
}
|
||||
|
||||
public struct RestoreOptions: Sendable {
|
||||
/// Where to drop project tarballs. Each project lands at
|
||||
/// `<targetProjectsRoot>/<basename>`. Defaults to
|
||||
/// `<targetHome>/projects` when not specified.
|
||||
public var targetProjectsRoot: String?
|
||||
/// Override the resolved target home (rarely needed; the
|
||||
/// default is whatever `bash -lc 'echo $HOME'` returned).
|
||||
public var targetHomeOverride: String?
|
||||
/// Pause every cron job after restore. Strongly recommended
|
||||
/// (the user re-enables intentionally).
|
||||
public var pauseCronJobs: Bool
|
||||
|
||||
public init(
|
||||
targetProjectsRoot: String? = nil,
|
||||
targetHomeOverride: String? = nil,
|
||||
pauseCronJobs: Bool = true
|
||||
) {
|
||||
self.targetProjectsRoot = targetProjectsRoot
|
||||
self.targetHomeOverride = targetHomeOverride
|
||||
self.pauseCronJobs = pauseCronJobs
|
||||
}
|
||||
}
|
||||
|
||||
public struct RestoreResult: Sendable {
|
||||
public var manifest: BackupManifest
|
||||
public var hermesHome: String
|
||||
public var projectsRestored: [RestoredProject]
|
||||
public var cronJobsPaused: Int
|
||||
|
||||
public struct RestoredProject: Sendable {
|
||||
public var name: String
|
||||
public var sourcePath: String
|
||||
public var targetPath: String
|
||||
}
|
||||
}
|
||||
|
||||
/// Unzip + manifest-validate + hash-verify in a temp dir. Cheap
|
||||
/// enough to call from a sheet's appearance handler so the user
|
||||
/// sees a populated preview before committing.
|
||||
public func inspect(archiveURL: URL) async throws -> InspectionResult {
|
||||
let workDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("scarf-restore-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
|
||||
|
||||
// Unzip outer archive.
|
||||
try Self.unzipArchive(at: archiveURL, into: workDir)
|
||||
|
||||
// Decode + validate manifest.
|
||||
let manifestURL = workDir.appendingPathComponent(BackupArchiveLayout.manifestPath)
|
||||
guard let data = try? Data(contentsOf: manifestURL) else {
|
||||
throw RestoreError.archiveUnreadable("missing manifest.json")
|
||||
}
|
||||
let manifest: BackupManifest
|
||||
do {
|
||||
manifest = try JSONDecoder().decode(BackupManifest.self, from: data)
|
||||
} catch {
|
||||
throw RestoreError.archiveUnreadable("manifest.json malformed: \(error.localizedDescription)")
|
||||
}
|
||||
guard manifest.kind == BackupManifest.kindMagic else {
|
||||
throw RestoreError.wrongKind(manifest.kind)
|
||||
}
|
||||
guard manifest.schemaVersion == BackupManifest.currentSchemaVersion else {
|
||||
throw RestoreError.unsupportedSchema(manifest.schemaVersion)
|
||||
}
|
||||
|
||||
// Hash-verify every inner tarball before any remote bytes are
|
||||
// pushed.
|
||||
try await Self.verifyHash(file: workDir.appendingPathComponent(manifest.hermes.tarballPath), expected: manifest.hermes.tarballSHA256)
|
||||
for project in manifest.projects {
|
||||
try await Self.verifyHash(file: workDir.appendingPathComponent(project.tarballPath), expected: project.tarballSHA256)
|
||||
}
|
||||
|
||||
// Probe the target for $HOME + hermes version. Doesn't fail
|
||||
// restore if the probe times out — the user can still pick
|
||||
// an override.
|
||||
let transport = context.makeTransport()
|
||||
let homeProbe = try? transport.runProcess(
|
||||
executable: "/bin/bash",
|
||||
args: ["-lc", "echo \"$HOME\""],
|
||||
stdin: nil,
|
||||
timeout: 30
|
||||
)
|
||||
let resolvedHome = homeProbe?.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let versionProbe = try? transport.runProcess(
|
||||
executable: "/bin/bash",
|
||||
args: ["-lc", "hermes --version 2>/dev/null || true"],
|
||||
stdin: nil,
|
||||
timeout: 30
|
||||
)
|
||||
let resolvedVersion = versionProbe?.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
return InspectionResult(
|
||||
manifest: manifest,
|
||||
workDir: workDir,
|
||||
targetHomeResolved: (resolvedHome?.isEmpty == false) ? resolvedHome : nil,
|
||||
targetHermesVersion: (resolvedVersion?.isEmpty == false) ? resolvedVersion : nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Run the restore. Pushes tarballs, re-anchors paths, optionally
|
||||
/// pauses cron. Caller owns the `workDir` URL from `inspect()` and
|
||||
/// is responsible for cleanup if `run` throws — on success this
|
||||
/// method removes the temp dir.
|
||||
public func run(
|
||||
inspection: InspectionResult,
|
||||
options: RestoreOptions,
|
||||
progress: @Sendable @escaping (Progress) -> Void
|
||||
) async throws -> RestoreResult {
|
||||
defer { try? FileManager.default.removeItem(at: inspection.workDir) }
|
||||
let transport = context.makeTransport()
|
||||
let manifest = inspection.manifest
|
||||
|
||||
try Task.checkCancellation()
|
||||
progress(.planning)
|
||||
|
||||
let targetHome = options.targetHomeOverride
|
||||
?? inspection.targetHomeResolved
|
||||
?? (manifest.hermes.homePath as NSString).deletingLastPathComponent
|
||||
let projectsRoot = options.targetProjectsRoot ?? (targetHome + "/projects")
|
||||
|
||||
// Make sure the projects root exists so `tar -xzf` doesn't
|
||||
// fail on a missing -C target.
|
||||
let mkdirCmd = "mkdir -p \(Self.shellQuote(projectsRoot))"
|
||||
let mkdirResult = try? transport.runProcess(
|
||||
executable: "/bin/bash",
|
||||
args: ["-lc", mkdirCmd],
|
||||
stdin: nil,
|
||||
timeout: 30
|
||||
)
|
||||
if let r = mkdirResult, r.exitCode != 0 {
|
||||
throw RestoreError.remoteCommandFailed("mkdir \(projectsRoot) failed: \(r.stderrString)")
|
||||
}
|
||||
|
||||
// Stage 1: hermes home. Pushes into $HOME so the inner
|
||||
// `.hermes/...` paths land at `<targetHome>/.hermes/...`.
|
||||
try Task.checkCancellation()
|
||||
let hermesTar = inspection.workDir.appendingPathComponent(manifest.hermes.tarballPath)
|
||||
try await pushTarball(
|
||||
transport: transport,
|
||||
tarball: hermesTar,
|
||||
extractInto: targetHome
|
||||
) { written in
|
||||
progress(.restoringHermes(bytesPushed: written))
|
||||
}
|
||||
|
||||
// Stage 2: per-project tarballs.
|
||||
var restoredProjects: [RestoreResult.RestoredProject] = []
|
||||
for project in manifest.projects {
|
||||
try Task.checkCancellation()
|
||||
let tar = inspection.workDir.appendingPathComponent(project.tarballPath)
|
||||
try await pushTarball(
|
||||
transport: transport,
|
||||
tarball: tar,
|
||||
extractInto: projectsRoot
|
||||
) { written in
|
||||
progress(.restoringProject(name: project.name, bytesPushed: written))
|
||||
}
|
||||
let basename = (project.path as NSString).lastPathComponent
|
||||
restoredProjects.append(RestoreResult.RestoredProject(
|
||||
name: project.name,
|
||||
sourcePath: project.path,
|
||||
targetPath: projectsRoot + "/" + basename
|
||||
))
|
||||
}
|
||||
|
||||
// Stage 3: re-anchor `~/.hermes/scarf/projects.json` so the
|
||||
// restored Hermes references the new project paths instead
|
||||
// of the source droplet's paths.
|
||||
try Task.checkCancellation()
|
||||
progress(.reanchoringPaths)
|
||||
try await reanchorProjectsRegistry(
|
||||
transport: transport,
|
||||
targetHome: targetHome,
|
||||
mapping: Dictionary(
|
||||
uniqueKeysWithValues: restoredProjects.map { ($0.sourcePath, $0.targetPath) }
|
||||
)
|
||||
)
|
||||
|
||||
// Stage 4: pause cron jobs.
|
||||
var paused = 0
|
||||
if options.pauseCronJobs {
|
||||
try Task.checkCancellation()
|
||||
progress(.pausingCron)
|
||||
paused = try await pauseAllCronJobs(transport: transport, targetHome: targetHome)
|
||||
}
|
||||
|
||||
progress(.finalizing)
|
||||
return RestoreResult(
|
||||
manifest: manifest,
|
||||
hermesHome: targetHome + "/.hermes",
|
||||
projectsRestored: restoredProjects,
|
||||
cronJobsPaused: paused
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Push (tarball -> remote stdin)
|
||||
|
||||
/// Stream a local `.tar.gz` into `tar -xzf - -C <target>` on the
|
||||
/// destination. We use `transport.makeProcess` so the command is
|
||||
/// shell-wrapped the same way the rest of the app talks to remotes
|
||||
/// (`bash -lc` for SSH, direct invocation for local).
|
||||
private func pushTarball(
|
||||
transport: any ServerTransport,
|
||||
tarball: URL,
|
||||
extractInto target: String,
|
||||
onProgress: @Sendable @escaping (Int64) -> Void
|
||||
) async throws {
|
||||
#if os(iOS)
|
||||
throw RestoreError.remoteCommandFailed("Remote restore is not supported on iOS in this build.")
|
||||
#else
|
||||
let cmd = "tar -xzf - -C \(Self.shellQuote(target))"
|
||||
let proc = transport.makeProcess(executable: "/bin/bash", args: ["-lc", cmd])
|
||||
|
||||
// standardInput: read end of an OS pipe whose write end we
|
||||
// pump from the local tarball file. Going through a pipe (vs
|
||||
// setting standardInput to a FileHandle directly) gives us
|
||||
// cooperative chunk-by-chunk control + cancellation.
|
||||
let inPipe = Pipe()
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
proc.standardInput = inPipe
|
||||
proc.standardOutput = outPipe
|
||||
proc.standardError = errPipe
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
throw RestoreError.remoteCommandFailed("Couldn't start remote tar: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
let writer = inPipe.fileHandleForWriting
|
||||
let reader: FileHandle
|
||||
do {
|
||||
reader = try FileHandle(forReadingFrom: tarball)
|
||||
} catch {
|
||||
try? writer.close()
|
||||
proc.terminate()
|
||||
throw RestoreError.localIO("Couldn't open tarball: \(error.localizedDescription)")
|
||||
}
|
||||
defer { try? reader.close() }
|
||||
|
||||
var written: Int64 = 0
|
||||
let chunkSize = 64 * 1024
|
||||
do {
|
||||
while true {
|
||||
try Task.checkCancellation()
|
||||
let chunk = reader.readData(ofLength: chunkSize)
|
||||
if chunk.isEmpty { break }
|
||||
try writer.write(contentsOf: chunk)
|
||||
written += Int64(chunk.count)
|
||||
onProgress(written)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
try? writer.close()
|
||||
proc.terminate()
|
||||
throw RestoreError.cancelled
|
||||
} catch {
|
||||
try? writer.close()
|
||||
proc.terminate()
|
||||
throw RestoreError.localIO("Couldn't pump tarball into remote: \(error.localizedDescription)")
|
||||
}
|
||||
try? writer.close() // signals EOF to the remote tar
|
||||
|
||||
proc.waitUntilExit()
|
||||
if proc.terminationStatus != 0 {
|
||||
let tail = (try? errPipe.fileHandleForReading.readToEnd())
|
||||
.flatMap { $0.flatMap { String(data: $0, encoding: .utf8) } } ?? ""
|
||||
throw RestoreError.remoteCommandFailed("tar -x exited \(proc.terminationStatus): \(tail)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Path re-anchor
|
||||
|
||||
/// Rewrite each entry's `path` in `~/.hermes/scarf/projects.json`
|
||||
/// from source-host paths to target-host paths. We do this on the
|
||||
/// remote rather than mutating the tarball locally — the Hermes
|
||||
/// home tarball can be GBs and re-packing would double the
|
||||
/// transfer cost. Python is universally present on droplets and
|
||||
/// keeps the JSON shape intact (preserves keys we don't know
|
||||
/// about).
|
||||
private func reanchorProjectsRegistry(
|
||||
transport: any ServerTransport,
|
||||
targetHome: String,
|
||||
mapping: [String: String]
|
||||
) async throws {
|
||||
guard !mapping.isEmpty else { return }
|
||||
let registryPath = targetHome + "/.hermes/scarf/projects.json"
|
||||
let mappingJSON: String
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: mapping)
|
||||
mappingJSON = String(data: data, encoding: .utf8) ?? "{}"
|
||||
} catch {
|
||||
throw RestoreError.localIO("Couldn't encode path mapping: \(error.localizedDescription)")
|
||||
}
|
||||
let script = """
|
||||
import json, os, sys
|
||||
path = os.path.expanduser(\(Self.pythonQuote(registryPath)))
|
||||
if not os.path.exists(path):
|
||||
sys.exit(0)
|
||||
try:
|
||||
with open(path) as f: data = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"projects.json parse failed: {e}", file=sys.stderr); sys.exit(1)
|
||||
mapping = json.loads(\(Self.pythonQuote(mappingJSON)))
|
||||
for entry in data.get('projects', []):
|
||||
old = entry.get('path')
|
||||
if old in mapping: entry['path'] = mapping[old]
|
||||
with open(path, 'w') as f: json.dump(data, f, indent=2)
|
||||
"""
|
||||
let cmd = "python3 -c \(Self.shellQuote(script))"
|
||||
let result = try? transport.runProcess(
|
||||
executable: "/bin/bash",
|
||||
args: ["-lc", cmd],
|
||||
stdin: nil,
|
||||
timeout: 60
|
||||
)
|
||||
if let r = result, r.exitCode != 0 {
|
||||
throw RestoreError.remoteCommandFailed("Path re-anchor failed: \(r.stderrString)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Set `enabled: false` on every cron job. Returns the count
|
||||
/// flipped (0 if jobs.json is absent).
|
||||
private func pauseAllCronJobs(transport: any ServerTransport, targetHome: String) async throws -> Int {
|
||||
let path = targetHome + "/.hermes/cron/jobs.json"
|
||||
let script = """
|
||||
import json, os, sys
|
||||
path = os.path.expanduser(\(Self.pythonQuote(path)))
|
||||
if not os.path.exists(path):
|
||||
print(0); sys.exit(0)
|
||||
with open(path) as f: data = json.load(f)
|
||||
count = 0
|
||||
for job in data.get('jobs', []):
|
||||
if job.get('enabled', False):
|
||||
job['enabled'] = False
|
||||
count += 1
|
||||
with open(path, 'w') as f: json.dump(data, f, indent=2)
|
||||
print(count)
|
||||
"""
|
||||
let cmd = "python3 -c \(Self.shellQuote(script))"
|
||||
let result = try? transport.runProcess(
|
||||
executable: "/bin/bash",
|
||||
args: ["-lc", cmd],
|
||||
stdin: nil,
|
||||
timeout: 60
|
||||
)
|
||||
if let r = result, r.exitCode == 0 {
|
||||
let count = Int(r.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
||||
return count
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Mac-only: iOS doesn't ship `/usr/bin/unzip` and Foundation's
|
||||
/// `Process` is unavailable in the iOS SDK. Restore is initiated from
|
||||
/// the Mac app; the iOS stub throws so any accidental call surfaces a
|
||||
/// clear message instead of a link-time failure.
|
||||
private static func unzipArchive(at archive: URL, into dest: URL) throws {
|
||||
#if os(iOS)
|
||||
throw RestoreError.archiveUnreadable("Restore unzip is not supported on iOS — run the restore from the Mac app.")
|
||||
#else
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
|
||||
proc.arguments = ["-q", archive.path, "-d", dest.path]
|
||||
let errPipe = Pipe()
|
||||
proc.standardError = errPipe
|
||||
proc.standardOutput = Pipe()
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
throw RestoreError.archiveUnreadable("Couldn't launch unzip: \(error.localizedDescription)")
|
||||
}
|
||||
proc.waitUntilExit()
|
||||
if proc.terminationStatus != 0 {
|
||||
let tail = (try? errPipe.fileHandleForReading.readToEnd())
|
||||
.flatMap { $0.flatMap { String(data: $0, encoding: .utf8) } } ?? ""
|
||||
throw RestoreError.archiveUnreadable("unzip exited \(proc.terminationStatus): \(tail)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Hash a local file in 1 MB chunks. We avoid loading the whole
|
||||
/// file into memory because tarballs can be multi-GB.
|
||||
private static func verifyHash(file: URL, expected: String) async throws {
|
||||
guard let fh = try? FileHandle(forReadingFrom: file) else {
|
||||
throw RestoreError.archiveUnreadable("missing inner file: \(file.lastPathComponent)")
|
||||
}
|
||||
defer { try? fh.close() }
|
||||
var hasher = SHA256()
|
||||
let chunkSize = 1024 * 1024
|
||||
while true {
|
||||
let chunk = fh.readData(ofLength: chunkSize)
|
||||
if chunk.isEmpty { break }
|
||||
hasher.update(data: chunk)
|
||||
}
|
||||
let actual = hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
||||
if actual != expected {
|
||||
throw RestoreError.integrityCheckFailed(path: file.lastPathComponent, expected: expected, actual: actual)
|
||||
}
|
||||
}
|
||||
|
||||
private static func shellQuote(_ s: String) -> String {
|
||||
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
}
|
||||
|
||||
/// Python source-literal quoting. Triple-quoted with backslash
|
||||
/// escapes for embedded triple-quotes, backslashes, and the
|
||||
/// language's own escape sequences. Used to safely embed JSON +
|
||||
/// path strings into a `python3 -c '...'` invocation.
|
||||
private static func pythonQuote(_ s: String) -> String {
|
||||
let escaped = s
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"\"\"", with: "\\\"\\\"\\\"")
|
||||
return "\"\"\"" + escaped + "\"\"\""
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,12 @@ import os
|
||||
public enum SkillsScanner: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "SkillsScanner")
|
||||
|
||||
public static func scan(context: ServerContext, transport: any ServerTransport) -> [HermesSkillCategory] {
|
||||
public static func scan(
|
||||
context: ServerContext,
|
||||
transport: any ServerTransport,
|
||||
disabledNames: Set<String> = [],
|
||||
pinnedNames: Set<String> = []
|
||||
) -> [HermesSkillCategory] {
|
||||
let dir = context.paths.skillsDir
|
||||
// Fresh install: skills/ may not exist yet — return [] without
|
||||
// logging an error.
|
||||
@@ -59,7 +64,9 @@ public enum SkillsScanner: Sendable {
|
||||
requiredConfig: requiredConfig,
|
||||
allowedTools: v011.allowedTools,
|
||||
relatedSkills: v011.relatedSkills,
|
||||
dependencies: v011.dependencies
|
||||
dependencies: v011.dependencies,
|
||||
enabled: !disabledNames.contains(skillName),
|
||||
pinned: pinnedNames.contains(skillName)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
/// Process-wide toggles for test-mode launches.
|
||||
///
|
||||
/// Read `CommandLine.arguments` once at first access and cache the result so
|
||||
/// any code path can ask `TestModeFlags.shared.isTestMode` without paying for
|
||||
/// a re-scan. The harness sets `--scarf-test-mode` from XCUITest's
|
||||
/// `XCUIApplication.launchArguments` and pairs it with `SCARF_HERMES_HOME`
|
||||
/// (read by `HermesProfileResolver`) to drive Scarf against an isolated
|
||||
/// Hermes home.
|
||||
///
|
||||
/// The flags themselves don't do anything on their own — they're hook points
|
||||
/// for production code paths to gate behavior. v1 lands the wiring; the
|
||||
/// gating sites (Sparkle update prompt, capability live-probe, first-run
|
||||
/// walkthrough) are added incrementally as the harness exercises them and
|
||||
/// surfaces flakes.
|
||||
public struct TestModeFlags: Sendable {
|
||||
/// True when the process was launched with `--scarf-test-mode`. Read
|
||||
/// once from `CommandLine.arguments`; never mutated.
|
||||
public let isTestMode: Bool
|
||||
|
||||
/// Default singleton — cached on first access. Production code reads
|
||||
/// this; tests that need a different shape construct their own value.
|
||||
public static let shared: TestModeFlags = TestModeFlags(
|
||||
arguments: CommandLine.arguments
|
||||
)
|
||||
|
||||
/// Constructor exposed for tests so a synthetic argv can be passed
|
||||
/// without involving the real `CommandLine`. Production callers use
|
||||
/// `.shared`.
|
||||
public init(arguments: [String]) {
|
||||
self.isTestMode = arguments.contains("--scarf-test-mode")
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,55 @@ public struct LocalTransport: ServerTransport {
|
||||
}
|
||||
#endif
|
||||
|
||||
public func streamRawBytes(executable: String, args: [String]) -> AsyncThrowingStream<Data, Error> {
|
||||
#if os(iOS)
|
||||
return AsyncThrowingStream { $0.finish() }
|
||||
#else
|
||||
return AsyncThrowingStream { continuation in
|
||||
Task.detached {
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: executable)
|
||||
proc.arguments = args
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
proc.standardOutput = outPipe
|
||||
proc.standardError = errPipe
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
return
|
||||
}
|
||||
try? outPipe.fileHandleForWriting.close()
|
||||
try? errPipe.fileHandleForWriting.close()
|
||||
let handle = outPipe.fileHandleForReading
|
||||
while true {
|
||||
let chunk = handle.availableData
|
||||
if chunk.isEmpty { break }
|
||||
continuation.yield(chunk)
|
||||
}
|
||||
proc.waitUntilExit()
|
||||
let stderrTail: String
|
||||
if proc.terminationStatus != 0 {
|
||||
stderrTail = (try? errPipe.fileHandleForReading.readToEnd())
|
||||
.flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? ""
|
||||
} else {
|
||||
stderrTail = ""
|
||||
}
|
||||
try? outPipe.fileHandleForReading.close()
|
||||
try? errPipe.fileHandleForReading.close()
|
||||
if proc.terminationStatus != 0 {
|
||||
continuation.finish(throwing: TransportError.commandFailed(
|
||||
exitCode: proc.terminationStatus, stderr: stderrTail
|
||||
))
|
||||
} else {
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
public func streamLines(executable: String, args: [String]) -> AsyncThrowingStream<String, Error> {
|
||||
#if os(iOS)
|
||||
// LocalTransport doesn't run on iOS at runtime — the iOS app
|
||||
@@ -247,6 +296,11 @@ public struct LocalTransport: ServerTransport {
|
||||
URL(fileURLWithPath: remotePath)
|
||||
}
|
||||
|
||||
/// Local transport reads the live DB directly — there's no cached
|
||||
/// snapshot to fall back to (and no failure mode where falling back
|
||||
/// would help, since a missing local file is missing both ways).
|
||||
public var cachedSnapshotPath: URL? { nil }
|
||||
|
||||
// MARK: - Watching
|
||||
|
||||
#if canImport(Darwin)
|
||||
|
||||
@@ -425,14 +425,18 @@ public struct SSHTransport: ServerTransport {
|
||||
public func makeProcess(executable: String, args: [String]) -> Process {
|
||||
ensureControlDir()
|
||||
// `-T` disables pty allocation — critical for binary-clean stdin/stdout
|
||||
// (ACP JSON-RPC, log tail bytes). Same sh -c wrapping as runProcess
|
||||
// so home-relative paths in `executable`/`args` actually expand.
|
||||
// (ACP JSON-RPC, log tail bytes). `bash -lc` (login shell) sources the
|
||||
// user's profile so PATH picks up pipx's `~/.local/bin`, Homebrew on
|
||||
// Linux, asdf shims, and conda envs. Plain `sh -c` is non-login, so
|
||||
// pipx-installed `hermes` isn't on PATH unless `hermesBinaryHint` was
|
||||
// set explicitly — exactly the failure that surfaces as a
|
||||
// "command not found" / opaque init timeout against fresh droplets.
|
||||
let cmd = ([executable] + args).map { Self.remotePathArg($0) }.joined(separator: " ")
|
||||
var sshArgv = sshArgs()
|
||||
sshArgv.insert("-T", at: 0)
|
||||
sshArgv.append(hostSpec)
|
||||
sshArgv.append("sh")
|
||||
sshArgv.append("-c")
|
||||
sshArgv.append("bash")
|
||||
sshArgv.append("-lc")
|
||||
sshArgv.append(Self.shellQuote(cmd))
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: sshBinary)
|
||||
@@ -453,12 +457,17 @@ public struct SSHTransport: ServerTransport {
|
||||
return AsyncThrowingStream { continuation in
|
||||
Task.detached { [self] in
|
||||
ensureControlDir()
|
||||
// `bash -lc` (login shell) so PATH picks up profile-only
|
||||
// entries like pipx's `~/.local/bin` — same rationale as
|
||||
// `makeProcess` above. Streaming consumers (log tails)
|
||||
// don't tolerate a missing-binary failure any better than
|
||||
// ACP does.
|
||||
let cmd = ([executable] + args).map { Self.remotePathArg($0) }.joined(separator: " ")
|
||||
var sshArgv = sshArgs()
|
||||
sshArgv.insert("-T", at: 0)
|
||||
sshArgv.append(hostSpec)
|
||||
sshArgv.append("sh")
|
||||
sshArgv.append("-c")
|
||||
sshArgv.append("bash")
|
||||
sshArgv.append("-lc")
|
||||
sshArgv.append(Self.shellQuote(cmd))
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: sshBinary)
|
||||
@@ -514,6 +523,69 @@ public struct SSHTransport: ServerTransport {
|
||||
#endif
|
||||
}
|
||||
|
||||
public func streamRawBytes(executable: String, args: [String]) -> AsyncThrowingStream<Data, Error> {
|
||||
#if os(iOS)
|
||||
return AsyncThrowingStream { $0.finish() }
|
||||
#else
|
||||
return AsyncThrowingStream { continuation in
|
||||
Task.detached { [self] in
|
||||
ensureControlDir()
|
||||
// Same `bash -lc` wrapping as `streamLines` so PATH picks
|
||||
// up profile-only entries (pipx, asdf, conda). The
|
||||
// difference here is we yield raw `Data` chunks — no
|
||||
// newline framing, no UTF-8 decoding. Required for
|
||||
// backup tarballs.
|
||||
let cmd = ([executable] + args).map { Self.remotePathArg($0) }.joined(separator: " ")
|
||||
var sshArgv = sshArgs()
|
||||
sshArgv.insert("-T", at: 0)
|
||||
sshArgv.append(hostSpec)
|
||||
sshArgv.append("bash")
|
||||
sshArgv.append("-lc")
|
||||
sshArgv.append(Self.shellQuote(cmd))
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: sshBinary)
|
||||
proc.arguments = sshArgv
|
||||
proc.environment = Self.sshSubprocessEnvironment()
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
proc.standardOutput = outPipe
|
||||
proc.standardError = errPipe
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
return
|
||||
}
|
||||
try? outPipe.fileHandleForWriting.close()
|
||||
try? errPipe.fileHandleForWriting.close()
|
||||
let handle = outPipe.fileHandleForReading
|
||||
while true {
|
||||
let chunk = handle.availableData
|
||||
if chunk.isEmpty { break }
|
||||
continuation.yield(chunk)
|
||||
}
|
||||
proc.waitUntilExit()
|
||||
let stderrTail: String
|
||||
if proc.terminationStatus != 0 {
|
||||
stderrTail = (try? errPipe.fileHandleForReading.readToEnd())
|
||||
.flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? ""
|
||||
} else {
|
||||
stderrTail = ""
|
||||
}
|
||||
try? outPipe.fileHandleForReading.close()
|
||||
try? errPipe.fileHandleForReading.close()
|
||||
if proc.terminationStatus != 0 {
|
||||
continuation.finish(throwing: TransportError.classifySSHFailure(
|
||||
host: config.host, exitCode: proc.terminationStatus, stderr: stderrTail
|
||||
))
|
||||
} else {
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Injection point for ssh/scp subprocess environment enrichment.
|
||||
///
|
||||
/// On the Mac app, this is wired at startup to
|
||||
@@ -603,6 +675,14 @@ public struct SSHTransport: ServerTransport {
|
||||
return URL(fileURLWithPath: localPath)
|
||||
}
|
||||
|
||||
/// Path where the most recent successful snapshot was written —
|
||||
/// returned even when the remote is currently unreachable. The
|
||||
/// data service falls back to this when `snapshotSQLite` throws so
|
||||
/// Dashboard / Sessions / Chat-history stay viewable offline.
|
||||
public var cachedSnapshotPath: URL? {
|
||||
URL(fileURLWithPath: snapshotDir + "/state.db")
|
||||
}
|
||||
|
||||
// MARK: - Watching
|
||||
|
||||
public func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
|
||||
|
||||
@@ -81,6 +81,21 @@ public protocol ServerTransport: Sendable {
|
||||
args: [String]
|
||||
) -> AsyncThrowingStream<String, Error>
|
||||
|
||||
/// Binary-safe streaming exec. Same shape as `streamLines` but yields
|
||||
/// arbitrary `Data` chunks of stdout instead of newline-delimited
|
||||
/// strings. Required by the backup feature: `tar -czf -` produces
|
||||
/// gzipped tar bytes that must NOT be decoded as UTF-8 / split on
|
||||
/// `\n` — `streamLines` would silently corrupt the archive.
|
||||
///
|
||||
/// Stream finishes on EOF / clean exit; errors with
|
||||
/// `TransportError.commandFailed` on non-zero exit (carrying the
|
||||
/// captured stderr tail). Chunk sizes are whatever the underlying
|
||||
/// pipe returns from `availableData`, typically 4–64 KB on macOS.
|
||||
nonisolated func streamRawBytes(
|
||||
executable: String,
|
||||
args: [String]
|
||||
) -> AsyncThrowingStream<Data, Error>
|
||||
|
||||
// MARK: - SQLite
|
||||
|
||||
/// Return a local filesystem URL pointing at a fresh, consistent copy of
|
||||
@@ -90,6 +105,19 @@ public protocol ServerTransport: Sendable {
|
||||
/// `~/Library/Caches/scarf/<serverID>/state.db`, returning that URL.
|
||||
nonisolated func snapshotSQLite(remotePath: String) throws -> URL
|
||||
|
||||
/// Local filesystem URL where this transport caches its SQLite snapshot,
|
||||
/// returned even when the remote is unreachable. Callers should
|
||||
/// `FileManager.default.fileExists(atPath:)` before reading — the
|
||||
/// transport can't atomically check existence and return the URL
|
||||
/// in one step without TOCTOU. Local transports return `nil`
|
||||
/// (their data is the live DB, not a cache).
|
||||
///
|
||||
/// Used by `HermesDataService.open()` to fall back to the last
|
||||
/// successful snapshot when a fresh `snapshotSQLite` call fails,
|
||||
/// so the app keeps showing data with a "Last updated X ago"
|
||||
/// affordance instead of a blank screen.
|
||||
nonisolated var cachedSnapshotPath: URL? { get }
|
||||
|
||||
// MARK: - Watching
|
||||
|
||||
/// Observe changes to a set of paths and yield events when any of them
|
||||
@@ -97,6 +125,25 @@ public protocol ServerTransport: Sendable {
|
||||
nonisolated func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent>
|
||||
}
|
||||
|
||||
public extension ServerTransport {
|
||||
/// Default: backup-class binary streaming isn't implemented for
|
||||
/// every transport (notably the iOS `CitadelServerTransport`,
|
||||
/// which doesn't expose a raw stdout pipe). Concrete Mac
|
||||
/// transports override this. The fallback yields a stream that
|
||||
/// throws on first iteration so callers fail fast rather than
|
||||
/// hanging silently.
|
||||
nonisolated func streamRawBytes(
|
||||
executable: String,
|
||||
args: [String]
|
||||
) -> AsyncThrowingStream<Data, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
continuation.finish(throwing: TransportError.other(
|
||||
message: "streamRawBytes is not supported on this transport"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stat-style file metadata. `nil` (return value) means the file does not
|
||||
/// exist or couldn't be queried.
|
||||
public struct FileStat: Sendable, Hashable {
|
||||
|
||||
+30
-20
@@ -16,7 +16,7 @@ public final class ConnectionStatusViewModel {
|
||||
#endif
|
||||
|
||||
public enum Status: Equatable {
|
||||
/// Healthy: SSH connected AND we can read `~/.hermes/config.yaml`.
|
||||
/// Healthy: SSH connected AND we can read `~/.hermes/state.db`.
|
||||
case connected
|
||||
/// SSH connects but the follow-up read-access probe failed. Data
|
||||
/// views will be empty until this is resolved.
|
||||
@@ -38,14 +38,17 @@ public final class ConnectionStatusViewModel {
|
||||
/// Specific tier-2 failure mode emitted by the probe script. Used to
|
||||
/// drive both the pill copy and the popover hint (issue #53).
|
||||
public enum DegradedCause: Equatable {
|
||||
/// `config.yaml` is missing entirely. Most common cause: Hermes
|
||||
/// hasn't run `setup` yet on this remote.
|
||||
/// `state.db` is missing entirely. Most common cause: Hermes
|
||||
/// is installed but no session has run on this remote yet.
|
||||
/// Case name kept as `configMissing` for back-compat with
|
||||
/// callers that pattern-match on it; "config" here is loose
|
||||
/// for "Scarf's required state file."
|
||||
case configMissing
|
||||
/// `~/.hermes` itself doesn't exist. Hermes isn't installed for
|
||||
/// the SSH user on this host.
|
||||
case homeMissing
|
||||
/// File exists but the SSH user can't read it. Permission /
|
||||
/// ownership mismatch.
|
||||
/// ownership mismatch. Same back-compat note as above.
|
||||
case configUnreadable
|
||||
/// `~/.hermes/active_profile` points at a non-default Hermes
|
||||
/// profile and the configured Hermes home doesn't carry the
|
||||
@@ -110,10 +113,18 @@ public final class ConnectionStatusViewModel {
|
||||
let hermesHome = context.paths.home
|
||||
// Two-tier probe in one SSH round-trip:
|
||||
// tier 1: `true` — raw connectivity / auth / ControlMaster path
|
||||
// tier 2: `test -r $HERMESHOME/config.yaml` — can we actually
|
||||
// read the file Dashboard reads on every tick? Green pill
|
||||
// only if both pass; yellow "degraded" if tier 1 passes
|
||||
// but tier 2 fails (the exact symptom in issue #19).
|
||||
// tier 2: `test -r $HERMESHOME/state.db` — can we actually read
|
||||
// the file Dashboard / Sessions / Activity all hit on
|
||||
// every tick? Green pill only if both pass.
|
||||
//
|
||||
// Probe historically targeted `config.yaml`, but Hermes v0.11+
|
||||
// doesn't materialize that file eagerly — it ships with sane
|
||||
// defaults and only writes config.yaml when the user actually
|
||||
// changes something. Result: a freshly-installed Hermes that's
|
||||
// running, persisting sessions, and serving Scarf was being
|
||||
// marked "degraded — config missing" indefinitely. `state.db`
|
||||
// is created on first agent run and is the actual surface
|
||||
// Scarf depends on, so we probe that instead.
|
||||
// Script emits two lines: TIER1:<exitcode> and TIER2:<exitcode>.
|
||||
let homeArg: String
|
||||
if hermesHome.hasPrefix("~/") {
|
||||
@@ -124,22 +135,21 @@ public final class ConnectionStatusViewModel {
|
||||
homeArg = "\"\(hermesHome.replacingOccurrences(of: "\"", with: "\\\""))\""
|
||||
}
|
||||
// Probe emits a granular `TIER2:1:<cause>` code so the pill can
|
||||
// surface a specific hint (issue #53) instead of the prior
|
||||
// collapsed-to-binary "can't read config.yaml". Causes:
|
||||
// surface a specific hint (issue #53). Causes:
|
||||
// no-home — $H itself doesn't exist
|
||||
// missing — config.yaml absent
|
||||
// missing — state.db absent (Hermes hasn't been run yet)
|
||||
// perm — exists but unreadable by SSH user
|
||||
// profile:<name> — config missing AND ~/.hermes/active_profile
|
||||
// profile:<name> — state.db missing AND ~/.hermes/active_profile
|
||||
// points at a Hermes profile, suggesting Scarf
|
||||
// is reading the wrong dir
|
||||
let script = """
|
||||
echo TIER1:0
|
||||
H=\(homeArg)
|
||||
if [ -r "$H/config.yaml" ]; then
|
||||
if [ -r "$H/state.db" ]; then
|
||||
echo TIER2:0
|
||||
elif [ ! -d "$H" ]; then
|
||||
echo TIER2:1:no-home
|
||||
elif [ ! -e "$H/config.yaml" ]; then
|
||||
elif [ ! -e "$H/state.db" ]; then
|
||||
ACTIVE=""
|
||||
if [ -r "$HOME/.hermes/active_profile" ]; then
|
||||
ACTIVE=$(head -n1 "$HOME/.hermes/active_profile" 2>/dev/null | tr -d ' \\t\\r\\n')
|
||||
@@ -263,23 +273,23 @@ public final class ConnectionStatusViewModel {
|
||||
)
|
||||
case .configMissing:
|
||||
return (
|
||||
"Hermes hasn't been set up yet",
|
||||
"`\(hermesHome)/config.yaml` is missing. Run `hermes setup` (or your first `hermes chat`) on the remote to create it. Scarf will go green automatically once it appears."
|
||||
"Hermes hasn't been run yet",
|
||||
"`\(hermesHome)/state.db` is missing — Hermes creates it on first agent run. Start any session on the remote (e.g. `hermes chat`) and Scarf will go green automatically."
|
||||
)
|
||||
case .configUnreadable:
|
||||
return (
|
||||
"Permission denied on config.yaml",
|
||||
"`\(hermesHome)/config.yaml` exists but the SSH user can't read it. Check ownership: `ls -l \(hermesHome)/config.yaml`. Either run Hermes as the SSH user, `chmod a+r` the file, or SSH as the Hermes user."
|
||||
"Permission denied on state.db",
|
||||
"`\(hermesHome)/state.db` exists but the SSH user can't read it. Check ownership: `ls -l \(hermesHome)/state.db`. Either run Hermes as the SSH user, `chmod a+r` the file, or SSH as the Hermes user."
|
||||
)
|
||||
case .profileActive(let name):
|
||||
return (
|
||||
"Hermes profile \"\(name)\" is active",
|
||||
"The remote is using Hermes profile `\(name)` — its config lives at `~/.hermes/profiles/\(name)/config.yaml`, not `\(hermesHome)/config.yaml`. Either set this server's Hermes home to `~/.hermes/profiles/\(name)` in Manage Servers → Edit, or run `hermes profile use default` on the remote to revert."
|
||||
"The remote is using Hermes profile `\(name)` — its state lives at `~/.hermes/profiles/\(name)/state.db`, not `\(hermesHome)/state.db`. Either set this server's Hermes home to `~/.hermes/profiles/\(name)` in Manage Servers → Edit, or run `hermes profile use default` on the remote to revert."
|
||||
)
|
||||
case .unknown:
|
||||
return (
|
||||
"Can't read Hermes state",
|
||||
"SSH is fine but Scarf can't reach `\(hermesHome)/config.yaml`. Run diagnostics for a full breakdown."
|
||||
"SSH is fine but Scarf can't reach `\(hermesHome)/state.db`. Run diagnostics for a full breakdown."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// Mac + iOS view model for the v0.12 Curator surface.
|
||||
///
|
||||
/// Drives `hermes curator status / run / pause / resume / pin / unpin /
|
||||
/// restore` plus a parsed view of `~/.hermes/skills/.curator_state`
|
||||
/// JSON. The CLI doesn't ship a `--json` flag for `status`, so we
|
||||
/// text-parse stdout (HermesCuratorStatusParser) and use the state
|
||||
/// file for richer last-run metadata.
|
||||
///
|
||||
/// Capability-gated: callers should construct this only when
|
||||
/// `HermesCapabilities.hasCurator` is true. The view model does not
|
||||
/// gate itself — the gate happens at sidebar/tab routing time.
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class CuratorViewModel {
|
||||
#if canImport(os)
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "CuratorViewModel")
|
||||
#endif
|
||||
|
||||
public let context: ServerContext
|
||||
|
||||
public private(set) var status: HermesCuratorStatus = .empty
|
||||
public private(set) var isLoading = false
|
||||
public private(set) var lastReportMarkdown: String?
|
||||
public var transientMessage: String?
|
||||
|
||||
public init(context: ServerContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
public func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
let context = self.context
|
||||
let parsed = await Task.detached(priority: .userInitiated) { () -> (HermesCuratorStatus, String?) in
|
||||
let textResult = Self.runCuratorStatus(context: context)
|
||||
let stateData = context.readData(context.paths.curatorStateFile)
|
||||
let parsed = HermesCuratorStatusParser.parse(text: textResult, stateFileJSON: stateData)
|
||||
// Best-effort markdown report: the state file points at the
|
||||
// most recent <YYYYMMDD-HHMMSS>/ dir; load REPORT.md from
|
||||
// there. Missing on first run, which is fine.
|
||||
var report: String?
|
||||
if let reportDir = parsed.lastReportPath {
|
||||
let reportPath = reportDir.hasSuffix("/")
|
||||
? "\(reportDir)REPORT.md"
|
||||
: "\(reportDir)/REPORT.md"
|
||||
report = context.readText(reportPath)
|
||||
}
|
||||
return (parsed, report)
|
||||
}.value
|
||||
self.status = parsed.0
|
||||
self.lastReportMarkdown = parsed.1
|
||||
}
|
||||
|
||||
public func runNow() async {
|
||||
await runAndReload(args: ["curator", "run"], successMessage: "Curator run started")
|
||||
}
|
||||
|
||||
public func pause() async {
|
||||
await runAndReload(args: ["curator", "pause"], successMessage: "Curator paused")
|
||||
}
|
||||
|
||||
public func resume() async {
|
||||
await runAndReload(args: ["curator", "resume"], successMessage: "Curator resumed")
|
||||
}
|
||||
|
||||
public func pin(_ skill: String) async {
|
||||
await runAndReload(args: ["curator", "pin", skill], successMessage: "Pinned \(skill)")
|
||||
}
|
||||
|
||||
public func unpin(_ skill: String) async {
|
||||
await runAndReload(args: ["curator", "unpin", skill], successMessage: "Unpinned \(skill)")
|
||||
}
|
||||
|
||||
public func restore(_ skill: String) async {
|
||||
await runAndReload(args: ["curator", "restore", skill], successMessage: "Restored \(skill)")
|
||||
}
|
||||
|
||||
private func runAndReload(args: [String], successMessage: String) async {
|
||||
let context = self.context
|
||||
let exitCode = await Task.detached(priority: .userInitiated) {
|
||||
Self.runHermes(context: context, args: args).exitCode
|
||||
}.value
|
||||
transientMessage = exitCode == 0 ? successMessage : "Command failed"
|
||||
await load()
|
||||
// Auto-clear toast after 3s.
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
self?.transientMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap the transport-level `runProcess` so the call sites don't
|
||||
/// have to reach for it directly. Combined stdout+stderr.
|
||||
nonisolated private static func runHermes(
|
||||
context: ServerContext,
|
||||
args: [String]
|
||||
) -> (exitCode: Int32, output: String) {
|
||||
let transport = context.makeTransport()
|
||||
do {
|
||||
let result = try transport.runProcess(
|
||||
executable: context.paths.hermesBinary,
|
||||
args: args,
|
||||
stdin: nil,
|
||||
timeout: 30
|
||||
)
|
||||
return (result.exitCode, result.stdoutString + result.stderrString)
|
||||
} catch let error as TransportError {
|
||||
return (-1, error.diagnosticStderr.isEmpty
|
||||
? (error.errorDescription ?? "transport error")
|
||||
: error.diagnosticStderr)
|
||||
} catch {
|
||||
return (-1, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func runCuratorStatus(context: ServerContext) -> String {
|
||||
runHermes(context: context, args: ["curator", "status"]).output
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,12 @@ public final class RichChatViewModel {
|
||||
/// users can copy-paste the raw output into a bug report.
|
||||
public var acpErrorDetails: String?
|
||||
|
||||
/// Lowercase OAuth provider name (`"nous"`, `"claude"`, …) when the
|
||||
/// most recent failure was an OAuth refresh-revocation Hermes asked
|
||||
/// the user to fix via re-authentication. Drives the chat banner's
|
||||
/// "Re-authenticate" button. Nil for any other failure mode.
|
||||
public var acpErrorOAuthProvider: String?
|
||||
|
||||
/// Optional stderr-tail provider the controller can hook up when it
|
||||
/// creates the ACPClient. Used by `handlePromptComplete` to enrich
|
||||
/// the error banner on non-retryable stopReasons. The closure is
|
||||
@@ -134,6 +140,7 @@ public final class RichChatViewModel {
|
||||
acpError = nil
|
||||
acpErrorHint = nil
|
||||
acpErrorDetails = nil
|
||||
acpErrorOAuthProvider = nil
|
||||
}
|
||||
|
||||
/// Populate the error triplet from a thrown Error + the ACPClient
|
||||
@@ -154,10 +161,11 @@ public final class RichChatViewModel {
|
||||
}
|
||||
let msg = error.localizedDescription
|
||||
let stderrTail = await client?.recentStderr ?? ""
|
||||
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||
let cls = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||
acpError = msg
|
||||
acpErrorHint = hint
|
||||
acpErrorHint = cls?.hint
|
||||
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
|
||||
acpErrorOAuthProvider = cls?.oauthProvider
|
||||
}
|
||||
|
||||
/// Populate the error triplet when `handlePromptComplete` sees a
|
||||
@@ -168,11 +176,11 @@ public final class RichChatViewModel {
|
||||
public func recordPromptStopFailure(stopReason: String, client: ACPClient?) async {
|
||||
let msg = "Prompt ended without a response (stopReason: \(stopReason))."
|
||||
let stderrTail = await client?.recentStderr ?? ""
|
||||
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||
?? Self.fallbackHint(for: stopReason)
|
||||
let cls = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||
acpError = msg
|
||||
acpErrorHint = hint
|
||||
acpErrorHint = cls?.hint ?? Self.fallbackHint(for: stopReason)
|
||||
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
|
||||
acpErrorOAuthProvider = cls?.oauthProvider
|
||||
}
|
||||
|
||||
/// Same as `recordPromptStopFailure` but pulls stderr from the
|
||||
@@ -182,11 +190,11 @@ public final class RichChatViewModel {
|
||||
private func recordPromptStopFailureUsingProvider(stopReason: String) async {
|
||||
let msg = "Prompt ended without a response (stopReason: \(stopReason))."
|
||||
let stderrTail = await acpStderrProvider?() ?? ""
|
||||
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||
?? Self.fallbackHint(for: stopReason)
|
||||
let cls = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||
acpError = msg
|
||||
acpErrorHint = hint
|
||||
acpErrorHint = cls?.hint ?? Self.fallbackHint(for: stopReason)
|
||||
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
|
||||
acpErrorOAuthProvider = cls?.oauthProvider
|
||||
}
|
||||
|
||||
private static func fallbackHint(for stopReason: String) -> String? {
|
||||
@@ -339,7 +347,34 @@ public final class RichChatViewModel {
|
||||
/// The original CLI session ID when resuming a CLI session via ACP.
|
||||
/// Used to combine old CLI messages with new ACP messages.
|
||||
public private(set) var originSessionId: String?
|
||||
/// Smallest DB id currently loaded for the *current session* (i.e.
|
||||
/// `sessionId`). Drives `loadEarlier()`: page back with
|
||||
/// `before: oldestLoadedMessageID`. `nil` when nothing has been
|
||||
/// loaded yet or the session has no DB-persisted messages.
|
||||
public private(set) var oldestLoadedMessageID: Int?
|
||||
/// Whether the most recent fetch suggests there are more older
|
||||
/// messages on disk that haven't been loaded into `messages` yet.
|
||||
/// Set to `true` when the initial fetch returned exactly `limit`
|
||||
/// rows (a strong hint the table has more). Drives the "Load
|
||||
/// earlier" button visibility in chat views.
|
||||
public private(set) var hasMoreHistory: Bool = false
|
||||
/// Cleared during a `loadEarlier()` fetch so the UI can show a
|
||||
/// spinner and we don't fan out duplicate page requests.
|
||||
public private(set) var isLoadingEarlier: Bool = false
|
||||
private var nextLocalId = -1
|
||||
|
||||
/// Issue #63: locally-created user messages awaiting state.db
|
||||
/// persistence, keyed by session id. ACP roundtrips Hermes' DB
|
||||
/// write asynchronously, so a user who sends a prompt and
|
||||
/// immediately switches to another session triggers `reset()`
|
||||
/// before Hermes flushes the row — `loadSessionHistory` then reads
|
||||
/// from a DB that doesn't have the message yet, and the bubble
|
||||
/// renders blank or vanishes on return. We hold a per-session
|
||||
/// copy here that survives `reset()` so `loadSessionHistory` can
|
||||
/// re-inject anything still in flight, and clean entries out as
|
||||
/// soon as a matching DB row appears.
|
||||
private var pendingLocalUserMessages: [String: [HermesMessage]] = [:]
|
||||
|
||||
private var streamingAssistantText = ""
|
||||
private var streamingThinkingText = ""
|
||||
private var streamingToolCalls: [HermesToolCall] = []
|
||||
@@ -382,6 +417,9 @@ public final class RichChatViewModel {
|
||||
lastKnownFingerprint = nil
|
||||
sessionId = nil
|
||||
originSessionId = nil
|
||||
oldestLoadedMessageID = nil
|
||||
hasMoreHistory = false
|
||||
isLoadingEarlier = false
|
||||
isAgentWorking = false
|
||||
userSendPending = false
|
||||
resetTimestamp = Date()
|
||||
@@ -451,6 +489,12 @@ public final class RichChatViewModel {
|
||||
reasoning: nil
|
||||
)
|
||||
messages.append(message)
|
||||
// Track the local message in the pending-user-messages cache
|
||||
// so a reset/resume cycle on this session before Hermes
|
||||
// persists the row can still re-inject it on return (#63).
|
||||
if let sid = sessionId {
|
||||
pendingLocalUserMessages[sid, default: []].append(message)
|
||||
}
|
||||
// Per-turn stopwatch (v2.5): record the start time only when
|
||||
// we're entering a fresh agent turn. /steer-style mid-run sends
|
||||
// arrive while isAgentWorking is already true; preserve the
|
||||
@@ -875,12 +919,15 @@ public final class RichChatViewModel {
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
|
||||
var dbMessages = await dataService.fetchMessages(sessionId: sessionId)
|
||||
// Reconnects don't generate hundreds of unseen messages, so a
|
||||
// 200-row tail is plenty for the merge — and it keeps us from
|
||||
// re-materializing 1000+ message sessions on every reconnect.
|
||||
var dbMessages = await dataService.fetchMessages(sessionId: sessionId, limit: HistoryPageSize.reconcile)
|
||||
|
||||
// If we have an origin session (CLI session continued via ACP),
|
||||
// include those messages too
|
||||
if let origin = originSessionId, origin != sessionId {
|
||||
let originMessages = await dataService.fetchMessages(sessionId: origin)
|
||||
let originMessages = await dataService.fetchMessages(sessionId: origin, limit: HistoryPageSize.reconcile)
|
||||
if !originMessages.isEmpty {
|
||||
dbMessages = originMessages + dbMessages
|
||||
dbMessages.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
|
||||
@@ -925,10 +972,18 @@ public final class RichChatViewModel {
|
||||
// would have cached a stale copy — on resume we need whatever
|
||||
// Hermes has actually persisted since then, or the resumed session
|
||||
// will show only history up to the moment the snapshot was taken.
|
||||
let opened = await dataService.refresh()
|
||||
// `forceFresh: true` refuses the stale-snapshot fallback the data
|
||||
// service grew in M11 — falling back here would silently hide
|
||||
// messages the agent streamed during the user's offline window.
|
||||
let opened = await dataService.refresh(forceFresh: true)
|
||||
guard opened else { return }
|
||||
|
||||
var allMessages = await dataService.fetchMessages(sessionId: sessionId)
|
||||
let pageSize = HistoryPageSize.initial
|
||||
var allMessages = await dataService.fetchMessages(sessionId: sessionId, limit: pageSize)
|
||||
// The DB has more on-disk history when the initial fetch
|
||||
// saturated the limit. The "Load earlier" affordance reads
|
||||
// this flag.
|
||||
var moreHistory = allMessages.count >= pageSize
|
||||
let session = await dataService.fetchSession(id: sessionId)
|
||||
|
||||
// If the ACP session is different from the origin, load its messages too
|
||||
@@ -936,17 +991,101 @@ public final class RichChatViewModel {
|
||||
if let acpId = acpSessionId, acpId != sessionId {
|
||||
originSessionId = sessionId
|
||||
self.sessionId = acpId
|
||||
let acpMessages = await dataService.fetchMessages(sessionId: acpId)
|
||||
let acpMessages = await dataService.fetchMessages(sessionId: acpId, limit: pageSize)
|
||||
if !acpMessages.isEmpty {
|
||||
allMessages.append(contentsOf: acpMessages)
|
||||
allMessages.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
|
||||
moreHistory = moreHistory || acpMessages.count >= pageSize
|
||||
}
|
||||
}
|
||||
|
||||
messages = allMessages
|
||||
// Issue #63 — re-inject any locally-created user messages
|
||||
// we still have on file for this session that haven't yet
|
||||
// shown up in state.db. Covers two paths:
|
||||
// 1. The user just sent a prompt then resumed a different
|
||||
// session before Hermes persisted the row. `reset()` had
|
||||
// cleared `messages` but the per-session pending cache
|
||||
// survived; restore the row here so the bubble doesn't
|
||||
// come back blank.
|
||||
// 2. The DB-resume path on first load — a previously-pending
|
||||
// message Hermes is still mid-write may not appear in
|
||||
// this fetch. We merge it in, and drop it from the cache
|
||||
// as soon as a matching DB row (same content, persisted
|
||||
// id ≥ 0) shows up.
|
||||
let pendingForSession = pendingLocalUserMessages[sessionId] ?? []
|
||||
if pendingForSession.isEmpty {
|
||||
messages = allMessages
|
||||
} else {
|
||||
var merged = allMessages
|
||||
var stillPending: [HermesMessage] = []
|
||||
for local in pendingForSession {
|
||||
let persisted = merged.contains { msg in
|
||||
msg.isUser && msg.id >= 0 && msg.content == local.content
|
||||
}
|
||||
if persisted {
|
||||
continue // DB caught up — drop the local copy
|
||||
}
|
||||
if !merged.contains(where: { $0.id == local.id }) {
|
||||
merged.append(local)
|
||||
}
|
||||
stillPending.append(local)
|
||||
}
|
||||
merged.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
|
||||
messages = merged
|
||||
if stillPending.isEmpty {
|
||||
pendingLocalUserMessages.removeValue(forKey: sessionId)
|
||||
} else {
|
||||
pendingLocalUserMessages[sessionId] = stillPending
|
||||
}
|
||||
}
|
||||
currentSession = session
|
||||
let minId = allMessages.map(\.id).min() ?? 0
|
||||
let minId = messages.map(\.id).min() ?? 0
|
||||
nextLocalId = min(minId - 1, -1)
|
||||
// Track the oldest loaded id from THIS session (not the merged
|
||||
// origin) so `loadEarlier()` pages back through the live ACP
|
||||
// session's history. Cross-session backfill (paging into the
|
||||
// CLI origin) isn't supported in v1 — the merged 2× pageSize
|
||||
// is enough headroom for the dashboard-resume case.
|
||||
let currentSessionId = self.sessionId ?? sessionId
|
||||
oldestLoadedMessageID = allMessages
|
||||
.filter { $0.sessionId == currentSessionId }
|
||||
.map(\.id)
|
||||
.min()
|
||||
hasMoreHistory = moreHistory
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
// MARK: - Load Earlier (pagination)
|
||||
|
||||
/// Page back through the current session's DB-persisted history
|
||||
/// before `oldestLoadedMessageID` and prepend the page to
|
||||
/// `messages`. Cheap on the SQLite side (`id` is the primary
|
||||
/// key); the cost is the data-service `open()` round-trip on
|
||||
/// remote contexts. `pageSize` defaults to the same 200-row
|
||||
/// budget as the initial load.
|
||||
public func loadEarlier(pageSize: Int = HistoryPageSize.initial) async {
|
||||
guard !isLoadingEarlier, hasMoreHistory else { return }
|
||||
guard let sessionId, let oldest = oldestLoadedMessageID else { return }
|
||||
isLoadingEarlier = true
|
||||
defer { isLoadingEarlier = false }
|
||||
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
|
||||
let older = await dataService.fetchMessages(
|
||||
sessionId: sessionId,
|
||||
limit: pageSize,
|
||||
before: oldest
|
||||
)
|
||||
guard !older.isEmpty else {
|
||||
hasMoreHistory = false
|
||||
return
|
||||
}
|
||||
messages.insert(contentsOf: older, at: 0)
|
||||
oldestLoadedMessageID = older.first?.id
|
||||
// If this fetch returned fewer than the page size we've hit
|
||||
// the bottom of the table — no further pages worth fetching.
|
||||
hasMoreHistory = older.count >= pageSize
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
@@ -990,7 +1129,7 @@ public final class RichChatViewModel {
|
||||
let fingerprint = await dataService.fetchMessageFingerprint(sessionId: sessionId)
|
||||
|
||||
if fingerprint != lastKnownFingerprint {
|
||||
let fetched = await dataService.fetchMessages(sessionId: sessionId)
|
||||
let fetched = await dataService.fetchMessages(sessionId: sessionId, limit: HistoryPageSize.polling)
|
||||
let session = await dataService.fetchSession(id: sessionId)
|
||||
lastKnownFingerprint = fingerprint
|
||||
|
||||
|
||||
@@ -70,19 +70,109 @@ public final class SkillsViewModel {
|
||||
/// Awaitable scan. iOS's `.task { await vm.load() }` and the
|
||||
/// ScarfCore unit tests use this directly; Mac call sites wrap in
|
||||
/// `Task { await ... }` from `onAppear`.
|
||||
///
|
||||
/// Pinned-name set is auto-fetched from the curator state file on
|
||||
/// v0.12+ hosts; callers can override by passing an explicit set
|
||||
/// (the Curator screen does this when it has a fresher snapshot in
|
||||
/// hand).
|
||||
@MainActor
|
||||
public func load() async {
|
||||
public func load(pinnedNames: Set<String>? = nil) async {
|
||||
isLoading = true
|
||||
lastError = nil
|
||||
let ctx = context
|
||||
let xport = transport
|
||||
let pins = pinnedNames
|
||||
let cats: [HermesSkillCategory] = await Task.detached {
|
||||
SkillsScanner.scan(context: ctx, transport: xport)
|
||||
let disabled = Self.readDisabledSkillNames(context: ctx)
|
||||
let pinned = pins ?? Self.readPinnedSkillNames(context: ctx)
|
||||
return SkillsScanner.scan(
|
||||
context: ctx,
|
||||
transport: xport,
|
||||
disabledNames: disabled,
|
||||
pinnedNames: pinned
|
||||
)
|
||||
}.value
|
||||
categories = cats
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
/// Read the curator's pinned-skills list from
|
||||
/// `~/.hermes/skills/.curator_state` (JSON despite the lack of an
|
||||
/// extension). Pre-v0.12 hosts won't have this file yet — return
|
||||
/// an empty set so the pin badge stays hidden.
|
||||
nonisolated static func readPinnedSkillNames(context: ServerContext) -> Set<String> {
|
||||
guard let data = context.readData(context.paths.curatorStateFile),
|
||||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else { return [] }
|
||||
// Curator stores pins in either `pinned: [name, ...]` or
|
||||
// `pinned_skills: [name, ...]` depending on Hermes version —
|
||||
// accept both shapes so we don't break on a future rename.
|
||||
let raw = (obj["pinned"] as? [String]) ?? (obj["pinned_skills"] as? [String]) ?? []
|
||||
return Set(raw)
|
||||
}
|
||||
|
||||
/// Read the `skills.disabled:` array from `~/.hermes/config.yaml`.
|
||||
/// Hermes v0.12 stores skill disable state there (one global list
|
||||
/// + optional `skills.platform_disabled` overrides). Returns the
|
||||
/// global list only — Scarf doesn't surface platform overrides
|
||||
/// today. Empty set on missing file / parse failure.
|
||||
nonisolated static func readDisabledSkillNames(context: ServerContext) -> Set<String> {
|
||||
guard let yaml = context.readText(context.paths.configYAML) else { return [] }
|
||||
// Lightweight match: find `skills:` block, then `disabled:` array
|
||||
// inside it. The full YAML parser is overkill for one nested array.
|
||||
var inSkillsBlock = false
|
||||
var disabledIndent: Int?
|
||||
var collected: [String] = []
|
||||
for raw in yaml.components(separatedBy: "\n") {
|
||||
// Top-level `skills:` declaration.
|
||||
if raw.hasPrefix("skills:") {
|
||||
inSkillsBlock = true
|
||||
continue
|
||||
}
|
||||
if inSkillsBlock {
|
||||
// A new top-level block ends the `skills:` scope.
|
||||
if !raw.hasPrefix(" ") && !raw.hasPrefix("\t") && raw.contains(":") {
|
||||
break
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.hasPrefix("disabled:") {
|
||||
// Inline form `disabled: [a, b, c]`
|
||||
let after = trimmed.dropFirst("disabled:".count).trimmingCharacters(in: .whitespaces)
|
||||
if after.hasPrefix("[") && after.hasSuffix("]") {
|
||||
let body = after.dropFirst().dropLast()
|
||||
let parts = body.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||
for p in parts where !p.isEmpty {
|
||||
collected.append(p.trimmingCharacters(in: CharacterSet(charactersIn: "\"' ")))
|
||||
}
|
||||
return Set(collected)
|
||||
}
|
||||
// Block form: `disabled:` followed by ` - name`
|
||||
disabledIndent = raw.prefix { $0 == " " || $0 == "\t" }.count
|
||||
continue
|
||||
}
|
||||
if let baseIndent = disabledIndent {
|
||||
let leading = raw.prefix { $0 == " " || $0 == "\t" }.count
|
||||
if !trimmed.isEmpty {
|
||||
// PyYAML's default `yaml.dump` emits list items at the
|
||||
// same indent as the parent key, so `- foo` lines for
|
||||
// `disabled:` arrive at `leading == baseIndent`. Only
|
||||
// a strictly shallower indent — or a same-indent line
|
||||
// that isn't a list item (sibling key) — ends the block.
|
||||
if leading < baseIndent { break }
|
||||
if leading == baseIndent && !trimmed.hasPrefix("- ") { break }
|
||||
}
|
||||
if trimmed.hasPrefix("- ") {
|
||||
let name = trimmed.dropFirst(2).trimmingCharacters(in: CharacterSet(charactersIn: "\"' "))
|
||||
if !name.isEmpty {
|
||||
collected.append(String(name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Set(collected)
|
||||
}
|
||||
|
||||
public func selectSkill(_ skill: HermesSkill) {
|
||||
selectedSkill = skill
|
||||
let mainFile = skill.files.first(where: { $0.hasSuffix(".md") }) ?? skill.files.first
|
||||
@@ -200,6 +290,68 @@ public final class SkillsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// v0.12: install a skill from a direct HTTPS URL pointing at a
|
||||
/// SKILL.md (or a tarball). Hermes pulls + installs without going
|
||||
/// through the registry indirection. The Mac UI gates this on
|
||||
/// `HermesCapabilities.hasSkillURLInstall` so a v0.11 host doesn't
|
||||
/// see a button that errors out with "unrecognized argument".
|
||||
///
|
||||
/// `categoryOverride` and `nameOverride` map to `--category` /
|
||||
/// `--name` flags Hermes ships for direct-URL installs (the URL's
|
||||
/// SKILL.md may not declare those, especially for one-off scripts).
|
||||
public func installFromURL(
|
||||
_ url: String,
|
||||
categoryOverride: String? = nil,
|
||||
nameOverride: String? = nil
|
||||
) {
|
||||
isHubLoading = true
|
||||
hubMessage = "Installing from URL…"
|
||||
let bin = context.paths.hermesBinary
|
||||
let xport = transport
|
||||
Task.detached { [weak self] in
|
||||
var args = ["skills", "install", url, "--yes"]
|
||||
if let category = categoryOverride, !category.isEmpty {
|
||||
args += ["--category", category]
|
||||
}
|
||||
if let name = nameOverride, !name.isEmpty {
|
||||
args += ["--name", name]
|
||||
}
|
||||
let result = Self.runHermes(
|
||||
executable: bin,
|
||||
args: args,
|
||||
transport: xport,
|
||||
timeout: 180
|
||||
)
|
||||
await self?.finishInstall(identifier: url, exitCode: result.exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
/// v0.12: trigger a hot reload of `~/.hermes/skills/` so the agent
|
||||
/// picks up file edits without a session restart. Hermes ships
|
||||
/// `/reload-skills` as a slash command in chat AND `hermes skills
|
||||
/// audit` as a CLI form. We use `audit` here so the reload works
|
||||
/// even when no chat session is active.
|
||||
public func reloadSkills() async {
|
||||
isHubLoading = true
|
||||
let bin = context.paths.hermesBinary
|
||||
let xport = transport
|
||||
let result = await Task.detached {
|
||||
Self.runHermes(
|
||||
executable: bin,
|
||||
args: ["skills", "audit"],
|
||||
transport: xport,
|
||||
timeout: 30
|
||||
)
|
||||
}.value
|
||||
hubMessage = result.exitCode == 0 ? "Skills reloaded" : "Reload failed"
|
||||
isHubLoading = false
|
||||
await load()
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
self?.hubMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
public func uninstallHubSkill(_ identifier: String) {
|
||||
let bin = context.paths.hermesBinary
|
||||
let xport = transport
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ScarfCore
|
||||
|
||||
/// Pure parser tests for `HermesCapabilities`. The detection store
|
||||
/// (`HermesCapabilitiesStore`) is exercised separately under integration
|
||||
/// tests since it spawns `hermes --version`.
|
||||
@Suite struct HermesCapabilitiesTests {
|
||||
|
||||
// MARK: - Version line parsing
|
||||
|
||||
@Test func parseV012ReleaseLine() {
|
||||
let caps = HermesCapabilities.parseLine("Hermes Agent v0.12.0 (2026.4.30)")
|
||||
#expect(caps.semver == HermesCapabilities.SemVer(major: 0, minor: 12, patch: 0))
|
||||
#expect(caps.dateVersion == HermesCapabilities.DateVersion(year: 2026, month: 4, day: 30))
|
||||
#expect(caps.detected)
|
||||
}
|
||||
|
||||
@Test func parseV011ReleaseLine() {
|
||||
let caps = HermesCapabilities.parseLine("Hermes Agent v0.11.0 (2026.4.23)")
|
||||
#expect(caps.semver == HermesCapabilities.SemVer(major: 0, minor: 11, patch: 0))
|
||||
#expect(caps.dateVersion == HermesCapabilities.DateVersion(year: 2026, month: 4, day: 23))
|
||||
}
|
||||
|
||||
@Test func parseSemverWithoutDate() {
|
||||
// Some older Hermes builds emit only the semver suffix.
|
||||
let caps = HermesCapabilities.parseLine("Hermes Agent v0.10.5")
|
||||
#expect(caps.semver == HermesCapabilities.SemVer(major: 0, minor: 10, patch: 5))
|
||||
#expect(caps.dateVersion == nil)
|
||||
}
|
||||
|
||||
@Test func parseFullStdoutBlock() {
|
||||
// Real `hermes --version` output is multi-line; the version sits on
|
||||
// the first line and the rest is metadata.
|
||||
let stdout = """
|
||||
Hermes Agent v0.12.0 (2026.4.30)
|
||||
Project: /Users/alan/.hermes/hermes-agent
|
||||
Python: 3.11.15
|
||||
OpenAI SDK: 2.31.0
|
||||
Up to date
|
||||
"""
|
||||
let caps = HermesCapabilities.parse(stdout)
|
||||
#expect(caps.semver?.minor == 12)
|
||||
#expect(caps.dateVersion?.year == 2026)
|
||||
}
|
||||
|
||||
@Test func parseRejectsUnrelatedOutput() {
|
||||
let caps = HermesCapabilities.parse("hermes: command not found")
|
||||
#expect(caps.semver == nil)
|
||||
#expect(!caps.detected)
|
||||
}
|
||||
|
||||
@Test func parseHandlesEmptyString() {
|
||||
let caps = HermesCapabilities.parse("")
|
||||
#expect(caps == .empty)
|
||||
}
|
||||
|
||||
@Test func parseHandlesPartialSemver() {
|
||||
// "v0.11" without the patch component shouldn't accidentally match.
|
||||
let caps = HermesCapabilities.parseLine("Hermes Agent v0.11")
|
||||
#expect(caps.semver == nil)
|
||||
}
|
||||
|
||||
// MARK: - SemVer ordering
|
||||
|
||||
@Test func semverOrdering() {
|
||||
let v0_11_0 = HermesCapabilities.SemVer(major: 0, minor: 11, patch: 0)
|
||||
let v0_12_0 = HermesCapabilities.SemVer(major: 0, minor: 12, patch: 0)
|
||||
let v0_12_5 = HermesCapabilities.SemVer(major: 0, minor: 12, patch: 5)
|
||||
let v1_0_0 = HermesCapabilities.SemVer(major: 1, minor: 0, patch: 0)
|
||||
#expect(v0_11_0 < v0_12_0)
|
||||
#expect(v0_12_0 < v0_12_5)
|
||||
#expect(v0_12_5 < v1_0_0)
|
||||
}
|
||||
|
||||
// MARK: - Capability flags
|
||||
|
||||
@Test func v012FlagsAllOn() {
|
||||
let caps = HermesCapabilities.parseLine("Hermes Agent v0.12.0 (2026.4.30)")
|
||||
#expect(caps.hasCurator)
|
||||
#expect(caps.hasFallbackCommand)
|
||||
#expect(caps.hasKanban)
|
||||
#expect(caps.hasOneShot)
|
||||
#expect(caps.hasSkillURLInstall)
|
||||
#expect(caps.hasACPImagePrompts)
|
||||
#expect(caps.hasUpdateCheck)
|
||||
#expect(caps.hasPiperTTS)
|
||||
#expect(caps.hasVercelTerminal)
|
||||
#expect(caps.hasCuratorAux)
|
||||
#expect(caps.hasTeamsPlatform)
|
||||
#expect(caps.hasYuanbaoPlatform)
|
||||
#expect(caps.hasCronWorkdir)
|
||||
#expect(caps.hasPromptCacheTTL)
|
||||
#expect(caps.hasRedactionToggle)
|
||||
// flush_memories was REMOVED in v0.12 — flag inverts.
|
||||
#expect(!caps.hasFlushMemoriesAux)
|
||||
}
|
||||
|
||||
@Test func v011FlagsAllOff() {
|
||||
let caps = HermesCapabilities.parseLine("Hermes Agent v0.11.0 (2026.4.23)")
|
||||
#expect(!caps.hasCurator)
|
||||
#expect(!caps.hasFallbackCommand)
|
||||
#expect(!caps.hasKanban)
|
||||
#expect(!caps.hasOneShot)
|
||||
#expect(!caps.hasSkillURLInstall)
|
||||
#expect(!caps.hasACPImagePrompts)
|
||||
#expect(!caps.hasUpdateCheck)
|
||||
#expect(!caps.hasPiperTTS)
|
||||
#expect(!caps.hasVercelTerminal)
|
||||
#expect(!caps.hasCuratorAux)
|
||||
#expect(!caps.hasTeamsPlatform)
|
||||
#expect(!caps.hasYuanbaoPlatform)
|
||||
#expect(!caps.hasCronWorkdir)
|
||||
#expect(!caps.hasPromptCacheTTL)
|
||||
#expect(!caps.hasRedactionToggle)
|
||||
// flush_memories aux row was still alive on v0.11.
|
||||
#expect(caps.hasFlushMemoriesAux)
|
||||
}
|
||||
|
||||
@Test func emptyCapabilitiesAllOff() {
|
||||
// Undetected installs should hide every gated UI surface.
|
||||
let caps = HermesCapabilities.empty
|
||||
#expect(!caps.hasCurator)
|
||||
#expect(!caps.hasFlushMemoriesAux) // unknown → hide either way
|
||||
#expect(!caps.detected)
|
||||
}
|
||||
|
||||
@Test func futureVersionRetainsCapabilities() {
|
||||
// A v0.13 (hypothetical) should still see all v0.12 capabilities on.
|
||||
let caps = HermesCapabilities.parseLine("Hermes Agent v0.13.0 (2026.6.1)")
|
||||
#expect(caps.hasCurator)
|
||||
#expect(caps.hasACPImagePrompts)
|
||||
// And flush_memories stays gone.
|
||||
#expect(!caps.hasFlushMemoriesAux)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ScarfCore
|
||||
|
||||
@Suite struct HermesCuratorParserTests {
|
||||
|
||||
/// Real `hermes curator status` output captured from a v0.12.0
|
||||
/// install with no curator runs yet. Locks in the empty-state
|
||||
/// happy path so a Hermes layout tweak surfaces here before
|
||||
/// CuratorView starts rendering "—" placeholders silently.
|
||||
private static let realFreshOutput = """
|
||||
curator: ENABLED
|
||||
runs: 0
|
||||
last run: never
|
||||
last summary: (none)
|
||||
interval: every 7d
|
||||
stale after: 30d unused
|
||||
archive after: 90d unused
|
||||
|
||||
agent-created skills: 18 total
|
||||
active 18
|
||||
stale 0
|
||||
archived 0
|
||||
|
||||
least recently active (top 5):
|
||||
Scarf Dashboard Chart Widget Parse Error Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
Scarf Project Registry Format Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
clip activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
find-nearby activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
gguf-quantization activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
|
||||
least active (top 5):
|
||||
Scarf Dashboard Chart Widget Parse Error Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
Scarf Project Registry Format Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
clip activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
find-nearby activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
gguf-quantization activity= 0 use= 0 view= 0 patches= 0 last_activity=never
|
||||
"""
|
||||
|
||||
@Test func parseRealFreshOutput() {
|
||||
let s = HermesCuratorStatusParser.parse(text: Self.realFreshOutput)
|
||||
#expect(s.state == .enabled)
|
||||
#expect(s.runCount == 0)
|
||||
#expect(s.lastRunISO == nil)
|
||||
#expect(s.lastSummary == nil)
|
||||
#expect(s.intervalLabel == "every 7d")
|
||||
#expect(s.staleAfterLabel == "30d unused")
|
||||
#expect(s.archiveAfterLabel == "90d unused")
|
||||
#expect(s.totalSkills == 18)
|
||||
#expect(s.activeSkills == 18)
|
||||
#expect(s.staleSkills == 0)
|
||||
#expect(s.archivedSkills == 0)
|
||||
#expect(s.pinnedNames.isEmpty)
|
||||
#expect(s.leastRecentlyActive.count == 5)
|
||||
#expect(s.leastActive.count == 5)
|
||||
#expect(s.mostActive.isEmpty)
|
||||
let firstRow = s.leastRecentlyActive.first
|
||||
#expect(firstRow?.name == "Scarf Dashboard Chart Widget Parse Error Fix")
|
||||
#expect(firstRow?.activityCount == 0)
|
||||
#expect(firstRow?.lastActivityLabel == "never")
|
||||
}
|
||||
|
||||
@Test func parsedPausedState() {
|
||||
let text = """
|
||||
curator: PAUSED
|
||||
runs: 5
|
||||
last run: 2026-04-29T03:10:00Z
|
||||
last summary: pruned 2 skills, consolidated 1
|
||||
interval: every 7d
|
||||
stale after: 30d unused
|
||||
archive after: 90d unused
|
||||
|
||||
agent-created skills: 12 total
|
||||
active 8
|
||||
stale 3
|
||||
archived 1
|
||||
|
||||
pinned (2): kanban-orchestrator, scarf-template-author
|
||||
"""
|
||||
let s = HermesCuratorStatusParser.parse(text: text)
|
||||
#expect(s.state == .paused)
|
||||
#expect(s.runCount == 5)
|
||||
#expect(s.lastRunISO == "2026-04-29T03:10:00Z")
|
||||
#expect(s.lastSummary == "pruned 2 skills, consolidated 1")
|
||||
#expect(s.totalSkills == 12)
|
||||
#expect(s.activeSkills == 8)
|
||||
#expect(s.staleSkills == 3)
|
||||
#expect(s.archivedSkills == 1)
|
||||
#expect(s.pinnedNames == ["kanban-orchestrator", "scarf-template-author"])
|
||||
}
|
||||
|
||||
@Test func stateFileOverridesTextSummary() {
|
||||
// The state file is authoritative for last_run_at /
|
||||
// last_run_summary / last_report_path because it carries full
|
||||
// ISO timestamps the text output may have rounded. Verify that
|
||||
// a state file with richer values overrides parsed text.
|
||||
let text = """
|
||||
curator: ENABLED
|
||||
runs: 1
|
||||
last run: 2026-04-30T11:00:00Z
|
||||
last summary: short
|
||||
interval: every 7d
|
||||
stale after: 30d unused
|
||||
archive after: 90d unused
|
||||
|
||||
agent-created skills: 3 total
|
||||
active 3
|
||||
stale 0
|
||||
archived 0
|
||||
"""
|
||||
let stateJSON: [String: Any] = [
|
||||
"run_count": 4,
|
||||
"last_run_at": "2026-04-30T18:42:13.001Z",
|
||||
"last_run_summary": "richer summary from state file",
|
||||
"last_report_path": "/Users/u/.hermes/logs/curator/20260430-184213"
|
||||
]
|
||||
let data = try! JSONSerialization.data(withJSONObject: stateJSON)
|
||||
let s = HermesCuratorStatusParser.parse(text: text, stateFileJSON: data)
|
||||
#expect(s.runCount == 4)
|
||||
#expect(s.lastRunISO == "2026-04-30T18:42:13.001Z")
|
||||
#expect(s.lastSummary == "richer summary from state file")
|
||||
#expect(s.lastReportPath == "/Users/u/.hermes/logs/curator/20260430-184213")
|
||||
}
|
||||
|
||||
@Test func parsedDisabledStatus() {
|
||||
let s = HermesCuratorStatusParser.parse(text: "curator: DISABLED\n runs: 0\n")
|
||||
#expect(s.state == .disabled)
|
||||
}
|
||||
|
||||
@Test func parsedEmptyOutputStaysSafe() {
|
||||
let s = HermesCuratorStatusParser.parse(text: "")
|
||||
#expect(s.state == .unknown)
|
||||
#expect(s.totalSkills == 0)
|
||||
#expect(s.leastRecentlyActive.isEmpty)
|
||||
}
|
||||
|
||||
@Test func skillRowParserHandlesMultiWordNames() {
|
||||
// Names with spaces are common (Scarf Dashboard Chart Widget…)
|
||||
// The parser slices at the first `activity=` so names can be
|
||||
// arbitrary length without breaking the counter columns.
|
||||
let row = " Some Long Skill Name v2 activity= 12 use= 4 view= 6 patches= 2 last_activity=2026-04-25"
|
||||
let s = HermesCuratorStatusParser.parse(text: """
|
||||
least recently active (top 5):
|
||||
\(row)
|
||||
""")
|
||||
let parsed = s.leastRecentlyActive.first
|
||||
#expect(parsed?.name == "Some Long Skill Name v2")
|
||||
#expect(parsed?.activityCount == 12)
|
||||
#expect(parsed?.useCount == 4)
|
||||
#expect(parsed?.viewCount == 6)
|
||||
#expect(parsed?.patchCount == 2)
|
||||
#expect(parsed?.lastActivityLabel == "2026-04-25")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ScarfCore
|
||||
|
||||
/// Exercises the `SCARF_HERMES_HOME` test-mode override on `HermesProfileResolver`.
|
||||
/// The override is the seam every E2E test relies on — without it, tests would
|
||||
/// touch the user's real `~/.hermes`. Serialized because we mutate process-wide
|
||||
/// environment.
|
||||
///
|
||||
/// **Marker file requirement.** As of v2.8 the override only activates when the
|
||||
/// path contains the sentinel `HermesProfileResolver.testHomeMarkerFilename`.
|
||||
/// Tests that want the override active drop the marker before `setenv`. Tests
|
||||
/// that want to verify the override is rejected (relative path, missing
|
||||
/// marker, empty value) skip the marker. The hardening prevents a leaked env
|
||||
/// var from ever pivoting Scarf off the user's real `~/.hermes`.
|
||||
@Suite(.serialized)
|
||||
struct HermesProfileResolverOverrideTests {
|
||||
|
||||
private static let envKey = "SCARF_HERMES_HOME"
|
||||
|
||||
@Test func absoluteOverrideTakesPrecedenceWhenMarkerPresent() throws {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
let tmp = NSTemporaryDirectory().appending("scarf-test-home-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(atPath: tmp, withIntermediateDirectories: true)
|
||||
try Data().write(to: URL(fileURLWithPath: tmp + "/" + HermesProfileResolver.testHomeMarkerFilename))
|
||||
defer { try? FileManager.default.removeItem(atPath: tmp) }
|
||||
setenv(Self.envKey, tmp, 1)
|
||||
|
||||
#expect(HermesProfileResolver.resolveLocalHome() == tmp)
|
||||
#expect(HermesProfileResolver.activeProfileName() == "test-override")
|
||||
}
|
||||
|
||||
@Test func overrideIsIgnoredWhenMarkerMissing() throws {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
// Real-looking dir, no marker — exactly the shape a leaked env
|
||||
// var or misconfigured launchctl plist would produce. Must NOT
|
||||
// override; must fall through to the real resolver.
|
||||
let tmp = NSTemporaryDirectory().appending("scarf-no-marker-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(atPath: tmp, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(atPath: tmp) }
|
||||
setenv(Self.envKey, tmp, 1)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
|
||||
let resolved = HermesProfileResolver.resolveLocalHome()
|
||||
#expect(resolved != tmp)
|
||||
#expect(resolved.hasSuffix("/.hermes") || resolved.contains("/.hermes/profiles/"))
|
||||
}
|
||||
|
||||
@Test func emptyOverrideFallsThrough() {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
setenv(Self.envKey, "", 1)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
|
||||
let resolved = HermesProfileResolver.resolveLocalHome()
|
||||
#expect(!resolved.isEmpty)
|
||||
#expect(resolved.hasSuffix("/.hermes") || resolved.contains("/.hermes/profiles/"))
|
||||
}
|
||||
|
||||
@Test func relativeOverrideIsRejected() {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
setenv(Self.envKey, "relative/path", 1)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
|
||||
let resolved = HermesProfileResolver.resolveLocalHome()
|
||||
#expect(!resolved.hasSuffix("relative/path"))
|
||||
}
|
||||
|
||||
@Test func unsetOverrideUsesProfileResolver() {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
unsetenv(Self.envKey)
|
||||
HermesProfileResolver.invalidateCache()
|
||||
|
||||
let resolved = HermesProfileResolver.resolveLocalHome()
|
||||
#expect(!resolved.isEmpty)
|
||||
}
|
||||
|
||||
@Test func overrideBypassesCacheWhenMarkerPresent() throws {
|
||||
let saved = ProcessInfo.processInfo.environment[Self.envKey]
|
||||
defer { restore(saved) }
|
||||
|
||||
let first = NSTemporaryDirectory().appending("scarf-cache-bypass-1-\(UUID().uuidString)")
|
||||
let second = NSTemporaryDirectory().appending("scarf-cache-bypass-2-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(atPath: first, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(atPath: second, withIntermediateDirectories: true)
|
||||
try Data().write(to: URL(fileURLWithPath: first + "/" + HermesProfileResolver.testHomeMarkerFilename))
|
||||
try Data().write(to: URL(fileURLWithPath: second + "/" + HermesProfileResolver.testHomeMarkerFilename))
|
||||
defer {
|
||||
try? FileManager.default.removeItem(atPath: first)
|
||||
try? FileManager.default.removeItem(atPath: second)
|
||||
}
|
||||
|
||||
setenv(Self.envKey, first, 1)
|
||||
#expect(HermesProfileResolver.resolveLocalHome() == first)
|
||||
|
||||
// Flip env var without invalidating the cache. Override is read
|
||||
// fresh on every call, so the new value takes effect immediately.
|
||||
setenv(Self.envKey, second, 1)
|
||||
#expect(HermesProfileResolver.resolveLocalHome() == second)
|
||||
}
|
||||
|
||||
private func restore(_ saved: String?) {
|
||||
if let saved {
|
||||
setenv(Self.envKey, saved, 1)
|
||||
} else {
|
||||
unsetenv(Self.envKey)
|
||||
}
|
||||
HermesProfileResolver.invalidateCache()
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,8 @@ import Foundation
|
||||
let b: ConnectionStatusViewModel.Status = .connected
|
||||
#expect(a == b)
|
||||
|
||||
let c: ConnectionStatusViewModel.Status = .degraded(reason: "x")
|
||||
let d: ConnectionStatusViewModel.Status = .degraded(reason: "x")
|
||||
let c: ConnectionStatusViewModel.Status = .degraded(reason: "x", hint: "y", cause: .unknown)
|
||||
let d: ConnectionStatusViewModel.Status = .degraded(reason: "x", hint: "y", cause: .unknown)
|
||||
#expect(c == d)
|
||||
|
||||
let e: ConnectionStatusViewModel.Status = .idle
|
||||
|
||||
@@ -265,19 +265,20 @@ import Foundation
|
||||
errorMessage: "No Anthropic credentials found",
|
||||
stderrTail: ""
|
||||
)
|
||||
#expect(noCreds?.contains("ANTHROPIC_API_KEY") == true)
|
||||
#expect(noCreds?.hint.contains("ANTHROPIC_API_KEY") == true)
|
||||
#expect(noCreds?.oauthProvider == nil)
|
||||
|
||||
let missingBinary = ACPErrorHint.classify(
|
||||
errorMessage: "",
|
||||
stderrTail: "No such file or directory: 'npx'"
|
||||
)
|
||||
#expect(missingBinary?.contains("npx") == true)
|
||||
#expect(missingBinary?.hint.contains("npx") == true)
|
||||
|
||||
let rateLimit = ACPErrorHint.classify(
|
||||
errorMessage: "",
|
||||
stderrTail: "HTTP 429 Too Many Requests: rate limit"
|
||||
)
|
||||
#expect(rateLimit?.contains("rate-limit") == true)
|
||||
#expect(rateLimit?.hint.contains("rate-limit") == true)
|
||||
|
||||
let unknown = ACPErrorHint.classify(
|
||||
errorMessage: "weird thing",
|
||||
@@ -286,6 +287,53 @@ import Foundation
|
||||
#expect(unknown == nil)
|
||||
}
|
||||
|
||||
@Test func errorHintsClassifyOAuthRefreshRevoked() {
|
||||
// Primary trigger — Hermes's verbatim message when an OAuth
|
||||
// refresh token can't mint a new access token. Provider name
|
||||
// appears alongside; classifier should extract it.
|
||||
let revoked = ACPErrorHint.classify(
|
||||
errorMessage: "",
|
||||
stderrTail: "Refresh session has been revoked. Run `hermes model` to re-authenticate."
|
||||
)
|
||||
#expect(revoked?.hint.contains("Re-authenticate") == true)
|
||||
|
||||
// With provider context — surfaces the affected provider name
|
||||
// so the chat banner can offer a one-click re-auth that targets
|
||||
// the right OAuth flow.
|
||||
let revokedWithProvider = ACPErrorHint.classify(
|
||||
errorMessage: "",
|
||||
stderrTail: "Provider claude: Refresh session has been revoked. Run `hermes model` to re-authenticate."
|
||||
)
|
||||
#expect(revokedWithProvider?.oauthProvider == "claude")
|
||||
|
||||
// 401 + OAuth provider name — broader catchall for providers
|
||||
// that don't print the verbatim "revoked" string.
|
||||
let unauthorized = ACPErrorHint.classify(
|
||||
errorMessage: "",
|
||||
stderrTail: "HTTP 401 Unauthorized from nous portal"
|
||||
)
|
||||
#expect(unauthorized?.oauthProvider == "nous")
|
||||
#expect(unauthorized?.hint.contains("OAuth") == true)
|
||||
|
||||
// Unauthorized on a non-OAuth provider (API-key based) should
|
||||
// NOT classify as OAuth revocation — no `oauthProvider` known
|
||||
// to dispatch the re-auth flow against.
|
||||
let unauthorizedNonOAuth = ACPErrorHint.classify(
|
||||
errorMessage: "",
|
||||
stderrTail: "HTTP 401 Unauthorized for groq"
|
||||
)
|
||||
#expect(unauthorizedNonOAuth?.oauthProvider == nil)
|
||||
|
||||
// Word-boundary check — "anthropicapi" must not false-trigger
|
||||
// on "anthropic". Without word boundaries this catches the
|
||||
// wrong cases.
|
||||
let substringNoMatch = ACPErrorHint.classify(
|
||||
errorMessage: "",
|
||||
stderrTail: "401 unauthorized: anthropicapi.example.com"
|
||||
)
|
||||
#expect(substringNoMatch?.oauthProvider != "anthropic")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Poll `predicate` every ~20ms up to `timeout` seconds. Fails if
|
||||
|
||||
@@ -456,6 +456,7 @@ import Foundation
|
||||
}
|
||||
}
|
||||
func snapshotSQLite(remotePath: String) throws -> URL { URL(fileURLWithPath: remotePath) }
|
||||
var cachedSnapshotPath: URL? { nil }
|
||||
func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
|
||||
AsyncStream { $0.finish() }
|
||||
}
|
||||
|
||||
@@ -165,6 +165,15 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
|
||||
try runSync { try await self.asyncSnapshotSQLite(remotePath: remotePath) }
|
||||
}
|
||||
|
||||
/// Path where the most recent successful snapshot was written —
|
||||
/// returned even when the SSH connection is currently down. The
|
||||
/// data service falls back to this when `snapshotSQLite` throws so
|
||||
/// Dashboard / Sessions / Chat-history stay viewable while the
|
||||
/// phone is offline.
|
||||
public var cachedSnapshotPath: URL? {
|
||||
snapshotBaseDir.appendingPathComponent("state.db")
|
||||
}
|
||||
|
||||
// MARK: - ServerTransport: watching
|
||||
|
||||
public func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
|
||||
@@ -398,8 +407,76 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
|
||||
let remoteTmp = "/tmp/scarf-snapshot-\(UUID().uuidString).db"
|
||||
// Double-quote paths; $HOME expansion happens inside double quotes.
|
||||
let rewritten = Self.rewriteHomeRelative(remotePath)
|
||||
let backupScript = #"sqlite3 "\#(rewritten)" ".backup '\#(remoteTmp)'" && sqlite3 '\#(remoteTmp)' "PRAGMA journal_mode=DELETE;" > /dev/null"#
|
||||
_ = try await client.executeCommand(backupScript + " 2>&1")
|
||||
|
||||
// Prepend the same PATH prefix `asyncRunProcess` uses so `sqlite3`
|
||||
// resolves on hosts where it lives in /usr/local/bin or
|
||||
// /opt/homebrew/bin (issue #56). Citadel's bare exec channel
|
||||
// inherits a stripped PATH (typically `/usr/bin:/bin` on Linux);
|
||||
// without this, statically-linked or custom-prefix sqlite3
|
||||
// installs fail "command not found" at exit 127.
|
||||
let backupScript =
|
||||
#"PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" "#
|
||||
+ #"sqlite3 "\#(rewritten)" ".backup '\#(remoteTmp)'" && sqlite3 '\#(remoteTmp)' "PRAGMA journal_mode=DELETE;" > /dev/null"#
|
||||
|
||||
// Drive `executeCommandStream` instead of `executeCommand` so we
|
||||
// capture stderr regardless of exit code (issue #56). Pre-fix
|
||||
// a non-zero exit threw `CommandFailed` and discarded the buffer
|
||||
// — surfaced as the unhelpful "Citadel.SSHClient.CommandFailed
|
||||
// error 1" banner. Now we propagate the real stderr so
|
||||
// `HermesDataService.humanize` can translate "sqlite3: command
|
||||
// not found" / "no such file" / "permission denied" into the
|
||||
// dashboard banner with actionable copy.
|
||||
let stream: AsyncThrowingStream<ExecCommandOutput, Error>
|
||||
do {
|
||||
stream = try await client.executeCommandStream(backupScript)
|
||||
} catch {
|
||||
throw NSError(
|
||||
domain: "CitadelServerTransport",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Failed to start snapshot stream: \(error.localizedDescription)"]
|
||||
)
|
||||
}
|
||||
var stdout = Data()
|
||||
var stderr = Data()
|
||||
var exitCode: Int32 = 0
|
||||
do {
|
||||
for try await chunk in stream {
|
||||
switch chunk {
|
||||
case .stdout(var buf):
|
||||
if let s = buf.readString(length: buf.readableBytes) {
|
||||
stdout.append(Data(s.utf8))
|
||||
}
|
||||
case .stderr(var buf):
|
||||
if let s = buf.readString(length: buf.readableBytes) {
|
||||
stderr.append(Data(s.utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch let failed as SSHClient.CommandFailed {
|
||||
exitCode = Int32(failed.exitCode)
|
||||
} catch {
|
||||
stderr.append(Data(error.localizedDescription.utf8))
|
||||
exitCode = -1
|
||||
}
|
||||
if exitCode != 0 {
|
||||
// Combine stdout + stderr into the error message — sqlite3
|
||||
// sometimes prints "Error: ..." on stdout depending on the
|
||||
// remote shell. HermesDataService.humanize keys off
|
||||
// substrings like "sqlite3: command not found",
|
||||
// "permission denied", "no such file", so as long as one of
|
||||
// them ends up in the message we get a useful banner.
|
||||
let messageBytes = stderr.isEmpty ? stdout : stderr
|
||||
let message = String(data: messageBytes, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
throw NSError(
|
||||
domain: "CitadelServerTransport",
|
||||
code: Int(exitCode),
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: message.isEmpty
|
||||
? "Snapshot exited \(exitCode) with no output (likely sqlite3 missing on remote)"
|
||||
: message
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// SFTP-download the remote tmp into our local snapshot cache.
|
||||
let sftp = try await connectionHolder.sftp()
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// Process-wide reachability monitor wrapping `NWPathMonitor`. Used by
|
||||
/// `ChatController` to decide when to attempt a reconnect (on
|
||||
/// `.satisfied`) vs. mark the chat offline (on `.unsatisfied`).
|
||||
///
|
||||
/// Singleton because `NWPathMonitor` is per-process by design — there's
|
||||
/// no benefit to instantiating multiple monitors and the cost (a small
|
||||
/// background queue per instance) accumulates if every controller
|
||||
/// spawns its own.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// Don't read the published state from a SwiftUI view body — the
|
||||
/// runtime samples through `NWPathMonitor`'s queue, but a `body`
|
||||
/// re-evaluation that touches `currentPath` directly would block. Read
|
||||
/// `isSatisfied` / observe `transitionTick` instead. Tests and
|
||||
/// non-iOS callers can use the no-op default behavior (`isSatisfied`
|
||||
/// reports `true`).
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class NetworkReachabilityService {
|
||||
public static let shared = NetworkReachabilityService()
|
||||
|
||||
/// `true` when the OS reports a usable network path (any
|
||||
/// interface). Inverted via `!isSatisfied` for "we're offline."
|
||||
public private(set) var isSatisfied: Bool = true
|
||||
|
||||
/// Mirrors `NWPath.isExpensive`. Useful as a hint to UI for not
|
||||
/// auto-fetching big payloads on cellular. Not consumed yet —
|
||||
/// reserved so callers don't have to add another property later.
|
||||
public private(set) var isExpensive: Bool = false
|
||||
|
||||
/// Monotonic counter that bumps every time `isSatisfied` changes.
|
||||
/// Views observe `transitionTick` rather than `isSatisfied` to
|
||||
/// kick a `.onChange` even if the value is the same as before
|
||||
/// (rare but possible during rapid network flapping).
|
||||
public private(set) var transitionTick: Int = 0
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "com.scarf.ios.reachability")
|
||||
|
||||
#if canImport(os)
|
||||
private static let logger = Logger(subsystem: "com.scarf.ios", category: "NetworkReachability")
|
||||
#endif
|
||||
|
||||
private init() {
|
||||
// Seed from the current path synchronously so first reads on
|
||||
// launch don't show "satisfied" while the OS reports otherwise.
|
||||
// `currentPath` is safe here at init (the monitor hasn't been
|
||||
// started yet, no queue handler is firing).
|
||||
let initial = monitor.currentPath
|
||||
self.isSatisfied = (initial.status == .satisfied)
|
||||
self.isExpensive = initial.isExpensive
|
||||
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
// Bounce back through MainActor — the `Observable`
|
||||
// protocol's published-property invariants require main-
|
||||
// thread mutation. The pathUpdateHandler is invoked on
|
||||
// `queue`, which is a private background queue.
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
let satisfied = (path.status == .satisfied)
|
||||
if self.isSatisfied != satisfied {
|
||||
self.isSatisfied = satisfied
|
||||
self.transitionTick &+= 1
|
||||
#if canImport(os)
|
||||
Self.logger.info(
|
||||
"Reachability transition: \(satisfied ? "satisfied" : "unsatisfied", privacy: .public)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
self.isExpensive = path.isExpensive
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Singleton is process-lifetime; this only runs on shutdown.
|
||||
monitor.cancel()
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,28 @@ final class ScarfGoCoordinator {
|
||||
/// `AppCoordinator.pendingProjectChat`.
|
||||
var pendingProjectChat: String?
|
||||
|
||||
/// Most-recent scene-phase value observed at the WindowGroup
|
||||
/// level. Tab-specific view models (e.g. `ChatController`)
|
||||
/// observe `scenePhaseTick` to react to transitions even when
|
||||
/// they're on a non-foreground tab — `.onChange(of: ScenePhase)`
|
||||
/// alone wouldn't fire for views that aren't on screen.
|
||||
private(set) var scenePhase: ScenePhase = .active
|
||||
private(set) var scenePhaseTick: Int = 0
|
||||
/// Wallclock when we last observed `.background`. Used by tab
|
||||
/// view-models to decide whether a quick `.active` transition is
|
||||
/// worth a full re-verify (long suspensions warrant it; brief
|
||||
/// notification-center peeks don't). `nil` until the first
|
||||
/// background transition.
|
||||
private(set) var lastBackgroundedAt: Date?
|
||||
|
||||
func setScenePhase(_ phase: ScenePhase) {
|
||||
if phase == .background, scenePhase != .background {
|
||||
lastBackgroundedAt = Date()
|
||||
}
|
||||
scenePhase = phase
|
||||
scenePhaseTick &+= 1
|
||||
}
|
||||
|
||||
enum Tab: Hashable {
|
||||
case dashboard, projects, chat, skills, system
|
||||
}
|
||||
|
||||
@@ -30,12 +30,49 @@ struct ScarfGoTabRoot: View {
|
||||
let onSoftDisconnect: @MainActor () async -> Void
|
||||
let onForget: @MainActor () async -> Void
|
||||
|
||||
/// Stable per-tab context UUID — used for the System tab's Curator
|
||||
/// row so its CuratorViewModel reuses the cached SSH connection
|
||||
/// keyed by this id rather than building a fresh one. Same pattern
|
||||
/// as `sharedContextID` on ChatView.
|
||||
static let systemTabContextID: ServerID = ServerID(
|
||||
uuidString: "00000000-0000-0000-0000-0000000000A2"
|
||||
)!
|
||||
|
||||
/// One coordinator per server-connected session. Cross-tab
|
||||
/// signalling (Dashboard row → Chat tab resume, Project Detail
|
||||
/// → in-project chat handoff, notification deep-link → Chat) flows
|
||||
/// through here.
|
||||
@State private var coordinator = ScarfGoCoordinator()
|
||||
|
||||
/// Hermes version + capability flags for this remote. Drives the
|
||||
/// iOS version banner (v0.11 hosts get a yellow "update for new
|
||||
/// features" banner) and capability-gated affordances like ACP
|
||||
/// image attachments. Constructed once per server connection so
|
||||
/// the detection runs over the active SSH transport.
|
||||
@State private var capabilities: HermesCapabilitiesStore
|
||||
|
||||
init(
|
||||
serverID: ServerID,
|
||||
config: IOSServerConfig,
|
||||
key: SSHKeyBundle,
|
||||
onSoftDisconnect: @escaping @MainActor () async -> Void,
|
||||
onForget: @escaping @MainActor () async -> Void
|
||||
) {
|
||||
self.serverID = serverID
|
||||
self.config = config
|
||||
self.key = key
|
||||
self.onSoftDisconnect = onSoftDisconnect
|
||||
self.onForget = onForget
|
||||
let ctx = config.toServerContext(id: serverID)
|
||||
_capabilities = State(initialValue: HermesCapabilitiesStore(context: ctx))
|
||||
}
|
||||
|
||||
/// SwiftUI's `.onChange(of: ScenePhase)` modifier on a non-active
|
||||
/// tab doesn't fire while the tab is unmounted — the coordinator
|
||||
/// is the single source of truth for scene-phase transitions
|
||||
/// across all tabs.
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
// The transport factory is keyed by ServerID, so the correct
|
||||
// Keychain slot + config is picked automatically. Reuses the
|
||||
@@ -112,6 +149,8 @@ struct ScarfGoTabRoot: View {
|
||||
.tabViewStyle(.sidebarAdaptable)
|
||||
.environment(\.serverContext, ctx)
|
||||
.environment(\.scarfGoCoordinator, coordinator)
|
||||
.environment(capabilities)
|
||||
.hermesCapabilities(capabilities)
|
||||
.onAppear {
|
||||
// Give the notification router a handle to this session's
|
||||
// coordinator so notification-taps can route across tabs.
|
||||
@@ -119,6 +158,12 @@ struct ScarfGoTabRoot: View {
|
||||
// just observes.
|
||||
NotificationRouter.shared.coordinator = coordinator
|
||||
}
|
||||
// Funnel scene-phase transitions through the coordinator so
|
||||
// tab view-models (notably ChatController) can react even
|
||||
// when their tab isn't currently on-screen.
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
coordinator.setScenePhase(newPhase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +180,8 @@ private struct SystemTab: View {
|
||||
let onSoftDisconnect: @MainActor () async -> Void
|
||||
let onForget: @MainActor () async -> Void
|
||||
|
||||
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||
|
||||
@State private var showForgetConfirmation = false
|
||||
@State private var isForgetting = false
|
||||
@State private var isDisconnecting = false
|
||||
@@ -169,6 +216,15 @@ private struct SystemTab: View {
|
||||
}
|
||||
.scarfGoCompactListRow()
|
||||
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||
if capabilitiesStore?.capabilities.hasCurator ?? false {
|
||||
NavigationLink {
|
||||
CuratorView(context: config.toServerContext(id: ScarfGoTabRoot.systemTabContextID))
|
||||
} label: {
|
||||
Label("Curator", systemImage: "sparkles")
|
||||
}
|
||||
.scarfGoCompactListRow()
|
||||
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||
}
|
||||
NavigationLink {
|
||||
CronListView(config: config)
|
||||
} label: {
|
||||
@@ -185,6 +241,36 @@ private struct SystemTab: View {
|
||||
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||
}
|
||||
|
||||
// v2.6: read-only mobile views over CLI-driven Hermes
|
||||
// surfaces. Mac owns the create/edit paths; phones get a
|
||||
// monitoring window into what the remote agent is honoring.
|
||||
// None of these are capability-gated — the underlying
|
||||
// `hermes plugins/profile/webhook list` verbs exist on
|
||||
// both v0.11 and v0.12, so the read views work on either.
|
||||
Section("Inspect") {
|
||||
NavigationLink {
|
||||
WebhooksView(config: config)
|
||||
} label: {
|
||||
Label("Webhooks", systemImage: "arrow.up.right.square")
|
||||
}
|
||||
.scarfGoCompactListRow()
|
||||
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||
NavigationLink {
|
||||
PluginsView(config: config)
|
||||
} label: {
|
||||
Label("Plugins", systemImage: "app.badge.checkmark")
|
||||
}
|
||||
.scarfGoCompactListRow()
|
||||
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||
NavigationLink {
|
||||
ProfilesView(config: config)
|
||||
} label: {
|
||||
Label("Profiles", systemImage: "person.2.crop.square.stack")
|
||||
}
|
||||
.scarfGoCompactListRow()
|
||||
.listRowBackground(ScarfColor.backgroundSecondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(isOn: $iCloudSyncEnabled) {
|
||||
HStack(spacing: 10) {
|
||||
|
||||
@@ -63,6 +63,13 @@ struct ScarfIOSApp: App {
|
||||
// Hermes gains a push sender.
|
||||
await MainActor.run { NotificationRouter.shared.setUpOnLaunch() }
|
||||
}
|
||||
.task {
|
||||
// Drop chat drafts older than 7 days so the
|
||||
// UserDefaults plist doesn't grow unbounded across
|
||||
// years of use. Cheap; UserDefaults is already in
|
||||
// memory by the time we read keys.
|
||||
ChatController.pruneStaleDrafts()
|
||||
}
|
||||
// Clamp Dynamic Type at the scene root. ScarfGo is a
|
||||
// developer tool that needs more density than Apple's
|
||||
// .xxxLarge default, but we still scale from .xSmall
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
|
||||
/// Yellow banner that nudges users to upgrade Hermes when the remote
|
||||
/// is running pre-v0.12. Shown on the Dashboard tab; auto-dismissed
|
||||
/// for the rest of the session when the user taps the X. Persistent
|
||||
/// re-show on each app open keeps the prompt visible without nagging
|
||||
/// inside a single session.
|
||||
///
|
||||
/// Hidden entirely on v0.12+ (the new features are reachable) and
|
||||
/// while capability detection is still in flight.
|
||||
struct HermesVersionBanner: View {
|
||||
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||
@State private var dismissedThisSession = false
|
||||
|
||||
/// Capability gate — only render when:
|
||||
/// - the store finished its initial detection AND
|
||||
/// - the host returned an actual version string AND
|
||||
/// - that version is below v0.12 AND
|
||||
/// - the user hasn't dismissed this banner during this session.
|
||||
private var shouldShow: Bool {
|
||||
guard let store = capabilitiesStore else { return false }
|
||||
let caps = store.capabilities
|
||||
guard caps.detected else { return false } // skip while loading / on detection failure
|
||||
guard !caps.hasCurator else { return false } // already on v0.12+
|
||||
return !dismissedThisSession
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if shouldShow {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Hermes update available")
|
||||
.font(.callout.weight(.semibold))
|
||||
Text("This server runs \(versionLabel). Update to v0.12 to unlock the autonomous curator, multimodal image input, GMI Cloud / Azure / LM Studio / MiniMax / Tencent providers, and more.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
dismissedThisSession = true
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Dismiss this version notice for the rest of the session")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(ScarfColor.warning.opacity(0.12))
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.fill(ScarfColor.warning.opacity(0.4))
|
||||
.frame(height: 1),
|
||||
alignment: .bottom
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pretty-print the detected version. Falls back to the raw line
|
||||
/// if parsing didn't extract semver — keeps the banner honest
|
||||
/// when Hermes ships an unexpected version string.
|
||||
private var versionLabel: String {
|
||||
let caps = capabilitiesStore?.capabilities
|
||||
if let semver = caps?.semver {
|
||||
return "Hermes v\(semver.description)"
|
||||
}
|
||||
return caps?.versionLine ?? "an older Hermes"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -529,7 +529,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -540,13 +540,13 @@
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -571,7 +571,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -582,13 +582,13 @@
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -612,7 +612,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -635,7 +635,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -658,7 +658,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -680,7 +680,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
@@ -834,7 +834,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
@@ -848,7 +848,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -870,7 +870,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
@@ -884,7 +884,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -902,12 +902,12 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -924,12 +924,12 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -945,11 +945,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -965,11 +965,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 31;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// One template entry as exposed by `awizemann.github.io/scarf/templates/catalog.json`.
|
||||
/// Mirrors the per-template shape `tools/build-catalog.py` emits — the
|
||||
/// validator is the source of truth on the schema, this struct is the
|
||||
/// Swift consumer. **Do not add fields here that aren't in `catalog.json`
|
||||
/// today.** Keeping the surface 1:1 means we can't accidentally render
|
||||
/// something the catalog doesn't actually carry.
|
||||
///
|
||||
/// Most fields are required-from-the-validator's-perspective but
|
||||
/// expressed as optionals here so a single-template typo on the
|
||||
/// website doesn't bring down the whole list — we drop the malformed
|
||||
/// entry and keep going (handled by the decoder in `CatalogService`).
|
||||
struct CatalogEntry: Codable, Sendable, Identifiable, Hashable {
|
||||
|
||||
// Hashable + Equatable conformance is identity-based on `id` —
|
||||
// `TemplateConfigSchema` only conforms to Equatable, so we can't
|
||||
// synthesize Hashable, and a content-based equality wouldn't be
|
||||
// useful anyway (the same template re-fetched from cache vs. fresh
|
||||
// is "the same entry" even if a description was edited upstream).
|
||||
static func == (lhs: CatalogEntry, rhs: CatalogEntry) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
|
||||
/// Stable identifier — `<author>/<template-name>`, e.g.
|
||||
/// `awizemann/hackernews-digest`. Matches the value in
|
||||
/// `template.json`'s `id` field.
|
||||
let id: String
|
||||
|
||||
/// Human-readable name shown in the catalog list.
|
||||
let name: String
|
||||
|
||||
/// Semver. Compared against the installed version from
|
||||
/// `InstalledTemplatesIndex` to detect "Update available".
|
||||
let version: String
|
||||
|
||||
let description: String?
|
||||
let category: String?
|
||||
let tags: [String]
|
||||
|
||||
let author: Author
|
||||
let minScarfVersion: String?
|
||||
let minHermesVersion: String?
|
||||
|
||||
/// HTTPS URL the install flow consumes.
|
||||
/// `TemplateInstallerViewModel.openRemoteURL(_:)` accepts this
|
||||
/// directly. The catalog itself only ships HTTPS URLs (validator
|
||||
/// enforced).
|
||||
let installUrl: String
|
||||
|
||||
/// Bundle metadata for size warnings and integrity checks. Optional
|
||||
/// because pre-v2 catalogs didn't carry these.
|
||||
let bundleSize: Int?
|
||||
let bundleSha256: String?
|
||||
|
||||
/// Slug used by the static-site generator for detail-page URLs.
|
||||
/// Reused as a stable accessibility-ID suffix so XCUITest can find
|
||||
/// rows even if the human-readable id contains slashes.
|
||||
let detailSlug: String?
|
||||
|
||||
/// What's inside the bundle, mirrored from `template.json`'s
|
||||
/// `contents` claim. Drives the "what will be installed" preview
|
||||
/// on the detail page.
|
||||
let contents: Contents?
|
||||
|
||||
/// Config schema + model recommendation if the template declares
|
||||
/// one. Using the existing `TemplateConfigSchema` decoder keeps
|
||||
/// parsing aligned with the install sheet's config form.
|
||||
let config: TemplateConfigSchema?
|
||||
|
||||
struct Author: Codable, Sendable, Equatable {
|
||||
let name: String
|
||||
let url: String?
|
||||
}
|
||||
|
||||
/// `template.json`'s `contents` object. All counts are optional —
|
||||
/// `nil` means "not declared," which the catalog renders as zero.
|
||||
struct Contents: Codable, Sendable, Equatable {
|
||||
let dashboard: Bool?
|
||||
let agentsMd: Bool?
|
||||
let cron: Int?
|
||||
let config: Int?
|
||||
let memory: Bool?
|
||||
let skills: [String]?
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level shape of `catalog.json`. Only carries what the Swift
|
||||
/// catalog browser actually uses — `templates` is the list itself,
|
||||
/// `schemaVersion` lets us reject incompatible future formats.
|
||||
///
|
||||
/// **The validator's `generated` field is intentionally NOT decoded.**
|
||||
/// It ships as a boolean (`true`) per `tools/build-catalog.py`'s
|
||||
/// "human reminder; a timestamp would churn the diff every run"
|
||||
/// comment. The catalog UI uses the cache file's `fetchedAt` for the
|
||||
/// "last refreshed" string, not anything from `catalog.json`.
|
||||
///
|
||||
/// **Per-element fault tolerance.** `templates` is decoded entry by
|
||||
/// entry through an unkeyed container — a single malformed entry
|
||||
/// (missing `tags`, `author`, etc.) is dropped with a logged warning
|
||||
/// rather than failing the whole catalog decode. Honors the contract
|
||||
/// the per-entry doc-comment promises.
|
||||
struct Catalog: Codable, Sendable {
|
||||
let schemaVersion: Int?
|
||||
let templates: [CatalogEntry]
|
||||
|
||||
init(schemaVersion: Int?, templates: [CatalogEntry]) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.templates = templates
|
||||
}
|
||||
|
||||
/// Custom decoder that drops every key other than `schemaVersion`
|
||||
/// and `templates`. Without this, `generated: true` would surface
|
||||
/// as a typeMismatch on `String?`.
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case schemaVersion
|
||||
case templates
|
||||
}
|
||||
|
||||
private static let decodeLogger = Logger(subsystem: "com.scarf", category: "CatalogDecoder")
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion)
|
||||
|
||||
var entries: [CatalogEntry] = []
|
||||
if container.contains(.templates) {
|
||||
var unkeyed = try container.nestedUnkeyedContainer(forKey: .templates)
|
||||
entries.reserveCapacity(unkeyed.count ?? 0)
|
||||
while !unkeyed.isAtEnd {
|
||||
do {
|
||||
entries.append(try unkeyed.decode(CatalogEntry.self))
|
||||
} catch {
|
||||
Self.decodeLogger.warning("dropping malformed catalog entry at index \(unkeyed.currentIndex - 1): \(error.localizedDescription, privacy: .public)")
|
||||
// Advance past the bad element so the loop terminates.
|
||||
// Decoding into a permissive `JSONValue` placeholder
|
||||
// would also work, but Foundation's Decoder API has
|
||||
// no built-in skip — `_Skip` consumes one element.
|
||||
_ = try? unkeyed.decode(_Skip.self)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.templates = entries
|
||||
}
|
||||
|
||||
/// Placeholder type used to consume a malformed array element after
|
||||
/// the real decode threw. Decodes anything by ignoring it.
|
||||
private struct _Skip: Decodable {
|
||||
init(from decoder: Decoder) throws {
|
||||
_ = try decoder.singleValueContainer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,119 @@ final class ServerRegistry {
|
||||
SSHTransport.sweepStaleControlSockets()
|
||||
}
|
||||
|
||||
// MARK: - Export / Import
|
||||
|
||||
/// Result summary returned from `importEntries(from:)`. The UI renders
|
||||
/// it as a one-line confirmation so the user knows whether anything
|
||||
/// changed (e.g. picking a stale export file imports zero entries
|
||||
/// because every ID is already present).
|
||||
struct ImportSummary: Equatable {
|
||||
var imported: Int
|
||||
var skippedDuplicates: Int
|
||||
}
|
||||
|
||||
/// Errors raised by `importEntries(from:)` for the user-facing alert.
|
||||
/// Validation is conservative — we'd rather refuse a malformed file
|
||||
/// than half-import garbage and leave the registry in a weird state.
|
||||
enum ImportError: Error, LocalizedError {
|
||||
case unreadable(String)
|
||||
case malformed(String)
|
||||
case unsupportedSchema(Int)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unreadable(let m): return "Couldn't read the file: \(m)"
|
||||
case .malformed(let m): return "The file isn't a valid Scarf servers export: \(m)"
|
||||
case .unsupportedSchema(let v): return "This export uses schema v\(v), which this version of Scarf doesn't recognize."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode the current registry as a portable export. `displayName`,
|
||||
/// `host`, `user`, `port`, `identityFile` (path string only),
|
||||
/// `remoteHome`, `projectsRoot`, `hermesBinaryHint`, `openOnLaunch`,
|
||||
/// and the entry's stable UUID travel. **No secrets** ride along —
|
||||
/// SSH private keys live at the path referenced by `identityFile`,
|
||||
/// not in `servers.json`. Importing on a different Mac requires the
|
||||
/// user to copy their `~/.ssh/` keys separately (or re-point each
|
||||
/// entry's identityFile in Edit Server).
|
||||
func exportFile() throws -> Data {
|
||||
let payload = ExportFile(
|
||||
schemaVersion: Self.currentSchemaVersion,
|
||||
exportedAt: ISO8601DateFormatter().string(from: Date()),
|
||||
entries: entries
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return try encoder.encode(payload)
|
||||
}
|
||||
|
||||
/// Merge entries from a `.scarfservers` file. Dedupe is by UUID
|
||||
/// — entries whose ID already exists are skipped (the existing
|
||||
/// entry wins, since it may carry edits the user made post-export).
|
||||
/// `openOnLaunch` is normalized after import: at most one entry
|
||||
/// can be the default, and conflicts resolve in favor of the
|
||||
/// pre-existing default.
|
||||
@discardableResult
|
||||
func importEntries(from data: Data) throws -> ImportSummary {
|
||||
let payload: ExportFile
|
||||
do {
|
||||
payload = try JSONDecoder().decode(ExportFile.self, from: data)
|
||||
} catch {
|
||||
throw ImportError.malformed(error.localizedDescription)
|
||||
}
|
||||
guard payload.schemaVersion == Self.currentSchemaVersion else {
|
||||
throw ImportError.unsupportedSchema(payload.schemaVersion)
|
||||
}
|
||||
|
||||
let existingIDs = Set(entries.map(\.id))
|
||||
var imported = 0
|
||||
var skipped = 0
|
||||
for incoming in payload.entries {
|
||||
if existingIDs.contains(incoming.id) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
var copy = incoming
|
||||
// Don't let an imported entry seize the default slot if the
|
||||
// user already has one assigned. Normalization below also
|
||||
// drops `openOnLaunch` if more than one survives.
|
||||
if entries.contains(where: { $0.openOnLaunch }) {
|
||||
copy.openOnLaunch = false
|
||||
}
|
||||
entries.append(copy)
|
||||
imported += 1
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: if multiple entries somehow ended up
|
||||
// flagged as default (e.g. user imported an export that itself
|
||||
// had the flag on a different entry than the local default),
|
||||
// keep only the first one.
|
||||
var sawDefault = false
|
||||
for idx in entries.indices {
|
||||
if entries[idx].openOnLaunch {
|
||||
if sawDefault { entries[idx].openOnLaunch = false }
|
||||
else { sawDefault = true }
|
||||
}
|
||||
}
|
||||
|
||||
save()
|
||||
if imported > 0 { onEntriesChanged?() }
|
||||
return ImportSummary(imported: imported, skippedDuplicates: skipped)
|
||||
}
|
||||
|
||||
/// Disk envelope distinct from `RegistryFile`. Adds the export
|
||||
/// timestamp; structurally compatible so a hand-edited export
|
||||
/// could in theory be dropped at `~/Library/Application
|
||||
/// Support/scarf/servers.json` and load — we don't rely on that,
|
||||
/// but keeping the shape close means one less migration surface
|
||||
/// when we eventually add fields here.
|
||||
private struct ExportFile: Codable {
|
||||
var schemaVersion: Int
|
||||
var exportedAt: String
|
||||
var entries: [ServerEntry]
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func load() {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Quits the running app and brings up a fresh instance of the same
|
||||
/// bundle. Used by the Profile-switching flow (issue #70) so the new
|
||||
/// active profile lands in a process that has never observed the old
|
||||
/// one — sidesteps any in-process cache or service-state bug that
|
||||
/// might still be reading from the previous profile's home directory.
|
||||
///
|
||||
/// The pairing is intentional:
|
||||
/// 1. Caller invokes `try AppRelauncher.relaunch()`. That spawns a
|
||||
/// fresh `open -n <bundleURL>`, captures stderr/exitCode, returns
|
||||
/// success once the launcher has acknowledged the dispatch.
|
||||
/// 2. Caller schedules `NSApp.terminate(nil)` 250ms later. The
|
||||
/// 250ms gives macOS time to begin launching the second PID so
|
||||
/// the dock-icon hand-off looks smooth (no flash of missing
|
||||
/// icon). Without the gap, macOS can briefly show zero Scarf
|
||||
/// icons in the dock.
|
||||
///
|
||||
/// Refuses to relaunch when the running bundle is under
|
||||
/// `DerivedData/` or `Build/Products/Debug` — that's an Xcode
|
||||
/// debug session, and `terminate(nil)` would kill the run mid-debug
|
||||
/// without giving the new instance any way to attach. The caller
|
||||
/// surfaces a "restart manually" toast in that case.
|
||||
@MainActor
|
||||
enum AppRelauncher {
|
||||
static let logger = Logger(subsystem: "com.scarf.app", category: "AppRelauncher")
|
||||
|
||||
enum RelaunchError: Error, LocalizedError {
|
||||
case debugBuild
|
||||
case openFailed(exitCode: Int32, stderr: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .debugBuild:
|
||||
return "Refusing to relaunch from an Xcode debug build."
|
||||
case .openFailed(let code, let stderr):
|
||||
return "open(1) exited \(code): \(stderr)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a fresh instance of the running app via `/usr/bin/open -n
|
||||
/// <bundleURL>` and returns once the launcher process has dispatched
|
||||
/// the new instance. The caller is responsible for the subsequent
|
||||
/// `NSApp.terminate(nil)` (deferred ~250ms for a smooth dock hand-off).
|
||||
/// Throws `.debugBuild` when launched from Xcode/DerivedData;
|
||||
/// `.openFailed` when `open` itself errored.
|
||||
static func relaunch() throws {
|
||||
let bundleURL = Bundle.main.bundleURL
|
||||
let path = bundleURL.path
|
||||
if path.contains("/DerivedData/")
|
||||
|| path.contains("/Build/Products/Debug")
|
||||
|| path.contains("/Build/Products/Debug-")
|
||||
{
|
||||
logger.warning("Refusing relaunch — running from Xcode build (\(path, privacy: .public))")
|
||||
throw RelaunchError.debugBuild
|
||||
}
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/open")
|
||||
// -n: force a NEW instance (without it, `open` activates the
|
||||
// running app and we'd never get a fresh process).
|
||||
// Pass the bundle URL directly (not -a <bundleId>) so signed
|
||||
// dev clones in `~/Applications` still resolve correctly.
|
||||
// No -W: we want `open` to return immediately after dispatch,
|
||||
// not block until the spawned app exits.
|
||||
proc.arguments = ["-n", path]
|
||||
|
||||
let stderrPipe = Pipe()
|
||||
let stdoutPipe = Pipe()
|
||||
proc.standardError = stderrPipe
|
||||
proc.standardOutput = stdoutPipe
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
throw RelaunchError.openFailed(exitCode: -1, stderr: error.localizedDescription)
|
||||
}
|
||||
|
||||
proc.waitUntilExit()
|
||||
|
||||
// Drain both streams BEFORE inspecting exit code so we don't leak fds.
|
||||
let errData = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
_ = try? stdoutPipe.fileHandleForReading.readToEnd()
|
||||
try? stderrPipe.fileHandleForReading.close()
|
||||
try? stdoutPipe.fileHandleForReading.close()
|
||||
|
||||
guard proc.terminationStatus == 0 else {
|
||||
let stderr = String(data: errData, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
logger.warning("open(1) failed (\(proc.terminationStatus)): \(stderr, privacy: .public)")
|
||||
throw RelaunchError.openFailed(exitCode: proc.terminationStatus, stderr: stderr)
|
||||
}
|
||||
|
||||
logger.info("Relaunch dispatched for \(path, privacy: .public)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// On-disk cache shape. Versioned so a future schema change can lift
|
||||
/// stale caches gracefully — bump `version` and the loader rejects
|
||||
/// anything older without trying to migrate. Stored next to the
|
||||
/// projects registry so a Hermes wipe takes it with the rest of the
|
||||
/// Scarf-owned state.
|
||||
struct CatalogCache: Codable, Sendable {
|
||||
static let currentVersion = 1
|
||||
let version: Int
|
||||
let fetchedAt: Date
|
||||
let catalog: Catalog
|
||||
|
||||
init(version: Int = CatalogCache.currentVersion, fetchedAt: Date, catalog: Catalog) {
|
||||
self.version = version
|
||||
self.fetchedAt = fetchedAt
|
||||
self.catalog = catalog
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a `loadCatalog` call. Distinguishes "fetched fresh" from
|
||||
/// "cache served, network failed" so the catalog UI can surface a
|
||||
/// "could not refresh" hint next to a stale-but-useful list.
|
||||
enum CatalogLoadResult: Sendable {
|
||||
case fresh(catalog: Catalog, fetchedAt: Date)
|
||||
case cache(catalog: Catalog, fetchedAt: Date, refreshError: String?)
|
||||
case fallback(catalog: Catalog, reason: String)
|
||||
}
|
||||
|
||||
enum CatalogServiceError: LocalizedError, Sendable {
|
||||
case transport(String)
|
||||
case http(status: Int)
|
||||
case decode(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .transport(let m): return "Catalog transport: \(m)"
|
||||
case .http(let status): return "Catalog HTTP \(status)"
|
||||
case .decode(let m): return "Catalog decode: \(m)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches + caches the public template catalog from
|
||||
/// awizemann.github.io. Mirrors `NousModelCatalogService` 1:1 in
|
||||
/// shape: cache-first, 24h TTL, fallback when both cache and fetch
|
||||
/// fail. The catalog is unauthenticated (a public static file on
|
||||
/// GitHub Pages), so no bearer-token plumbing.
|
||||
struct CatalogService: Sendable {
|
||||
|
||||
/// Where the catalog lives in production. The static-site builder
|
||||
/// publishes here on `./scripts/catalog.sh publish`. **Versioned
|
||||
/// constant**: if we ever move this URL, every old Scarf install
|
||||
/// pegs at its bundled fallback until the user updates Scarf — so
|
||||
/// keep it stable. Settings-configurable in v2.9 only if anyone
|
||||
/// asks.
|
||||
static let baseURL = URL(string: "https://awizemann.github.io/scarf/templates/catalog.json")!
|
||||
static let cacheTTL: TimeInterval = 24 * 60 * 60 // 24h
|
||||
static let requestTimeout: TimeInterval = 10 // seconds
|
||||
|
||||
/// Hard-coded fallback for offline-with-no-cache. Keeps the picker
|
||||
/// non-empty on a fresh install so the user sees *something* even
|
||||
/// before the first network call. **Update on every release that
|
||||
/// adds a template** — the validator's `tools/check-catalog-fallback-sync.py`
|
||||
/// (TODO) catches drift between this list and `templates/`.
|
||||
static let fallbackCatalog: Catalog = Catalog(
|
||||
schemaVersion: 1,
|
||||
templates: [
|
||||
CatalogEntry(
|
||||
id: "awizemann/site-status-checker",
|
||||
name: "Site Status Checker",
|
||||
version: "1.1.0",
|
||||
description: "Daily uptime check for a list of URLs you configure on install.",
|
||||
category: "monitoring",
|
||||
tags: ["monitoring", "uptime", "cron", "starter"],
|
||||
author: .init(name: "Alan Wizemann", url: "https://github.com/awizemann"),
|
||||
minScarfVersion: "2.3.0",
|
||||
minHermesVersion: "0.9.0",
|
||||
installUrl: "https://raw.githubusercontent.com/awizemann/scarf/main/templates/awizemann/site-status-checker/site-status-checker.scarftemplate",
|
||||
bundleSize: nil,
|
||||
bundleSha256: nil,
|
||||
detailSlug: "awizemann-site-status-checker",
|
||||
contents: .init(dashboard: true, agentsMd: true, cron: 1, config: 2, memory: nil, skills: nil),
|
||||
config: nil
|
||||
),
|
||||
CatalogEntry(
|
||||
id: "awizemann/hackernews-digest",
|
||||
name: "HackerNews Daily Digest",
|
||||
version: "1.0.0",
|
||||
description: "A daily digest of HackerNews top stories. No API keys required.",
|
||||
category: "news",
|
||||
tags: ["news", "digest", "hackernews", "cron", "starter"],
|
||||
author: .init(name: "Alan Wizemann", url: "https://github.com/awizemann"),
|
||||
minScarfVersion: "2.3.0",
|
||||
minHermesVersion: "0.9.0",
|
||||
installUrl: "https://raw.githubusercontent.com/awizemann/scarf/main/templates/awizemann/hackernews-digest/hackernews-digest.scarftemplate",
|
||||
bundleSize: nil,
|
||||
bundleSha256: nil,
|
||||
detailSlug: "awizemann-hackernews-digest",
|
||||
contents: .init(dashboard: true, agentsMd: true, cron: 1, config: 3, memory: nil, skills: nil),
|
||||
config: nil
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "CatalogService")
|
||||
|
||||
let context: ServerContext
|
||||
private let session: URLSession
|
||||
private let cachePath: String
|
||||
|
||||
init(context: ServerContext = .local, session: URLSession = .shared) {
|
||||
self.context = context
|
||||
self.session = session
|
||||
self.cachePath = context.paths.catalogCache
|
||||
}
|
||||
|
||||
// MARK: - Cache I/O
|
||||
|
||||
/// Read the cache via the active transport so a remote droplet's
|
||||
/// cache lands on the droplet, not the user's Mac. Missing or
|
||||
/// malformed cache → nil; the loader treats that as "no cache" and
|
||||
/// kicks off a fresh fetch.
|
||||
func readCache() -> CatalogCache? {
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(cachePath) else { return nil }
|
||||
do {
|
||||
let data = try transport.readFile(cachePath)
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let cache = try decoder.decode(CatalogCache.self, from: data)
|
||||
guard cache.version == CatalogCache.currentVersion else {
|
||||
Self.logger.info("catalog cache schema mismatch (got v\(cache.version), expected v\(CatalogCache.currentVersion)); ignoring")
|
||||
return nil
|
||||
}
|
||||
return cache
|
||||
} catch {
|
||||
Self.logger.warning("couldn't decode catalog cache: \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func writeCache(_ cache: CatalogCache) {
|
||||
let transport = context.makeTransport()
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(cache)
|
||||
// Make sure the parent dir exists — fresh remote installs
|
||||
// may not yet have `~/.hermes/scarf/`. mkdir -p is cheap
|
||||
// and idempotent on both transports.
|
||||
let parent = (cachePath as NSString).deletingLastPathComponent
|
||||
if !parent.isEmpty {
|
||||
try? transport.createDirectory(parent)
|
||||
}
|
||||
try transport.writeFile(cachePath, data: data)
|
||||
} catch {
|
||||
Self.logger.warning("couldn't write catalog cache: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func isCacheStale(_ cache: CatalogCache) -> Bool {
|
||||
Date().timeIntervalSince(cache.fetchedAt) > Self.cacheTTL
|
||||
}
|
||||
|
||||
// MARK: - Network fetch
|
||||
|
||||
/// Make the catalog GET. Times out after `requestTimeout` so a
|
||||
/// hung network doesn't block the picker indefinitely. Returns the
|
||||
/// parsed catalog on success, throws on any HTTP / decode error.
|
||||
func fetchCatalog() async throws -> Catalog {
|
||||
var request = URLRequest(url: Self.baseURL)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = Self.requestTimeout
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw CatalogServiceError.transport("non-HTTP response")
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw CatalogServiceError.http(status: http.statusCode)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(Catalog.self, from: data)
|
||||
} catch {
|
||||
throw CatalogServiceError.decode(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public entry
|
||||
|
||||
/// Top-level "give me the catalog" entry point. Cache-first: serve
|
||||
/// from cache if fresh, fetch + write through if stale or empty,
|
||||
/// fall back to the hard-coded list when both fail. The caller
|
||||
/// renders based on the case so it can show a "could not refresh"
|
||||
/// hint next to a stale-but-still-useful list.
|
||||
func loadCatalog(forceRefresh: Bool = false) async -> CatalogLoadResult {
|
||||
let cached = readCache()
|
||||
|
||||
if let cached, !forceRefresh, !isCacheStale(cached) {
|
||||
return .cache(catalog: cached.catalog, fetchedAt: cached.fetchedAt, refreshError: nil)
|
||||
}
|
||||
|
||||
do {
|
||||
let catalog = try await fetchCatalog()
|
||||
let now = Date()
|
||||
writeCache(CatalogCache(fetchedAt: now, catalog: catalog))
|
||||
return .fresh(catalog: catalog, fetchedAt: now)
|
||||
} catch let error as CatalogServiceError {
|
||||
if let cached {
|
||||
Self.logger.warning("catalog refresh failed (\(error.localizedDescription, privacy: .public)); serving stale cache")
|
||||
return .cache(catalog: cached.catalog, fetchedAt: cached.fetchedAt, refreshError: error.localizedDescription)
|
||||
}
|
||||
Self.logger.warning("catalog refresh failed and no cache; serving fallback (\(error.localizedDescription, privacy: .public))")
|
||||
return .fallback(catalog: Self.fallbackCatalog, reason: error.localizedDescription)
|
||||
} catch {
|
||||
if let cached {
|
||||
return .cache(catalog: cached.catalog, fetchedAt: cached.fetchedAt, refreshError: error.localizedDescription)
|
||||
}
|
||||
return .fallback(catalog: Self.fallbackCatalog, reason: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
import os
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Posts a "Hermes finished responding" local notification when an
|
||||
/// agent prompt completes while Scarf is not in the foreground
|
||||
/// (issue #64). Users can switch to other work and learn when their
|
||||
/// prompt has landed without polling the chat pane.
|
||||
///
|
||||
/// Authorization is requested lazily on first use. The user's global
|
||||
/// toggle (`scarf.chat.notifyOnComplete`, default on) gates posting,
|
||||
/// and notifications are suppressed when `NSApp.isActive` so users
|
||||
/// who happen to be looking at the chat aren't pinged for nothing.
|
||||
@MainActor
|
||||
final class ChatNotificationService {
|
||||
static let shared = ChatNotificationService()
|
||||
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ChatNotifications")
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
private var hasRequestedAuthorization = false
|
||||
private var isAuthorized = false
|
||||
|
||||
/// AppStorage-shared key for the "notify on completion" toggle.
|
||||
/// Default true; the toggle lives under Settings → Display.
|
||||
static let toggleKey = "scarf.chat.notifyOnComplete"
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Post a local notification announcing prompt completion. Quietly
|
||||
/// no-ops when:
|
||||
/// - The user has disabled the toggle.
|
||||
/// - Scarf is the foreground app (the in-chat status indicator
|
||||
/// is sufficient).
|
||||
/// - The system has not yet granted (or has denied) notification
|
||||
/// authorization.
|
||||
/// `preview` is the first line of the assistant's reply, truncated
|
||||
/// to a sensible length for the lock-screen / notification center.
|
||||
func postPromptCompleted(sessionTitle: String?, preview: String) {
|
||||
let enabled = UserDefaults.standard.object(forKey: Self.toggleKey) as? Bool ?? true
|
||||
guard enabled else { return }
|
||||
|
||||
#if canImport(AppKit)
|
||||
if NSApp?.isActive == true { return }
|
||||
#endif
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let granted = await self.ensureAuthorized()
|
||||
guard granted else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = sessionTitle?.isEmpty == false
|
||||
? "Hermes finished — \(sessionTitle ?? "")"
|
||||
: "Hermes finished responding"
|
||||
content.body = Self.trimmedPreview(preview)
|
||||
content.sound = .default
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: UUID().uuidString,
|
||||
content: content,
|
||||
trigger: nil
|
||||
)
|
||||
do {
|
||||
try await self.center.add(request)
|
||||
} catch {
|
||||
self.logger.warning("Notification post failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureAuthorized() async -> Bool {
|
||||
if isAuthorized { return true }
|
||||
if hasRequestedAuthorization {
|
||||
// Already asked once this run; respect the current settings.
|
||||
let settings = await center.notificationSettings()
|
||||
isAuthorized = settings.authorizationStatus == .authorized
|
||||
return isAuthorized
|
||||
}
|
||||
hasRequestedAuthorization = true
|
||||
do {
|
||||
let granted = try await center.requestAuthorization(options: [.alert, .sound])
|
||||
isAuthorized = granted
|
||||
return granted
|
||||
} catch {
|
||||
logger.warning("Notification authorization failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// First non-empty line, capped at ~140 chars so the notification
|
||||
/// surface stays readable on every macOS notification style.
|
||||
static func trimmedPreview(_ raw: String) -> String {
|
||||
let firstLine = raw
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.first
|
||||
.map(String.init) ?? raw
|
||||
let trimmed = firstLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.count <= 140 { return trimmed }
|
||||
let prefix = trimmed.prefix(140).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return prefix + "…"
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,8 @@ struct HermesFileService: Sendable {
|
||||
skillsHub: aux("skills_hub"),
|
||||
approval: aux("approval"),
|
||||
mcp: aux("mcp"),
|
||||
flushMemories: aux("flush_memories")
|
||||
flushMemories: aux("flush_memories"),
|
||||
curator: aux("curator")
|
||||
)
|
||||
|
||||
let security = SecuritySettings(
|
||||
@@ -287,7 +288,10 @@ struct HermesFileService: Sendable {
|
||||
matrix: matrix,
|
||||
mattermost: mattermost,
|
||||
whatsapp: whatsapp,
|
||||
homeAssistant: homeAssistant
|
||||
homeAssistant: homeAssistant,
|
||||
cacheTTL: str("prompt_caching.cache_ttl", default: "5m"),
|
||||
redactionEnabled: bool("redaction.enabled", default: false),
|
||||
runtimeMetadataFooter: bool("agent.runtime_metadata_footer", default: false)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -486,12 +490,35 @@ struct HermesFileService: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the most-recent run output for a cron job. Hermes writes
|
||||
/// `~/.hermes/cron/output/<jobId>/<YYYY-MM-DD_HH-MM-SS>.md` per run
|
||||
/// (one file per execution); we resolve the per-job subdir, take
|
||||
/// the lexicographically-last filename (which is the newest given
|
||||
/// the timestamp prefix), and return its contents. Returns nil
|
||||
/// when the subdir is missing, empty, or the read fails — the cron
|
||||
/// detail surface treats nil as "no output yet."
|
||||
///
|
||||
/// A legacy flat-file layout (`<dir>/<filename containing jobId>`)
|
||||
/// is checked as a fallback so older Hermes installs that used a
|
||||
/// non-nested layout still surface their last run.
|
||||
nonisolated func loadCronOutput(jobId: String) -> String? {
|
||||
let dir = context.paths.cronOutputDir
|
||||
guard let files = try? transport.listDirectory(dir) else { return nil }
|
||||
let matching = files.filter { $0.contains(jobId) }.sorted().last
|
||||
guard let filename = matching else { return nil }
|
||||
return readFile(dir + "/" + filename)
|
||||
let perJobDir = dir + "/" + jobId
|
||||
if let runs = try? transport.listDirectory(perJobDir),
|
||||
let latest = runs.sorted().last {
|
||||
if let content = readFile(perJobDir + "/" + latest) {
|
||||
return content
|
||||
}
|
||||
}
|
||||
// Legacy fallback: pre-subdir layouts had files like
|
||||
// `<jobId>-<timestamp>.log` directly under cronOutputDir. Keep
|
||||
// matching them so users on older Hermes versions still see
|
||||
// their tail.
|
||||
if let files = try? transport.listDirectory(dir),
|
||||
let matching = files.filter({ $0.contains(jobId) }).sorted().last {
|
||||
return readFile(dir + "/" + matching)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Skills
|
||||
@@ -1442,17 +1469,44 @@ struct HermesFileService: Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scan auth.json (Credential Pools file written by the Configure →
|
||||
// Credential Pools UI). Schema:
|
||||
// { "credential_pool": { "<provider>": [ { "access_token": "...", ... }, ... ] } }
|
||||
// Defensive parse: any malformed input falls through to the next check.
|
||||
// Scan auth.json. Two shapes need to count as "credential present":
|
||||
//
|
||||
// 1. credential_pool.<provider>[].access_token
|
||||
// — written by Configure → Credential Pools (manual key entry,
|
||||
// round-robin / least-used routing).
|
||||
//
|
||||
// 2. providers.<name>.access_token
|
||||
// — written by `hermes auth add <name>` for OAuth-authed
|
||||
// providers (Nous Portal, Spotify, GitHub Copilot ACP, etc.).
|
||||
// Pre-fix this was ignored, so a user with only Nous OAuth
|
||||
// kept seeing the "No AI provider credentials" banner even
|
||||
// after a successful Nous sign-in.
|
||||
//
|
||||
// Defensive parse: malformed input falls through to the next check.
|
||||
if let data = readFileData(context.paths.authJSON),
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let pool = root["credential_pool"] as? [String: Any] {
|
||||
for (_, entries) in pool {
|
||||
guard let list = entries as? [[String: Any]] else { continue }
|
||||
for cred in list {
|
||||
if let token = cred["access_token"] as? String, !token.isEmpty {
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
{
|
||||
if let pool = root["credential_pool"] as? [String: Any] {
|
||||
for (_, entries) in pool {
|
||||
guard let list = entries as? [[String: Any]] else { continue }
|
||||
for cred in list {
|
||||
if let token = cred["access_token"] as? String, !token.isEmpty {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let providers = root["providers"] as? [String: Any] {
|
||||
for (_, value) in providers {
|
||||
guard let entry = value as? [String: Any] else { continue }
|
||||
if let token = entry["access_token"] as? String, !token.isEmpty {
|
||||
return true
|
||||
}
|
||||
// Some auth records (Spotify) carry only a refresh
|
||||
// token until the first access-token mint — count
|
||||
// that too so we don't false-negative seconds-old
|
||||
// OAuth flows.
|
||||
if let refresh = entry["refresh_token"] as? String, !refresh.isEmpty {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1473,6 +1527,42 @@ struct HermesFileService: Sendable {
|
||||
return false
|
||||
}
|
||||
|
||||
/// Persist the primary model + provider to `config.yaml` in one call.
|
||||
/// Used by the chat-start preflight when the user picks a model from
|
||||
/// the picker sheet — we need to write both keys before re-attempting
|
||||
/// `client.start()`. Wraps two `hermes config set` invocations because
|
||||
/// Hermes doesn't expose a combined "set model" command.
|
||||
///
|
||||
/// Returns `true` only if both writes succeed. If the second write
|
||||
/// fails the first is left in place — `model.default` without a
|
||||
/// matching `model.provider` is no worse than the all-empty state we
|
||||
/// started in, and the next preflight pass will re-prompt anyway.
|
||||
@discardableResult
|
||||
nonisolated func setModelAndProvider(model: String, provider: String) -> Bool {
|
||||
let trimmedModel = model.trimmingCharacters(in: .whitespaces)
|
||||
let trimmedProvider = provider.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedProvider.isEmpty else { return false }
|
||||
|
||||
let providerResult = runHermesCLI(args: ["config", "set", "model.provider", trimmedProvider], timeout: 30)
|
||||
guard providerResult.exitCode == 0 else {
|
||||
Self.logger.warning("hermes config set model.provider failed: \(providerResult.output, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
// Subscription-gated overlay providers (Nous Portal) accept an
|
||||
// empty model — Hermes picks its own default. Skip the model
|
||||
// write in that case rather than persisting the empty string,
|
||||
// which Hermes would treat as "unset" and the preflight would
|
||||
// catch again on the next start.
|
||||
guard !trimmedModel.isEmpty else { return true }
|
||||
|
||||
let modelResult = runHermesCLI(args: ["config", "set", "model.default", trimmedModel], timeout: 30)
|
||||
guard modelResult.exitCode == 0 else {
|
||||
Self.logger.warning("hermes config set model.default failed: \(modelResult.output, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) {
|
||||
// Resolve the executable path — for remote, prefer the cached
|
||||
@@ -1510,6 +1600,39 @@ struct HermesFileService: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Split-stream variant of `runHermesCLI`. Use this when you need to
|
||||
/// parse stdout (e.g. JSON output) without stderr contamination, and
|
||||
/// surface stderr separately as a user-facing error message. Transport
|
||||
/// failures land in `stderr` with an empty `stdout`.
|
||||
@discardableResult
|
||||
nonisolated func runHermesCLISplit(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, stdout: String, stderr: String) {
|
||||
let binary: String
|
||||
if context.isRemote {
|
||||
binary = context.paths.hermesBinary
|
||||
} else {
|
||||
guard let local = hermesBinaryPath() else { return (-1, "", "hermes binary not found") }
|
||||
binary = local
|
||||
}
|
||||
|
||||
let stdinData = stdinInput?.data(using: .utf8)
|
||||
do {
|
||||
let result = try transport.runProcess(
|
||||
executable: binary,
|
||||
args: args,
|
||||
stdin: stdinData,
|
||||
timeout: timeout
|
||||
)
|
||||
return (result.exitCode, result.stdoutString, result.stderrString)
|
||||
} catch let error as TransportError {
|
||||
let message = error.diagnosticStderr.isEmpty
|
||||
? (error.errorDescription ?? "transport error")
|
||||
: error.diagnosticStderr
|
||||
return (-1, "", message)
|
||||
} catch {
|
||||
return (-1, "", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File I/O
|
||||
|
||||
/// Read a UTF-8 text file through the transport. Missing files and any
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// Maps `templateId → installedVersion` for every project the user has
|
||||
/// installed via a template. Used by the catalog browser to render
|
||||
/// each row's "Installed" / "Update available" / "Not installed" badge.
|
||||
///
|
||||
/// **Read-only.** This service walks the projects registry + each
|
||||
/// project's `.scarf/template.lock.json`. It never writes anything.
|
||||
///
|
||||
/// **Per-call rebuild.** The index is cheap to compute (a registry
|
||||
/// read + N lock-file reads, each a few hundred bytes) and changes
|
||||
/// infrequently from the user's perspective. We rebuild on every
|
||||
/// catalog-sheet open instead of caching with invalidation rules —
|
||||
/// the cost of a stale "Installed" badge would surprise users far more
|
||||
/// than the cost of one extra `[String:Data]` walk on each refresh.
|
||||
struct InstalledTemplatesIndex: Sendable {
|
||||
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "InstalledTemplatesIndex")
|
||||
|
||||
let context: ServerContext
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// Build the index. Returns `[templateId: version]`. Projects
|
||||
/// without a lock file (ad-hoc projects added via "Add Project")
|
||||
/// are skipped silently — they aren't template-installed and don't
|
||||
/// belong in the index.
|
||||
func build() -> [String: String] {
|
||||
let transport = context.makeTransport()
|
||||
let registryPath = context.paths.projectsRegistry
|
||||
guard transport.fileExists(registryPath) else { return [:] }
|
||||
|
||||
let data: Data
|
||||
do {
|
||||
data = try transport.readFile(registryPath)
|
||||
} catch {
|
||||
Self.logger.warning("couldn't read projects registry at \(registryPath, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
return [:]
|
||||
}
|
||||
|
||||
let registry: ProjectRegistry
|
||||
do {
|
||||
registry = try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
||||
} catch {
|
||||
Self.logger.warning("couldn't decode projects registry: \(error.localizedDescription, privacy: .public)")
|
||||
return [:]
|
||||
}
|
||||
|
||||
var index: [String: String] = [:]
|
||||
for project in registry.projects {
|
||||
guard let lock = readLock(for: project) else { continue }
|
||||
// Last-write-wins on duplicates. Two installs of the same
|
||||
// template id at different versions is rare but possible
|
||||
// (user installed it in two project dirs); the catalog
|
||||
// doesn't need to render which version, just that
|
||||
// *something* is installed.
|
||||
index[lock.templateId] = lock.templateVersion
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/// Update-availability classification for a single catalog entry.
|
||||
/// `installedVersion == nil` → not installed. Equal versions →
|
||||
/// `.installed`. Catalog version newer than installed → `.updateAvailable`.
|
||||
/// Catalog version older or equal-but-different format → `.installed`
|
||||
/// (we trust the catalog; semver-noise comparisons aren't worth a
|
||||
/// full parse here).
|
||||
static func classify(catalogVersion: String, installedVersion: String?) -> InstallState {
|
||||
guard let installedVersion else { return .notInstalled }
|
||||
if catalogVersion == installedVersion {
|
||||
return .installed(version: installedVersion)
|
||||
}
|
||||
if isVersionNewer(catalogVersion, than: installedVersion) {
|
||||
return .updateAvailable(installedVersion: installedVersion, catalogVersion: catalogVersion)
|
||||
}
|
||||
return .installed(version: installedVersion)
|
||||
}
|
||||
|
||||
enum InstallState: Sendable, Equatable {
|
||||
case notInstalled
|
||||
case installed(version: String)
|
||||
case updateAvailable(installedVersion: String, catalogVersion: String)
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
/// Read `<project>/.scarf/template.lock.json`. Returns nil for
|
||||
/// ad-hoc (non-templated) projects, malformed JSON, or any I/O
|
||||
/// failure — the catalog shouldn't crash because one project's
|
||||
/// lock file got corrupted.
|
||||
private func readLock(for project: ProjectEntry) -> TemplateLock? {
|
||||
let path = project.path + "/.scarf/template.lock.json"
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(path) else { return nil }
|
||||
|
||||
let data: Data
|
||||
do {
|
||||
data = try transport.readFile(path)
|
||||
} catch {
|
||||
Self.logger.warning("couldn't read template lock at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
return try JSONDecoder().decode(TemplateLock.self, from: data)
|
||||
} catch {
|
||||
Self.logger.warning("couldn't decode template lock at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain semver-ish comparison: split on `.`, compare numerically
|
||||
/// from major down. Pre-release suffixes (anything after `-` in a
|
||||
/// segment) make that release *older* than the same numeric prefix
|
||||
/// without a suffix — matches semver §11 ("a pre-release version has
|
||||
/// lower precedence than the associated normal version"), so
|
||||
/// `1.0.0-beta` is *not* newer than `1.0.0`. Two pre-releases on the
|
||||
/// same numeric prefix fall back to lexicographic compare on the
|
||||
/// suffix. Good enough for "is the catalog ahead?" — this isn't a
|
||||
/// package manager.
|
||||
static func isVersionNewer(_ candidate: String, than other: String) -> Bool {
|
||||
let (aCore, aPre) = splitPrerelease(candidate)
|
||||
let (bCore, bPre) = splitPrerelease(other)
|
||||
let a = aCore.split(separator: ".").map(String.init)
|
||||
let b = bCore.split(separator: ".").map(String.init)
|
||||
for i in 0..<max(a.count, b.count) {
|
||||
let ai = i < a.count ? a[i] : "0"
|
||||
let bi = i < b.count ? b[i] : "0"
|
||||
if let an = Int(ai), let bn = Int(bi) {
|
||||
if an != bn { return an > bn }
|
||||
} else if ai != bi {
|
||||
return ai > bi
|
||||
}
|
||||
}
|
||||
// Numeric cores match. Pre-release tiebreak: an absent pre-release
|
||||
// outranks any present pre-release.
|
||||
switch (aPre, bPre) {
|
||||
case (nil, nil): return false
|
||||
case (nil, _): return true // candidate has no pre-release; older has one → newer
|
||||
case (_, nil): return false // candidate has pre-release; other is the release → older
|
||||
case (let ap?, let bp?): return ap > bp
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a version string into its numeric core and pre-release
|
||||
/// suffix on the first `-`. `"1.0.0-beta.2"` → `("1.0.0", "beta.2")`.
|
||||
/// `"1.0.0"` → `("1.0.0", nil)`.
|
||||
private static func splitPrerelease(_ version: String) -> (core: String, pre: String?) {
|
||||
if let dash = version.firstIndex(of: "-") {
|
||||
return (String(version[..<dash]), String(version[version.index(after: dash)...]))
|
||||
}
|
||||
return (version, nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import os
|
||||
import Observation
|
||||
|
||||
/// Per-message text-to-speech for assistant chat replies (issue #66).
|
||||
/// Uses `AVSpeechSynthesizer` with the system voice — no Hermes
|
||||
/// dependency, works offline, picks up the user's macOS Spoken Content
|
||||
/// voice selection automatically.
|
||||
///
|
||||
/// One synthesizer is shared across the app so starting a second
|
||||
/// message's playback automatically interrupts the first. The
|
||||
/// per-message speaker button reads `playingMessageId` to render
|
||||
/// play vs. stop state.
|
||||
///
|
||||
/// The full Hermes-provider TTS pipeline (Edge / ElevenLabs / OpenAI
|
||||
/// / NeuTTS / Piper from Settings → Voice) is deferred to a follow-up
|
||||
/// — wiring per-provider audio fetching, caching, and interruption
|
||||
/// is a much bigger surface than what's needed to give users a
|
||||
/// listen-while-doing-other-work affordance today.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MessageSpeechService: NSObject {
|
||||
static let shared = MessageSpeechService()
|
||||
|
||||
/// The message id currently being spoken, or `nil` when idle.
|
||||
/// Bubbles read this to flip their speaker icon to a stop glyph.
|
||||
private(set) var playingMessageId: Int?
|
||||
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "MessageSpeech")
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
synthesizer.delegate = self
|
||||
}
|
||||
|
||||
/// Speak `content`. If a different message is currently playing,
|
||||
/// interrupt it. If the same message is currently playing, this
|
||||
/// stops playback (toggle behavior).
|
||||
func toggle(messageId: Int, content: String) {
|
||||
if playingMessageId == messageId {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
if synthesizer.isSpeaking {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
}
|
||||
let cleaned = Self.strippedForSpeech(content)
|
||||
guard !cleaned.isEmpty else { return }
|
||||
let utterance = AVSpeechUtterance(string: cleaned)
|
||||
// AVSpeechUtterance honors the user's Spoken Content default
|
||||
// voice when `voice` is `nil`, which is the right behavior:
|
||||
// users who configured a specific macOS voice get it
|
||||
// automatically.
|
||||
utterance.rate = AVSpeechUtteranceDefaultSpeechRate
|
||||
playingMessageId = messageId
|
||||
synthesizer.speak(utterance)
|
||||
}
|
||||
|
||||
/// Stop any in-progress speech and clear `playingMessageId`.
|
||||
func stop() {
|
||||
guard playingMessageId != nil else { return }
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
playingMessageId = nil
|
||||
}
|
||||
|
||||
/// Strip markdown control characters before speech so the user
|
||||
/// doesn't hear "asterisk asterisk bold". Code fences and inline
|
||||
/// code are spoken verbatim minus the backticks. Keeps URLs
|
||||
/// readable but drops square-bracket link wrappers.
|
||||
static func strippedForSpeech(_ raw: String) -> String {
|
||||
var out = raw
|
||||
// Fenced code blocks → keep contents
|
||||
out = out.replacingOccurrences(of: "```", with: "")
|
||||
// Inline code → drop backticks
|
||||
out = out.replacingOccurrences(of: "`", with: "")
|
||||
// Bold/italic markers
|
||||
out = out.replacingOccurrences(of: "**", with: "")
|
||||
out = out.replacingOccurrences(of: "__", with: "")
|
||||
// Link syntax: [text](url) → text
|
||||
if let regex = try? NSRegularExpression(
|
||||
pattern: #"\[([^\]]+)\]\([^)]+\)"#,
|
||||
options: []
|
||||
) {
|
||||
let range = NSRange(out.startIndex..., in: out)
|
||||
out = regex.stringByReplacingMatches(
|
||||
in: out,
|
||||
options: [],
|
||||
range: range,
|
||||
withTemplate: "$1"
|
||||
)
|
||||
}
|
||||
return out.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageSpeechService: AVSpeechSynthesizerDelegate {
|
||||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||
Task { @MainActor in
|
||||
self.playingMessageId = nil
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||||
Task { @MainActor in
|
||||
self.playingMessageId = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ struct ProjectTemplateInstaller: Sendable {
|
||||
/// to the registry so the caller can set `AppCoordinator.selectedProjectName`.
|
||||
@discardableResult
|
||||
nonisolated func install(plan: TemplateInstallPlan) throws -> ProjectEntry {
|
||||
try bootstrapProjectsRoot(plan: plan)
|
||||
try preflight(plan: plan)
|
||||
try createProjectFiles(plan: plan)
|
||||
try createSkillsFiles(plan: plan)
|
||||
@@ -32,6 +33,24 @@ struct ProjectTemplateInstaller: Sendable {
|
||||
return entry
|
||||
}
|
||||
|
||||
// MARK: - Bootstrap
|
||||
|
||||
/// Idempotently `mkdir -p` the parent directory so a fresh remote
|
||||
/// host (or a local user with no `~/Projects`) can complete the
|
||||
/// first install. Runs *before* preflight — preflight then checks
|
||||
/// the project dir itself, which we deliberately don't create
|
||||
/// here so the "already exists" collision check still fires for
|
||||
/// repeat installs at the same path.
|
||||
///
|
||||
/// Safe on both transports: `LocalTransport.createDirectory` uses
|
||||
/// `withIntermediateDirectories: true`; `SSHTransport.createDirectory`
|
||||
/// runs `mkdir -p`. Idempotent for existing dirs in both cases.
|
||||
nonisolated private func bootstrapProjectsRoot(plan: TemplateInstallPlan) throws {
|
||||
let parentDir = (plan.projectDir as NSString).deletingLastPathComponent
|
||||
guard !parentDir.isEmpty, parentDir != "/" else { return }
|
||||
try context.makeTransport().createDirectory(parentDir)
|
||||
}
|
||||
|
||||
// MARK: - Preflight
|
||||
|
||||
nonisolated private func preflight(plan: TemplateInstallPlan) throws {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import Sparkle
|
||||
|
||||
/// Thin wrapper around Sparkle's `SPUStandardUpdaterController`.
|
||||
@@ -24,9 +25,15 @@ final class UpdaterService: NSObject {
|
||||
|
||||
override init() {
|
||||
// startingUpdater: true → Sparkle scans for updates on launch per Info.plist schedule.
|
||||
// Default delegates are sufficient for a non-sandboxed app.
|
||||
// Under `--scarf-test-mode` we keep Sparkle inert so XCUITest runs
|
||||
// never see a "an update is available" sheet pop on top of the
|
||||
// window the test is trying to drive. The controller still
|
||||
// initializes — `automaticallyChecksForUpdates` reads/writes
|
||||
// continue to work — it just doesn't fire the on-launch check
|
||||
// or surface UI.
|
||||
let startUpdater = !TestModeFlags.shared.isTestMode
|
||||
self.controller = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
startingUpdater: startUpdater,
|
||||
updaterDelegate: nil,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
|
||||
@@ -3,12 +3,22 @@ import SwiftUI
|
||||
struct MarkdownContentView: View {
|
||||
let content: String
|
||||
|
||||
/// Chat font scale plumbed from `RichChatView` (issue #68). Defaults
|
||||
/// to 1.0 when this view is used outside the chat surface so other
|
||||
/// callers see the un-scaled rendering.
|
||||
@Environment(\.chatFontScale) private var chatFontScale: Double
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(Array(parseBlocks().enumerated()), id: \.offset) { _, block in
|
||||
blockView(block)
|
||||
}
|
||||
}
|
||||
// Paragraphs are rendered as plain `Text(AttributedString)` and
|
||||
// inherit whatever font is set on the enclosing scope. Pin the
|
||||
// scope to the scaled body font so the chat slider actually
|
||||
// moves the visible text.
|
||||
.font(ChatFontScale.body(chatFontScale))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -37,15 +47,19 @@ struct MarkdownContentView: View {
|
||||
// MARK: - Block Views
|
||||
|
||||
private func headingView(level: Int, text: String) -> some View {
|
||||
let font: Font = switch level {
|
||||
case 1: .title.bold()
|
||||
case 2: .title2.bold()
|
||||
case 3: .title3.bold()
|
||||
case 4: .headline
|
||||
default: .subheadline.bold()
|
||||
// Heading sizes scale with `chatFontScale` (issue #68). Bases
|
||||
// mirror the SwiftUI semantic tokens we used previously
|
||||
// (`.title` ≈ 28, `.title2` ≈ 22, `.title3` ≈ 20, `.headline`
|
||||
// ≈ 17, `.subheadline` ≈ 15) so 100% matches today's UI.
|
||||
let baseSize: CGFloat = switch level {
|
||||
case 1: 28
|
||||
case 2: 22
|
||||
case 3: 20
|
||||
case 4: 17
|
||||
default: 15
|
||||
}
|
||||
return Text(MarkdownRenderer.inlineAttributedString(text))
|
||||
.font(font)
|
||||
.font(.system(size: baseSize * chatFontScale, weight: .semibold))
|
||||
.textSelection(.enabled)
|
||||
.padding(.top, level <= 2 ? 8 : 4)
|
||||
}
|
||||
@@ -54,11 +68,11 @@ struct MarkdownContentView: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let lang = language, !lang.isEmpty {
|
||||
Text(lang)
|
||||
.font(.caption2.bold())
|
||||
.font(ChatFontScale.caption2(chatFontScale).bold())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(code)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.font(ChatFontScale.codeInline(chatFontScale))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@ enum ChatDensityKeys {
|
||||
static let toolCardStyle = "scarf.chat.toolCardStyle"
|
||||
static let reasoningStyle = "scarf.chat.reasoningStyle"
|
||||
static let fontScale = "scarf.chat.fontScale"
|
||||
/// Whether the left sessions list pane is visible in the Mac
|
||||
/// 3-pane chat layout. Defaults true (today's behavior). Issue #58.
|
||||
static let showSessionsList = "scarf.chat.showSessionsList"
|
||||
/// Whether the right tool inspector pane is visible. Defaults true.
|
||||
/// When hidden, clicking a tool card auto-flips it back on so the
|
||||
/// click does what the user expects (`ToolCallCard.onFocus`). Issue #58.
|
||||
static let showInspector = "scarf.chat.showInspector"
|
||||
}
|
||||
|
||||
/// How `RichMessageBubble` renders the per-call tool widgets.
|
||||
@@ -99,4 +106,74 @@ enum ChatFontScale {
|
||||
let pct = Int((scale * 100).rounded())
|
||||
return "\(pct)%"
|
||||
}
|
||||
|
||||
// MARK: - Scaled font helpers
|
||||
//
|
||||
// ScarfFont's tokens are fixed-point (`Font.system(size: 14, …)`),
|
||||
// so `.environment(\.dynamicTypeSize, …)` doesn't reach them — the
|
||||
// Mac chat slider had no visible effect on bubbles, reasoning,
|
||||
// tool chips, or code blocks (issue #68). These helpers mirror the
|
||||
// ScarfFont base sizes, multiplied by the user's chat scale, and
|
||||
// are used by `RichMessageBubble`, `MarkdownContentView`, and
|
||||
// `CodeBlockView` in place of the static tokens. At scale = 1.0
|
||||
// they're byte-for-byte identical to ScarfFont so the default UI
|
||||
// is unchanged.
|
||||
|
||||
static func body(_ scale: Double) -> Font {
|
||||
.system(size: 14 * scale, weight: .regular)
|
||||
}
|
||||
|
||||
static func bodyEmph(_ scale: Double) -> Font {
|
||||
.system(size: 14 * scale, weight: .medium)
|
||||
}
|
||||
|
||||
static func callout(_ scale: Double) -> Font {
|
||||
.system(size: 15 * scale, weight: .regular)
|
||||
}
|
||||
|
||||
static func caption(_ scale: Double) -> Font {
|
||||
.system(size: 12 * scale, weight: .regular)
|
||||
}
|
||||
|
||||
static func captionStrong(_ scale: Double) -> Font {
|
||||
.system(size: 12 * scale, weight: .semibold)
|
||||
}
|
||||
|
||||
static func caption2(_ scale: Double) -> Font {
|
||||
.system(size: 10 * scale, weight: .medium)
|
||||
}
|
||||
|
||||
static func mono(_ scale: Double) -> Font {
|
||||
.system(size: 13 * scale, weight: .regular, design: .monospaced)
|
||||
}
|
||||
|
||||
static func monoSmall(_ scale: Double) -> Font {
|
||||
.system(size: 12 * scale, weight: .regular, design: .monospaced)
|
||||
}
|
||||
|
||||
/// Code-block body — matches `CodeBlockView`'s 12pt mono.
|
||||
static func codeBlock(_ scale: Double) -> Font {
|
||||
.system(size: 12 * scale, weight: .regular, design: .monospaced)
|
||||
}
|
||||
|
||||
/// Inline code in markdown paragraphs — `.callout` (15pt) mono.
|
||||
static func codeInline(_ scale: Double) -> Font {
|
||||
.system(size: 15 * scale, weight: .regular, design: .monospaced)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Environment plumbing
|
||||
|
||||
private struct ChatFontScaleKey: EnvironmentKey {
|
||||
static let defaultValue: Double = ChatFontScale.default
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// Multiplier applied to chat content fonts. Set once on
|
||||
/// `RichChatView`'s root so message bubbles, markdown paragraphs,
|
||||
/// and code blocks scale together. Default 1.0 = today's UI.
|
||||
var chatFontScale: Double {
|
||||
get { self[ChatFontScaleKey.self] }
|
||||
set { self[ChatFontScaleKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,9 +139,27 @@ final class ChatViewModel {
|
||||
get { richChatViewModel.acpErrorDetails }
|
||||
set { richChatViewModel.acpErrorDetails = newValue }
|
||||
}
|
||||
var acpErrorOAuthProvider: String? {
|
||||
get { richChatViewModel.acpErrorOAuthProvider }
|
||||
set { richChatViewModel.acpErrorOAuthProvider = newValue }
|
||||
}
|
||||
/// True when `hasAnyAICredential()` returned false at last preflight.
|
||||
var missingCredentials: Bool = false
|
||||
|
||||
/// Set when chat-start is blocked because the active server's
|
||||
/// `config.yaml` has no `model.default` / `model.provider`. The chat
|
||||
/// view observes this and presents `ChatModelPreflightSheet`; on
|
||||
/// successful pick we persist via `setModelAndProvider` and re-attempt
|
||||
/// the original `startACPSession` call from `pendingStartArgs`.
|
||||
/// Nil when no preflight is pending.
|
||||
var modelPreflightReason: String?
|
||||
|
||||
/// Stash of the original `startACPSession` arguments while we wait
|
||||
/// for the user to pick a model. Replayed verbatim once
|
||||
/// `confirmModelPreflight` writes the chosen model+provider to
|
||||
/// config.yaml. Cleared on cancel or after replay.
|
||||
private var pendingStartArgs: (sessionId: String?, projectPath: String?)?
|
||||
|
||||
private static let maxReconnectAttempts = 5
|
||||
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
|
||||
private static let maxReconnectDelay: UInt64 = 16_000_000_000 // 16 seconds
|
||||
@@ -240,14 +258,32 @@ final class ChatViewModel {
|
||||
// MARK: - Send Message
|
||||
|
||||
func sendText(_ text: String) {
|
||||
sendText(text, images: [])
|
||||
}
|
||||
|
||||
/// v0.12+ overload: forward image attachments alongside the text.
|
||||
/// Empty `images` keeps the legacy v0.11 wire shape; non-empty images
|
||||
/// only flow when `HermesCapabilities.hasACPImagePrompts` is true
|
||||
/// (the input bar gates the attachment UI on the same flag, so a
|
||||
/// non-empty array reaching here means we've already verified the
|
||||
/// agent supports it).
|
||||
///
|
||||
/// Terminal mode silently drops attachments — there's no way to
|
||||
/// pipe binary content through the TTY. Surface a one-shot warning
|
||||
/// so the user knows.
|
||||
func sendText(_ text: String, images: [ChatImageAttachment]) {
|
||||
if displayMode == .richChat {
|
||||
if let client = acpClient {
|
||||
sendViaACP(client: client, text: text)
|
||||
sendViaACP(client: client, text: text, images: images)
|
||||
} else {
|
||||
// Auto-start ACP and send the queued message
|
||||
autoStartACPAndSend(text: text)
|
||||
autoStartACPAndSend(text: text, images: images)
|
||||
}
|
||||
} else if let tv = terminalView {
|
||||
if !images.isEmpty {
|
||||
logger.warning("Terminal-mode chat dropped \(images.count) image attachment(s) — image input only works in ACP rich-chat mode")
|
||||
acpError = "Image attachments require ACP mode (rich chat)."
|
||||
}
|
||||
sendToTerminal(tv, text: text + "\r")
|
||||
}
|
||||
}
|
||||
@@ -260,7 +296,7 @@ final class ChatViewModel {
|
||||
/// user never interacted with; those can be garbage-collected by Hermes
|
||||
/// between the DB read and ACP `session/load`, producing a silent prompt
|
||||
/// failure with no UI feedback.
|
||||
private func autoStartACPAndSend(text: String) {
|
||||
private func autoStartACPAndSend(text: String, images: [ChatImageAttachment] = []) {
|
||||
// Show the user message immediately
|
||||
richChatViewModel.addUserMessage(text: text)
|
||||
|
||||
@@ -299,7 +335,7 @@ final class ChatViewModel {
|
||||
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
|
||||
|
||||
// Now send the queued prompt
|
||||
sendViaACP(client: client, text: text)
|
||||
sendViaACP(client: client, text: text, images: images)
|
||||
} catch {
|
||||
acpStatus = "Failed"
|
||||
await recordACPFailure(error, client: client, context: "Auto-start ACP failed")
|
||||
@@ -336,7 +372,7 @@ final class ChatViewModel {
|
||||
return ProjectSlashCommandService(context: context).expand(cmd, withArgument: argument)
|
||||
}
|
||||
|
||||
private func sendViaACP(client: ACPClient, text: String) {
|
||||
private func sendViaACP(client: ACPClient, text: String, images: [ChatImageAttachment] = []) {
|
||||
guard let sessionId = richChatViewModel.sessionId else {
|
||||
clearACPErrorState()
|
||||
acpError = "No session ID — cannot send"
|
||||
@@ -376,13 +412,28 @@ final class ChatViewModel {
|
||||
}
|
||||
acpPromptTask = Task { @MainActor in
|
||||
do {
|
||||
let result = try await client.sendPrompt(sessionId: sessionId, text: wireText)
|
||||
let result = try await client.sendPrompt(sessionId: sessionId, text: wireText, images: images)
|
||||
acpStatus = "Ready"
|
||||
richChatViewModel.handleACPEvent(
|
||||
.promptComplete(sessionId: sessionId, response: result)
|
||||
)
|
||||
// Re-fetch session from DB to pick up cost/token data Hermes may have written
|
||||
await richChatViewModel.refreshSessionFromDB()
|
||||
// Issue #64 — notify the user that Hermes has
|
||||
// finished if Scarf isn't the foreground app. The
|
||||
// notifier handles the foreground/disabled gating;
|
||||
// we just hand it the latest assistant text and
|
||||
// session title for the body line.
|
||||
if !isSteer {
|
||||
let preview = richChatViewModel.messages
|
||||
.last(where: { $0.isAssistant })?
|
||||
.content ?? ""
|
||||
let title = richChatViewModel.currentSession?.title
|
||||
ChatNotificationService.shared.postPromptCompleted(
|
||||
sessionTitle: title,
|
||||
preview: preview
|
||||
)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
acpStatus = "Cancelled"
|
||||
} catch {
|
||||
@@ -404,6 +455,23 @@ final class ChatViewModel {
|
||||
private func startACPSession(resume sessionId: String?, projectPath: String? = nil) {
|
||||
stopACP()
|
||||
clearACPErrorState()
|
||||
|
||||
// Pre-flight: bail before opening any ACP plumbing if the
|
||||
// active server's `config.yaml` has no primary model or
|
||||
// provider. Hermes would otherwise let `session/new` succeed
|
||||
// and only fail at first prompt with an opaque
|
||||
// "Model parameter is required" 400. Stashing the start
|
||||
// arguments here lets `confirmModelPreflight` replay them
|
||||
// unchanged after the user picks a model.
|
||||
let preflight = ModelPreflight.check(fileService.loadConfig())
|
||||
if !preflight.isConfigured {
|
||||
pendingStartArgs = (sessionId, projectPath)
|
||||
modelPreflightReason = preflight.reason
|
||||
acpStatus = ""
|
||||
hasActiveProcess = false
|
||||
return
|
||||
}
|
||||
|
||||
acpStatus = "Starting..."
|
||||
|
||||
let client = ACPClient.forMacApp(context: context)
|
||||
@@ -716,6 +784,44 @@ final class ChatViewModel {
|
||||
isHandlingDisconnect = false
|
||||
}
|
||||
|
||||
// MARK: - Model preflight
|
||||
|
||||
/// Called by `ChatModelPreflightSheet` once the user has picked a
|
||||
/// model in the embedded `ModelPickerSheet`. Persists the choice via
|
||||
/// `hermes config set` (transport-aware — works on remote droplets
|
||||
/// too) and replays the pending `startACPSession` call so the chat
|
||||
/// the user originally tried to open finally lands.
|
||||
@MainActor
|
||||
func confirmModelPreflight(model: String, provider: String) {
|
||||
let pending = pendingStartArgs
|
||||
modelPreflightReason = nil
|
||||
pendingStartArgs = nil
|
||||
|
||||
let svc = fileService
|
||||
Task.detached { [weak self] in
|
||||
let ok = svc.setModelAndProvider(model: model, provider: provider)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
if ok {
|
||||
if let pending {
|
||||
self.startACPSession(resume: pending.sessionId, projectPath: pending.projectPath)
|
||||
}
|
||||
} else {
|
||||
self.acpError = "Couldn't save model+provider to config.yaml. Open Settings to retry."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User dismissed the preflight sheet without picking a model. Drop
|
||||
/// the stashed start arguments and leave the chat in its idle state
|
||||
/// — no error banner, since this isn't a failure, just a deferral.
|
||||
@MainActor
|
||||
func cancelModelPreflight() {
|
||||
modelPreflightReason = nil
|
||||
pendingStartArgs = nil
|
||||
}
|
||||
|
||||
/// Respond to a permission request from the ACP agent.
|
||||
func respondToPermission(optionId: String) {
|
||||
guard let client = acpClient,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
|
||||
/// Pre-flight sheet shown when a chat-start hits a server whose
|
||||
/// `config.yaml` has no `model.default` / `model.provider`. Wraps the
|
||||
/// existing `ModelPickerSheet` so the picker surface, validation, and
|
||||
/// Nous-catalog branch all remain in one place.
|
||||
///
|
||||
/// The host (`ChatView`) owns persistence + retry: this sheet only
|
||||
/// captures the user's selection and calls `onSelect`. The
|
||||
/// `ChatViewModel` writes via `hermes config set` and replays the
|
||||
/// original `startACPSession` arguments, so the chat the user
|
||||
/// originally opened lands without them having to click the project
|
||||
/// row again.
|
||||
struct ChatModelPreflightSheet: View {
|
||||
let reason: String
|
||||
let serverDisplayName: String
|
||||
let onSelect: (_ model: String, _ provider: String) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
ModelPickerSheet(
|
||||
initialProvider: "",
|
||||
initialModel: "",
|
||||
onSelect: { modelID, providerID in
|
||||
onSelect(modelID, providerID)
|
||||
dismiss()
|
||||
},
|
||||
onCancel: {
|
||||
onCancel()
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "cpu")
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
.font(.title2)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Pick a model to start chatting")
|
||||
.scarfStyle(.headline)
|
||||
Text(detailMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var detailMessage: String {
|
||||
let suffix = "Hermes uses `model.default` + `model.provider` from `config.yaml`. Pick one and Scarf will save it on \(serverDisplayName) before starting the chat."
|
||||
guard !reason.isEmpty else { return suffix }
|
||||
return "\(reason) \(suffix)"
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import ScarfDesign
|
||||
struct ChatTranscriptPane: View {
|
||||
@Bindable var richChat: RichChatViewModel
|
||||
@Bindable var chatViewModel: ChatViewModel
|
||||
var onSend: (String) -> Void
|
||||
var onSend: (String, [ChatImageAttachment]) -> Void
|
||||
var isEnabled: Bool
|
||||
|
||||
var body: some View {
|
||||
@@ -34,19 +34,33 @@ struct ChatTranscriptPane: View {
|
||||
isWorking: richChat.isGenerating,
|
||||
isLoadingSession: chatViewModel.isPreparingSession,
|
||||
scrollTrigger: richChat.scrollTrigger,
|
||||
turnDurations: richChat.turnDurations
|
||||
turnDurations: richChat.turnDurations,
|
||||
hasMoreHistory: richChat.hasMoreHistory,
|
||||
isLoadingEarlier: richChat.isLoadingEarlier,
|
||||
onLoadEarlier: { Task { await richChat.loadEarlier() } }
|
||||
)
|
||||
|
||||
Divider()
|
||||
if let hint = richChat.transientHint {
|
||||
steeringToast(hint)
|
||||
}
|
||||
// Issue #62: bind composer identity to the active session
|
||||
// ID so SwiftUI rebuilds `RichChatInputBar` (and its
|
||||
// `@State` `text`/`attachments`) when the user switches
|
||||
// conversations. Without this the composer is structurally
|
||||
// identical across sessions and SwiftUI happily reuses the
|
||||
// instance, leaking the unsent draft into the new session.
|
||||
// A stable fallback id covers the brief "no session
|
||||
// selected" window — using `UUID()` here would mint a
|
||||
// fresh value per render and trash the composer on every
|
||||
// body re-eval.
|
||||
RichChatInputBar(
|
||||
onSend: onSend,
|
||||
isEnabled: isEnabled,
|
||||
commands: richChat.availableCommands,
|
||||
showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu
|
||||
)
|
||||
.id(richChat.sessionId ?? "scarf.chat.no-session")
|
||||
}
|
||||
.background(ScarfColor.backgroundPrimary)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,15 @@ struct ChatView: View {
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@State private var showErrorDetails = false
|
||||
|
||||
/// Side-pane visibility toggles (issue #58). Drive the new
|
||||
/// sidebar.left / sidebar.right toolbar buttons; `RichChatView.body`
|
||||
/// reads the same `@AppStorage` keys and conditionally renders the
|
||||
/// panes with a slide animation.
|
||||
@AppStorage(ChatDensityKeys.showSessionsList)
|
||||
private var showSessionsList: Bool = true
|
||||
@AppStorage(ChatDensityKeys.showInspector)
|
||||
private var showInspector: Bool = true
|
||||
|
||||
var body: some View {
|
||||
@Bindable var vm = viewModel
|
||||
@Bindable var coord = coordinator
|
||||
@@ -107,6 +116,15 @@ struct ChatView: View {
|
||||
.lineLimit(showErrorDetails ? nil : 2)
|
||||
}
|
||||
Spacer()
|
||||
if let provider = viewModel.acpErrorOAuthProvider {
|
||||
Button("Re-authenticate") {
|
||||
coordinator.pendingOAuthReauth = provider
|
||||
coordinator.selectedSection = .credentialPools
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.help("Open Credential Pools and re-authenticate \(provider).")
|
||||
}
|
||||
if viewModel.acpErrorDetails != nil {
|
||||
Button(showErrorDetails ? "Hide details" : "Show details") {
|
||||
showErrorDetails.toggle()
|
||||
@@ -225,6 +243,30 @@ struct ChatView: View {
|
||||
voiceControls
|
||||
}
|
||||
|
||||
// Side-pane toggles (issue #58). Only meaningful in rich-chat
|
||||
// mode where the 3-pane layout exists; terminal mode is a
|
||||
// single SwiftTerm view and these would do nothing. Hide
|
||||
// them on the terminal side so the toolbar stays uncluttered.
|
||||
if viewModel.displayMode == .richChat {
|
||||
Button {
|
||||
showSessionsList.toggle()
|
||||
} label: {
|
||||
Image(systemName: "sidebar.left")
|
||||
.foregroundStyle(showSessionsList ? Color.accentColor : .secondary)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help(showSessionsList ? "Hide sessions list" : "Show sessions list")
|
||||
|
||||
Button {
|
||||
showInspector.toggle()
|
||||
} label: {
|
||||
Image(systemName: "sidebar.right")
|
||||
.foregroundStyle(showInspector ? Color.accentColor : .secondary)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help(showInspector ? "Hide tool inspector" : "Show tool inspector")
|
||||
}
|
||||
|
||||
Picker("View", selection: Bindable(viewModel).displayMode) {
|
||||
Image(systemName: "terminal")
|
||||
.help("Terminal")
|
||||
@@ -363,7 +405,7 @@ struct ChatView: View {
|
||||
if viewModel.hermesBinaryExists {
|
||||
RichChatView(
|
||||
richChat: viewModel.richChatViewModel,
|
||||
onSend: { viewModel.sendText($0) },
|
||||
onSend: { text, images in viewModel.sendText(text, images: images) },
|
||||
isEnabled: viewModel.hasActiveProcess || viewModel.hermesBinaryExists
|
||||
)
|
||||
} else {
|
||||
@@ -386,6 +428,23 @@ struct ChatView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
// Model preflight — open before any ACP plumbing when the active
|
||||
// server has no `model.default` / `model.provider` set. Keeps the
|
||||
// user from typing a prompt only to find out the upstream
|
||||
// provider rejected it.
|
||||
.sheet(isPresented: modelPreflightBinding) {
|
||||
ChatModelPreflightSheet(
|
||||
reason: viewModel.modelPreflightReason ?? "",
|
||||
serverDisplayName: viewModel.context.displayName,
|
||||
onSelect: { model, provider in
|
||||
viewModel.confirmModelPreflight(model: model, provider: provider)
|
||||
},
|
||||
onCancel: {
|
||||
viewModel.cancelModelPreflight()
|
||||
}
|
||||
)
|
||||
.environment(\.serverContext, viewModel.context)
|
||||
}
|
||||
}
|
||||
|
||||
private var permissionBinding: Binding<RichChatViewModel.PendingPermission?> {
|
||||
@@ -394,11 +453,24 @@ struct ChatView: View {
|
||||
set: { viewModel.richChatViewModel.pendingPermission = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
private var modelPreflightBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { viewModel.modelPreflightReason != nil },
|
||||
set: { newValue in
|
||||
if !newValue { viewModel.cancelModelPreflight() }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Permission Approval View
|
||||
|
||||
extension RichChatViewModel.PendingPermission: Identifiable {
|
||||
// `@retroactive` acknowledges that we're declaring conformance for a
|
||||
// type (`PendingPermission`) and protocol (`Identifiable`) we don't own
|
||||
// — the Swift 6 compiler flags this otherwise so that downstream
|
||||
// breakage is loud if `ScarfCore` ever adds the conformance upstream.
|
||||
extension RichChatViewModel.PendingPermission: @retroactive Identifiable {
|
||||
public var id: Int { requestId }
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,16 @@ struct CodeBlockView: View {
|
||||
|
||||
@State private var copied = false
|
||||
|
||||
/// Chat font scale plumbed from `RichChatView` (issue #68). Defaults
|
||||
/// to 1.0 outside the chat surface.
|
||||
@Environment(\.chatFontScale) private var chatFontScale: Double
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if let language, !language.isEmpty {
|
||||
HStack {
|
||||
Text(language)
|
||||
.font(.caption2.bold())
|
||||
.font(ChatFontScale.caption2(chatFontScale).bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
copyButton
|
||||
@@ -31,7 +35,7 @@ struct CodeBlockView: View {
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
Text(code)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.font(ChatFontScale.codeBlock(chatFontScale))
|
||||
.foregroundStyle(Color(nsColor: NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)))
|
||||
.textSelection(.enabled)
|
||||
.padding(.horizontal, 10)
|
||||
|
||||
@@ -1,20 +1,51 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
import UniformTypeIdentifiers
|
||||
import os
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct RichChatInputBar: View {
|
||||
let onSend: (String) -> Void
|
||||
/// Send the user's text and any attached images. Empty `images`
|
||||
/// preserves the v0.11 wire shape; non-empty images are forwarded
|
||||
/// as ACP image content blocks (Hermes v0.12+; the composer hides
|
||||
/// the attachment UI on older hosts).
|
||||
let onSend: (String, [ChatImageAttachment]) -> Void
|
||||
let isEnabled: Bool
|
||||
var commands: [HermesSlashCommand] = []
|
||||
var showCompressButton: Bool = false
|
||||
|
||||
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||
|
||||
@State private var text = ""
|
||||
@State private var showCompressSheet = false
|
||||
@State private var compressFocus = ""
|
||||
@State private var showMenu = false
|
||||
@State private var selectedIndex = 0
|
||||
@State private var attachments: [ChatImageAttachment] = []
|
||||
/// True while ImageEncoder is decoding/encoding pasted/dropped bytes.
|
||||
/// Renders a small spinner in the preview strip so the user knows
|
||||
/// their drop landed.
|
||||
@State private var isEncodingAttachment = false
|
||||
/// User-visible failure (decode failed, format unsupported). Auto-clears.
|
||||
@State private var attachmentError: String?
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
/// Hard cap matches what Hermes' vision aux model swallows comfortably
|
||||
/// in one prompt. Going higher costs tokens without a quality gain.
|
||||
private static let maxAttachments = 5
|
||||
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ChatComposer")
|
||||
|
||||
/// `nil` until detection finishes — we hide the attachment UI in
|
||||
/// that brief window (~50ms locally, longer over SSH) so we never
|
||||
/// flash an attachment chip a v0.11 host couldn't honor.
|
||||
private var supportsImagePrompts: Bool {
|
||||
capabilitiesStore?.capabilities.hasACPImagePrompts ?? false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if showMenu {
|
||||
@@ -36,6 +67,10 @@ struct RichChatInputBar: View {
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
if !attachments.isEmpty || isEncodingAttachment || attachmentError != nil {
|
||||
attachmentStrip
|
||||
}
|
||||
|
||||
HStack(alignment: .bottom, spacing: ScarfSpace.s2) {
|
||||
if showCompressButton {
|
||||
Button {
|
||||
@@ -52,6 +87,10 @@ struct RichChatInputBar: View {
|
||||
.help("Compress conversation (/compress)")
|
||||
}
|
||||
|
||||
if supportsImagePrompts {
|
||||
attachmentButton
|
||||
}
|
||||
|
||||
TextEditor(text: $text)
|
||||
.font(ScarfFont.body)
|
||||
.scrollContentBackground(.hidden)
|
||||
@@ -69,14 +108,66 @@ struct RichChatInputBar: View {
|
||||
)
|
||||
)
|
||||
.overlay(alignment: .topLeading) {
|
||||
if text.isEmpty {
|
||||
Text("Message Hermes… / for commands")
|
||||
.scarfStyle(.body)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
// Placeholder ghosting (#65): TextEditor's
|
||||
// NSTextView updates the visible glyphs a frame
|
||||
// before the SwiftUI binding propagates, so a
|
||||
// bare `if text.isEmpty` overlay renders the
|
||||
// translucent placeholder text on top of the
|
||||
// just-typed character — visible as a "behind
|
||||
// or around" ghost. Three mitigations:
|
||||
//
|
||||
// 1. Pin an opaque rectangle behind the
|
||||
// placeholder text. During any single-
|
||||
// frame lag the user sees a clean
|
||||
// placeholder, never layered glyphs.
|
||||
// 2. Use `.opacity(...)` instead of an `if`.
|
||||
// Keeps the view tree stable per
|
||||
// keystroke (removes the per-keystroke
|
||||
// view-mutation churn the composer was
|
||||
// already paying for).
|
||||
// 3. Constrain to a single line with
|
||||
// `frame(maxWidth: .infinity)` and
|
||||
// `truncationMode(.tail)` so the long-form
|
||||
// hint can't escape the rounded
|
||||
// TextEditor bounds when the sidebar /
|
||||
// detail-pane geometry compresses the
|
||||
// composer (was visibly overflowing).
|
||||
Text(supportsImagePrompts
|
||||
? "Message Hermes… / for commands · drag images to attach"
|
||||
: "Message Hermes… / for commands")
|
||||
.scarfStyle(.body)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(ScarfColor.backgroundSecondary)
|
||||
// Hide once the field has any content OR
|
||||
// the user is actively focused — matches
|
||||
// standard NSTextField / UITextField
|
||||
// placeholder semantics.
|
||||
.opacity((text.isEmpty && !isFocused) ? 1 : 0)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
// Drag-drop image attachments. Receives both file URLs
|
||||
// (from Finder) and raw image bitmap data (from
|
||||
// screenshot tools that drop tiff/png directly).
|
||||
// Capability-gated so v0.11 hosts don't surface a
|
||||
// drop target that does nothing.
|
||||
.onDrop(
|
||||
of: supportsImagePrompts ? [.image, .fileURL] : [],
|
||||
isTargeted: nil
|
||||
) { providers in
|
||||
guard supportsImagePrompts else { return false }
|
||||
ingestProviders(providers)
|
||||
return true
|
||||
}
|
||||
// Paste from screenshots / browser context menu.
|
||||
// Accepting `Data` keeps us off `NSImage` which would
|
||||
// require AppKit-typed paste. v0.12+ only.
|
||||
.onPasteCommand(of: pasteAcceptedTypes) { providers in
|
||||
ingestProviders(providers)
|
||||
}
|
||||
.onKeyPress(.upArrow, phases: .down) { _ in
|
||||
guard showMenu, !filteredCommands.isEmpty else { return .ignored }
|
||||
@@ -140,7 +231,12 @@ struct RichChatInputBar: View {
|
||||
.onChange(of: text) { _, _ in
|
||||
updateMenuState()
|
||||
}
|
||||
.onChange(of: commands.map(\.id)) { _, _ in
|
||||
// Watch `commands.count` rather than `commands.map(\.id)` — the
|
||||
// mapped form allocates a fresh `[String]` on every body
|
||||
// re-eval (i.e. every keystroke), which is wasted work even
|
||||
// when the array compares equal. The count proxy fires when
|
||||
// the agent advertises new commands.
|
||||
.onChange(of: commands.count) { _, _ in
|
||||
updateMenuState()
|
||||
}
|
||||
.sheet(isPresented: $showCompressSheet) {
|
||||
@@ -148,6 +244,96 @@ struct RichChatInputBar: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal preview strip for attached images. Each chip shows the
|
||||
/// thumbnail (or a placeholder icon if we couldn't render one) plus
|
||||
/// an X to remove the attachment.
|
||||
@ViewBuilder
|
||||
private var attachmentStrip: some View {
|
||||
HStack(alignment: .center, spacing: ScarfSpace.s2) {
|
||||
if isEncodingAttachment {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Encoding…")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
}
|
||||
ForEach(attachments) { attachment in
|
||||
attachmentChip(attachment)
|
||||
}
|
||||
if let err = attachmentError {
|
||||
Text(err)
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.danger)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if !attachments.isEmpty {
|
||||
Text("\(attachments.count)/\(Self.maxAttachments)")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, ScarfSpace.s3)
|
||||
.padding(.top, ScarfSpace.s2)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func attachmentChip(_ attachment: ChatImageAttachment) -> some View {
|
||||
let thumb = chipThumbnail(for: attachment)
|
||||
HStack(spacing: 4) {
|
||||
thumb
|
||||
.frame(width: 32, height: 32)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
Button {
|
||||
attachments.removeAll { $0.id == attachment.id }
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(attachment.filename ?? "Image attachment")
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.md)
|
||||
.fill(ScarfColor.backgroundTertiary)
|
||||
)
|
||||
}
|
||||
|
||||
/// Render the inline thumbnail for a chip. Falls back to a generic
|
||||
/// photo icon when the encoder didn't produce a thumbnail (e.g. the
|
||||
/// image was already small enough to skip the resize step).
|
||||
@ViewBuilder
|
||||
private func chipThumbnail(for attachment: ChatImageAttachment) -> some View {
|
||||
if let thumb = attachment.thumbnailBase64,
|
||||
let data = Data(base64Encoded: thumb),
|
||||
let image = NSImage(data: data) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} else {
|
||||
Image(systemName: "photo")
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(ScarfColor.backgroundSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var attachmentButton: some View {
|
||||
Button {
|
||||
presentImagePicker()
|
||||
} label: {
|
||||
Image(systemName: "paperclip")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.padding(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!isEnabled || attachments.count >= Self.maxAttachments)
|
||||
.help("Attach image (\(attachments.count)/\(Self.maxAttachments))")
|
||||
}
|
||||
|
||||
private var compressSheet: some View {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s3) {
|
||||
Text("Compress Conversation")
|
||||
@@ -164,7 +350,7 @@ struct RichChatInputBar: View {
|
||||
Button("Compress") {
|
||||
let focus = compressFocus.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let command = focus.isEmpty ? "/compress" : "/compress \(focus)"
|
||||
onSend(command)
|
||||
onSend(command, [])
|
||||
showCompressSheet = false
|
||||
}
|
||||
.buttonStyle(ScarfPrimaryButton())
|
||||
@@ -176,7 +362,18 @@ struct RichChatInputBar: View {
|
||||
}
|
||||
|
||||
private var canSend: Bool {
|
||||
isEnabled && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
guard isEnabled else { return false }
|
||||
// Allow sending image-only messages once at least one attachment
|
||||
// exists — vision models accept "describe this" with no text.
|
||||
if !attachments.isEmpty { return true }
|
||||
return !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
/// MIME types accepted for paste. Restricting to image-bearing
|
||||
/// providers stops macOS from offering a paste menu when the user
|
||||
/// has plain text on the clipboard.
|
||||
private var pasteAcceptedTypes: [UTType] {
|
||||
supportsImagePrompts ? [.image, .png, .jpeg, .tiff, .heic] : []
|
||||
}
|
||||
|
||||
/// Show the slash menu only while the user is typing the command token:
|
||||
@@ -197,17 +394,37 @@ struct RichChatInputBar: View {
|
||||
|
||||
private func updateMenuState() {
|
||||
let shouldShow = shouldShowMenu
|
||||
|
||||
// Common case: user is composing normal text and the menu is
|
||||
// already hidden. Skip the filter computation + state writes
|
||||
// entirely so onChange stays cheap. Without this guard typing
|
||||
// recomputes `filteredCommands` on every keystroke even when
|
||||
// the menu can't possibly appear.
|
||||
guard shouldShow || showMenu else { return }
|
||||
|
||||
// Compute desired selection, then only write what changed.
|
||||
// SwiftUI emits "onChange action tried to update multiple
|
||||
// times per frame" when an onChange handler mutates more than
|
||||
// one piece of state per frame; the warning correlates with
|
||||
// unusable typing lag because each redundant write triggers
|
||||
// another body re-eval.
|
||||
let count = filteredCommands.count
|
||||
let newSelection: Int
|
||||
if count == 0 {
|
||||
newSelection = 0
|
||||
} else if selectedIndex >= count {
|
||||
newSelection = count - 1
|
||||
} else if selectedIndex < 0 {
|
||||
newSelection = 0
|
||||
} else {
|
||||
newSelection = selectedIndex
|
||||
}
|
||||
|
||||
if shouldShow != showMenu {
|
||||
showMenu = shouldShow
|
||||
}
|
||||
// Re-clamp selection whenever the filtered list may have shrunk.
|
||||
let count = filteredCommands.count
|
||||
if count == 0 {
|
||||
selectedIndex = 0
|
||||
} else if selectedIndex >= count {
|
||||
selectedIndex = count - 1
|
||||
} else if selectedIndex < 0 {
|
||||
selectedIndex = 0
|
||||
if newSelection != selectedIndex {
|
||||
selectedIndex = newSelection
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,12 +441,118 @@ struct RichChatInputBar: View {
|
||||
|
||||
private func send() {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, isEnabled else { return }
|
||||
onSend(trimmed)
|
||||
guard canSend else { return }
|
||||
onSend(trimmed, attachments)
|
||||
text = ""
|
||||
attachments.removeAll()
|
||||
showMenu = false
|
||||
selectedIndex = 0
|
||||
}
|
||||
|
||||
// MARK: - Attachment ingestion
|
||||
|
||||
/// Pull image bytes out of a set of `NSItemProvider`s (drag/drop or
|
||||
/// paste). Each provider may carry a file URL OR raw image data —
|
||||
/// we try both. Caps at `maxAttachments`; surplus drops are
|
||||
/// dropped silently with a status message.
|
||||
private func ingestProviders(_ providers: [NSItemProvider]) {
|
||||
let remainingSlots = Self.maxAttachments - attachments.count
|
||||
guard remainingSlots > 0 else {
|
||||
attachmentError = "Limit of \(Self.maxAttachments) images reached"
|
||||
scheduleAttachmentErrorClear()
|
||||
return
|
||||
}
|
||||
let toIngest = providers.prefix(remainingSlots)
|
||||
for provider in toIngest {
|
||||
ingestProvider(provider)
|
||||
}
|
||||
}
|
||||
|
||||
private func ingestProvider(_ provider: NSItemProvider) {
|
||||
// Prefer file URL when available — gives us the original filename
|
||||
// for the attachment chip's tooltip.
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
|
||||
isEncodingAttachment = true
|
||||
provider.loadObject(ofClass: URL.self) { url, _ in
|
||||
guard let url, let data = try? Data(contentsOf: url) else {
|
||||
Task { @MainActor in
|
||||
isEncodingAttachment = false
|
||||
attachmentError = "Couldn't read dropped file"
|
||||
scheduleAttachmentErrorClear()
|
||||
}
|
||||
return
|
||||
}
|
||||
encode(data: data, filename: url.lastPathComponent)
|
||||
}
|
||||
return
|
||||
}
|
||||
for typeId in [UTType.image.identifier, UTType.png.identifier, UTType.jpeg.identifier, UTType.tiff.identifier, UTType.heic.identifier] {
|
||||
if provider.hasItemConformingToTypeIdentifier(typeId) {
|
||||
isEncodingAttachment = true
|
||||
provider.loadDataRepresentation(forTypeIdentifier: typeId) { data, _ in
|
||||
guard let data else {
|
||||
Task { @MainActor in
|
||||
isEncodingAttachment = false
|
||||
attachmentError = "Couldn't decode pasted image"
|
||||
scheduleAttachmentErrorClear()
|
||||
}
|
||||
return
|
||||
}
|
||||
encode(data: data, filename: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func encode(data: Data, filename: String?) {
|
||||
Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
let attachment = try ImageEncoder().encode(rawBytes: data, sourceFilename: filename)
|
||||
await MainActor.run {
|
||||
isEncodingAttachment = false
|
||||
attachments.append(attachment)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isEncodingAttachment = false
|
||||
attachmentError = (error as? LocalizedError)?.errorDescription ?? "Couldn't encode image"
|
||||
Self.logger.warning("ImageEncoder failed: \(error.localizedDescription, privacy: .public)")
|
||||
scheduleAttachmentErrorClear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleAttachmentErrorClear() {
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
attachmentError = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func presentImagePicker() {
|
||||
#if canImport(AppKit)
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowsMultipleSelection = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
panel.allowedContentTypes = [.image, .png, .jpeg, .tiff, .heic]
|
||||
panel.message = "Choose images to attach"
|
||||
panel.prompt = "Attach"
|
||||
let response = panel.runModal()
|
||||
guard response == .OK else { return }
|
||||
let urls = Array(panel.urls.prefix(Self.maxAttachments - attachments.count))
|
||||
guard !urls.isEmpty else { return }
|
||||
isEncodingAttachment = true
|
||||
Task.detached(priority: .userInitiated) {
|
||||
for url in urls {
|
||||
guard let data = try? Data(contentsOf: url) else { continue }
|
||||
encode(data: data, filename: url.lastPathComponent)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
|
||||
@@ -15,6 +15,13 @@ struct RichChatMessageList: View {
|
||||
/// bubble's metadata footer can render the v2.5 stopwatch pill.
|
||||
/// Defaults empty so callers that don't care can omit it.
|
||||
var turnDurations: [Int: TimeInterval] = [:]
|
||||
/// Show the "Load earlier messages" button at the top of the
|
||||
/// transcript when the underlying session has more on-disk
|
||||
/// history that hasn't been paged in yet. Hidden by default so
|
||||
/// existing callers who haven't opted in see no UI change.
|
||||
var hasMoreHistory: Bool = false
|
||||
var isLoadingEarlier: Bool = false
|
||||
var onLoadEarlier: (() -> Void)? = nil
|
||||
|
||||
/// Scrolling strategy: plain `VStack` (not `LazyVStack`) plus
|
||||
/// `.defaultScrollAnchor(.bottom)`.
|
||||
@@ -57,6 +64,30 @@ struct RichChatMessageList: View {
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if hasMoreHistory, let onLoadEarlier {
|
||||
Button {
|
||||
onLoadEarlier()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
if isLoadingEarlier {
|
||||
ProgressView().scaleEffect(0.7)
|
||||
} else {
|
||||
Image(systemName: "arrow.up.circle")
|
||||
.font(.caption)
|
||||
}
|
||||
Text(isLoadingEarlier ? "Loading earlier…" : "Load earlier messages")
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(.regularMaterial, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isLoadingEarlier)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
ForEach(groups) { group in
|
||||
MessageGroupView(group: group, turnDurations: turnDurations)
|
||||
.equatable()
|
||||
|
||||
@@ -17,7 +17,7 @@ import ScarfDesign
|
||||
/// can scroll horizontally inside the panes rather than losing them.
|
||||
struct RichChatView: View {
|
||||
@Bindable var richChat: RichChatViewModel
|
||||
var onSend: (String) -> Void
|
||||
var onSend: (String, [ChatImageAttachment]) -> Void
|
||||
var isEnabled: Bool
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
@Environment(ChatViewModel.self) private var chatViewModel
|
||||
@@ -29,14 +29,25 @@ struct RichChatView: View {
|
||||
@AppStorage(ChatDensityKeys.fontScale)
|
||||
private var fontScale: Double = ChatFontScale.default
|
||||
|
||||
/// Sessions-list / inspector pane visibility (issue #58). Defaults
|
||||
/// `true` so existing users see no change until they opt out via
|
||||
/// the toolbar buttons or Settings → Display → Chat density.
|
||||
@AppStorage(ChatDensityKeys.showSessionsList)
|
||||
private var showSessionsList: Bool = true
|
||||
@AppStorage(ChatDensityKeys.showInspector)
|
||||
private var showInspector: Bool = true
|
||||
|
||||
/// In ACP mode, events drive updates directly — no DB polling needed.
|
||||
private var isACPMode: Bool { chatViewModel.isACPConnected }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
ChatSessionListPane(chatViewModel: chatViewModel, richChat: richChat)
|
||||
.frame(width: 264)
|
||||
Divider().background(ScarfColor.border)
|
||||
if showSessionsList {
|
||||
ChatSessionListPane(chatViewModel: chatViewModel, richChat: richChat)
|
||||
.frame(width: 264)
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
Divider().background(ScarfColor.border)
|
||||
}
|
||||
ChatTranscriptPane(
|
||||
richChat: richChat,
|
||||
chatViewModel: chatViewModel,
|
||||
@@ -44,12 +55,35 @@ struct RichChatView: View {
|
||||
isEnabled: isEnabled
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
Divider().background(ScarfColor.border)
|
||||
ChatInspectorPane(chatViewModel: chatViewModel)
|
||||
.frame(width: 320)
|
||||
if showInspector {
|
||||
Divider().background(ScarfColor.border)
|
||||
ChatInspectorPane(chatViewModel: chatViewModel)
|
||||
.frame(width: 320)
|
||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)
|
||||
.environment(\.dynamicTypeSize, ChatFontScale.dynamicTypeSize(for: fontScale))
|
||||
// ScarfFont tokens are fixed-point so dynamicTypeSize alone
|
||||
// doesn't move bubble / markdown / code-block text. Plumb the
|
||||
// raw scale via `\.chatFontScale` so chat content views can
|
||||
// read it and scale their explicit sizes too (issue #68).
|
||||
.environment(\.chatFontScale, fontScale)
|
||||
// Animate side-pane shows/hides so the transcript reflows
|
||||
// smoothly rather than snapping. ~180ms feels responsive
|
||||
// without being jarring.
|
||||
.animation(.easeInOut(duration: 0.18), value: showSessionsList)
|
||||
.animation(.easeInOut(duration: 0.18), value: showInspector)
|
||||
// Auto-show inspector when a tool call is focused so a click
|
||||
// on a tool card is never silently lost (issue #58 follow-up).
|
||||
// Tool clicks set `chatViewModel.focusedToolCallId`; if that
|
||||
// becomes non-nil while the inspector is hidden, flip it back
|
||||
// on. The animation modifiers above cover the slide-in.
|
||||
.onChange(of: chatViewModel.focusedToolCallId) { _, new in
|
||||
if new != nil, !showInspector {
|
||||
showInspector = true
|
||||
}
|
||||
}
|
||||
// DB polling fallback for terminal mode only — never overwrite ACP messages
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil {
|
||||
|
||||
@@ -14,6 +14,11 @@ struct RichMessageBubble: View, Equatable {
|
||||
|
||||
@Environment(ChatViewModel.self) private var chatViewModel
|
||||
|
||||
/// Chat-only font scale set on `RichChatView`. Chat content uses
|
||||
/// these multiplied sizes (issue #68); other surfaces still see
|
||||
/// the static ScarfFont tokens at scale = 1.0.
|
||||
@Environment(\.chatFontScale) private var chatFontScale: Double
|
||||
|
||||
/// Scarf-local chat density preferences (issues #47 / #48). All
|
||||
/// three default to today's UI. Read here so the reasoning + tool-
|
||||
/// call switches don't have to thread the values through every
|
||||
@@ -68,7 +73,7 @@ struct RichMessageBubble: View, Equatable {
|
||||
HStack {
|
||||
Spacer(minLength: 80)
|
||||
Text(message.content)
|
||||
.scarfStyle(.body)
|
||||
.font(ChatFontScale.body(chatFontScale))
|
||||
.foregroundStyle(ScarfColor.onAccent)
|
||||
.textSelection(.enabled)
|
||||
.padding(.horizontal, 14)
|
||||
@@ -91,7 +96,7 @@ struct RichMessageBubble: View, Equatable {
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(ScarfColor.success)
|
||||
Text(time, style: .time)
|
||||
.font(ScarfFont.caption2)
|
||||
.font(ChatFontScale.caption2(chatFontScale))
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
}
|
||||
.padding(.trailing, 4)
|
||||
@@ -183,7 +188,7 @@ struct RichMessageBubble: View, Equatable {
|
||||
private var reasoningDisclosure: some View {
|
||||
DisclosureGroup {
|
||||
Text(message.preferredReasoning ?? "")
|
||||
.font(ScarfFont.monoSmall)
|
||||
.font(ChatFontScale.monoSmall(chatFontScale))
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.italic()
|
||||
.textSelection(.enabled)
|
||||
@@ -194,11 +199,11 @@ struct RichMessageBubble: View, Equatable {
|
||||
Image(systemName: "brain")
|
||||
.font(.system(size: 11))
|
||||
Text("REASONING")
|
||||
.scarfStyle(.captionStrong)
|
||||
.font(ChatFontScale.captionStrong(chatFontScale))
|
||||
.tracking(0.5)
|
||||
if let tokens = message.tokenCount, tokens > 0 {
|
||||
Text("· \(tokens) tok")
|
||||
.font(ScarfFont.monoSmall)
|
||||
.font(ChatFontScale.monoSmall(chatFontScale))
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
}
|
||||
}
|
||||
@@ -222,7 +227,7 @@ struct RichMessageBubble: View, Equatable {
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
Text(message.preferredReasoning ?? "")
|
||||
.font(ScarfFont.caption)
|
||||
.font(ChatFontScale.caption(chatFontScale))
|
||||
.italic()
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.textSelection(.enabled)
|
||||
@@ -281,7 +286,7 @@ struct RichMessageBubble: View, Equatable {
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(color)
|
||||
Text(call.functionName)
|
||||
.font(ScarfFont.monoSmall)
|
||||
.font(ChatFontScale.monoSmall(chatFontScale))
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
.lineLimit(1)
|
||||
@@ -341,28 +346,70 @@ struct RichMessageBubble: View, Equatable {
|
||||
HStack(spacing: 8) {
|
||||
if let tokens = message.tokenCount, tokens > 0 {
|
||||
Text("\(tokens) tok")
|
||||
.font(ScarfFont.monoSmall)
|
||||
.font(ChatFontScale.monoSmall(chatFontScale))
|
||||
}
|
||||
if let reason = message.finishReason, !reason.isEmpty {
|
||||
Text("·")
|
||||
Text(reason)
|
||||
.scarfStyle(.caption)
|
||||
.font(ChatFontScale.caption(chatFontScale))
|
||||
}
|
||||
if let time = message.timestamp {
|
||||
Text("·")
|
||||
Text(time, style: .time)
|
||||
.scarfStyle(.caption)
|
||||
.font(ChatFontScale.caption(chatFontScale))
|
||||
}
|
||||
if let seconds = turnDuration {
|
||||
Text("·")
|
||||
Text(RichChatViewModel.formatTurnDuration(seconds))
|
||||
.font(ScarfFont.monoSmall)
|
||||
.font(ChatFontScale.monoSmall(chatFontScale))
|
||||
.help("Wall-clock duration of this turn")
|
||||
}
|
||||
// Per-message TTS playback toggle (issue #66). Only on
|
||||
// settled assistant bubbles — streaming bubble (id == 0)
|
||||
// would speak partial text. Empty content has nothing to
|
||||
// speak.
|
||||
if message.id != 0, !message.content.isEmpty {
|
||||
speakButton
|
||||
}
|
||||
}
|
||||
.font(ChatFontScale.caption(chatFontScale))
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
|
||||
/// Speaker glyph that toggles `AVSpeechSynthesizer` playback for
|
||||
/// the assistant reply. Lives in its own view so the
|
||||
/// `MessageSpeechService` observation doesn't fight the bubble's
|
||||
/// `Equatable` short-circuit — the parent only needs to pass
|
||||
/// stable id + content; this view re-renders on its own when
|
||||
/// playback state flips.
|
||||
private var speakButton: some View {
|
||||
SpeakMessageButton(messageId: message.id, content: message.content)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stand-alone speaker button so the `MessageSpeechService`
|
||||
/// observation doesn't get short-circuited by `RichMessageBubble`'s
|
||||
/// `Equatable`. Only the button re-renders when playback flips —
|
||||
/// the bubble itself stays optimised.
|
||||
private struct SpeakMessageButton: View {
|
||||
let messageId: Int
|
||||
let content: String
|
||||
|
||||
@State private var speech = MessageSpeechService.shared
|
||||
|
||||
var body: some View {
|
||||
let isPlaying = speech.playingMessageId == messageId
|
||||
Button {
|
||||
speech.toggle(messageId: messageId, content: content)
|
||||
} label: {
|
||||
Image(systemName: isPlaying ? "stop.circle.fill" : "speaker.wave.2")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(isPlaying ? ScarfColor.accent : ScarfColor.foregroundFaint)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(isPlaying ? "Stop speaking" : "Read this reply aloud")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Block Parsing
|
||||
|
||||
@@ -52,6 +52,21 @@ struct HermesCredentialPool: Identifiable, Sendable {
|
||||
let credentials: [HermesCredential]
|
||||
}
|
||||
|
||||
/// OAuth-authed provider parsed from `auth.json.providers.<name>`. Distinct
|
||||
/// from `HermesCredentialPool` because OAuth providers don't pool — one
|
||||
/// active token per provider, refresh handled by Hermes. Nous, Spotify,
|
||||
/// GitHub Copilot ACP, Qwen, Gemini all land here.
|
||||
struct HermesOAuthProvider: Identifiable, Sendable, Equatable {
|
||||
var id: String { provider }
|
||||
let provider: String // "nous" | "spotify" | ...
|
||||
let tokenTail: String // last 4 of access_token, never the full token
|
||||
let hasAccessToken: Bool
|
||||
let hasRefreshToken: Bool
|
||||
let expiresAt: Date?
|
||||
let portalURL: String? // "portal_base_url" — Nous-specific but generic-shaped
|
||||
let updatedAt: Date?
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class CredentialPoolsViewModel {
|
||||
@@ -64,6 +79,13 @@ final class CredentialPoolsViewModel {
|
||||
}
|
||||
|
||||
var pools: [HermesCredentialPool] = []
|
||||
/// OAuth-authed providers from `auth.json.providers.<name>` (Nous,
|
||||
/// Spotify, etc.). These have a different shape from `credential_pool`
|
||||
/// entries — one access token per provider, no rotation strategy —
|
||||
/// so they render in a parallel section rather than as a single-entry
|
||||
/// pool. Without this, OAuth providers were invisible in the UI even
|
||||
/// after a successful sign-in.
|
||||
var oauthProviders: [HermesOAuthProvider] = []
|
||||
var isLoading = false
|
||||
var message: String?
|
||||
|
||||
@@ -101,13 +123,70 @@ final class CredentialPoolsViewModel {
|
||||
decodedPools = []
|
||||
}
|
||||
|
||||
// OAuth providers are a parallel surface — different shape, so
|
||||
// we parse via `JSONSerialization` instead of folding into the
|
||||
// strict `AuthFile` decoder. A malformed `providers` block is
|
||||
// a non-fatal shrug: empty list, no banner.
|
||||
let oauth = Self.parseOAuthProviders(from: authData)
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
self?.pools = decodedPools
|
||||
self?.oauthProviders = oauth
|
||||
self?.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull `providers.<name>` entries out of `auth.json` and shape them
|
||||
/// for the UI. Returns an empty array when the file is missing,
|
||||
/// unparseable, or has no `providers` key.
|
||||
nonisolated private static func parseOAuthProviders(from data: Data?) -> [HermesOAuthProvider] {
|
||||
guard let data,
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let providers = root["providers"] as? [String: Any]
|
||||
else { return [] }
|
||||
|
||||
return providers.keys.sorted().compactMap { name in
|
||||
guard let entry = providers[name] as? [String: Any] else { return nil }
|
||||
let access = entry["access_token"] as? String ?? ""
|
||||
let refresh = entry["refresh_token"] as? String ?? ""
|
||||
// Worth surfacing if there's ANY token shape — pre-mint
|
||||
// refresh-only entries shouldn't be hidden.
|
||||
guard !access.isEmpty || !refresh.isEmpty else { return nil }
|
||||
|
||||
let expiresAt: Date? = {
|
||||
if let ms = entry["expires_at_ms"] as? Double, ms > 0 {
|
||||
return Date(timeIntervalSince1970: ms / 1000.0)
|
||||
}
|
||||
if let secs = entry["expires_at"] as? Double, secs > 0 {
|
||||
// Hermes' Nous flow writes epoch seconds as a Double here.
|
||||
return Date(timeIntervalSince1970: secs)
|
||||
}
|
||||
if let iso = entry["expires_at"] as? String {
|
||||
return Self.parseISO8601(iso)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
let updatedAt: Date? = {
|
||||
if let iso = entry["obtained_at"] as? String {
|
||||
return Self.parseISO8601(iso)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
return HermesOAuthProvider(
|
||||
provider: name,
|
||||
tokenTail: Self.tail(of: access.isEmpty ? refresh : access),
|
||||
hasAccessToken: !access.isEmpty,
|
||||
hasRefreshToken: !refresh.isEmpty,
|
||||
expiresAt: expiresAt,
|
||||
portalURL: entry["portal_base_url"] as? String,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `credential_pool_strategies:` map lives in config.yaml as `<provider>: <strategy>`.
|
||||
/// Pure-function form so it's safe to call from the detached load task.
|
||||
nonisolated private static func parseStrategies(from yaml: String) -> [String: String] {
|
||||
|
||||
@@ -6,6 +6,14 @@ struct CredentialPoolsView: View {
|
||||
@State private var viewModel: CredentialPoolsViewModel
|
||||
@State private var showAddSheet = false
|
||||
@State private var pendingRemove: HermesCredential?
|
||||
/// When non-nil, `AddCredentialSheet` opens pre-seeded with this
|
||||
/// provider name + OAuth type — driven by the chat banner's
|
||||
/// "Re-authenticate" button via `AppCoordinator.pendingOAuthReauth`,
|
||||
/// or by clicking the per-row "Re-authenticate" button in this
|
||||
/// view. Reset to nil when the sheet dismisses so the next plain
|
||||
/// "Add Credential" press doesn't accidentally inherit it.
|
||||
@State private var reauthInitialProvider: String?
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: CredentialPoolsViewModel(context: context))
|
||||
@@ -20,9 +28,12 @@ struct CredentialPoolsView: View {
|
||||
safetyNotice
|
||||
if viewModel.isLoading {
|
||||
ProgressView().padding()
|
||||
} else if viewModel.pools.isEmpty {
|
||||
} else if viewModel.pools.isEmpty && viewModel.oauthProviders.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
if !viewModel.oauthProviders.isEmpty {
|
||||
oauthProvidersSection
|
||||
}
|
||||
ForEach(viewModel.pools) { pool in
|
||||
poolSection(pool)
|
||||
}
|
||||
@@ -37,11 +48,17 @@ struct CredentialPoolsView: View {
|
||||
.loadingOverlay(
|
||||
viewModel.isLoading,
|
||||
label: "Loading credentials…",
|
||||
isEmpty: viewModel.pools.isEmpty
|
||||
isEmpty: viewModel.pools.isEmpty && viewModel.oauthProviders.isEmpty
|
||||
)
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $showAddSheet) {
|
||||
AddCredentialSheet(viewModel: viewModel) {
|
||||
.onAppear {
|
||||
viewModel.load()
|
||||
consumePendingReauth()
|
||||
}
|
||||
.onChange(of: coordinator.pendingOAuthReauth) { _, _ in
|
||||
consumePendingReauth()
|
||||
}
|
||||
.sheet(isPresented: $showAddSheet, onDismiss: { reauthInitialProvider = nil }) {
|
||||
AddCredentialSheet(viewModel: viewModel, initialProvider: reauthInitialProvider) {
|
||||
showAddSheet = false
|
||||
}
|
||||
}
|
||||
@@ -61,6 +78,19 @@ struct CredentialPoolsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain any pending re-auth hand-off from the chat banner: the
|
||||
/// banner's "Re-authenticate" button writes to
|
||||
/// `coordinator.pendingOAuthReauth` and switches to this view; we
|
||||
/// pick the value up here, seed the sheet's initial provider, and
|
||||
/// clear the slot so navigating back to this view doesn't re-open
|
||||
/// the sheet.
|
||||
private func consumePendingReauth() {
|
||||
guard let pending = coordinator.pendingOAuthReauth else { return }
|
||||
reauthInitialProvider = pending
|
||||
showAddSheet = true
|
||||
coordinator.pendingOAuthReauth = nil
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
ScarfPageHeader(
|
||||
"Credential Pools",
|
||||
@@ -114,6 +144,108 @@ struct CredentialPoolsView: View {
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
|
||||
/// Render OAuth-authed providers (`auth.json.providers.<name>`) as a
|
||||
/// single section above the rotation pools. Read-only — Hermes owns
|
||||
/// the write path via `hermes auth add <name>`. Rendered only when
|
||||
/// `viewModel.oauthProviders` is non-empty so users without any
|
||||
/// OAuth-authed providers don't see an empty header.
|
||||
@ViewBuilder
|
||||
private var oauthProvidersSection: some View {
|
||||
SettingsSection(title: LocalizedStringKey("OAuth providers"), icon: "person.badge.key") {
|
||||
ForEach(viewModel.oauthProviders) { provider in
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "person.badge.key")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(provider.provider.capitalized)
|
||||
.font(.system(.body, weight: .medium))
|
||||
Text("oauth")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
if !provider.hasAccessToken && provider.hasRefreshToken {
|
||||
Text("refresh-only")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
oauthExpiryBadge(provider)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Text(provider.tokenTail.isEmpty ? "—" : provider.tokenTail)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
if let updated = provider.updatedAt {
|
||||
Text("authed · \(Self.relativeAge(updated))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
if let url = provider.portalURL, !url.isEmpty {
|
||||
Text(url)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button("Re-authenticate") {
|
||||
reauthInitialProvider = provider.provider
|
||||
showAddSheet = true
|
||||
}
|
||||
.controlSize(.small)
|
||||
// `Text(verbatim:)` skips the LocalizedStringKey
|
||||
// overload that would interpret the backticks as
|
||||
// markdown inline-code styling — `.help(_:)` rejects
|
||||
// styled Text. Plain string preserves the backticks
|
||||
// literally.
|
||||
.help(Text(verbatim: "Run `hermes auth add \(provider.provider) --type oauth` again to refresh this provider's tokens."))
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
HStack {
|
||||
Text("Click Re-authenticate to refresh tokens. Removing or rotating providers is still done via `hermes auth …` in a terminal.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func oauthExpiryBadge(_ provider: HermesOAuthProvider) -> some View {
|
||||
if let expiresAt = provider.expiresAt {
|
||||
let secondsRemaining = expiresAt.timeIntervalSinceNow
|
||||
if secondsRemaining <= 0 {
|
||||
Text("expired")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.red)
|
||||
.clipShape(Capsule())
|
||||
} else if secondsRemaining < 7 * 86_400 {
|
||||
let days = max(1, Int(secondsRemaining / 86_400))
|
||||
Text("expires in \(days)d")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.orange)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func poolSection(_ pool: HermesCredentialPool) -> some View {
|
||||
SettingsSection(title: LocalizedStringKey(pool.provider), icon: "key.horizontal") {
|
||||
@@ -243,8 +375,25 @@ struct CredentialPoolsView: View {
|
||||
/// OAuth flow so the user can paste the authorization code back.
|
||||
private struct AddCredentialSheet: View {
|
||||
@Bindable var viewModel: CredentialPoolsViewModel
|
||||
/// Optional pre-fill from the re-auth path. When non-nil, the sheet
|
||||
/// opens with this provider name + OAuth selected, mirroring the
|
||||
/// state the user would otherwise have to type. Plain "Add
|
||||
/// Credential" presses leave it nil.
|
||||
let initialProvider: String?
|
||||
let onDismiss: () -> Void
|
||||
|
||||
init(
|
||||
viewModel: CredentialPoolsViewModel,
|
||||
initialProvider: String? = nil,
|
||||
onDismiss: @escaping () -> Void
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.initialProvider = initialProvider
|
||||
self.onDismiss = onDismiss
|
||||
_providerID = State(initialValue: initialProvider ?? "")
|
||||
_authType = State(initialValue: initialProvider == nil ? .apiKey : .oauth)
|
||||
}
|
||||
|
||||
enum AuthType: String, CaseIterable, Identifiable {
|
||||
case apiKey = "API Key"
|
||||
case oauth = "OAuth"
|
||||
@@ -258,11 +407,16 @@ private struct AddCredentialSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
@State private var providerID: String = ""
|
||||
@State private var authType: AuthType = .apiKey
|
||||
@State private var providerID: String
|
||||
@State private var authType: AuthType
|
||||
@State private var apiKey: String = ""
|
||||
@State private var label: String = ""
|
||||
@State private var providers: [HermesProviderInfo] = []
|
||||
/// True while the initial models.dev catalog read is in flight.
|
||||
/// Drives the loading-overlay placeholder. Pre-fix this work ran
|
||||
/// synchronously inside `.onAppear` and froze the sheet for 1–2
|
||||
/// minutes on remote contexts (issue #59).
|
||||
@State private var isLoadingProviders: Bool = true
|
||||
@State private var oauthStarted: Bool = false
|
||||
@State private var authCode: String = ""
|
||||
/// Drives presentation of the dedicated Nous sign-in sheet from inside
|
||||
@@ -291,8 +445,23 @@ private struct AddCredentialSheet: View {
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 600, minHeight: 460)
|
||||
.onAppear {
|
||||
providers = catalog.loadProviders()
|
||||
.overlay {
|
||||
if isLoadingProviders {
|
||||
ProgressView("Loading providers…")
|
||||
.progressViewStyle(.circular)
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Off-MainActor read of the multi-megabyte models.dev cache
|
||||
// (via SSHTransport on remote contexts). Pre-fix this ran
|
||||
// sync inside `.onAppear` and froze the Add Credential sheet
|
||||
// for 1–2 minutes on remote contexts (issue #59).
|
||||
isLoadingProviders = true
|
||||
providers = await catalog.loadProvidersAsync()
|
||||
isLoadingProviders = false
|
||||
}
|
||||
// Auto-close the sheet once a credential is actually saved. We key
|
||||
// off `succeeded` which the controller sets only when hermes exited
|
||||
|
||||
@@ -24,6 +24,16 @@ final class CronViewModel {
|
||||
var editingJob: HermesCronJob?
|
||||
var isLoading = false
|
||||
|
||||
/// Classified hint for the selected job's `lastError`, computed via
|
||||
/// `ACPErrorHint.classify` so cron rows surface the same OAuth-revoked
|
||||
/// affordance that ChatView's banner offers. `nil` when the selected
|
||||
/// job has no error or the error doesn't match a known pattern — the
|
||||
/// detail pane falls back to rendering `lastError` raw.
|
||||
var selectedErrorClassification: ACPErrorHint.Classification? {
|
||||
guard let job = selectedJob, let lastError = job.lastError, !lastError.isEmpty else { return nil }
|
||||
return ACPErrorHint.classify(errorMessage: lastError, stderrTail: "")
|
||||
}
|
||||
|
||||
func load() {
|
||||
isLoading = true
|
||||
let svc = fileService
|
||||
@@ -131,19 +141,24 @@ final class CronViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func createJob(schedule: String, prompt: String, name: String, deliver: String, skills: [String], script: String, repeatCount: String) {
|
||||
func createJob(schedule: String, prompt: String, name: String, deliver: String, skills: [String], script: String, repeatCount: String, workdir: String = "") {
|
||||
var args = ["cron", "create"]
|
||||
if !name.isEmpty { args += ["--name", name] }
|
||||
if !deliver.isEmpty { args += ["--deliver", deliver] }
|
||||
if !repeatCount.isEmpty { args += ["--repeat", repeatCount] }
|
||||
for skill in skills where !skill.isEmpty { args += ["--skill", skill] }
|
||||
if !script.isEmpty { args += ["--script", script] }
|
||||
// v0.12+: --workdir injects AGENTS.md/CLAUDE.md context and pins
|
||||
// cwd for terminal/file/code_exec tools. Hermes pre-v0.12 doesn't
|
||||
// know the flag — argparse rejects unknown args, so the form
|
||||
// omits the flag when the field is empty.
|
||||
if !workdir.isEmpty { args += ["--workdir", workdir] }
|
||||
args.append(schedule)
|
||||
if !prompt.isEmpty { args.append(prompt) }
|
||||
runAndReload(args, success: "Job created")
|
||||
}
|
||||
|
||||
func updateJob(id: String, schedule: String?, prompt: String?, name: String?, deliver: String?, repeatCount: String?, newSkills: [String]?, clearSkills: Bool, script: String?) {
|
||||
func updateJob(id: String, schedule: String?, prompt: String?, name: String?, deliver: String?, repeatCount: String?, newSkills: [String]?, clearSkills: Bool, script: String?, workdir: String? = nil) {
|
||||
var args = ["cron", "edit", id]
|
||||
if let schedule, !schedule.isEmpty { args += ["--schedule", schedule] }
|
||||
if let prompt, !prompt.isEmpty { args += ["--prompt", prompt] }
|
||||
@@ -156,6 +171,10 @@ final class CronViewModel {
|
||||
for skill in newSkills where !skill.isEmpty { args += ["--skill", skill] }
|
||||
}
|
||||
if let script { args += ["--script", script] }
|
||||
// `nil` = caller didn't touch the field (omit the flag). Empty string
|
||||
// = user cleared an existing workdir; Hermes documents `--workdir ""`
|
||||
// on edit as the explicit clear gesture, mirroring the `--script` shape.
|
||||
if let workdir { args += ["--workdir", workdir] }
|
||||
runAndReload(args, success: "Updated")
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,19 @@ import ScarfDesign
|
||||
struct CronView: View {
|
||||
@State private var viewModel: CronViewModel
|
||||
@State private var pendingDelete: HermesCronJob?
|
||||
@State private var showOutputPanel: Bool = false
|
||||
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: CronViewModel(context: context))
|
||||
}
|
||||
|
||||
private var hasCronWorkdir: Bool {
|
||||
capabilitiesStore?.capabilities.hasCronWorkdir ?? false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
pageHeader
|
||||
@@ -31,8 +39,15 @@ struct CronView: View {
|
||||
.navigationTitle("Cron Jobs")
|
||||
.loadingOverlay(viewModel.isLoading, label: "Loading cron jobs…", isEmpty: viewModel.jobs.isEmpty)
|
||||
.onAppear { viewModel.load() }
|
||||
// Reload on Hermes file mutations — Hermes flips `state` between
|
||||
// "scheduled" and "running" inside `~/.hermes/cron/jobs.json`
|
||||
// when a job starts/finishes, and writes a new run-output file
|
||||
// under `~/.hermes/cron/output/`. The watcher gives us the
|
||||
// running indicator + log tail refresh "for free" without a
|
||||
// polling timer. Same wiring ActivityView uses.
|
||||
.onChange(of: fileWatcher.lastChangeDate) { viewModel.load() }
|
||||
.sheet(isPresented: $viewModel.showCreateSheet) {
|
||||
CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills) { form in
|
||||
CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills, supportsWorkdir: hasCronWorkdir) { form in
|
||||
viewModel.createJob(
|
||||
schedule: form.schedule,
|
||||
prompt: form.prompt,
|
||||
@@ -40,7 +55,8 @@ struct CronView: View {
|
||||
deliver: form.deliver,
|
||||
skills: form.skills,
|
||||
script: form.script,
|
||||
repeatCount: form.repeatCount
|
||||
repeatCount: form.repeatCount,
|
||||
workdir: hasCronWorkdir ? form.workdir : ""
|
||||
)
|
||||
viewModel.showCreateSheet = false
|
||||
} onCancel: {
|
||||
@@ -48,7 +64,7 @@ struct CronView: View {
|
||||
}
|
||||
}
|
||||
.sheet(item: $viewModel.editingJob) { job in
|
||||
CronJobEditor(mode: .edit(job), availableSkills: viewModel.availableSkills) { form in
|
||||
CronJobEditor(mode: .edit(job), availableSkills: viewModel.availableSkills, supportsWorkdir: hasCronWorkdir) { form in
|
||||
viewModel.updateJob(
|
||||
id: job.id,
|
||||
schedule: form.schedule,
|
||||
@@ -58,7 +74,8 @@ struct CronView: View {
|
||||
repeatCount: form.repeatCount,
|
||||
newSkills: form.skills,
|
||||
clearSkills: form.clearSkills,
|
||||
script: form.script
|
||||
script: form.script,
|
||||
workdir: hasCronWorkdir ? form.workdir : nil
|
||||
)
|
||||
viewModel.editingJob = nil
|
||||
} onCancel: {
|
||||
@@ -165,6 +182,13 @@ struct CronView: View {
|
||||
Circle()
|
||||
.fill(statusDotColor(job))
|
||||
.frame(width: 7, height: 7)
|
||||
.opacity(job.state == "running" ? 0.55 : 1.0)
|
||||
.animation(
|
||||
job.state == "running"
|
||||
? .easeInOut(duration: 0.9).repeatForever(autoreverses: true)
|
||||
: .default,
|
||||
value: job.state
|
||||
)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
Text(job.schedule.expression ?? job.schedule.display ?? "—")
|
||||
@@ -214,7 +238,13 @@ struct CronView: View {
|
||||
}
|
||||
|
||||
private func statusDotColor(_ job: HermesCronJob) -> Color {
|
||||
// Order matters: a currently-running job overrides a stale
|
||||
// lastError so the user sees "yes, retrying right now" rather
|
||||
// than "still showing the old failure." Disabled wins over
|
||||
// everything else — a paused job isn't running, regardless
|
||||
// of state-field churn.
|
||||
if !job.enabled { return ScarfColor.foregroundFaint }
|
||||
if job.state == "running" { return ScarfColor.info }
|
||||
if job.lastError != nil { return ScarfColor.danger }
|
||||
return ScarfColor.success
|
||||
}
|
||||
@@ -265,6 +295,9 @@ struct CronView: View {
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
ScarfBadge(job.enabled ? "active" : "paused",
|
||||
kind: job.enabled ? .success : .neutral)
|
||||
if job.state == "running" {
|
||||
ScarfBadge("running…", kind: .info)
|
||||
}
|
||||
}
|
||||
Text(CronScheduleFormatter.humanReadable(from: job.schedule))
|
||||
.scarfStyle(.footnote)
|
||||
@@ -413,26 +446,165 @@ struct CronView: View {
|
||||
}
|
||||
|
||||
if let error = job.lastError {
|
||||
errorBanner(job: job, error: error)
|
||||
}
|
||||
|
||||
outputPanel(job: job)
|
||||
}
|
||||
|
||||
/// Last-error surface. When `ACPErrorHint` recognizes the message
|
||||
/// (OAuth refresh-revoked, missing credentials, SSH failure, etc.),
|
||||
/// it renders the human hint + raw error + a re-auth button when
|
||||
/// applicable. Otherwise falls back to the legacy single-line
|
||||
/// red text — same chrome the view used pre-PR for unrecognized
|
||||
/// errors. Mirrors `ChatView.errorBanner` so the recovery flow is
|
||||
/// identical between cron and chat.
|
||||
@ViewBuilder
|
||||
private func errorBanner(job: HermesCronJob, error: String) -> some View {
|
||||
if let classification = viewModel.selectedErrorClassification {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(classification.hint)
|
||||
.scarfStyle(.body)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
.textSelection(.enabled)
|
||||
Text(error)
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: ScarfSpace.s2)
|
||||
if let provider = classification.oauthProvider {
|
||||
Button("Re-authenticate") {
|
||||
coordinator.pendingOAuthReauth = provider
|
||||
coordinator.selectedSection = .credentialPools
|
||||
}
|
||||
.buttonStyle(ScarfPrimaryButton())
|
||||
.help("Open Credential Pools and re-authenticate \(provider).")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(ScarfSpace.s3)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||
.fill(ScarfColor.warning.opacity(0.08))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||
.strokeBorder(ScarfColor.warning.opacity(0.25), lineWidth: 1)
|
||||
)
|
||||
} else {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
Text(error)
|
||||
.scarfStyle(.caption)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.foregroundStyle(ScarfColor.danger)
|
||||
}
|
||||
}
|
||||
|
||||
if let output = viewModel.jobOutput {
|
||||
sectionBlock("LAST OUTPUT") {
|
||||
Text(output)
|
||||
.font(ScarfFont.monoSmall)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
.textSelection(.enabled)
|
||||
.padding(ScarfSpace.s3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
/// Per-job run-output panel. Always visible; collapsed by default
|
||||
/// with a one-line summary so the detail pane stays scannable when
|
||||
/// the user has dozens of cron jobs. Expanded body mirrors the
|
||||
/// dark monospaced tail layout `LogsView` uses, fed by
|
||||
/// `HermesFileService.loadCronOutput` (Hermes writes per-run files
|
||||
/// under `~/.hermes/cron/output/<jobId>-*`). Reload happens via the
|
||||
/// outer `HermesFileWatcher` `.onChange` — when a fresh run lands a
|
||||
/// new output file, the VM re-reads on the next mtime tick.
|
||||
@ViewBuilder
|
||||
private func outputPanel(job: HermesCronJob) -> some View {
|
||||
let summary = outputSummary(job)
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
Button {
|
||||
showOutputPanel.toggle()
|
||||
} label: {
|
||||
HStack(spacing: ScarfSpace.s2) {
|
||||
Image(systemName: showOutputPanel ? "chevron.down" : "chevron.right")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
Text("LAST RUN OUTPUT")
|
||||
.scarfStyle(.captionUppercase)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
Text(summary)
|
||||
.font(ScarfFont.monoSmall)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if showOutputPanel {
|
||||
if let output = viewModel.jobOutput, !output.isEmpty {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
Text(output)
|
||||
.font(ScarfFont.monoSmall)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(ScarfSpace.s3)
|
||||
.id("cron-output-bottom")
|
||||
}
|
||||
.frame(maxHeight: 320)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||
.fill(Color(red: 0.07, green: 0.06, blue: 0.05))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||
.strokeBorder(ScarfColor.border, lineWidth: 1)
|
||||
)
|
||||
// Auto-scroll to the latest line whenever the
|
||||
// output content changes (a new run lands).
|
||||
.onChange(of: output) {
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
proxy.scrollTo("cron-output-bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
proxy.scrollTo("cron-output-bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("No output yet — this job hasn't run, or its output file is gone.")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(ScarfSpace.s3)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||
.fill(ScarfColor.backgroundSecondary)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||
.strokeBorder(ScarfColor.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line summary rendered next to the LAST RUN OUTPUT chevron
|
||||
/// when the panel is collapsed. Gives a quick "yes there's content"
|
||||
/// (or "no output yet") read without expanding.
|
||||
private func outputSummary(_ job: HermesCronJob) -> String {
|
||||
let timestamp = job.lastRunAt.map { CronScheduleFormatter.formatNextRun(iso: $0) } ?? "never"
|
||||
let status: String = {
|
||||
if job.state == "running" { return "running…" }
|
||||
if job.lastError != nil { return "error" }
|
||||
if job.lastRunAt != nil { return "ok" }
|
||||
return "no runs yet"
|
||||
}()
|
||||
return "\(timestamp) — \(status)"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionBlock<Content: View>(_ title: String, @ViewBuilder _ content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
@@ -468,10 +640,16 @@ struct CronJobEditor: View {
|
||||
var skills: [String] = []
|
||||
var clearSkills: Bool = false
|
||||
var script: String = ""
|
||||
/// v0.12+ workdir flag — fills `--workdir <path>`. Empty string
|
||||
/// preserves the v0.11 behaviour of running with no cwd hint.
|
||||
var workdir: String = ""
|
||||
}
|
||||
|
||||
let mode: Mode
|
||||
let availableSkills: [String]
|
||||
/// Pass `false` on pre-v0.12 hosts; the `--workdir` field is hidden and
|
||||
/// the form's value is dropped when the parent calls `createJob`/`updateJob`.
|
||||
let supportsWorkdir: Bool
|
||||
let onSave: (FormState) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@@ -506,6 +684,9 @@ struct CronJobEditor: View {
|
||||
formField("Deliver", text: $form.deliver, placeholder: "origin | local | discord:CHANNEL | telegram:CHAT", mono: true)
|
||||
formField("Repeat", text: $form.repeatCount, placeholder: "Optional count")
|
||||
formField("Script path", text: $form.script, placeholder: "Python script whose stdout is injected", mono: true)
|
||||
if supportsWorkdir {
|
||||
formField("Workdir", text: $form.workdir, placeholder: "Absolute path; pulls AGENTS.md/CLAUDE.md context", mono: true)
|
||||
}
|
||||
if !availableSkills.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Skills")
|
||||
@@ -564,6 +745,7 @@ struct CronJobEditor: View {
|
||||
form.deliver = job.deliver ?? ""
|
||||
form.skills = job.skills ?? []
|
||||
form.script = job.preRunScript ?? ""
|
||||
form.workdir = job.workdir ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
|
||||
/// Modal that lists archived skills (state ≠ active) and exposes a
|
||||
/// one-click "Restore" action per row. v0.12 archives are recoverable —
|
||||
/// `hermes curator restore <name>` brings the skill back into
|
||||
/// `~/.hermes/skills/<category>/<name>/` and re-marks it active.
|
||||
///
|
||||
/// The Curator's `status` text doesn't enumerate archived skills with
|
||||
/// names; we surface what's available (counts + pinned list) and rely
|
||||
/// on the user knowing the names. Hermes ergo does an interactive
|
||||
/// `--name` arg if missing — but Scarf prefers explicit selection so
|
||||
/// users don't have to remember names. For v2.6 we render a free-form
|
||||
/// text field; once Hermes ships a `curator list-archived` (tracked
|
||||
/// upstream), swap to a pickable list.
|
||||
struct CuratorRestoreSheet: View {
|
||||
let viewModel: CuratorViewModel
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var skillName: String = ""
|
||||
@State private var isRestoring = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s3) {
|
||||
Text("Restore Archived Skill")
|
||||
.scarfStyle(.headline)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
|
||||
Text("Hermes archives skills the curator decides are stale or redundant. Restoring brings the original SKILL.md back into place — no data lost.")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s1) {
|
||||
Text("Skill name")
|
||||
.scarfStyle(.captionUppercase)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
ScarfTextField("e.g. legacy-helper", text: $skillName)
|
||||
}
|
||||
|
||||
Text("\(viewModel.status.archivedSkills) archived skill(s) available — list them with `hermes curator status`.")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { dismiss() }
|
||||
.buttonStyle(ScarfGhostButton())
|
||||
Button("Restore") {
|
||||
let trimmed = skillName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
isRestoring = true
|
||||
Task {
|
||||
await viewModel.restore(trimmed)
|
||||
isRestoring = false
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.buttonStyle(ScarfPrimaryButton())
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(isRestoring || skillName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(ScarfSpace.s5)
|
||||
.frame(width: 420)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
|
||||
/// Mac UI for Hermes v0.12's autonomous skill curator.
|
||||
///
|
||||
/// Surfaces the running state (enabled / paused / disabled), last-run
|
||||
/// metadata, agent-created skill counts, and the most/least-active /
|
||||
/// least-recently-active leaderboards. Pin-and-restore actions hit
|
||||
/// `hermes curator pin/unpin/restore` via CuratorViewModel.
|
||||
///
|
||||
/// Capability-gated upstream: AppCoordinator only wires the sidebar
|
||||
/// item when `HermesCapabilities.hasCurator` is true. This view assumes
|
||||
/// it's reachable on a v0.12+ host.
|
||||
struct CuratorView: View {
|
||||
@State private var viewModel: CuratorViewModel
|
||||
@State private var showRestoreSheet = false
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: CuratorViewModel(context: context))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s4) {
|
||||
ScarfPageHeader(
|
||||
"Curator",
|
||||
subtitle: "Autonomous skill maintenance — Hermes v0.12+"
|
||||
) {
|
||||
HStack(spacing: ScarfSpace.s2) {
|
||||
if viewModel.isLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
Button("Run Now") {
|
||||
Task { await viewModel.runNow() }
|
||||
}
|
||||
.buttonStyle(ScarfPrimaryButton())
|
||||
.disabled(viewModel.isLoading)
|
||||
Menu {
|
||||
switch viewModel.status.state {
|
||||
case .paused:
|
||||
Button("Resume") { Task { await viewModel.resume() } }
|
||||
case .enabled:
|
||||
Button("Pause") { Task { await viewModel.pause() } }
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
Button("Restore Archived…") {
|
||||
showRestoreSheet = true
|
||||
}
|
||||
.disabled(viewModel.status.archivedSkills == 0)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let toast = viewModel.transientMessage {
|
||||
transientToast(toast)
|
||||
}
|
||||
|
||||
statusSummary
|
||||
skillCountsSection
|
||||
pinnedSection
|
||||
activityTables
|
||||
|
||||
if let report = viewModel.lastReportMarkdown {
|
||||
lastReportSection(markdown: report)
|
||||
}
|
||||
}
|
||||
.padding(ScarfSpace.s4)
|
||||
}
|
||||
.background(ScarfColor.backgroundPrimary)
|
||||
.task { await viewModel.load() }
|
||||
.sheet(isPresented: $showRestoreSheet) {
|
||||
CuratorRestoreSheet(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
private var statusSummary: some View {
|
||||
ScarfCard {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
HStack {
|
||||
statusBadge
|
||||
Spacer()
|
||||
Text("\(viewModel.status.runCount) runs")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
}
|
||||
ScarfDivider()
|
||||
infoRow(label: "Last run", value: viewModel.status.lastRunISO ?? "Never")
|
||||
if let summary = viewModel.status.lastSummary {
|
||||
infoRow(label: "Last summary", value: summary)
|
||||
}
|
||||
infoRow(label: "Interval", value: viewModel.status.intervalLabel)
|
||||
infoRow(label: "Stale after", value: viewModel.status.staleAfterLabel)
|
||||
infoRow(label: "Archive after", value: viewModel.status.archiveAfterLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var statusBadge: some View {
|
||||
let kind: ScarfBadgeKind
|
||||
let label: String
|
||||
switch viewModel.status.state {
|
||||
case .enabled: kind = .success; label = "Enabled"
|
||||
case .paused: kind = .warning; label = "Paused"
|
||||
case .disabled: kind = .neutral; label = "Disabled"
|
||||
case .unknown: kind = .neutral; label = "Unknown"
|
||||
}
|
||||
return ScarfBadge(label, kind: kind)
|
||||
}
|
||||
|
||||
private var skillCountsSection: some View {
|
||||
ScarfCard {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
ScarfSectionHeader("Agent-created skills")
|
||||
HStack(spacing: ScarfSpace.s4) {
|
||||
countCell(value: viewModel.status.totalSkills, label: "Total")
|
||||
countCell(value: viewModel.status.activeSkills, label: "Active")
|
||||
countCell(value: viewModel.status.staleSkills, label: "Stale")
|
||||
countCell(value: viewModel.status.archivedSkills, label: "Archived")
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var pinnedSection: some View {
|
||||
if !viewModel.status.pinnedNames.isEmpty {
|
||||
ScarfCard {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
ScarfSectionHeader("Pinned")
|
||||
Text("Pinned skills are never auto-archived or rewritten by the curator.")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
FlowLayout(spacing: ScarfSpace.s2) {
|
||||
ForEach(viewModel.status.pinnedNames, id: \.self) { name in
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "pin.fill")
|
||||
.font(.system(size: 10))
|
||||
Text(name)
|
||||
.scarfStyle(.caption)
|
||||
Button {
|
||||
Task { await viewModel.unpin(name) }
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Unpin")
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.md)
|
||||
.fill(ScarfColor.accentTint)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activityTables: some View {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s4) {
|
||||
if !viewModel.status.leastRecentlyActive.isEmpty {
|
||||
skillTable(title: "Least recently active", rows: viewModel.status.leastRecentlyActive)
|
||||
}
|
||||
if !viewModel.status.mostActive.isEmpty {
|
||||
skillTable(title: "Most active", rows: viewModel.status.mostActive)
|
||||
}
|
||||
if !viewModel.status.leastActive.isEmpty {
|
||||
skillTable(title: "Least active", rows: viewModel.status.leastActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func skillTable(title: String, rows: [HermesCuratorSkillRow]) -> some View {
|
||||
ScarfCard {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
ScarfSectionHeader(title)
|
||||
ForEach(rows) { row in
|
||||
HStack(alignment: .center, spacing: ScarfSpace.s2) {
|
||||
Text(row.name)
|
||||
.scarfStyle(.body)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
counterChip(label: "use", value: row.useCount)
|
||||
counterChip(label: "view", value: row.viewCount)
|
||||
counterChip(label: "patch", value: row.patchCount)
|
||||
Text(row.lastActivityLabel)
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.frame(width: 92, alignment: .trailing)
|
||||
Button {
|
||||
Task { await viewModel.pin(row.name) }
|
||||
} label: {
|
||||
Image(systemName: viewModel.status.pinnedNames.contains(row.name)
|
||||
? "pin.fill" : "pin")
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(viewModel.status.pinnedNames.contains(row.name) ? "Pinned" : "Pin skill")
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func counterChip(label: String, value: Int) -> some View {
|
||||
Text("\(label) \(value)")
|
||||
.font(ScarfFont.monoSmall)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.sm)
|
||||
.fill(ScarfColor.backgroundTertiary)
|
||||
)
|
||||
}
|
||||
|
||||
private func lastReportSection(markdown: String) -> some View {
|
||||
ScarfCard {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
ScarfSectionHeader("Last report")
|
||||
Text(markdown)
|
||||
.scarfStyle(.mono)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func infoRow(label: String, value: String) -> some View {
|
||||
HStack(alignment: .top) {
|
||||
Text(label)
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
Text(value)
|
||||
.scarfStyle(.body)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func countCell(value: Int, label: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(value)")
|
||||
.scarfStyle(.title2)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
Text(label)
|
||||
.scarfStyle(.captionUppercase)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
}
|
||||
.frame(minWidth: 64, alignment: .leading)
|
||||
}
|
||||
|
||||
private func transientToast(_ text: String) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(ScarfColor.success)
|
||||
Text(text)
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, ScarfSpace.s3)
|
||||
.padding(.vertical, 6)
|
||||
.background(ScarfColor.accentTint)
|
||||
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.md))
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple `FlowLayout` for the pinned-skill chips. Custom layout
|
||||
/// keeps the chip wrap behaviour predictable across DynamicType
|
||||
/// scales without resorting to LazyVGrid (which forces fixed columns).
|
||||
private struct FlowLayout: Layout {
|
||||
var spacing: CGFloat = 8
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
var x: CGFloat = 0
|
||||
var y: CGFloat = 0
|
||||
var rowHeight: CGFloat = 0
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if x + size.width > maxWidth {
|
||||
x = 0
|
||||
y += rowHeight + spacing
|
||||
rowHeight = 0
|
||||
}
|
||||
x += size.width + spacing
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
}
|
||||
return CGSize(width: maxWidth, height: y + rowHeight)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
var x = bounds.minX
|
||||
var y = bounds.minY
|
||||
var rowHeight: CGFloat = 0
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if x + size.width > bounds.maxX {
|
||||
x = bounds.minX
|
||||
y += rowHeight + spacing
|
||||
rowHeight = 0
|
||||
}
|
||||
subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||
x += size.width + spacing
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,14 @@ final class DashboardViewModel {
|
||||
/// surfaceable error.
|
||||
var lastReadError: String?
|
||||
|
||||
/// Projects with their own `<project>/.hermes/` directory shadowing
|
||||
/// the global Hermes home. Hermes' CLI uses the closest `.hermes/`
|
||||
/// when invoked from inside such a project, which silently routes
|
||||
/// `hermes auth add` / setup writes into the project-local copy
|
||||
/// instead of `~/.hermes/`. Surfaced as a yellow banner so users
|
||||
/// can consolidate before more state drifts.
|
||||
var hermesShadows: [ProjectHermesShadowDetector.Shadow] = []
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
// refresh() = close + reopen, forces a fresh remote snapshot. Cheap
|
||||
@@ -110,6 +118,17 @@ final class DashboardViewModel {
|
||||
} else {
|
||||
lastReadError = nil
|
||||
}
|
||||
|
||||
// Probe for projects with shadow `.hermes/` directories. Read-only
|
||||
// — we just stat each registered project's path. Detached so the
|
||||
// SSH round-trips don't block the load completion.
|
||||
let ctx = context
|
||||
let detector = ProjectHermesShadowDetector(context: ctx)
|
||||
let projects = await Task.detached {
|
||||
ProjectDashboardService(context: ctx).loadRegistry().projects
|
||||
}.value
|
||||
hermesShadows = await detector.detect(in: projects)
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ struct DashboardView: View {
|
||||
if let err = viewModel.lastReadError {
|
||||
readErrorBanner(err)
|
||||
}
|
||||
if !viewModel.hermesShadows.isEmpty {
|
||||
hermesShadowBanner(viewModel.hermesShadows)
|
||||
}
|
||||
statusRow
|
||||
statsSection
|
||||
recentTwoColumn
|
||||
@@ -126,6 +129,99 @@ struct DashboardView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Hermes shadow banner
|
||||
|
||||
/// One row per project that carries its own `<project>/.hermes/`
|
||||
/// directory. Hermes' CLI binds to that as `$HERMES_HOME` when run
|
||||
/// from inside, which silently shadows the user's global setup —
|
||||
/// `hermes auth add nous` lands in the project, not in `~/.hermes/`,
|
||||
/// and Scarf's global probes show "missing provider" until consolidated.
|
||||
private func hermesShadowBanner(_ shadows: [ProjectHermesShadowDetector.Shadow]) -> some View {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
HStack(alignment: .top, spacing: ScarfSpace.s2) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Project-local Hermes home shadowing global setup")
|
||||
.scarfStyle(.bodyEmph)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
Text("These projects carry their own `.hermes/` directory. Hermes' CLI uses the closest one as `$HERMES_HOME` when run from inside the project, so credentials and config written there don't show up in your global Hermes setup. Consolidate to clear this warning.")
|
||||
.scarfStyle(.footnote)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
ForEach(shadows) { shadow in
|
||||
shadowRow(shadow)
|
||||
}
|
||||
}
|
||||
.padding(ScarfSpace.s3)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||
.fill(ScarfColor.warning.opacity(0.10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
|
||||
.strokeBorder(ScarfColor.warning.opacity(0.30), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func shadowRow(_ shadow: ProjectHermesShadowDetector.Shadow) -> some View {
|
||||
HStack(alignment: .top, spacing: ScarfSpace.s2) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(shadow.projectName)
|
||||
.scarfStyle(.bodyEmph)
|
||||
Text(shadow.shadowPath)
|
||||
.font(ScarfFont.monoSmall)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.textSelection(.enabled)
|
||||
HStack(spacing: 6) {
|
||||
if shadow.hasAuthJSON {
|
||||
Text("auth.json present")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(ScarfColor.warning.opacity(0.20))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
if shadow.hasStateDB {
|
||||
Text("state.db present")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(ScarfColor.warning.opacity(0.20))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button("Copy fix command") {
|
||||
Task { @MainActor in
|
||||
let home = await viewModel.context.resolvedUserHome() + "/.hermes"
|
||||
if let cmd = ProjectHermesShadowDetector.consolidationCommand(
|
||||
for: shadow,
|
||||
hermesHome: home
|
||||
) {
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
pb.setString(cmd, forType: .string)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(ScarfSecondaryButton())
|
||||
.controlSize(.small)
|
||||
.help(shadow.hasAuthJSON
|
||||
? "Copies a one-liner that consolidates this project's auth.json into your global ~/.hermes/ and renames the shadow .hermes/ aside as .hermes.scarf-bak.<timestamp>/ so it stops binding. Run it on the remote, then refresh the Dashboard."
|
||||
: "Copies a one-liner that renames this project's shadow .hermes/ aside as .hermes.scarf-bak.<timestamp>/ so Hermes' CLI stops binding to it as $HERMES_HOME. Run it on the remote, then refresh the Dashboard.")
|
||||
}
|
||||
.padding(ScarfSpace.s2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
|
||||
.fill(ScarfColor.warning.opacity(0.06))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Status row
|
||||
|
||||
private var statusRow: some View {
|
||||
|
||||
@@ -180,7 +180,7 @@ final class HealthViewModel {
|
||||
("skills_hub", config.auxiliary.skillsHub.provider),
|
||||
("approval", config.auxiliary.approval.provider),
|
||||
("mcp", config.auxiliary.mcp.provider),
|
||||
("flush_memories", config.auxiliary.flushMemories.provider),
|
||||
("curator", config.auxiliary.curator.provider),
|
||||
].filter { $0.1 == "nous" }.map(\.0)
|
||||
if !auxOnNous.isEmpty {
|
||||
checks.append(HealthCheck(
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import ScarfCore
|
||||
import os
|
||||
|
||||
/// Read-only view of `hermes kanban list --json`. Multi-profile
|
||||
/// collaboration was reverted upstream while the design is reworked,
|
||||
/// so v2.6 ships read-only on Mac and defers create/claim/dispatch UI
|
||||
/// to v2.7+.
|
||||
///
|
||||
/// Polls every 5s while foregrounded so dispatcher progress is visible
|
||||
/// without manual refresh; the polling task is suspended when the view
|
||||
/// disappears so background windows don't keep hammering SSH.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class KanbanViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "KanbanViewModel")
|
||||
|
||||
let context: ServerContext
|
||||
private let fileService: HermesFileService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.fileService = HermesFileService(context: context)
|
||||
}
|
||||
|
||||
var tasks: [HermesKanbanTask] = []
|
||||
var isLoading = false
|
||||
var lastError: String?
|
||||
var statusFilter: StatusFilter = .all
|
||||
|
||||
/// Subset Hermes accepts on `--status`. `.all` skips the flag.
|
||||
enum StatusFilter: String, CaseIterable, Identifiable {
|
||||
case all
|
||||
case triage
|
||||
case todo
|
||||
case ready
|
||||
case running
|
||||
case blocked
|
||||
case done
|
||||
case archived
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .all: return "All"
|
||||
default: return rawValue.capitalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pollTask: Task<Void, Never>?
|
||||
|
||||
func startPolling() {
|
||||
stopPolling()
|
||||
pollTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
await self?.load()
|
||||
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopPolling() {
|
||||
pollTask?.cancel()
|
||||
pollTask = nil
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
let svc = fileService
|
||||
let filter = statusFilter
|
||||
let result = await Task.detached { () -> (exitCode: Int32, stdout: String, stderr: String) in
|
||||
var args = ["kanban", "list", "--json"]
|
||||
if filter != .all {
|
||||
args.append(contentsOf: ["--status", filter.rawValue])
|
||||
}
|
||||
return svc.runHermesCLISplit(args: args, timeout: 15)
|
||||
}.value
|
||||
|
||||
defer { isLoading = false }
|
||||
|
||||
guard result.exitCode == 0 else {
|
||||
lastError = result.stderr.isEmpty
|
||||
? "kanban list failed (\(result.exitCode))"
|
||||
: result.stderr
|
||||
tasks = []
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = result.stdout.data(using: .utf8) else {
|
||||
lastError = "kanban list returned non-UTF8 output"
|
||||
tasks = []
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode([HermesKanbanTask].self, from: data)
|
||||
tasks = decoded
|
||||
lastError = nil
|
||||
} catch {
|
||||
// Hermes may print a "no matching tasks" line as text instead of
|
||||
// empty JSON; handle gracefully so the UI shows an empty list
|
||||
// without raising an error banner.
|
||||
if result.stdout.contains("no matching tasks") {
|
||||
tasks = []
|
||||
lastError = nil
|
||||
return
|
||||
}
|
||||
logger.warning("kanban JSON decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
lastError = "Couldn't parse kanban list output"
|
||||
tasks = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
|
||||
/// Mac UI for `hermes kanban list` (v0.12+). Read-only — create / claim
|
||||
/// / dispatch / dependency-link UI is deferred until upstream
|
||||
/// stabilizes the multi-profile collaboration design.
|
||||
///
|
||||
/// Capability-gated upstream: AppCoordinator only routes to this view
|
||||
/// when `HermesCapabilities.hasKanban` is true.
|
||||
struct KanbanView: View {
|
||||
@State private var viewModel: KanbanViewModel
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: KanbanViewModel(context: context))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScarfPageHeader(
|
||||
"Kanban",
|
||||
subtitle: "Hermes v0.12+ task board (read-only)"
|
||||
) {
|
||||
HStack(spacing: ScarfSpace.s2) {
|
||||
Picker("Status", selection: $viewModel.statusFilter) {
|
||||
ForEach(KanbanViewModel.StatusFilter.allCases) { f in
|
||||
Text(f.label).tag(f)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 120)
|
||||
Button {
|
||||
Task { await viewModel.load() }
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(ScarfGhostButton())
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
|
||||
if let err = viewModel.lastError {
|
||||
errorBanner(err)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
if viewModel.tasks.isEmpty && !viewModel.isLoading {
|
||||
emptyState
|
||||
} else {
|
||||
taskTable
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(ScarfColor.backgroundPrimary)
|
||||
.onChange(of: viewModel.statusFilter) { _, _ in
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
.onAppear { viewModel.startPolling() }
|
||||
.onDisappear { viewModel.stopPolling() }
|
||||
}
|
||||
|
||||
private var taskTable: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(viewModel.tasks) { task in
|
||||
taskRow(task)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
.padding(ScarfSpace.s3)
|
||||
}
|
||||
|
||||
private func taskRow(_ task: HermesKanbanTask) -> some View {
|
||||
HStack(alignment: .top, spacing: ScarfSpace.s3) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: ScarfSpace.s2) {
|
||||
statusBadge(for: task.status)
|
||||
Text(task.title)
|
||||
.scarfStyle(.bodyEmph)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
metaChip(systemImage: "number", value: task.id.prefix(8) + "")
|
||||
if let assignee = task.assignee, !assignee.isEmpty {
|
||||
metaChip(systemImage: "person.fill", value: assignee)
|
||||
}
|
||||
if let workspace = task.workspaceKind {
|
||||
metaChip(systemImage: "folder", value: workspace)
|
||||
}
|
||||
if !task.skills.isEmpty {
|
||||
metaChip(systemImage: "lightbulb", value: task.skills.joined(separator: ", "))
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
if let createdAt = task.createdAt {
|
||||
Text(createdAt)
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
}
|
||||
if let priority = task.priority {
|
||||
Text("p\(priority)")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, ScarfSpace.s2)
|
||||
}
|
||||
|
||||
private func statusBadge(for status: String) -> some View {
|
||||
let kind: ScarfBadgeKind
|
||||
switch status.lowercased() {
|
||||
case "done": kind = .success
|
||||
case "running": kind = .info
|
||||
case "ready": kind = .info
|
||||
case "blocked": kind = .warning
|
||||
case "archived": kind = .neutral
|
||||
default: kind = .neutral
|
||||
}
|
||||
return ScarfBadge(status, kind: kind)
|
||||
}
|
||||
|
||||
private func metaChip(systemImage: String, value: String) -> some View {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 10))
|
||||
Text(value)
|
||||
.font(ScarfFont.monoSmall)
|
||||
}
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "rectangle.split.3x1")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
Text("No kanban tasks")
|
||||
.scarfStyle(.headline)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
Text("Create one with `hermes kanban create \"task title\"`. Tasks dispatched by the gateway show up here automatically.")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 460)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.vertical, 60)
|
||||
}
|
||||
|
||||
private func errorBanner(_ message: String) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
Text(message)
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
}
|
||||
.padding(.horizontal, ScarfSpace.s3)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(ScarfColor.warning.opacity(0.12))
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,8 @@ struct PlatformsView: View {
|
||||
case "imessage": IMessageSetupView(context: ctx)
|
||||
case "homeassistant": HomeAssistantSetupView(context: ctx)
|
||||
case "webhook": WebhookSetupView(context: ctx)
|
||||
case "yuanbao": yuanbaoPanel
|
||||
case "microsoft-teams": microsoftTeamsPanel
|
||||
default:
|
||||
SettingsSection(title: LocalizedStringKey(viewModel.selected.displayName), icon: KnownPlatforms.icon(for: viewModel.selected.name)) {
|
||||
ReadOnlyRow(label: "Setup", value: "No setup form for this platform yet.")
|
||||
@@ -154,6 +156,30 @@ struct PlatformsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hermes v0.12 — Yuanbao 元宝 ships as a native gateway adapter
|
||||
/// (the 18th platform). Setup is YAML-driven; we surface the
|
||||
/// shell command and a docs link rather than a per-field form
|
||||
/// because the auth dance is OAuth-style and lives outside Scarf.
|
||||
private var yuanbaoPanel: some View {
|
||||
SettingsSection(title: "Yuanbao 元宝", icon: KnownPlatforms.icon(for: "yuanbao")) {
|
||||
ReadOnlyRow(label: "Type", value: "Native gateway adapter (v0.12+)")
|
||||
ReadOnlyRow(label: "Setup", value: "Run `hermes setup` and select Yuanbao to walk the OAuth flow.")
|
||||
ReadOnlyRow(label: "Multi-image", value: "Supported via the gateway's centralized media routing.")
|
||||
ReadOnlyRow(label: "Configured", value: viewModel.hasConfigBlock(for: viewModel.selected) ? "Yes" : "No")
|
||||
}
|
||||
}
|
||||
|
||||
/// Hermes v0.12 — Microsoft Teams ships as a plugin (the 19th
|
||||
/// platform). Surface that explicitly so users know the setup
|
||||
/// path differs from the native adapters.
|
||||
private var microsoftTeamsPanel: some View {
|
||||
SettingsSection(title: "Microsoft Teams", icon: KnownPlatforms.icon(for: "microsoft-teams")) {
|
||||
ReadOnlyRow(label: "Type", value: "Plugin-shipped gateway platform (v0.12+)")
|
||||
ReadOnlyRow(label: "Setup", value: "Install the plugin from the Plugins tab, then run `hermes setup` to register the bot.")
|
||||
ReadOnlyRow(label: "Configured", value: viewModel.hasConfigBlock(for: viewModel.selected) ? "Yes" : "No")
|
||||
}
|
||||
}
|
||||
|
||||
private var cliPanel: some View {
|
||||
SettingsSection(title: "CLI", icon: "terminal") {
|
||||
ReadOnlyRow(label: "Scope", value: "Local terminal sessions")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
import os
|
||||
@@ -50,8 +51,65 @@ final class ProfilesViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the active profile via `hermes profile use <name>` without
|
||||
/// relaunching Scarf. Most users will reach for `switchAndRelaunch`
|
||||
/// instead — kept here so the context-menu "Use" item stays
|
||||
/// functional and so callers that genuinely want a no-relaunch
|
||||
/// switch (tests, scripted setups) have a path. Invalidates the
|
||||
/// resolver cache on success so the next `context.paths` access
|
||||
/// picks up the new home directory.
|
||||
func switchTo(_ profile: HermesProfile) {
|
||||
runAndReload(["profile", "use", profile.name], success: "Active profile set to \(profile.name)")
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["profile", "use", profile.name], timeout: 60)
|
||||
await MainActor.run {
|
||||
if result.exitCode == 0 {
|
||||
HermesProfileResolver.invalidateCache()
|
||||
self.message = "Active profile set to \(profile.name) — restart Scarf to refresh."
|
||||
} else {
|
||||
self.message = "Failed: \(result.output.prefix(120))"
|
||||
}
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the active profile and immediately relaunch Scarf. The
|
||||
/// canonical user-facing switch path (issue #70): a fresh process
|
||||
/// guarantees every service constructs from the new
|
||||
/// `~/.hermes/active_profile` value, sidestepping any in-process
|
||||
/// state that might still be holding the previous profile's
|
||||
/// data. Failures fall back to a "restart manually" toast.
|
||||
@MainActor
|
||||
func switchAndRelaunch(_ profile: HermesProfile) {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["profile", "use", profile.name], timeout: 30)
|
||||
await MainActor.run {
|
||||
guard result.exitCode == 0 else {
|
||||
self.message = "Failed: \(result.output.prefix(120))"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
HermesProfileResolver.invalidateCache()
|
||||
do {
|
||||
try AppRelauncher.relaunch()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
} catch AppRelauncher.RelaunchError.debugBuild {
|
||||
self.message = "Profile switched to \(profile.name). Restart Scarf manually (Xcode-launched instance)."
|
||||
self.load()
|
||||
} catch {
|
||||
self.message = "Profile switched to \(profile.name). Please quit and reopen Scarf manually."
|
||||
self.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func create(name: String, cloneConfig: Bool, cloneAll: Bool) {
|
||||
|
||||
@@ -20,6 +20,22 @@ struct ProfilesView: View {
|
||||
@State private var renameTarget: HermesProfile?
|
||||
@State private var renameNewName = ""
|
||||
@State private var pendingDelete: HermesProfile?
|
||||
/// Profile the user has clicked "Switch & Relaunch" on, awaiting
|
||||
/// confirmation before we run `hermes profile use` and exit. The
|
||||
/// confirmation step is load-bearing — relaunching closes every
|
||||
/// open Scarf window in the process, so the user needs an explicit
|
||||
/// agreement.
|
||||
@State private var pendingSwitch: HermesProfile?
|
||||
/// Remote-import sheet visibility. Local imports use `NSOpenPanel`
|
||||
/// inline; remote imports route through `RemoteProfilePathSheet`
|
||||
/// because the zip the user wants to import lives on the remote
|
||||
/// host (that's where `hermes profile export` produced it), and
|
||||
/// `NSOpenPanel` can only browse the local Mac.
|
||||
@State private var showRemoteImportSheet = false
|
||||
/// When non-nil, the export button on the named profile presents
|
||||
/// `RemoteProfilePathSheet` to ask for an output path on the
|
||||
/// remote host. Local exports continue to use `NSSavePanel`.
|
||||
@State private var pendingRemoteExport: HermesProfile?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -53,6 +69,48 @@ struct ProfilesView: View {
|
||||
} message: {
|
||||
Text("This removes the profile directory and all data within it. This cannot be undone.")
|
||||
}
|
||||
.confirmationDialog(
|
||||
pendingSwitch.map { "Switch to '\($0.name)' and relaunch Scarf?" } ?? "",
|
||||
isPresented: Binding(get: { pendingSwitch != nil }, set: { if !$0 { pendingSwitch = nil } })
|
||||
) {
|
||||
Button("Switch & Relaunch") {
|
||||
if let profile = pendingSwitch { viewModel.switchAndRelaunch(profile) }
|
||||
pendingSwitch = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingSwitch = nil }
|
||||
} message: {
|
||||
Text("All Scarf windows will close and reopen. Unsaved chat input may be lost.")
|
||||
}
|
||||
.sheet(isPresented: $showRemoteImportSheet) {
|
||||
RemoteProfilePathSheet(
|
||||
context: viewModel.context,
|
||||
title: "Import profile",
|
||||
prompt: "Enter the path to a profile `.zip` on \(viewModel.context.displayName).",
|
||||
placeholder: "e.g. ~/profiles/my-profile.zip",
|
||||
confirmLabel: "Import",
|
||||
mode: .existingFile,
|
||||
onCancel: { showRemoteImportSheet = false },
|
||||
onConfirm: { path in
|
||||
showRemoteImportSheet = false
|
||||
viewModel.import(from: path)
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(item: $pendingRemoteExport) { profile in
|
||||
RemoteProfilePathSheet(
|
||||
context: viewModel.context,
|
||||
title: "Export profile '\(profile.name)'",
|
||||
prompt: "Enter the destination path on \(viewModel.context.displayName) where the `.zip` should be written.",
|
||||
placeholder: "e.g. ~/\(profile.name)-profile.zip",
|
||||
confirmLabel: "Export",
|
||||
mode: .writableFile(initialName: "\(profile.name)-profile.zip"),
|
||||
onCancel: { pendingRemoteExport = nil },
|
||||
onConfirm: { path in
|
||||
pendingRemoteExport = nil
|
||||
viewModel.export(profile, to: path)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var listSection: some View {
|
||||
@@ -72,13 +130,21 @@ struct ProfilesView: View {
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
viewModel.import(from: url.path)
|
||||
if viewModel.context.isRemote {
|
||||
// The zip lives on the remote (where `hermes profile
|
||||
// export` produced it). NSOpenPanel can only browse
|
||||
// the user's Mac, so route through a remote-path
|
||||
// input sheet instead.
|
||||
showRemoteImportSheet = true
|
||||
} else {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
viewModel.import(from: url.path)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Import", systemImage: "square.and.arrow.down")
|
||||
@@ -112,18 +178,29 @@ struct ProfilesView: View {
|
||||
}
|
||||
.tag(profile.id)
|
||||
.contextMenu {
|
||||
Button("Use") { viewModel.switchTo(profile) }
|
||||
Button("Switch & Relaunch") { pendingSwitch = profile }
|
||||
.disabled(profile.isActive)
|
||||
Button("Set Active (no relaunch)") { viewModel.switchTo(profile) }
|
||||
.disabled(profile.isActive)
|
||||
Button("Rename") {
|
||||
renameTarget = profile
|
||||
renameNewName = profile.name
|
||||
}
|
||||
Button("Export…") {
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.nameFieldStringValue = "\(profile.name)-profile.zip"
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
viewModel.export(profile, to: url.path)
|
||||
if viewModel.context.isRemote {
|
||||
// Exporting a remote profile must write to a
|
||||
// remote path — NSSavePanel would write to
|
||||
// the user's Mac, leaving the remote
|
||||
// profile zip nowhere on the host where
|
||||
// anyone can use it.
|
||||
pendingRemoteExport = profile
|
||||
} else {
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.nameFieldStringValue = "\(profile.name)-profile.zip"
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
viewModel.export(profile, to: url.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
@@ -158,16 +235,17 @@ struct ProfilesView: View {
|
||||
Spacer()
|
||||
if !profile.isActive {
|
||||
Button {
|
||||
viewModel.switchTo(profile)
|
||||
pendingSwitch = profile
|
||||
} label: {
|
||||
Label("Switch to This Profile", systemImage: "arrow.triangle.swap")
|
||||
Label("Switch & Relaunch", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.help("Set as active profile and relaunch Scarf so every tab loads from \(profile.name)")
|
||||
}
|
||||
}
|
||||
if !profile.isActive {
|
||||
profileSwitchWarning
|
||||
profileSwitchInfo
|
||||
}
|
||||
SettingsSection(title: "Details", icon: "info.circle") {
|
||||
if !profile.path.isEmpty {
|
||||
@@ -198,16 +276,16 @@ struct ProfilesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var profileSwitchWarning: some View {
|
||||
private var profileSwitchInfo: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundStyle(.orange)
|
||||
Text("Switching the active profile changes the `~/.hermes` directory hermes uses. Restart Scarf after switching so it re-reads from the new profile's files.")
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("**Switch & Relaunch** sets this as the active profile (writes `~/.hermes/active_profile`) and relaunches Scarf so every tab — Webhooks, Sessions, SOUL.md, Memory — reloads from the new profile's `~/.hermes/profiles/<name>/` directory.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.orange.opacity(0.1))
|
||||
.background(ScarfColor.backgroundSecondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
@@ -264,3 +342,147 @@ struct ProfilesView: View {
|
||||
.frame(minWidth: 440, minHeight: 180)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remote-path picker for profile import + export. Used when the active
|
||||
/// `ServerContext` is `.ssh` — `NSOpenPanel` / `NSSavePanel` would
|
||||
/// browse the user's Mac, which is the wrong host. The sheet takes a
|
||||
/// remote path string and verifies it via the active transport before
|
||||
/// handing it back. The `mode` distinguishes "must already exist" from
|
||||
/// "we're about to write here," each with appropriate validation.
|
||||
private struct RemoteProfilePathSheet: View {
|
||||
enum Mode {
|
||||
/// Import flow: zip must already exist on the remote.
|
||||
case existingFile
|
||||
/// Export flow: we'll be writing to the path. Permissive on
|
||||
/// non-existence (that's expected); warn on existing dir or
|
||||
/// non-zip extension.
|
||||
case writableFile(initialName: String)
|
||||
}
|
||||
|
||||
let context: ServerContext
|
||||
let title: String
|
||||
let prompt: String
|
||||
let placeholder: String
|
||||
let confirmLabel: String
|
||||
let mode: Mode
|
||||
let onCancel: () -> Void
|
||||
let onConfirm: (String) -> Void
|
||||
|
||||
@State private var path: String = ""
|
||||
@State private var verification: Verification = .idle
|
||||
|
||||
private enum Verification: Equatable {
|
||||
case idle
|
||||
case verifying
|
||||
case ok(String)
|
||||
case warn(String)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(title).font(.headline)
|
||||
Text(prompt)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
HStack {
|
||||
TextField(placeholder, text: $path)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: path) { _, _ in
|
||||
if verification != .idle { verification = .idle }
|
||||
}
|
||||
Button("Verify") { Task { await verify() } }
|
||||
.disabled(path.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| verification == .verifying)
|
||||
}
|
||||
verificationBadge
|
||||
HStack {
|
||||
Button("Cancel") { onCancel() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button(confirmLabel) {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
onConfirm(trimmed)
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(path.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 520)
|
||||
.onAppear {
|
||||
if case .writableFile(let initialName) = mode, path.isEmpty {
|
||||
path = "~/" + initialName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var verificationBadge: some View {
|
||||
switch verification {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
case .verifying:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Checking on \(context.displayName)…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
case .ok(let detail):
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text(detail).font(.caption)
|
||||
}
|
||||
case .warn(let detail):
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text(detail).font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func verify() async {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
verification = .verifying
|
||||
let snapshot = context
|
||||
let snapshotMode = mode
|
||||
let result: Verification = await Task.detached {
|
||||
let transport = snapshot.makeTransport()
|
||||
let exists = transport.fileExists(trimmed)
|
||||
switch snapshotMode {
|
||||
case .existingFile:
|
||||
guard exists else {
|
||||
return .warn("Path doesn't exist on \(snapshot.displayName).")
|
||||
}
|
||||
guard let stat = transport.stat(trimmed) else {
|
||||
return .warn("Found, but couldn't stat — check permissions.")
|
||||
}
|
||||
if stat.isDirectory {
|
||||
return .warn("Path is a directory, not a file.")
|
||||
}
|
||||
if !trimmed.lowercased().hasSuffix(".zip") {
|
||||
return .warn("File found, but extension isn't `.zip`. Profile import expects a zip archive.")
|
||||
}
|
||||
return .ok("File found on \(snapshot.displayName).")
|
||||
case .writableFile:
|
||||
if exists {
|
||||
if let stat = transport.stat(trimmed), stat.isDirectory {
|
||||
return .warn("Path is a directory. Choose a file path that doesn't yet exist.")
|
||||
}
|
||||
return .warn("File already exists on \(snapshot.displayName) — export will overwrite it.")
|
||||
}
|
||||
if !trimmed.lowercased().hasSuffix(".zip") {
|
||||
return .warn("Extension isn't `.zip`. The export command writes a zip archive.")
|
||||
}
|
||||
return .ok("Path is available on \(snapshot.displayName).")
|
||||
}
|
||||
}.value
|
||||
verification = result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ struct ProjectsSidebar: View {
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.tag(project)
|
||||
.accessibilityIdentifier("projects.row.\(project.name)")
|
||||
.contextMenu {
|
||||
projectContextMenu(project)
|
||||
}
|
||||
@@ -190,6 +191,7 @@ struct ProjectsSidebar: View {
|
||||
Button("Uninstall Template (remove installed files)…", systemImage: "trash") {
|
||||
onUninstallTemplate(project)
|
||||
}
|
||||
.accessibilityIdentifier("projects.contextMenu.uninstallTemplate")
|
||||
Divider()
|
||||
}
|
||||
Button("Remove from List (keep files)…", systemImage: "minus.circle") {
|
||||
|
||||
@@ -40,6 +40,7 @@ struct ProjectsView: View {
|
||||
@State private var exportSheetProject: ProjectEntry?
|
||||
@State private var showingInstallURLPrompt = false
|
||||
@State private var installURLInput = ""
|
||||
@State private var showingCatalogSheet = false
|
||||
@State private var showingUninstallSheet = false
|
||||
@State private var configEditorProject: ProjectEntry?
|
||||
/// Project queued for the "remove from list" confirmation dialog.
|
||||
@@ -132,6 +133,17 @@ struct ProjectsView: View {
|
||||
.sheet(isPresented: $showingInstallURLPrompt) {
|
||||
installURLSheet
|
||||
}
|
||||
.sheet(isPresented: $showingCatalogSheet) {
|
||||
CatalogView { url in
|
||||
// Hand the catalog's HTTPS URL to the existing install
|
||||
// flow — no new entry-point logic, just a different
|
||||
// way to surface the URL. The install sheet's
|
||||
// `awaitingParentDirectory` stage takes over from here.
|
||||
installerViewModel.openRemoteURL(url)
|
||||
showingCatalogSheet = false
|
||||
showingInstallSheet = true
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingUninstallSheet) {
|
||||
TemplateUninstallSheet(viewModel: uninstallerViewModel) { removed in
|
||||
// Refresh the registry and clear selection if we just
|
||||
@@ -198,13 +210,20 @@ struct ProjectsView: View {
|
||||
private var templatesToolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu {
|
||||
Button("Browse Catalog…", systemImage: "books.vertical") {
|
||||
showingCatalogSheet = true
|
||||
}
|
||||
.accessibilityIdentifier("templates.browseCatalog")
|
||||
Divider()
|
||||
Button("Install from File…", systemImage: "tray.and.arrow.down") {
|
||||
openInstallFilePicker()
|
||||
}
|
||||
.accessibilityIdentifier("templates.installFromFile")
|
||||
Button("Install from URL…", systemImage: "link") {
|
||||
installURLInput = ""
|
||||
showingInstallURLPrompt = true
|
||||
}
|
||||
.accessibilityIdentifier("templates.installFromURL")
|
||||
Divider()
|
||||
if let selected = viewModel.selectedProject {
|
||||
Button("Export \"\(selected.name)\" as Template…", systemImage: "tray.and.arrow.up") {
|
||||
@@ -217,6 +236,16 @@ struct ProjectsView: View {
|
||||
} label: {
|
||||
Label("Templates", systemImage: "shippingbox")
|
||||
}
|
||||
// `.accessibilityElement(children: .ignore)` collapses
|
||||
// the inner Label's automatic accessibility tree so our
|
||||
// explicit identifier sticks. Without it, SwiftUI uses
|
||||
// the systemImage name (`chevron.down` in macOS toolbar
|
||||
// contexts) as the menu button's accessibility identifier
|
||||
// and our `.accessibilityIdentifier` is silently
|
||||
// overridden — verified via XCUITest tree dump.
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Templates")
|
||||
.accessibilityIdentifier("templates.toolbar.menu")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +258,7 @@ struct ProjectsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("https://example.com/my.scarftemplate", text: $installURLInput)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityIdentifier("templates.installURL.field")
|
||||
HStack {
|
||||
Button("Cancel") { showingInstallURLPrompt = false }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
@@ -243,6 +273,7 @@ struct ProjectsView: View {
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(URL(string: installURLInput)?.scheme?.lowercased() != "https")
|
||||
.accessibilityIdentifier("templates.installURL.confirm")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
@@ -17,6 +17,10 @@ final class AddServerViewModel {
|
||||
var identityFile: String = ""
|
||||
/// Override for `~/.hermes` on the remote. Empty = default.
|
||||
var remoteHome: String = ""
|
||||
/// Override for the parent dir under which template installs land on
|
||||
/// this host. Empty = default (`~/projects`). Created on first install
|
||||
/// if missing.
|
||||
var projectsRoot: String = ""
|
||||
|
||||
var isTesting: Bool = false
|
||||
/// Outcome of the most recent Test Connection run. `nil` = not yet run.
|
||||
@@ -44,6 +48,7 @@ final class AddServerViewModel {
|
||||
port: Int(port),
|
||||
identityFile: nonEmpty(identityFile),
|
||||
remoteHome: nonEmpty(remoteHome),
|
||||
projectsRoot: nonEmpty(projectsRoot),
|
||||
hermesBinaryHint: nil
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user